Docking deactivates the ship, and Entity.Update returned before the parent pin, so a docked ship kept integrating its own velocity and gravity (Ship.Update) for as long as it sat in the bay, and undocked wherever it had fallen to at whatever speed it had reached. Drag used to bleed that off; with no drag below top speed it flung the ship into the void. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2741 lines
No EOL
136 KiB
C#
2741 lines
No EOL
136 KiB
C#
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using GameCult.Caching;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Linq;
|
|
using MessagePack;
|
|
using UniRx;
|
|
using CultMath;
|
|
using static CultMath.math;
|
|
using int2 = CultMath.int2;
|
|
|
|
public abstract class Entity
|
|
{
|
|
// Every hull cell's temperature (kelvin) when the entity is built.
|
|
public const float StartingCellTemperature = 280;
|
|
|
|
public Zone Zone;
|
|
public Faction Faction;
|
|
// The mark of a zone's boss, written only by ZoneGenerator and restored from the pack. RunGoal reads it.
|
|
public bool IsBoss;
|
|
public EquippableItem Hull;
|
|
public EquippedItem EquippedHull;
|
|
|
|
public float3 Position;
|
|
public float2 Direction = float2(0,1);
|
|
public float2 Velocity;
|
|
// Rad per sim second, signed as the step turns Direction: the sum of this step's rotation actuators' applied
|
|
// angle over the step. Runtime only, zeroed at the start of Update, so a staged, loaded or wormhole write to
|
|
// Direction is never a turn. Read only by presenters that draw ahead of the step.
|
|
public float TurnRate;
|
|
|
|
// Runtime only, never saved; Entity.Update is the one writer of both. Acceleration is the planar velocity change
|
|
// across the base update over its delta (thrust, not the drag or gravity a subclass applies before calling it),
|
|
// and Manoeuvre is what that acceleration says about how unpredictably this entity changes its motion.
|
|
public float2 Acceleration;
|
|
public ManoeuvreTrack Manoeuvre;
|
|
|
|
// The planar velocity a VelocityLimit removed during the current base update. A limit is the ship's ceiling, not a
|
|
// change of vector the pilot made, so Acceleration reads the velocity change with this put back. VelocityLimit is
|
|
// the one writer, Entity.Update zeroes it, and it is never saved.
|
|
public float2 LimitClamp;
|
|
|
|
public float[,] Temperature;
|
|
public float[,] NewTemperature;
|
|
public bool2[,] HullConductivity;
|
|
public float[,] ThermalMass;
|
|
|
|
public readonly ReactiveCollection<EquippedItem> Equipment = new ReactiveCollection<EquippedItem>();
|
|
public readonly ReactiveCollection<EquippedCargoBay> CargoBays = new ReactiveCollection<EquippedCargoBay>();
|
|
public readonly ReactiveCollection<EquippedDockingBay> DockingBays = new ReactiveCollection<EquippedDockingBay>();
|
|
public readonly ReactiveCollection<Entity> VisibleEntities = new ReactiveCollection<Entity>();
|
|
// The cache of IsHostileTo for each zone entity: written only by RefreshStance, refreshed each Update.
|
|
public readonly ReactiveDictionary<Entity, bool> EntityHostility = new ReactiveDictionary<Entity, bool>();
|
|
public readonly ReactiveCollection<Entity> VisibleEnemies = new ReactiveCollection<Entity>();
|
|
public readonly ReactiveCollection<Entity> VisibleFriendlies = new ReactiveCollection<Entity>();
|
|
|
|
// Runtime-only IFF overrides: this entity's own decisions (player input, scenario stance, Provoke); not persisted in packs.
|
|
// When set for another entity, it decides IsHostileTo outright, ahead of the belief branch.
|
|
// Reactive so a change reaches EntityHostility at once (RefreshStance), not on the next tick.
|
|
private readonly ReactiveDictionary<Entity, bool> _iffOverrides = new ReactiveDictionary<Entity, bool>();
|
|
|
|
// The observer's belief of each contact's allegiance (aetheria-ew belief row, allegiance part): the faction its telemetry
|
|
// claimed when received (ruling ew-telemetry-with-the-track); absent = unknown. A null value is a subject that claims no
|
|
// faction (the player). Written only by ReceiveTelemetry; ends with the contact.
|
|
private readonly Dictionary<Entity, Faction> _telemetry = new Dictionary<Entity, Faction>();
|
|
|
|
public Entity Parent;
|
|
public List<Entity> Children = new List<Entity>();
|
|
// Mining Cut 3 (docs/mining-cut-refresh.md): the one target slot -- an entity, a chunk, or none. Read-only from
|
|
// outside: SetTarget is its one writer, for the player's handlers and the agents alike.
|
|
private readonly ReactiveProperty<TargetRef> _target = new ReactiveProperty<TargetRef>(TargetRef.None);
|
|
public IReadOnlyReactiveProperty<TargetRef> Target => _target;
|
|
|
|
// The one aim slot on the Target (docs/fire-control-cut.md, Cut 2): a revealed item or a mount of the hull this
|
|
// observer believes the target to be, never both. Written only by TrySelectTargetItem (a revealed item: Combat's path
|
|
// and the player's) and TrySelectMount (a believed mount), and nulled whenever Target changes (subscribed in
|
|
// Activate). Do not read it outside Entity -- read ResolvedAim, which re-validates both gates on every call, so decay,
|
|
// a perceived destruction, or the item leaving the target drops the aim the moment it is no longer earned, with no
|
|
// loop that has to notice and clear it. (Not `Aim`: that is the pilot's pointing direction below.)
|
|
public AimTarget AimedAt { get; private set; }
|
|
|
|
// Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the
|
|
// hull turns only by Ship.Turn.
|
|
public float3 Aim { get; internal set; }
|
|
|
|
// The entity's one pilot input, written by its one pilot (its agent, the player, a scripted pilot) and applied only by
|
|
// PilotApplier in the zone step. Runtime only: never packed.
|
|
public PilotCommand Command { get; } = new PilotCommand();
|
|
|
|
public string Name;
|
|
|
|
// Names the entity for assertion messages and logs: xunit and the debugger format an object by walking its graph
|
|
// unless it has a ToString, and an Entity's graph is the whole zone. Entity has no id; Name is the hull's or the scenario's.
|
|
public override string ToString() => $"{GetType().Name} '{Name}'";
|
|
|
|
// public int Population;
|
|
// public Dictionary<Guid, float> Personality = new Dictionary<Guid, float>();
|
|
|
|
public readonly Dictionary<string, float> Messages = new Dictionary<string, float>();
|
|
private readonly Dictionary<object, Signature> _visibilitySources = new Dictionary<object, Signature>();
|
|
// A read-only view: the only writes are SetSignature and the decay loop in Update, both below.
|
|
// A ReadOnlyDictionary over the private field, created once: a cast back to Dictionary fails.
|
|
private ReadOnlyDictionary<object, Signature> _visibilityView;
|
|
public IReadOnlyDictionary<object, Signature> VisibilitySources => _visibilityView ??= new ReadOnlyDictionary<object, Signature>(_visibilitySources);
|
|
|
|
// The one writer of VisibilitySources (the decay loop in Update is the one decayer). Every light signature
|
|
// names its channel and the item that produced it (null for the hull). Emission is active emission (paints now,
|
|
// Entity.Emit after emissions-heard), never light: ruling visibility-per-capability-reveal, map R1.
|
|
public void SetSignature(object source, SignatureChannel channel, float strength, EquippedItem item)
|
|
{
|
|
if (channel == SignatureChannel.Emission)
|
|
throw new ArgumentException("Emission is active emission, never a light signature.", nameof(channel));
|
|
// A light signature is thinned by the ship's own gear at the moment it is written (aetheria-ew, lie-is-own-emission):
|
|
// each equipped suppressor folds the strength in equipment order and pays for what it alone removed.
|
|
foreach (var suppressor in GetBehaviors<LaunchSuppressor>())
|
|
strength = suppressor.Suppress(channel, strength);
|
|
_visibilitySources[source] = new Signature(channel, strength, item, ClaimOf(item));
|
|
}
|
|
|
|
// What this ship's acts claim (aetheria-ew, lie-is-own-emission, classify-from-belief): per equipped item, the
|
|
// equippable class every act of that item is stamped with (SetSignature, Emit, ReceivePaint). The hull is the
|
|
// item EquippedHull, so its claim lives in the same slot as every other: the hull class its own acts carry and
|
|
// the profile an observer derives (ClaimedHull) are one value. The truth unless claimed otherwise. Claim is the
|
|
// one write port; its production writer is the EW behaviour of cut ew-masquerade, tests only until then.
|
|
// Deactivate clears the slots. Aim, damage and physics keep the truth.
|
|
private readonly Dictionary<EquippedItem, EquippableItemData> _itemClaims = new Dictionary<EquippedItem, EquippableItemData>();
|
|
public HullData ClaimedHull => (ClaimOf(EquippedHull) as HullData) ?? HullData;
|
|
public EquippableItemData ClaimOf(EquippedItem item) =>
|
|
item == null ? null : _itemClaims.TryGetValue(item, out var claimed) ? claimed : item.Data;
|
|
internal void Claim(HullData hull) => Claim(EquippedHull, hull);
|
|
internal void Claim(EquippedItem item, EquippableItemData claimed)
|
|
{
|
|
// One hull class in the book: a hull class is claimed by the hull and only by the hull, whichever side asks.
|
|
if (claimed != null && (item == EquippedHull) != (claimed is HullData))
|
|
throw new ArgumentException("Only the hull claims a hull class, and the hull claims nothing else.", nameof(claimed));
|
|
if (claimed == null) _itemClaims.Remove(item);
|
|
else _itemClaims[item] = claimed;
|
|
}
|
|
public readonly ReactiveDictionary<Entity, float> EntityInfoGathered = new ReactiveDictionary<Entity, float>();
|
|
|
|
public (List<Weapon> weapons, List<EquippedItem> items)[] WeaponGroups;
|
|
|
|
public List<IPopulationAssignment> PopulationAssignments = new List<IPopulationAssignment>();
|
|
|
|
// Cut 2 (docs/stats-and-power-cut.md): the sole owner of every resolved (item, stat) value for this entity.
|
|
// Constructed with the entity and reachable only from it -- nothing on the catalog side ever points back --
|
|
// so it is collected with the entity instead of surviving it for the life of the process (§0.3, §1.1).
|
|
public readonly StatResolver Resolver = new StatResolver();
|
|
|
|
// Cut 3 (docs/stats-and-power-cut.md): the sole owner of every power grant for this entity, same lifecycle
|
|
// reasoning as Resolver above (§1.2).
|
|
public readonly PowerBus PowerBus;
|
|
|
|
public EquippedItem[,] GearOccupancy;
|
|
public HardpointData[,] Hardpoints;
|
|
public float[,] Armor;
|
|
public float[,] MaxArmor;
|
|
|
|
private EquippedItem[] _orderedEquipment;
|
|
private List<Weapon> _weapons = new List<Weapon>();
|
|
private List<Radiator> _heatsinks = new List<Radiator>();
|
|
|
|
private List<ConsumableItemEffect> _activeConsumables = new List<ConsumableItemEffect>();
|
|
|
|
protected bool _active;
|
|
|
|
private bool _heatsinksEnabled = true;
|
|
|
|
public bool HeatsinksEnabled
|
|
{
|
|
get => _heatsinksEnabled;
|
|
internal set
|
|
{
|
|
if (value == _heatsinksEnabled) return;
|
|
_heatsinksEnabled = value;
|
|
foreach (var heatsink in _heatsinks)
|
|
heatsink.Item.Enabled.Value = value;
|
|
}
|
|
}
|
|
|
|
public HullData HullData { get; }
|
|
|
|
public EntitySettings Settings { get; }
|
|
|
|
public bool OverrideShutdown { get; internal set; }
|
|
|
|
// The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it.
|
|
public float TractorCommand { get; internal set; }
|
|
|
|
// The give the pilot has under way, and why the last one ended. CargoTransfer is the only writer of both.
|
|
public GiveOrder Giving { get; internal set; }
|
|
public GiveEnd? LastGiveEnd { get; internal set; }
|
|
|
|
// The beam's spun-up state: Pickup's, or 0 without one.
|
|
public float TractorPower => GetBehavior<Pickup>()?.Power ?? 0f;
|
|
|
|
public bool Active
|
|
{
|
|
get => _active;
|
|
}
|
|
|
|
public IEnumerable<Weapon> Weapons
|
|
{
|
|
get => _weapons;
|
|
}
|
|
|
|
public Shield Shield { get; private set; }
|
|
public Cockpit Cockpit { get; private set; }
|
|
public Sensor Sensor { get; private set; }
|
|
public float Heatstroke { get; private set; }
|
|
public float Hypothermia { get; private set; }
|
|
|
|
public float TargetRange { get; private set; }
|
|
public float MaxTemp { get; private set; }
|
|
public float MinTemp { get; private set; }
|
|
public ItemManager ItemManager { get; }
|
|
public int AssignedPopulation => PopulationAssignments.Sum(pa => pa.AssignedPopulation);
|
|
public float Mass { get; private set; }
|
|
public float Visibility => VisibilitySources.Values.Sum(s => s.Strength);
|
|
|
|
public Subject<Entity> IncomingHit = new Subject<Entity>();
|
|
// One per entity, created with it, never replaced: FireControl publishes each landed impact on it (Absorb).
|
|
public readonly CapabilityEvents Capabilities = new CapabilityEvents();
|
|
public Subject<(int2 pos, float damage)> ArmorDamage = new Subject<(int2, float)>();
|
|
public Subject<(EquippedItem item, float damage)> ItemDamage = new Subject<(EquippedItem, float)>();
|
|
// public Subject<EquippedItem> ItemOffline = new Subject<EquippedItem>();
|
|
// public Subject<EquippedItem> ItemOnline = new Subject<EquippedItem>();
|
|
public Subject<float> HullDamage = new Subject<float>();
|
|
public Subject<Entity> Docked = new Subject<Entity>();
|
|
public Subject<Unit> HeatstrokeRisk = new Subject<Unit>();
|
|
public Subject<Unit> HeatstrokeDeath = new Subject<Unit>();
|
|
public Subject<Unit> HypothermiaRisk = new Subject<Unit>();
|
|
public Subject<Unit> HypothermiaDeath = new Subject<Unit>();
|
|
// Selection tells the selected ship nothing: a warning comes only from a paint. A search paint is a ping
|
|
// sweeping this ship (Sensor.Execute); a track paint is a lock (LockWeapon.Execute). Each reports into the
|
|
// inbox the tick it paints; PublishInbox publishes the inbox once, so a warning lives exactly one tick after
|
|
// its last paint.
|
|
// The inbox keeps who painted and with what beside the warning: the warning is all a reader of IncomingPaints
|
|
// gets, the emitter and item are for PublishInbox, which records the emitter's reveal.
|
|
private readonly struct PendingPaint
|
|
{
|
|
public readonly PaintWarning Warning;
|
|
public readonly Entity Emitter;
|
|
public readonly EquippedItem Item;
|
|
|
|
public PendingPaint(PaintWarning warning, Entity emitter, EquippedItem item)
|
|
{
|
|
Warning = warning;
|
|
Emitter = emitter;
|
|
Item = item;
|
|
}
|
|
}
|
|
private readonly List<PendingPaint> _paintInbox = new List<PendingPaint>();
|
|
private readonly List<PaintWarning> _incomingPaints = new List<PaintWarning>();
|
|
public IReadOnlyList<PaintWarning> IncomingPaints => _incomingPaints;
|
|
// An ally's sensor posts a return on the tick its ping crosses a subject; published with paints, read by this
|
|
// ship's own sensors for one tick.
|
|
private readonly List<PingReturn> _returnInbox = new List<PingReturn>();
|
|
private readonly List<PingReturn> _pingReturns = new List<PingReturn>();
|
|
internal IReadOnlyList<PingReturn> PingReturns => _pingReturns;
|
|
// What this ship's active sensors and lock paints emit this tick (Emit), heard by other ships' Sense (ruling
|
|
// sensor-stat-set): written in Update, read by Sense in the same tick, cleared by the next PublishInbox.
|
|
private readonly List<ActiveEmission> _emissions = new List<ActiveEmission>();
|
|
// What this observer has seen each ship's items do (ruling visibility-per-capability-reveal), keyed (subject,
|
|
// item). Written only by RecordReveal (an act, or a scan through RecordInspection, enters) and EndReveals (an end the observer perceives, or a departed
|
|
// subject's entries aged past BearingMemory), cleared by Deactivate (ruling reveal-ends-on-perception).
|
|
private readonly Dictionary<Entity, Dictionary<EquippedItem, Reveal>> _reveals = new Dictionary<Entity, Dictionary<EquippedItem, Reveal>>();
|
|
// The mounts of each subject whose item this observer perceived destroyed (PerceivesDestroyed), so the target cycle
|
|
// skips them and no aim rests on them: observer state, written only by RecordSeenDestroyed in Sense, dropped with the
|
|
// subject's reveals (EndReveals) and cleared by Deactivate. Truth enters only through the one perception rule.
|
|
private readonly Dictionary<Entity, HashSet<HardpointData>> _seenDestroyed = new Dictionary<Entity, HashSet<HardpointData>>();
|
|
private readonly List<EquippedItem> _endedItems = new List<EquippedItem>();
|
|
private readonly List<Entity> _agedSubjects = new List<Entity>();
|
|
public ReactiveProperty<SecurityLevel> CurrentSecurityLevel = new ReactiveProperty<SecurityLevel>(SecurityLevel.Open);
|
|
public ReadOnlyReactiveProperty<bool> PresencePermitted;
|
|
|
|
public UniRx.IObservable<EquippedItem> ItemDestroyed;
|
|
public UniRx.IObservable<int2> HullArmorDepleted;
|
|
public UniRx.IObservable<HardpointData> HardpointArmorDepleted;
|
|
public UniRx.IObservable<Weapon> WeaponDestroyed;
|
|
public UniRx.IObservable<CauseOfDeath> Death;
|
|
|
|
private List<IDisposable> _subscriptions = new List<IDisposable>();
|
|
private Dictionary<Entity, List<IDisposable>> _watchedEntitySubscriptions = new Dictionary<Entity, List<IDisposable>>();
|
|
|
|
// Grudges are a non-player behavior only: a player's own stance is always the operator's explicit call.
|
|
private bool IsPlayerControlled => this is Ship {IsPlayerShip: true};
|
|
|
|
public virtual void Activate()
|
|
{
|
|
//ItemManager.Log($"Entity {Name} is activating!");
|
|
_active = true;
|
|
Heatstroke = 0;
|
|
|
|
// The entity's presence is permitted when its faction owns the zone, or the current security level at its location is low enough
|
|
PresencePermitted = new ReadOnlyReactiveProperty<bool>(CurrentSecurityLevel.Select(security =>
|
|
{
|
|
var factionRelationship = GetFactionRelationship(Zone.GalaxyZone.Owner);
|
|
var presencePermitted = IsPresencePermitted(factionRelationship, security);
|
|
return presencePermitted;
|
|
}), initialValue: true);
|
|
|
|
// One initialiser for everything that computes targets here: gear and the effects still running, so a
|
|
// consumable active across a refit retargets to the new items exactly as gear does.
|
|
InitializeBehaviors(Equipment.SelectMany(item => item.Behaviors));
|
|
InitializeBehaviors(_activeConsumables.SelectMany(effect => effect.Behaviors));
|
|
foreach(var entity in Zone.Entities)
|
|
{
|
|
EntityInfoGathered[entity] = 0;
|
|
EntityHostility[entity] = false;
|
|
RefreshStance(entity);
|
|
}
|
|
_subscriptions.Add(Zone.Entities.ObserveAdd().Subscribe(add =>
|
|
{
|
|
EntityInfoGathered[add.Value] = 0;
|
|
EntityHostility[add.Value] = false;
|
|
RefreshStance(add.Value);
|
|
}));
|
|
_subscriptions.Add(Zone.Entities.ObserveRemove().Subscribe(remove =>
|
|
{
|
|
if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None);
|
|
EntityInfoGathered.Remove(remove.Value);
|
|
EntityHostility.Remove(remove.Value);
|
|
VisibleEntities.Remove(remove.Value);
|
|
VisibleEnemies.Remove(remove.Value);
|
|
VisibleFriendlies.Remove(remove.Value);
|
|
if (remove.Value == this)
|
|
{
|
|
// A ship out of the zone is no longer published to: it holds no warnings (nothing would ever clear them).
|
|
_paintInbox.Clear();
|
|
_incomingPaints.Clear();
|
|
}
|
|
ReleaseStance(remove.Value);
|
|
if (!_reveals.ContainsKey(remove.Value)) _telemetry.Remove(remove.Value);
|
|
}));
|
|
|
|
// An override changing updates this entity's hostility toward that entity immediately,
|
|
// rather than waiting for the next per-tick refresh.
|
|
_subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshStance(add.Key)));
|
|
_subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshStance(replace.Key)));
|
|
_subscriptions.Add(_iffOverrides.ObserveRemove().Subscribe(remove => RefreshStance(remove.Key)));
|
|
_subscriptions.Add(VisibleEnemies.ObserveRemove().Subscribe(remove =>
|
|
{
|
|
if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None);
|
|
}));
|
|
|
|
//
|
|
_subscriptions.Add(EntityInfoGathered.ObserveReplace().Subscribe(replace =>
|
|
{
|
|
if (replace.OldValue < ItemManager.GameplaySettings.TargetDetectionInfoThreshold &&
|
|
replace.NewValue > ItemManager.GameplaySettings.TargetDetectionInfoThreshold)
|
|
{
|
|
// Tracked: the contact's telemetry arrives with the track and the stance is rated on it before the contact is filed.
|
|
ReceiveTelemetry(replace.Key);
|
|
RefreshStance(replace.Key);
|
|
VisibleEntities.Add(replace.Key);
|
|
if(EntityHostility[replace.Key])
|
|
VisibleEnemies.Add(replace.Key);
|
|
else VisibleFriendlies.Add(replace.Key);
|
|
}
|
|
if (replace.OldValue > ItemManager.GameplaySettings.TargetDetectionInfoThreshold &&
|
|
replace.NewValue < ItemManager.GameplaySettings.TargetDetectionInfoThreshold)
|
|
{
|
|
VisibleEntities.Remove(replace.Key);
|
|
VisibleEnemies.Remove(replace.Key);
|
|
VisibleFriendlies.Remove(replace.Key);
|
|
}
|
|
}));
|
|
|
|
// Create subscriptions to events occurring on any visible entity
|
|
_subscriptions.Add(VisibleEntities.ObserveAdd()
|
|
.Select(add=>add.Value)
|
|
.Subscribe(entity =>
|
|
{
|
|
var newVisibleEntitySubscriptions = new List<IDisposable>
|
|
{
|
|
// Respond to changes in the target's hostility status by updating the contents of VisibleEnemies and VisibleFriendlies
|
|
EntityHostility.ObserveReplace()
|
|
.Where(replace => replace.Key == entity)
|
|
.Select(replace => replace.NewValue)
|
|
.Subscribe(isHostile =>
|
|
{
|
|
(isHostile ? VisibleEnemies : VisibleFriendlies).Add(entity);
|
|
(isHostile ? VisibleFriendlies : VisibleEnemies).Remove(entity);
|
|
})
|
|
};
|
|
|
|
_watchedEntitySubscriptions[entity] = newVisibleEntitySubscriptions;
|
|
}));
|
|
|
|
// Cleanup visible entity subscriptions
|
|
_subscriptions.Add(VisibleEntities.ObserveRemove().Subscribe(disappearingEntity =>
|
|
{
|
|
foreach(var subscription in _watchedEntitySubscriptions[disappearingEntity.Value]) subscription.Dispose();
|
|
_watchedEntitySubscriptions.Remove(disappearingEntity.Value);
|
|
}));
|
|
|
|
// Cut 2 (docs/fire-control-cut.md): a Target change nulls the aim. Fires immediately on
|
|
// subscribe with whatever Target already holds, which is harmless -- AimedAt starts empty anyway.
|
|
_subscriptions.Add(Target.Subscribe(_ => AimedAt = AimTarget.None));
|
|
|
|
// 9.3 (docs/fire-control-cut.md, Soul C3): a Target surviving Deactivate can point at an entity
|
|
// this zone removed while inactive -- Deactivate disposes the Zone.Entities.ObserveRemove
|
|
// subscription that is the only thing that nulls a stale Target, and EntityInfoGathered was just
|
|
// rebuilt above from the zone's *current* membership, not from whatever Target still remembers.
|
|
// Reconcile here, now that both the fresh EntityInfoGathered and the Target-change subscription
|
|
// above are live (so this write also clears the aim through it): an active entity's Target
|
|
// must always be an entity it has info on. Activate owns this reconciliation rather than
|
|
// Deactivate nulling Target outright, because nulling on every dock would throw away a target
|
|
// that is still alive and simply never changed while docked -- the common case, and not the one
|
|
// that crashed.
|
|
// Mining Cut 3: a chunk target is held to the same "still tracked" test through the chunk path -- kept
|
|
// while this entity can see it, dropped once it cannot (a broken chunk is dark, so it is dropped too).
|
|
if (Target.Value.Entity != null && !EntityInfoGathered.ContainsKey(Target.Value.Entity) ||
|
|
Target.Value.Chunk is ChunkId heldChunk && !ChunkVisible(heldChunk))
|
|
SetTarget(TargetRef.None);
|
|
|
|
if(WeaponGroups.All(wg=>!wg.items.Any()))
|
|
GenerateWeaponGroups();
|
|
}
|
|
|
|
// Cut 2 (docs/fire-control-cut.md): one of the aim slot's two writers (the other is TrySelectMount). Accepts null
|
|
// (clearing the aim) or a revealed item (FireControl.IsRevealed: this entity saw it act) belonging to the current
|
|
// Target. A fitted item the observer has only located is aimed as its mount, never as an item. Returns whether the
|
|
// write took effect; a rejected non-null selection leaves the aim unchanged.
|
|
internal bool TrySelectTargetItem(EquippedItem item)
|
|
{
|
|
if (item == null)
|
|
{
|
|
AimedAt = AimTarget.None;
|
|
return true;
|
|
}
|
|
|
|
// Mining Cut 3: entity-only. A chunk has no items, so an item never belongs to a chunk target.
|
|
if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsRevealed(this, item))
|
|
return false;
|
|
|
|
AimedAt = AimTarget.OfItem(item);
|
|
return true;
|
|
}
|
|
|
|
// The aim slot's other writer: a mount of the hull this entity believes the Target to be (LayoutOf, by reference),
|
|
// unless this entity perceived that mount's item destroyed (SeenDestroyed). Reads only observer state, so whether a
|
|
// mount can be aimed at says nothing of the Target's truth (ruling cycle-skips-only-perceived-destroyed).
|
|
internal bool TrySelectMount(HardpointData mount)
|
|
{
|
|
if (!MountAimable(Target.Value.Entity, mount)) return false;
|
|
AimedAt = AimTarget.OfMount(mount);
|
|
return true;
|
|
}
|
|
|
|
private bool MountAimable(Entity subject, HardpointData mount) =>
|
|
subject != null && mount != null && LayoutOf(subject).Contains(mount) && !SeenDestroyed(subject, mount);
|
|
|
|
// The read path for the aim slot (see its comment): the aim only while its gate still holds -- an item still revealed
|
|
// and still the Target's, a mount still in the believed layout and not seen destroyed -- else none, even though
|
|
// nothing wrote the slot at the moment that stopped being true. Re-checked on every call; never cached.
|
|
public AimTarget ResolvedAim
|
|
{
|
|
get
|
|
{
|
|
var subject = Target.Value.Entity;
|
|
var aim = AimedAt;
|
|
if (subject == null) return AimTarget.None;
|
|
if (aim.Item != null) return FireControl.IsRevealed(this, aim.Item) ? aim : AimTarget.None;
|
|
return MountAimable(subject, aim.Mount) ? aim : AimTarget.None;
|
|
}
|
|
}
|
|
|
|
public EquippedItem ResolvedTargetItem => ResolvedAim.Item;
|
|
|
|
// What the cycle visits on `subject`, in schematic order: its rows (revealed items, then every mount of the hull this
|
|
// ship believes it to be, empty or not) minus the mounts this ship saw destroyed. Reads the same rows the schematic
|
|
// shows and the record, never `subject`'s equipment or live Durability.
|
|
public IEnumerable<AimTarget> AimCycle(Entity subject) =>
|
|
SchematicRows(subject)
|
|
.Where(r => r.Item != null || !SeenDestroyed(subject, r.Mount))
|
|
.Select(r => r.Item != null ? AimTarget.OfItem(r.Item) : AimTarget.OfMount(r.Mount));
|
|
|
|
// The player's cycle key: steps the aim to the next AimCycle entry after the resolved one and wraps; an entry its
|
|
// writer refuses (a revealed item since destroyed) is passed over; nothing to visit clears the aim.
|
|
internal void CycleAim()
|
|
{
|
|
var subject = Target.Value.Entity;
|
|
if (subject == null) return;
|
|
var cycle = AimCycle(subject).ToList();
|
|
var now = ResolvedAim;
|
|
var index = cycle.FindIndex(a => a.Item == now.Item && a.Mount == now.Mount && (now.Item != null || now.Mount != null));
|
|
for (var step = 1; step <= cycle.Count; step++)
|
|
{
|
|
var next = cycle[(index + step + cycle.Count) % cycle.Count];
|
|
if (next.Item != null ? TrySelectTargetItem(next.Item) : TrySelectMount(next.Mount)) return;
|
|
}
|
|
AimedAt = AimTarget.None;
|
|
}
|
|
|
|
// Mining Cut 3 (docs/mining-cut-refresh.md): the one writer of Target, called by the player's handlers and the
|
|
// agents alike. A chunk is taken only while this entity can see it, so a rock in darkness cannot be picked; an
|
|
// entity is written as given, as every handler wrote it before. Returns whether the write took effect.
|
|
internal bool SetTarget(TargetRef target)
|
|
{
|
|
if (target.Chunk is ChunkId chunk && !ChunkVisible(chunk)) return false;
|
|
_target.Value = target;
|
|
return true;
|
|
}
|
|
|
|
// Mining Cut 3 (Q14 A): this entity's info on a chunk, computed when asked and stored nowhere. It is the value
|
|
// the per-tick sensor rule (Sensor.Execute, through Sensor.Gain) would settle at for a target this bright,
|
|
// here, now (PassiveInfo at the chunk's brightness), capped at 1.
|
|
public float ChunkInfo(ChunkId chunk)
|
|
{
|
|
var visibility = Zone.ChunkVisibility(chunk);
|
|
if (visibility <= 0f) return 0f;
|
|
return saturate(PassiveInfo(Zone.ChunkPose(chunk.Field, chunk.Index).xy, visibility));
|
|
}
|
|
|
|
// The info a target of `signature` at `position` would settle at under this entity's passive sensing, before
|
|
// the cap at 1. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the
|
|
// info settles where the gain balances the decay: sum(G_i) / (n k). A ping adds nothing that lasts, so only the
|
|
// passive gain enters. ChunkInfo asks it for a chunk's brightness; Sense asks it for a unit signature once per
|
|
// ship, because it is linear in the signature below saturation and so answers for every act at a place. Zero
|
|
// with no active sensor.
|
|
public float PassiveInfo(float2 position, float signature)
|
|
{
|
|
var rate = 0f;
|
|
var sensors = 0;
|
|
foreach (var sensor in ActiveSensors())
|
|
{
|
|
rate += sensor.PassiveRate(signature, position);
|
|
sensors++;
|
|
}
|
|
return sensors == 0 ? 0f : rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay);
|
|
}
|
|
|
|
// Mining Cut 3: the detection threshold, read through the chunk path. A chunk is visible exactly as an entity
|
|
// enters VisibleEntities: its info exceeds TargetDetectionInfoThreshold.
|
|
public bool ChunkVisible(ChunkId chunk) => ChunkInfo(chunk) > ItemManager.GameplaySettings.TargetDetectionInfoThreshold;
|
|
|
|
// Mining Cut 3 (Q13 A): the reach for picking a chunk -- the longest range among this entity's active weapons
|
|
// that can mine (Weapon.CanMine, Q12 A). Every visible chunk within it, written into `into` (cleared first), by
|
|
// index then field. Cycling and any programmatic chunk pick read this, so there is one reach rule. The zone's
|
|
// targeting index proposes the chunks that could pass (no observer, so no entities); ChunkVisible decides.
|
|
public void VisibleChunksInReach(List<ChunkId> into)
|
|
{
|
|
into.Clear();
|
|
Zone.Targets.Within(MiningSearch(), _targetCandidates);
|
|
foreach (var candidate in _targetCandidates)
|
|
if (candidate.Target.Chunk is ChunkId chunk && ChunkVisible(chunk))
|
|
into.Add(chunk);
|
|
into.Sort(ByIndexThenField);
|
|
}
|
|
|
|
// The one search every chunk question asks the zone's targeting index: from here, as far as the longest range
|
|
// among this entity's active weapons that can mine (Q13 A; Weapon.CanMine, Q12 A), bounded by what its sensors
|
|
// could detect.
|
|
private TargetSearch MiningSearch()
|
|
{
|
|
var reach = 0f;
|
|
foreach (var weapon in _weapons)
|
|
if (weapon.CanMine && (weapon.Item == null || weapon.Item.Active.Value))
|
|
reach = max(reach, weapon.Range);
|
|
return new TargetSearch(Position.xz, reach, reachPerVisibility: DetectionReachPerVisibility());
|
|
}
|
|
|
|
// Mining target-queries cut: which target a press of reticle, next, previous or nearest picks. Each is one
|
|
// best-first question to the index (no candidate list is built, sorted or kept), answered through SetTarget, the
|
|
// one writer. Candidates are visible ships at any range and visible chunks within mining reach.
|
|
|
|
// The candidate nearest the look direction by planar angle; picking the held target again clears it.
|
|
internal bool TargetUnderReticle()
|
|
{
|
|
var look = float2(Aim.x, Aim.z);
|
|
if (!PickTarget(new AngleKey(this, Position.xz, look), out var picked)) return false;
|
|
return SetTarget(Target.Value.Equals(picked) ? TargetRef.None : picked);
|
|
}
|
|
|
|
// Enemies only, as it drives weapon lock; the nearest by distance.
|
|
internal bool TargetNearestEnemy()
|
|
{
|
|
Entity nearest = null;
|
|
var nearestDistance = float.PositiveInfinity;
|
|
foreach (var enemy in VisibleEnemies)
|
|
{
|
|
if (enemy == this) continue;
|
|
var distance = length(enemy.Position - Position);
|
|
if (distance < nearestDistance)
|
|
{
|
|
nearest = enemy;
|
|
nearestDistance = distance;
|
|
}
|
|
}
|
|
return nearest != null && SetTarget(nearest);
|
|
}
|
|
|
|
// The next candidate after the current target by (planar distance, TargetRef), wrapping to the nearest; with
|
|
// nothing targeted, the nearest.
|
|
internal bool TargetNext() => CycleTarget(false);
|
|
|
|
// The reverse: the next one nearer, wrapping to the farthest; with nothing targeted, the farthest.
|
|
internal bool TargetPrevious() => CycleTarget(true);
|
|
|
|
private bool CycleTarget(bool reverse)
|
|
{
|
|
var origin = Position.xz;
|
|
var current = Target.Value;
|
|
TargetRef picked = default;
|
|
var found = !current.IsNone &&
|
|
PickTarget(new DistanceKey(this, origin, reverse, true, DistanceFrom(origin, current), current), out picked);
|
|
if (!found && !PickTarget(new DistanceKey(this, origin, reverse, false, 0f, default), out picked)) return false;
|
|
return SetTarget(picked);
|
|
}
|
|
|
|
private float DistanceFrom(float2 origin, TargetRef target) =>
|
|
length((target.Chunk is ChunkId chunk ? Zone.ChunkPose(chunk.Field, chunk.Index).xy : target.Entity.Position.xz) - origin);
|
|
|
|
// The least-key eligible candidate: the best visible ship, and the index's best chunk within mining reach. Ships
|
|
// are not bounded by reach, so they are read from VisibleEntities (the ships this entity sees); the belt's size
|
|
// never enters.
|
|
private bool PickTarget<TKey>(TKey query, out TargetRef picked) where TKey : ITargetKey
|
|
{
|
|
var best = default(TargetCandidate);
|
|
var bestKey = float.PositiveInfinity;
|
|
var found = false;
|
|
foreach (var entity in VisibleEntities)
|
|
Consider(query, new TargetCandidate(entity, entity.Position.xz), ref found, ref best, ref bestKey);
|
|
if (Zone.Targets.Best(MiningSearch(), query, out var chunk))
|
|
Consider(query, chunk, ref found, ref best, ref bestKey);
|
|
picked = found ? best.Target : TargetRef.None;
|
|
return found;
|
|
}
|
|
|
|
private static void Consider<TKey>(TKey query, in TargetCandidate candidate, ref bool found, ref TargetCandidate best, ref float bestKey)
|
|
where TKey : ITargetKey
|
|
{
|
|
if (!query.Key(candidate, out var key)) return;
|
|
if (found && !(key < bestKey || key == bestKey && query.Precedes(candidate, best))) return;
|
|
best = candidate;
|
|
bestKey = key;
|
|
found = true;
|
|
}
|
|
|
|
// What a press may pick: another ship, or a chunk this entity can see. Reach is the index's to apply.
|
|
private bool Eligible(in TargetCandidate candidate) =>
|
|
candidate.Target.Chunk is ChunkId chunk ? ChunkVisible(chunk) : candidate.Target.Entity != this;
|
|
|
|
// Entities come before chunks; entities by their place in the zone, chunks by field key then index.
|
|
private static int Order(TargetRef a, TargetRef b)
|
|
{
|
|
if (a.Chunk is ChunkId ca)
|
|
{
|
|
if (!(b.Chunk is ChunkId cb)) return 1;
|
|
var byField = string.CompareOrdinal(ca.Field.Value, cb.Field.Value);
|
|
return byField != 0 ? byField : ca.Index.CompareTo(cb.Index);
|
|
}
|
|
if (b.Chunk.HasValue) return -1;
|
|
var entities = a.Entity.Zone.Entities;
|
|
return entities.IndexOf(a.Entity).CompareTo(entities.IndexOf(b.Entity));
|
|
}
|
|
|
|
// The planar angle from the look direction to the candidate. A region's least angle is its bearing interval's
|
|
// distance from the look direction.
|
|
private readonly struct AngleKey : ITargetKey
|
|
{
|
|
private readonly Entity _self;
|
|
private readonly float2 _origin, _look;
|
|
|
|
public AngleKey(Entity self, float2 origin, float2 look)
|
|
{
|
|
_self = self;
|
|
_origin = origin;
|
|
_look = look;
|
|
}
|
|
|
|
public float Bound(in TargetRegion region)
|
|
{
|
|
var off = abs(atan2(_look.y, _look.x) - region.BearingCentre);
|
|
off = min(off, 2 * PI - off);
|
|
// Slack covers float rounding in the bearings; it only ever lowers the bound.
|
|
return max(0f, off - region.BearingHalfWidth - 1e-4f);
|
|
}
|
|
|
|
public bool Key(in TargetCandidate candidate, out float key)
|
|
{
|
|
var to = candidate.Position - _origin;
|
|
key = abs(atan2(_look.x * to.y - _look.y * to.x, dot(_look, to)));
|
|
return _self.Eligible(candidate);
|
|
}
|
|
|
|
public bool Precedes(in TargetCandidate a, in TargetCandidate b) => Order(a.Target, b.Target) < 0;
|
|
}
|
|
|
|
// Planar distance from the ship, nearest first, or farthest first when reversed. With a pivot only candidates
|
|
// strictly after it by (distance, TargetRef) are eligible, so cycling is a total, stable order.
|
|
private readonly struct DistanceKey : ITargetKey
|
|
{
|
|
private readonly Entity _self;
|
|
private readonly float2 _origin;
|
|
private readonly bool _reverse, _hasPivot;
|
|
private readonly float _pivotDistance;
|
|
private readonly TargetRef _pivot;
|
|
|
|
public DistanceKey(Entity self, float2 origin, bool reverse, bool hasPivot, float pivotDistance, TargetRef pivot)
|
|
{
|
|
_self = self;
|
|
_origin = origin;
|
|
_reverse = reverse;
|
|
_hasPivot = hasPivot;
|
|
_pivotDistance = pivotDistance;
|
|
_pivot = pivot;
|
|
}
|
|
|
|
public float Bound(in TargetRegion region)
|
|
{
|
|
if (_hasPivot && (_reverse ? region.Nearest > _pivotDistance : region.Farthest < _pivotDistance))
|
|
return float.PositiveInfinity;
|
|
return _reverse ? -region.Farthest : region.Nearest;
|
|
}
|
|
|
|
public bool Key(in TargetCandidate candidate, out float key)
|
|
{
|
|
var distance = length(candidate.Position - _origin);
|
|
key = _reverse ? -distance : distance;
|
|
if (!_self.Eligible(candidate)) return false;
|
|
if (!_hasPivot) return true;
|
|
var order = Order(candidate.Target, _pivot);
|
|
return _reverse
|
|
? distance < _pivotDistance || distance == _pivotDistance && order < 0
|
|
: distance > _pivotDistance || distance == _pivotDistance && order > 0;
|
|
}
|
|
|
|
public bool Precedes(in TargetCandidate a, in TargetCandidate b) =>
|
|
_reverse ? Order(a.Target, b.Target) > 0 : Order(a.Target, b.Target) < 0;
|
|
}
|
|
|
|
private readonly List<TargetCandidate> _targetCandidates = new List<TargetCandidate>();
|
|
private static readonly Comparison<ChunkId> ByIndexThenField = (a, b) =>
|
|
a.Index != b.Index ? a.Index.CompareTo(b.Index) : a.Field.GetHashCode().CompareTo(b.Field.GetHashCode());
|
|
|
|
// The planar distance per unit of visibility beyond which no target can pass ChunkVisible's threshold: an upper
|
|
// bound, never a decision. ChunkInfo is saturate(sum_i v s_i c_i(angle) / d / (n k)), linear in v and falling as
|
|
// 1/d, and a Bezier curve lies within the hull of its control points, so each sensor's s_i c_i is bounded by
|
|
// Sensor.PassiveGainBound. Zero with no active sensor (nothing is visible); infinity when the threshold is not
|
|
// positive. Slack covers float rounding and only ever widens it.
|
|
public float DetectionReachPerVisibility()
|
|
{
|
|
var gain = 0f;
|
|
var sensors = 0;
|
|
foreach (var sensor in ActiveSensors())
|
|
{
|
|
gain += sensor.PassiveGainBound();
|
|
sensors++;
|
|
}
|
|
if (sensors == 0) return 0f;
|
|
var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold;
|
|
if (!(threshold > 0f)) return float.PositiveInfinity;
|
|
return max(gain, 0f) / (sensors * ItemManager.GameplaySettings.TargetInfoDecay * threshold) * 1.0001f;
|
|
}
|
|
|
|
// Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables. The
|
|
// list is this entity's one buffer, refilled per call (Sense asks once per other ship per tick, allocating
|
|
// nothing), so callers use it before asking again.
|
|
private readonly List<Sensor> _activeSensors = new List<Sensor>();
|
|
private List<Sensor> ActiveSensors()
|
|
{
|
|
_activeSensors.Clear();
|
|
for (var i = 0; i < Equipment.Count; i++)
|
|
{
|
|
var item = Equipment[i];
|
|
if (!item.Active.Value) continue;
|
|
foreach (var behavior in item.Behaviors)
|
|
if (behavior is Sensor sensor) _activeSensors.Add(sensor);
|
|
}
|
|
foreach (var effect in _activeConsumables)
|
|
foreach (var behavior in effect.Behaviors)
|
|
if (behavior is Sensor sensor) _activeSensors.Add(sensor);
|
|
return _activeSensors;
|
|
}
|
|
|
|
// How much of an emission's reach this observer's instruments hear it at (ruling sensor-stat-set: listeners hear at
|
|
// a multiple of an emitter's own range): the largest of 1 (any active sensor) and each active listener's Multiple,
|
|
// 0 with neither. Catalog values come from AetherDb sensor-stat-set.
|
|
private float HearingMultiple()
|
|
{
|
|
var multiple = ActiveSensors().Count > 0 ? 1f : 0f;
|
|
for (var i = 0; i < Equipment.Count; i++)
|
|
{
|
|
var item = Equipment[i];
|
|
if (!item.Active.Value) continue;
|
|
foreach (var behavior in item.Behaviors)
|
|
if (behavior is Listener listener) multiple = max(multiple, listener.Multiple);
|
|
}
|
|
foreach (var effect in _activeConsumables)
|
|
foreach (var behavior in effect.Behaviors)
|
|
if (behavior is Listener listener) multiple = max(multiple, listener.Multiple);
|
|
return multiple;
|
|
}
|
|
|
|
// Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of
|
|
// what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so
|
|
// Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per
|
|
// cell -- always local to one lane, since armour never spans cells -- while deferring the item phase to
|
|
// ItemAbsorb's own pool for EVERY occupied cell, not only ones shared with another lane: a lane that turns
|
|
// out to be an item's only contributor still pools through a list of one (ItemAbsorb's own degenerate
|
|
// case), never a separate solo path. ArmorDamage fires only for incoming > 0.
|
|
public float ArmorAbsorb(int2 cell, float damage)
|
|
{
|
|
var d = damage;
|
|
|
|
if (d > 0f)
|
|
{
|
|
var prevArmor = Armor[cell.x, cell.y];
|
|
Armor[cell.x, cell.y] = max(prevArmor - d, 0);
|
|
ArmorDamage.OnNext((cell, d));
|
|
d = max(d - prevArmor, 0);
|
|
}
|
|
|
|
return d;
|
|
}
|
|
|
|
// Cut 12.3 fix batch (one apply path, operator ruling 2026-09-25 "do not expect items taking up multiple
|
|
// cells to be an exception, this should be one code path"): the ONE function that decides an item's own
|
|
// absorption, whatever the item's shape or however many lanes of one shot reach it. `incoming` holds every
|
|
// contributing lane's own post-armour remainder for this resolve, one lane's worth included -- a
|
|
// single-lane item is this rule's own degenerate case, not a separate function or a separate threshold.
|
|
// The existing .1f threshold is decided once, on the POOLED total, never per contribution (a pooled path
|
|
// that gated each contribution separately would let two shares under .1f each slip past an item that a
|
|
// single .08f solo hit would already have stopped at). Absorbed durability is split back across `incoming`
|
|
// in place, pro-rata to what each lane brought -- the divide below never sees total <= 0.1f, so it never
|
|
// sees zero (the guard above already returned). ItemDamage fires once per contributing lane, reporting its
|
|
// own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb already uses -- and only
|
|
// when that lane's own share is itself > 0, so a zero-deposit contributor (a lane whose armour ate its
|
|
// whole share before reaching this item) never fires a phantom event. `item` is never null here: both
|
|
// callers (`FireControl.ApplyPooled`'s `Resolve`, one lane's worth of contributions included, and
|
|
// `FireControl.Detonate`'s own per-entity item-pool resolution, one covered cell's worth included) already
|
|
// guarantee it -- a pool is only ever opened under a non-null `GearOccupancy` cell.
|
|
public void ItemAbsorb(EquippedItem item, Span<float> incoming)
|
|
{
|
|
var total = 0f;
|
|
for (var i = 0; i < incoming.Length; i++) total += incoming[i];
|
|
if (total <= 0.1f) return;
|
|
|
|
var before = item.EquippableItem.Durability;
|
|
var absorbed = min(total, before);
|
|
item.EquippableItem.Durability = max(before - absorbed, 0f);
|
|
var fraction = absorbed / total;
|
|
for (var i = 0; i < incoming.Length; i++)
|
|
{
|
|
if (incoming[i] > 0f) ItemDamage.OnNext((item, incoming[i]));
|
|
incoming[i] -= incoming[i] * fraction;
|
|
}
|
|
}
|
|
|
|
// Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one
|
|
// HullDamage event are unchanged. FireControl.Apply calls this once per resolved hit, with the summed
|
|
// remainder every lane's own walk left over (Q12-3 = A: a lane's remainder goes to the hull wherever the
|
|
// lane ends -- penetration exhausted, a gap, or the far side).
|
|
public void DamageHull(float damage)
|
|
{
|
|
if (damage > .1f)
|
|
{
|
|
Hull.Durability -= damage;
|
|
HullDamage.OnNext(damage);
|
|
}
|
|
}
|
|
|
|
// Cut 12.1 (docs/fire-control-cut.md): the one schematic-frame owner. Maps a world-planar vector into this
|
|
// entity's own schematic frame -- x = starboard, y = bow -- so FireControl.Apply's lane walk and
|
|
// FireControl.Detonate's per-entity point conversion both read the same transform instead of each carrying
|
|
// their own copy of forward/right.
|
|
public float2 ToSchematic(float2 worldPlanar)
|
|
{
|
|
var forward = normalize(Direction);
|
|
var right = float2(forward.y, -forward.x);
|
|
return float2(dot(worldPlanar, right), dot(worldPlanar, forward));
|
|
}
|
|
|
|
// Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the world<->schematic POINT owners, built on
|
|
// ToSchematic's frame -- the entity's own position is the schematic's centre of mass. FireControl.Detonate
|
|
// uses ToSchematicPoint to find where a blast sits in each candidate's own schematic (the host of a contact
|
|
// or delayed blast included: it is treated like any other entity in the radius, its cells found again
|
|
// here, not carried over from the commit). ToWorldPoint is the inverse, used once, to convert a contact or
|
|
// delayed fuse point (found on the committed lane, in the host's schematic frame at commit) into the one
|
|
// world point Detonate's single input shape takes -- the round trip through the host's own pose at arrival
|
|
// is what fixes the host's damage to the commit (R4) while every bystander is judged live.
|
|
public float2 ToSchematicPoint(float2 worldPlanar)
|
|
{
|
|
var hullData = ItemManager.GetData(Hull) as HullData;
|
|
var cellSize = ItemManager.GameplaySettings.SchematicCellSize;
|
|
return ToSchematic(worldPlanar - Position.xz) / cellSize + hullData.Shape.CenterOfMass;
|
|
}
|
|
|
|
public float2 ToWorldPoint(float2 schematicPoint)
|
|
{
|
|
var hullData = ItemManager.GetData(Hull) as HullData;
|
|
var cellSize = ItemManager.GameplaySettings.SchematicCellSize;
|
|
var local = (schematicPoint - hullData.Shape.CenterOfMass) * cellSize;
|
|
var forward = normalize(Direction);
|
|
var right = float2(forward.y, -forward.x);
|
|
return Position.xz + local.x * right + local.y * forward;
|
|
}
|
|
|
|
// The act owner of a grudge (ruling ew-hostility-from-belief-ruled: a contact becomes hostile when it fires at or locks the
|
|
// observer's side): this ship and, given a faction, every entity in the zone of it hold a hostile override on `by`. A player's
|
|
// stance is always the operator's call, and an override already held stands. It never forgives on its own.
|
|
internal void Provoke(Entity by)
|
|
{
|
|
foreach (var entity in Zone.Entities.Where(e => e == this || (Faction != null && e.Faction == Faction)).ToList())
|
|
if (!entity.IsPlayerControlled && !entity._iffOverrides.ContainsKey(by)) entity.SetIff(by, true);
|
|
}
|
|
|
|
public virtual void Deactivate()
|
|
{
|
|
//ItemManager.Log($"Entity {Name} is deactivating!");
|
|
foreach(var s in _subscriptions) s.Dispose();
|
|
_subscriptions.Clear();
|
|
foreach(var ss in _watchedEntitySubscriptions.Values) foreach(var s in ss) s.Dispose();
|
|
_watchedEntitySubscriptions.Clear();
|
|
_active = false;
|
|
EntityInfoGathered.Clear();
|
|
VisibleEntities.Clear();
|
|
VisibleEnemies.Clear();
|
|
VisibleFriendlies.Clear();
|
|
_paintInbox.Clear();
|
|
_incomingPaints.Clear();
|
|
_returnInbox.Clear();
|
|
_pingReturns.Clear();
|
|
_emissions.Clear();
|
|
_reveals.Clear();
|
|
_seenDestroyed.Clear();
|
|
_itemClaims.Clear();
|
|
_telemetry.Clear();
|
|
}
|
|
|
|
// Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published
|
|
// at the start of tick N+1 whatever order the emitter and this ship update in; a paint that stops is gone
|
|
// one tick later for every ordering.
|
|
internal void PublishInbox()
|
|
{
|
|
_emissions.Clear(); // written in Update, read by Sense in the same tick
|
|
_incomingPaints.Clear();
|
|
foreach (var paint in _paintInbox)
|
|
{
|
|
_incomingPaints.Add(paint.Warning);
|
|
RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission, paint.Warning.EmitterClass);
|
|
if (paint.Warning.Mode == PaintMode.Track) Provoke(paint.Emitter);
|
|
}
|
|
_paintInbox.Clear();
|
|
_pingReturns.Clear();
|
|
_pingReturns.AddRange(_returnInbox);
|
|
_returnInbox.Clear();
|
|
}
|
|
|
|
internal void ReceiveReturn(PingReturn pingReturn)
|
|
{
|
|
if (!_active) return;
|
|
_returnInbox.Add(pingReturn);
|
|
}
|
|
|
|
// The callers are Sensor.Execute (a sweeping ping) and LockWeapon.Execute (a lock paint): this ship's `item` emits this
|
|
// tick and is heard by another ship's Sense within `hearRange` (the emitter's range over its intercept factor,
|
|
// fixed here), bearing only.
|
|
internal void Emit(EquippedItem item, float hearRange)
|
|
{
|
|
if (!_active) return;
|
|
_emissions.Add(new ActiveEmission(item, hearRange, ClaimOf(item)));
|
|
}
|
|
|
|
// The callers are Sensor.Execute (a Search paint, Strength 0) and LockWeapon.Execute (a Track paint, Strength
|
|
// the emitter's own Lock at that paint). The emitter's class is the class its ship claims for the item (ClaimOf: the
|
|
// item's data unless claimed otherwise; null with no item: a consumable's effect, which has no item and so is never
|
|
// revealed).
|
|
internal void ReceivePaint(Entity emitter, EquippedItem item, PaintMode mode, float strength)
|
|
{
|
|
if (!_active) return;
|
|
_paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), emitter.ClaimOf(item), mode, strength), emitter, item));
|
|
}
|
|
|
|
// The reveal book's one way in and its one membership gate (callers: Sense, PublishInbox and RecordInspection, all in
|
|
// this file): this observer saw `item` of `subject` act on `channel`, now, from this bearing. An item-less act (hull
|
|
// radiation, a consumable) reveals nothing, and neither does a subject that is not in the zone: a departed
|
|
// subject's entries are never created or refreshed, from any caller, and neither does an item this observer
|
|
// perceives destroyed (PerceivesDestroyed). A later act of the same item replaces the
|
|
// earlier one. `claimed` is the class the carrier (signature, emission or paint) stamped: what this observer's
|
|
// classifying readers will believe of the act; `item` stays the aim and book key.
|
|
internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel? channel, EquippableItemData claimed)
|
|
{
|
|
if (!_active || item == null || !Zone.Entities.Contains(subject) || PerceivesDestroyed(subject, item)) return;
|
|
if (!_reveals.TryGetValue(subject, out var book))
|
|
_reveals[subject] = book = new Dictionary<EquippedItem, Reveal>();
|
|
book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time, claimed);
|
|
}
|
|
|
|
// A finished scan (Scanner.Execute; rulings scan-is-an-act, quiet-gear-identifiable-hull-layout-known: quiet gear
|
|
// that never acts stays identifiable through special gear): this observer learns every fitted item of `subject`,
|
|
// channel null. It records what the item claims, not what it is (ClaimOf, as every act's carrier stamps it: the
|
|
// aetheria-ew one judge, classify-from-belief), skips the hull (its class is the track's to give) and a destroyed
|
|
// item, and goes through RecordReveal's gate like any act. It calls no EndReveals: a scan ends nothing
|
|
// (reveal-ends-on-perception).
|
|
internal void RecordInspection(Entity subject)
|
|
{
|
|
foreach (var item in subject.Equipment)
|
|
{
|
|
if (item == subject.EquippedHull || item.EquippableItem.IsDestroyed) continue;
|
|
RecordReveal(subject, item, null, subject.ClaimOf(item));
|
|
}
|
|
}
|
|
|
|
// This observer resolves `subject`'s gear (ContactOf's Identified gate) and `item` of it is destroyed: the one rule
|
|
// for a destroyed item an observer perceives, read by the book's entry (RecordReveal), its end (Sense) and the
|
|
// seen-destroyed record (RecordSeenDestroyed).
|
|
private bool PerceivesDestroyed(Entity subject, EquippedItem item) =>
|
|
item.EquippableItem.IsDestroyed
|
|
&& EntityInfoGathered.TryGetValue(subject, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold;
|
|
|
|
// A stance lasts the sitting: it goes with a subject gone for good (destroyed, warped away, unloaded) once the book no
|
|
// longer remembers it, not with a ship docking, which leaves the zone parented to its dock (TryDock) and rejoins it on
|
|
// undock. Called when the subject leaves the zone and when the book drops it, so the override and the book end together.
|
|
private void ReleaseStance(Entity subject)
|
|
{
|
|
if (subject.Parent == null && !Zone.Entities.Contains(subject) && !_reveals.ContainsKey(subject)) _iffOverrides.Remove(subject);
|
|
}
|
|
|
|
// This observer perceives `subject`'s gear (ContactOf's Identified gate): each of its destroyed items leaves its mount
|
|
// in the seen-destroyed record. The same PerceivesDestroyed Sense ends reveals by, applied to mounts. The record is read
|
|
// only for mounts of LayoutOf (MountAimable, AimCycle), so a true mount outside the believed layout is never asked for.
|
|
private void RecordSeenDestroyed(Entity subject)
|
|
{
|
|
// Indexed: Sense runs for every pair of ships every tick.
|
|
for (var i = 0; i < subject.Equipment.Count; i++)
|
|
{
|
|
var item = subject.Equipment[i];
|
|
if (item.Hardpoint == null || !PerceivesDestroyed(subject, item)) continue;
|
|
if (!_seenDestroyed.TryGetValue(subject, out var seen)) _seenDestroyed[subject] = seen = new HashSet<HardpointData>();
|
|
seen.Add(item.Hardpoint);
|
|
}
|
|
}
|
|
|
|
private bool SeenDestroyed(Entity subject, HardpointData mount) =>
|
|
_seenDestroyed.TryGetValue(subject, out var seen) && seen.Contains(mount);
|
|
|
|
// The book's one way out (caller: Sense): `item` null ends every reveal of `subject`, otherwise that item's.
|
|
// The subject's key goes with its last entry; ending all of them also drops the subject's seen-destroyed record.
|
|
private void EndReveals(Entity subject, EquippedItem item)
|
|
{
|
|
if (item == null) _seenDestroyed.Remove(subject);
|
|
if (!_reveals.TryGetValue(subject, out var book)) return;
|
|
if (item != null) book.Remove(item);
|
|
else book.Clear();
|
|
if (book.Count != 0) return;
|
|
_reveals.Remove(subject);
|
|
ReleaseStance(subject);
|
|
if (!VisibleEntities.Contains(subject)) _telemetry.Remove(subject);
|
|
}
|
|
|
|
// The subjects this observer holds any reveal of, departed ones included until the prune: with VisibleEntities, every
|
|
// contact it has (ContactOf is Detected for a tracked subject or one with a young reveal).
|
|
public IReadOnlyCollection<Entity> RevealedSubjects => _reveals.Keys;
|
|
|
|
// The hostile subjects this observer holds only by a bearing: revealed, Detected, not Tracked (what bearing markers and
|
|
// the music show). Hostile is the observer's own live stance (IsHostileTo): the observer always knows its own decision,
|
|
// and an override outlives a departed subject exactly as long as the book remembers it (ReleaseStance). Reads the book
|
|
// and ContactOf, never the subject's Position or gear.
|
|
public IEnumerable<Entity> RememberedHostiles => RevealedSubjects.Where(s => IsHostileTo(s) && ContactOf(s) is { Detected: true, Tracked: false });
|
|
|
|
// What this observer has seen `subject`'s items do: each item's latest act, and nothing the subject's present
|
|
// gear or Durability says (ruling contact-reads-book-aim-reads-live). A reveal ends only on what this observer
|
|
// perceives (ruling reveal-ends-on-perception): the destruction of an item of a ship it identifies, the death,
|
|
// dock or warp of a subject its instruments could see leave, or, for a subject that has left the zone, the
|
|
// freshest entry aging past BearingMemory; Deactivate clears it all. Whether an item can be aimed at now is
|
|
// FireControl.IsRevealed's question, not this one.
|
|
public IEnumerable<Reveal> RevealsOf(Entity subject) =>
|
|
_reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty<Reveal>();
|
|
|
|
// The weapons this observer believes `subject` has: each reveal whose claimed class carries a weapon behaviour, in the
|
|
// subject's equipment order (never the book's slot order, which follows when reveals were made and ended). The one
|
|
// classifying read of the book for weapons, shared by the enemy schematic and Combat (aetheria-ew, classify-from-belief);
|
|
// a gun claimed as a non-weapon is not listed, though RevealsOf holds it for aim.
|
|
public IEnumerable<Reveal> RevealedWeapons(Entity subject) =>
|
|
InFittedOrder(subject, RevealsOf(subject).Where(r => r.Claimed != null && r.Claimed.Behaviors.Any(b => b is WeaponData)));
|
|
|
|
// The gear this observer believes `subject` has: each reveal whose claimed class carries no weapon behaviour and is not a
|
|
// hull, in the order of RevealedWeapons. A hull's layout is known, not revealed, so it is never a gear row.
|
|
public IEnumerable<Reveal> RevealedGear(Entity subject) =>
|
|
InFittedOrder(subject, RevealsOf(subject).Where(r => r.Claimed != null && !(r.Claimed is HullData) && !r.Claimed.Behaviors.Any(b => b is WeaponData)));
|
|
|
|
private static IEnumerable<Reveal> InFittedOrder(Entity subject, IEnumerable<Reveal> reveals) =>
|
|
reveals.OrderBy(r => FittedIndex(subject, r.Item)).ThenBy(r => r.Item.EquippableItem.Lot);
|
|
|
|
// The classes `subject` has claimed to this observer, distinct and by name: what a bearing marker is labelled with. Reads
|
|
// each reveal's Claimed only, never the item's true data (aetheria-ew, classify-from-belief).
|
|
public IEnumerable<EquippableItemData> ClaimedClasses(Entity subject) =>
|
|
RevealsOf(subject).Select(r => r.Claimed).Where(c => c != null).Distinct().OrderBy(c => c.Name, StringComparer.Ordinal);
|
|
|
|
// A revealed item's place in the subject's equipment; gear the subject no longer carries follows all fitted gear
|
|
// (ties among it fall to the lot, a stable id), never ahead of it.
|
|
private static int FittedIndex(Entity subject, EquippedItem item)
|
|
{
|
|
var index = subject.Equipment.IndexOf(item);
|
|
return index < 0 ? int.MaxValue : index;
|
|
}
|
|
|
|
// The rows of `subject`'s schematic as this ship reads it: its own weapons when `subject` is this ship (each row its
|
|
// own data and live range, nothing to doubt about your own ship), otherwise the revealed weapons (each row the claimed
|
|
// class and that design's rated range) then the revealed gear (each row the claimed class, no range), then one mount
|
|
// row for every mount of the hull this ship believes `subject` to be (LayoutOf), empty or not and whether or not a
|
|
// revealed item has a row of its own (ruling quiet-gear-identifiable-hull-layout-known): the rows never compare the
|
|
// subject's items to the believed layout, so a liar and an honest ship of the claimed hull show the same count. The
|
|
// one owner of which rows, which class and which range an enemy schematic shows; the Unity SchematicDisplay renders it.
|
|
public IEnumerable<SchematicRow> SchematicRows(Entity subject)
|
|
{
|
|
if (subject == this)
|
|
return subject.Equipment.Where(i => i.Behaviors.Any(b => b.Data is WeaponData)).Select(i => new SchematicRow(i, i.Data, true));
|
|
var claimed = RevealedWeapons(subject).Concat(RevealedGear(subject)).Select(r => new SchematicRow(r.Item, r.Claimed, false)).ToList();
|
|
return claimed.Concat(LayoutOf(subject).Select(h => new SchematicRow(h)));
|
|
}
|
|
|
|
// Where `subject`'s mounts are as this ship believes them: the hull it is identified as (Contact.Profile, the claimed
|
|
// hull), never the subject's own HullData; empty below identification. Derived on every read, nothing stored.
|
|
public IReadOnlyList<HardpointData> LayoutOf(Entity subject) => ContactOf(subject).Profile?.Hardpoints ?? (IReadOnlyList<HardpointData>) Array.Empty<HardpointData>();
|
|
|
|
// Whether this observer's book for `subject` holds `item`: the book's membership, no live gear read.
|
|
internal bool HasReveal(Entity subject, EquippedItem item) =>
|
|
_reveals.TryGetValue(subject, out var book) && book.ContainsKey(item);
|
|
|
|
// The one derivation of how much this observer holds of `subject`: see Contact.
|
|
public Contact ContactOf(Entity subject)
|
|
{
|
|
var settings = ItemManager.GameplaySettings;
|
|
var info = EntityInfoGathered.TryGetValue(subject, out var gathered) ? gathered : 0f;
|
|
var tracked = VisibleEntities.Contains(subject);
|
|
var any = false;
|
|
var freshest = default(Reveal);
|
|
foreach (var reveal in RevealsOf(subject))
|
|
if (!any || reveal.Time > freshest.Time)
|
|
{
|
|
any = true;
|
|
freshest = reveal;
|
|
}
|
|
var remembered = any && Zone.Time - freshest.Time < settings.BearingMemory;
|
|
var detected = tracked || remembered;
|
|
var bearing = tracked ? normalize((subject.Position - Position).xz) : remembered ? freshest.Bearing : default;
|
|
var identified = info >= settings.TargetGearInfoThreshold;
|
|
// Profile is the one derivation of the hull this observer believes (no stored belief until the judge, ew-masquerade).
|
|
return new Contact(detected, tracked, detected && any, identified, bearing, info, identified ? subject.ClaimedHull : null);
|
|
}
|
|
|
|
// What this observer's instruments receive of other ships' acts this tick (Zone.Update calls it once, after every
|
|
// entity has updated): every light signature of an item that would alone settle this observer's passive info above
|
|
// TargetDetectionInfoThreshold is revealed (ruling visibility-per-capability-reveal). Hull radiation names no
|
|
// item and only feeds the track. One PassiveInfo per ship. Zone.Update calls it
|
|
// for every entity after all of them have updated, so what a ship shows is what every observer receives on that
|
|
// tick whatever order the zone lists them in.
|
|
internal void Sense()
|
|
{
|
|
if (!_active) return;
|
|
var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold;
|
|
var hearing = HearingMultiple();
|
|
var entities = Zone.Entities;
|
|
for (var i = 0; i < entities.Count; i++)
|
|
{
|
|
var other = entities[i];
|
|
if (other == this) continue;
|
|
var perSignature = -1f;
|
|
foreach (var signature in other._visibilitySources.Values)
|
|
{
|
|
if (signature.Item == null) continue;
|
|
if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f);
|
|
if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel, signature.Claimed);
|
|
}
|
|
// Every emission this ship made this tick that reaches this observer is revealed, bearing only (ruling
|
|
// sensor-stat-set), through RecordReveal's one gate. It feeds no track.
|
|
if (hearing > 0 && other._emissions.Count > 0)
|
|
{
|
|
var planar = length(other.Position.xz - Position.xz);
|
|
var obscuration = Zone.Obscuration(Position.xz, other.Position.xz);
|
|
for (var j = 0; j < other._emissions.Count; j++)
|
|
{
|
|
var emission = other._emissions[j];
|
|
if (planar <= emission.HearRange * hearing * obscuration) RecordReveal(other, emission.Item, SignatureChannel.Emission, emission.Claimed);
|
|
}
|
|
}
|
|
// The observer resolves this ship's gear (PerceivesDestroyed), so it perceives an item of it destroyed: the
|
|
// item's mount is recorded and its reveal ends here; RecordReveal refuses the same item's lingering flash.
|
|
RecordSeenDestroyed(other);
|
|
if (_reveals.TryGetValue(other, out var book))
|
|
{
|
|
foreach (var item in book.Keys)
|
|
if (PerceivesDestroyed(other, item)) _endedItems.Add(item);
|
|
for (var j = 0; j < _endedItems.Count; j++) EndReveals(other, _endedItems[j]);
|
|
_endedItems.Clear();
|
|
}
|
|
}
|
|
// A subject that left the zone (death, dock, warp, any removal) is perceived gone under the light-act gate: as
|
|
// bright as its whole Visibility was at removal, from where it was. A re-admitted subject did not leave.
|
|
var departures = Zone.Departures;
|
|
for (var i = 0; i < departures.Count; i++)
|
|
{
|
|
var departure = departures[i];
|
|
if (!_reveals.ContainsKey(departure.Subject) || entities.Contains(departure.Subject)) continue;
|
|
if (departure.Signature * PassiveInfo(departure.Position, 1f) > threshold) EndReveals(departure.Subject, null);
|
|
}
|
|
// What stays of a departed subject is memory: pruned once its freshest entry is as old as ContactOf stops
|
|
// remembering it.
|
|
var memory = ItemManager.GameplaySettings.BearingMemory;
|
|
foreach (var pair in _reveals)
|
|
{
|
|
if (entities.Contains(pair.Key)) continue;
|
|
var freshest = float.MinValue;
|
|
foreach (var reveal in pair.Value.Values) freshest = Math.Max(freshest, reveal.Time);
|
|
if (Zone.Time - freshest >= memory) _agedSubjects.Add(pair.Key);
|
|
}
|
|
// A record of a subject that left and holds no book (a quiet ship, never seen to act) has no entry to age: it goes now.
|
|
foreach (var subject in _seenDestroyed.Keys)
|
|
if (!entities.Contains(subject) && !_reveals.ContainsKey(subject)) _agedSubjects.Add(subject);
|
|
for (var i = 0; i < _agedSubjects.Count; i++) EndReveals(_agedSubjects[i], null);
|
|
_agedSubjects.Clear();
|
|
}
|
|
|
|
public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings)
|
|
{
|
|
Settings = MessagePackSerializer.Deserialize<EntitySettings>(MessagePackSerializer.Serialize(settings));
|
|
ItemManager = itemManager;
|
|
Zone = zone;
|
|
Hull = hull;
|
|
HullData = itemManager.GetData(hull) as HullData;
|
|
Name = HullData.Name;
|
|
PowerBus = new PowerBus(this);
|
|
MapEntity();
|
|
WeaponGroups = new (List<Weapon> weapons, List<EquippedItem> items)[itemManager.GameplaySettings.WeaponGroupCount];
|
|
for(int i=0; i<itemManager.GameplaySettings.WeaponGroupCount; i++)
|
|
WeaponGroups[i] = (new List<Weapon>(), new List<EquippedItem>());
|
|
|
|
ItemDestroyed = ItemDamage.Where(x => x.item.EquippableItem.IsDestroyed).Select(x=>x.item);
|
|
WeaponDestroyed = ItemDestroyed.Select(x => x.Behaviors.FirstOrDefault(b => b is Weapon) as Weapon).Where(x => x != null);
|
|
HullArmorDepleted = ArmorDamage.Where(x => Armor[x.pos.x, x.pos.y] < .01f).Select(x => x.pos);
|
|
Death = HullDamage.Where(_ => Hull.IsDestroyed).Select(_ => CauseOfDeath.HullDestroyed)
|
|
.Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke))
|
|
.Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia))
|
|
.Merge(ItemDestroyed.Where(i=>i.GetBehavior<Cockpit>()!=null).Select(_ => CauseOfDeath.CockpitDestroyed))
|
|
// Death is terminal: the first cause ends the stream, so no damage path (a blast whose cockpit kill is
|
|
// followed by its own hull damage, a second item, a heat tick) can raise it twice to any subscriber.
|
|
.Take(1);
|
|
|
|
//CurrentSecurityLevel.Value = SecurityLevel.Open;
|
|
}
|
|
|
|
// Sets or clears a runtime-only IFF override deciding this entity's hostility toward another.
|
|
// Pass null to clear the override and restore the derived (faction-based) rule.
|
|
internal void SetIff(Entity other, bool? hostile)
|
|
{
|
|
if (hostile.HasValue) _iffOverrides[other] = hostile.Value;
|
|
else _iffOverrides.Remove(other);
|
|
}
|
|
|
|
// The override this entity holds on another, or null when it holds none (the derived rule decides).
|
|
public bool? IffOverride(Entity other) => _iffOverrides.TryGetValue(other, out var hostile) ? hostile : (bool?) null;
|
|
|
|
// This observer's stance toward `other`: its own override, else its belief. Allies (the observer's own non-null faction) are
|
|
// known by datalink; any other contact is believed by the telemetry received with its track, and unknown (never hostile)
|
|
// without it. In a zone owned by a faction a contact is hostile when its claimed faction is the owner's and the owner does
|
|
// not permit its presence, or when this is the owner's ship and believes the contact trespasses.
|
|
public bool IsHostileTo(Entity other)
|
|
{
|
|
if (_iffOverrides.TryGetValue(other, out var overrideHostile)) return overrideHostile;
|
|
if (Faction != null && other.Faction == Faction) return false;
|
|
if (!_telemetry.TryGetValue(other, out var claimed)) return false;
|
|
var owner = Zone.GalaxyZone.Owner;
|
|
if (owner == null) return false;
|
|
return (claimed == owner && !(PresencePermitted?.Value ?? true)) || (Faction == owner && BelievesTrespassing(other));
|
|
}
|
|
|
|
// The faction `subject`'s telemetry claimed when this observer first tracked it; false while the contact is unknown.
|
|
public bool TryGetTelemetry(Entity subject, out Faction faction) => _telemetry.TryGetValue(subject, out faction);
|
|
|
|
// Honest telemetry carries the subject's registry, so its standing with the zone's owner is the subject's own
|
|
// (until cut ew-masquerade).
|
|
public bool BelievesTrespassing(Entity subject) => _telemetry.ContainsKey(subject) && !(subject.PresencePermitted?.Value ?? true);
|
|
|
|
// Honest until cut ew-masquerade gives the subject a ClaimedFaction to read here.
|
|
private void ReceiveTelemetry(Entity subject) => _telemetry[subject] = subject.Faction;
|
|
|
|
// The one writer of EntityHostility: only on change, since the filing subscription adds on every Replace.
|
|
private void RefreshStance(Entity other)
|
|
{
|
|
if (EntityHostility.TryGetValue(other, out var current) && current != IsHostileTo(other))
|
|
EntityHostility[other] = !current;
|
|
}
|
|
|
|
// A contact ends when it is neither tracked nor remembered by a reveal younger than BearingMemory; its telemetry goes with it.
|
|
private bool HoldsContact(Entity subject)
|
|
{
|
|
if (VisibleEntities.Contains(subject)) return true;
|
|
if (!_reveals.TryGetValue(subject, out var book)) return false;
|
|
foreach (var reveal in book.Values)
|
|
if (Zone.Time - reveal.Time < ItemManager.GameplaySettings.BearingMemory) return true;
|
|
return false;
|
|
}
|
|
|
|
// TODO: Inter-faction relationships
|
|
public FactionRelationship GetFactionRelationship(Faction faction)
|
|
{
|
|
if (faction == null)
|
|
return FactionRelationship.Neutral;
|
|
if (this is Ship {IsPlayerShip: true})
|
|
return Zone.Galaxy.FactionRelationships[faction];
|
|
return faction == Faction ? FactionRelationship.Beloved : FactionRelationship.Neutral;
|
|
}
|
|
|
|
public static bool IsPresencePermitted(FactionRelationship relationship, SecurityLevel securityLevel) =>
|
|
(int) relationship - (int) securityLevel > 0;
|
|
public static bool IsDockingPermitted(FactionRelationship relationship, SecurityLevel securityLevel) =>
|
|
(int) relationship - (int) securityLevel > 1;
|
|
|
|
internal void ActivateConsumable(ConsumableItem item)
|
|
{
|
|
var effect = new ConsumableItemEffect(item, this);
|
|
// The same hook Activate runs over Equipment: a behaviour that computes its targets there (StatModifier)
|
|
// would otherwise reach its second tick uninitialised.
|
|
InitializeBehaviors(effect.Behaviors);
|
|
_activeConsumables.Add(effect);
|
|
}
|
|
|
|
private static void InitializeBehaviors(IEnumerable<Behavior> behaviors)
|
|
{
|
|
foreach (var behavior in behaviors.ToList())
|
|
if (behavior is IInitializableBehavior initializable)
|
|
initializable.Initialize();
|
|
}
|
|
|
|
// Derived, never written: true iff an active consumable effect carries a ThrottleLock.
|
|
public bool ThrottleLocked => _activeConsumables.Any(e => e.Behaviors.OfType<ThrottleLock>().Any());
|
|
|
|
public ConsumableItemEffect FindActiveConsumable(ConsumableItemData data)
|
|
{
|
|
return _activeConsumables.FirstOrDefault(ac => ac.Data == data);
|
|
}
|
|
|
|
public bool CanActivateConsumable(ConsumableItemData data)
|
|
{
|
|
return data.Stackable || FindActiveConsumable(data) == null;
|
|
}
|
|
|
|
// The first consumable design in cargo with a behaviour of the asked kind, or null. Callers ask by kind, never
|
|
// by name: a design's name is a label and its behaviours are what it does.
|
|
public ConsumableItemData FirstCarriedConsumable(Predicate<BehaviorData> kind) =>
|
|
CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType<ConsumableItem>()
|
|
.Select(item => ItemManager.GetData(item) as ConsumableItemData)
|
|
.FirstOrDefault(data => data != null && data.Behaviors.Any(b => kind(b)));
|
|
|
|
public bool HasActiveConsumable(Predicate<BehaviorData> kind) =>
|
|
_activeConsumables.Any(e => e.Data.Behaviors.Any(b => kind(b)));
|
|
|
|
internal bool TryActivateConsumable(ConsumableItemData data)
|
|
{
|
|
// Death and docking Remove and Deactivate the ship while its agent keeps ticking.
|
|
if (!_active) return false;
|
|
if (!CanActivateConsumable(data)) return false;
|
|
|
|
var key = ItemManager.ItemData.RefOf<ItemData>(data).Key;
|
|
var bay = FindItemInCargo(key);
|
|
if (bay == null) return false;
|
|
|
|
var item = (ConsumableItem) bay.ItemsOfType[key].First();
|
|
ActivateConsumable(item);
|
|
bay.Remove(item);
|
|
return true;
|
|
}
|
|
|
|
private void MapEntity()
|
|
{
|
|
var hullData = ItemManager.GetData(Hull) as HullData;
|
|
EquippedHull = new EquippedItem(ItemManager, Hull, int2.zero, this, null);
|
|
Equipment.Add(EquippedHull);
|
|
// A bare hull, one TryEquip never ran on, still updates: its ordered equipment is the hull alone.
|
|
_orderedEquipment = Equipment.ToArray();
|
|
Mass = hullData.Mass;
|
|
Temperature = new float[hullData.Shape.Width, hullData.Shape.Height];
|
|
NewTemperature = new float[hullData.Shape.Width, hullData.Shape.Height];
|
|
HullConductivity = new bool2[hullData.Shape.Width,hullData.Shape.Height];
|
|
ThermalMass = new float[hullData.Shape.Width, hullData.Shape.Height];
|
|
Armor = new float[hullData.Shape.Width, hullData.Shape.Height];
|
|
MaxArmor = new float[hullData.Shape.Width, hullData.Shape.Height];
|
|
Hardpoints = new HardpointData[hullData.Shape.Width, hullData.Shape.Height];
|
|
foreach (var hardpoint in hullData.Hardpoints)
|
|
{
|
|
foreach (var hardpointCoord in hardpoint.Shape.Coordinates)
|
|
{
|
|
var hullCoord = hardpoint.Position + hardpointCoord;
|
|
Hardpoints[hullCoord.x, hullCoord.y] = hardpoint;
|
|
}
|
|
}
|
|
var cellCount = hullData.Shape.Coordinates.Length;
|
|
foreach (var v in hullData.Shape.Coordinates)
|
|
{
|
|
Armor[v.x, v.y] = hullData.Armor;
|
|
MaxArmor[v.x, v.y] = hullData.Armor;
|
|
if (Hardpoints[v.x, v.y] != null)
|
|
{
|
|
Armor[v.x, v.y] += Hardpoints[v.x, v.y].Armor;
|
|
MaxArmor[v.x, v.y] += Hardpoints[v.x, v.y].Armor;
|
|
}
|
|
Temperature[v.x, v.y] = StartingCellTemperature;
|
|
ThermalMass[v.x, v.y] = hullData.Mass * hullData.SpecificHeat / cellCount;
|
|
}
|
|
GearOccupancy = new EquippedItem[hullData.Shape.Width, hullData.Shape.Height];
|
|
}
|
|
|
|
public void GenerateWeaponGroups()
|
|
{
|
|
foreach (var group in Weapons
|
|
.GroupBy(w => w.Item.EquippableItem.Data.Key)
|
|
.OrderBy(wg=>wg.Average(w=>w.Range))
|
|
.Select((weapons, index) => (weapons, index)))
|
|
{
|
|
WeaponGroups[group.index].weapons = group.weapons.ToList();
|
|
WeaponGroups[group.index].items = group.weapons.Select(w=>w.Item).ToList();
|
|
}
|
|
}
|
|
|
|
public void AddHeat(int2 position, float heat, bool ignoreThermalMass = false)
|
|
{
|
|
if (ignoreThermalMass)
|
|
Temperature[position.x, position.y] += heat;
|
|
else
|
|
Temperature[position.x, position.y] += heat / ThermalMass[position.x, position.y];
|
|
}
|
|
|
|
public int CountItemsInCargo(CultRecordKey itemDataID)
|
|
{
|
|
int sum = 0;
|
|
foreach (var x in CargoBays)
|
|
{
|
|
if (x.ItemsOfType.ContainsKey(itemDataID))
|
|
{
|
|
foreach (var i in x.ItemsOfType[itemDataID]) sum += i is SimpleCommodity simpleCommodity ? simpleCommodity.Quantity : 1;
|
|
}
|
|
}
|
|
|
|
return sum;
|
|
}
|
|
|
|
public EquippedCargoBay FindItemInCargo(CultRecordKey itemDataID)
|
|
{
|
|
return CargoBays.FirstOrDefault(c => c.ItemsOfType.ContainsKey(itemDataID));
|
|
}
|
|
|
|
public Shape UnoccupiedSpace
|
|
{
|
|
get
|
|
{
|
|
var emptyShape = new Shape(HullData.Shape.Width, HullData.Shape.Height);
|
|
foreach (var v in HullData.Shape.Coordinates)
|
|
{
|
|
// Empty hardpoint cells count as free: general gear may use them until hardpoint gear claims them
|
|
if (HullData.InteriorCells[v] && GearOccupancy[v.x, v.y] == null)
|
|
emptyShape[v] = true;
|
|
}
|
|
|
|
return emptyShape;
|
|
}
|
|
}
|
|
|
|
public EquippableItem TryUnequip(EquippedItem item)
|
|
{
|
|
// Don't allow unequipping when the entity is active
|
|
if (_active) return null;
|
|
|
|
if (item.EquippableItem == null)
|
|
{
|
|
ItemManager.Log("Attempted to remove equipped item with no equippable item on it! This should be impossible!");
|
|
return null;
|
|
}
|
|
|
|
if (item is EquippedCargoBay cargoBay)
|
|
{
|
|
if(cargoBay.Cargo.Count > 0)
|
|
{
|
|
ItemManager.Log("Attempted to remove cargo bay that is not empty! Please check first before doing this!");
|
|
return null;
|
|
}
|
|
|
|
CargoBays.Remove(cargoBay);
|
|
}
|
|
|
|
Equipment.Remove(item);
|
|
_orderedEquipment = Equipment.ToArray();
|
|
|
|
var hullData = ItemManager.GetData(Hull) as HullData;
|
|
var itemData = ItemManager.GetData(item.EquippableItem);
|
|
foreach (var i in hullData.Shape.Coordinates)
|
|
if (GearOccupancy[i.x, i.y] == item)
|
|
{
|
|
ThermalMass[i.x, i.y] -= ItemManager.GetThermalMass(item.EquippableItem) / itemData.Shape.Coordinates.Length;
|
|
GearOccupancy[i.x, i.y] = null;
|
|
}
|
|
Mass -= itemData.Mass;
|
|
foreach (var b in item.Behaviors)
|
|
{
|
|
if (b is Weapon weapon)
|
|
{
|
|
_weapons.Remove(weapon);
|
|
foreach (var group in WeaponGroups) { group.items.Remove(item); group.weapons.Remove(weapon); }
|
|
}
|
|
if (b is Radiator heatsink)
|
|
_heatsinks.Remove(heatsink);
|
|
if (b is Shield)
|
|
Shield = null;
|
|
if (b is Cockpit)
|
|
Cockpit = null;
|
|
if (b is Sensor)
|
|
Sensor = null;
|
|
}
|
|
|
|
// Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): the resolver's §0b lifecycle promise -- a resolver
|
|
// entry is "destroyed at unequip" -- was never wired up. Without this, every unequipped item stayed
|
|
// reachable from the resolver's dictionaries for the entity's whole remaining life.
|
|
Resolver.Forget(item);
|
|
|
|
return item.EquippableItem;
|
|
}
|
|
|
|
// Check whether the given item will fit when its origin is placed at the given coordinate. For hardpoint gear,
|
|
// hardpoint is the one hardpoint the item would occupy there (HardpointAt); Tool gear occupies none.
|
|
private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord, out HardpointData hardpoint)
|
|
{
|
|
hardpoint = null;
|
|
// Items without specific hardpoints on the ship can be freely rotated and placed anywhere
|
|
if (itemData.HardpointType == HardpointType.Tool)
|
|
{
|
|
// Check every cell of the item's shape
|
|
foreach (var i in itemData.Shape.Coordinates)
|
|
{
|
|
// An interior cell is usable when no gear occupies it. An empty hardpoint cell counts as usable:
|
|
// general gear may take it until hardpoint gear claims it, which is the same rule
|
|
// UnoccupiedSpace reports, so what generation is offered and what equipping accepts agree.
|
|
var itemCoord = hullCoord + itemData.Shape.Rotate(i, item.Rotation);
|
|
if (!hullData.InteriorCells[itemCoord] || GearOccupancy[itemCoord.x, itemCoord.y] != null)
|
|
return false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
hardpoint = HardpointAt(itemData, hullData, hullCoord);
|
|
if (hardpoint == null) return false;
|
|
item.Rotation = hardpoint.Rotation;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// The one placement rule for hardpoint gear. At this origin the item lies within a hardpoint of its type, turned to
|
|
// that hardpoint's rotation (HardpointData.TakesAt, the fit rule); its own cells are free, since a hardpoint's
|
|
// leftover cells may hold general gear (operator, 2026-09-30: "saving slots in a hardpoint can be a valid tradeoff
|
|
// for crowded ships"); and that hardpoint holds no other hardpoint item, since it holds one at most.
|
|
private HardpointData HardpointAt(EquippableItemData itemData, HullData hullData, int2 hullCoord)
|
|
{
|
|
foreach (var hardpoint in hullData.Hardpoints)
|
|
{
|
|
if (!hardpoint.TakesAt(itemData, hullCoord - hardpoint.Position)) continue;
|
|
var ownCellsFree = itemData.Shape.Coordinates
|
|
.Select(v => hullCoord + itemData.Shape.Rotate(v, hardpoint.Rotation))
|
|
.All(c => GearOccupancy[c.x, c.y] == null);
|
|
var holdsAnother = hardpoint.Shape.Coordinates
|
|
.Select(v => GearOccupancy[hardpoint.Position.x + v.x, hardpoint.Position.y + v.y])
|
|
.Any(occupant => occupant != null && occupant.Data.HardpointType != HardpointType.Tool);
|
|
if (ownCellsFree && !holdsAnother) return hardpoint;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Check whether the given item will fit when its origin is placed at the given coordinate on the hull
|
|
public bool ItemFits(EquippableItem item, int2 hullCoord)
|
|
{
|
|
// Don't allow equipping while deployed
|
|
if (_active) return false;
|
|
|
|
var itemData = ItemManager.GetData(item);
|
|
var hullData = ItemManager.GetData(Hull) as HullData;
|
|
return ItemFits(itemData, hullData, item, hullCoord, out _);
|
|
}
|
|
|
|
public bool TryFindSpace(EquippableItem item, out int2 hullCoord)
|
|
{
|
|
// Don't allow equipping while deployed
|
|
if (_active)
|
|
{
|
|
hullCoord = int2.zero;
|
|
return false;
|
|
}
|
|
var itemData = ItemManager.GetData(item);
|
|
var hullData = ItemManager.GetData(Hull) as HullData;
|
|
|
|
// Tools and thermal equipment can be installed anywhere on the ship
|
|
// Search the whole ship for somewhere the item will fit
|
|
if (itemData.HardpointType == HardpointType.Tool)
|
|
{
|
|
foreach (var hullCoord2 in hullData.InteriorCells.Coordinates)
|
|
{
|
|
if (ItemFits(itemData, hullData, item, hullCoord2, out _))
|
|
{
|
|
hullCoord = hullCoord2;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Everything else has to be equipped onto a hardpoint of the same type
|
|
// Search the ship for an empty hardpoint that matches the type and shape of the item
|
|
else
|
|
{
|
|
// Every origin the fit rule (HardpointData.TakesAt) can accept: the item, turned to the hardpoint's rotation,
|
|
// may sit as far up and left as its own extent allows, so a design whose shape lacks its origin cell is
|
|
// found wherever it fits, not only where its origin lies inside the hardpoint.
|
|
foreach (var hardpoint in hullData.Hardpoints)
|
|
{
|
|
if(hardpoint.Type == itemData.HardpointType)
|
|
{
|
|
var turned = hardpoint.Rotation == ItemRotation.Clockwise || hardpoint.Rotation == ItemRotation.CounterClockwise
|
|
? int2(itemData.Shape.Height, itemData.Shape.Width)
|
|
: int2(itemData.Shape.Width, itemData.Shape.Height);
|
|
for (var x = 1 - turned.x; x < hardpoint.Shape.Width; x++)
|
|
for (var y = 1 - turned.y; y < hardpoint.Shape.Height; y++)
|
|
{
|
|
var hullCoord2 = hardpoint.Position + int2(x, y);
|
|
if (ItemFits(itemData, hullData, item, hullCoord2, out _))
|
|
{
|
|
hullCoord = hullCoord2;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
hullCoord = int2.zero;
|
|
return false;
|
|
}
|
|
|
|
// Try to equip the given item anywhere it will fit, returns true when the item was successfully equipped
|
|
public bool TryEquip(EquippableItem item) => TryFindSpace(item, out var hullCoord) && TryEquip(item, hullCoord);
|
|
|
|
// Try to equip the given item to the given location
|
|
public bool TryEquip(EquippableItem item, int2 hullCoord)
|
|
{
|
|
// Don't allow equipping while deployed
|
|
if (_active) return false;
|
|
|
|
var itemData = ItemManager.GetData(item);
|
|
var hullData = ItemManager.GetData(Hull) as HullData;
|
|
|
|
if (!ItemFits(itemData, hullData, item, hullCoord, out var hardpoint)) return false;
|
|
|
|
EquippedItem equippedItem;
|
|
if (itemData.HardpointType == HardpointType.Tool)
|
|
{
|
|
if(itemData is CargoBayData)
|
|
{
|
|
if (itemData is DockingBayData)
|
|
{
|
|
equippedItem = new EquippedDockingBay(ItemManager, item, hullCoord, this, $"{Name} Docking Bay {DockingBays.Count + 1}");
|
|
DockingBays.Add((EquippedDockingBay) equippedItem);
|
|
}
|
|
else
|
|
{
|
|
equippedItem = new EquippedCargoBay(ItemManager, item, hullCoord, this, $"{Name} Cargo Bay {CargoBays.Count + 1}");
|
|
CargoBays.Add((EquippedCargoBay) equippedItem);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
equippedItem = new EquippedItem(ItemManager, item, hullCoord, this, null);
|
|
Equipment.Add(equippedItem);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
equippedItem = new EquippedItem(ItemManager, item, hullCoord, this, hardpoint);
|
|
Equipment.Add(equippedItem);
|
|
}
|
|
|
|
foreach (var b in equippedItem.Behaviors)
|
|
{
|
|
if (b is Weapon weapon)
|
|
_weapons.Add(weapon);
|
|
if(b is Radiator heatsink)
|
|
_heatsinks.Add(heatsink);
|
|
if (b is Shield shield)
|
|
Shield = shield;
|
|
if (b is Cockpit cockpit)
|
|
Cockpit = cockpit;
|
|
if (b is Sensor sensor)
|
|
Sensor = sensor;
|
|
}
|
|
|
|
// equippedItem.OnOnline += () => ItemOnline.OnNext(equippedItem);
|
|
// equippedItem.OnOffline += () => ItemOffline.OnNext(equippedItem);
|
|
|
|
foreach (var i in itemData.Shape.Coordinates)
|
|
{
|
|
var occupiedCoord = hullCoord + itemData.Shape.Rotate(i, item.Rotation);
|
|
// TODO: Track thermal mass of cargo bay contents as reactive property
|
|
ThermalMass[occupiedCoord.x, occupiedCoord.y] += ItemManager.GetThermalMass(item) / itemData.Shape.Coordinates.Length;
|
|
GearOccupancy[occupiedCoord.x, occupiedCoord.y] = equippedItem;
|
|
}
|
|
|
|
Mass += itemData.Mass;
|
|
_orderedEquipment = Equipment.ToArray();
|
|
return true;
|
|
}
|
|
|
|
public EquippedDockingBay TryDock(Ship ship)
|
|
{
|
|
//if (!IsDockingPermitted(ship.GetFactionRelationship(Faction), CurrentSecurityLevel.Value)) return null;
|
|
|
|
var bay = DockingBays.FirstOrDefault(x => x.DockedShip == null);
|
|
if (bay != null)
|
|
{
|
|
bay.DockedShip = ship;
|
|
CargoTransfer.End(ship, GiveEnd.GiverDocked);
|
|
ship.SetParent(this);
|
|
Zone.Remove(ship);
|
|
ship.Deactivate();
|
|
ship.Docked.OnNext(this);
|
|
}
|
|
|
|
return bay;
|
|
}
|
|
|
|
public bool TryUndock(Ship ship)
|
|
{
|
|
var bay = DockingBays.FirstOrDefault(x => x.DockedShip == ship);
|
|
if (bay == null)
|
|
{
|
|
ItemManager.Log($"Ship {ship.Name} attempted to undock from {Name}, but it was not docked!");
|
|
return false;
|
|
}
|
|
|
|
if (bay.Cargo.Any())
|
|
return false;
|
|
|
|
bay.DockedShip = null;
|
|
ship.RemoveParent();
|
|
Zone.Admit(ship, piloted: false);
|
|
|
|
return true;
|
|
}
|
|
|
|
// Cut 4 (docs/stats-and-power-cut.md, Cut 4): CanSpendCapacitorCharge/TrySpendCapacitorCharge died here with
|
|
// their last caller. Cut 3 named them a temporary exception for the four instant draws (a burst, a shot, a
|
|
// ping, a hit taken); all four now spend from their own InputCapacitor instead (InstantWeapon, Sensor,
|
|
// Shield), fed continuously by PowerBus like every other consumer. The entity's shared bus capacitors are
|
|
// now touched only by PowerBus itself (§0b: "the bus fills; the owning behaviour spends").
|
|
|
|
private void AddChild(Entity entity)
|
|
{
|
|
Mass += entity.Mass;
|
|
Children.Add(entity);
|
|
}
|
|
|
|
private void RemoveChild(Entity entity)
|
|
{
|
|
Mass -= entity.Mass;
|
|
Children.Remove(entity);
|
|
}
|
|
|
|
public void SetParent(Entity parent)
|
|
{
|
|
Parent = parent;
|
|
parent.AddChild(this);
|
|
}
|
|
|
|
public void RemoveParent()
|
|
{
|
|
if (Parent == null)
|
|
return;
|
|
|
|
Parent.RemoveChild(this);
|
|
Parent = null;
|
|
}
|
|
|
|
// Indexed, not foreach: Equipment is a ReactiveCollection whose enumerator is heap-allocated, and every round a gun fires
|
|
// asks for the targeting system several times (rounds per step: hundreds of rounds a step must not allocate each).
|
|
public T GetBehavior<T>() where T : Behavior
|
|
{
|
|
for (var i = 0; i < Equipment.Count; i++)
|
|
{
|
|
var behaviors = Equipment[i].Behaviors;
|
|
if (behaviors == null) continue;
|
|
foreach (var behavior in behaviors)
|
|
if (behavior is T b)
|
|
return b;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public IEnumerable<T> GetBehaviors<T>() where T : Behavior
|
|
{
|
|
foreach (var equippedItem in Equipment)
|
|
if(equippedItem.Behaviors != null)
|
|
foreach (var behavior in equippedItem.Behaviors)
|
|
if (behavior is T b)
|
|
yield return b;
|
|
}
|
|
|
|
public IEnumerable<T> GetBehaviorData<T>() where T : BehaviorData
|
|
{
|
|
foreach (var equippedItem in Equipment)
|
|
foreach (var behavior in equippedItem.Behaviors)
|
|
if (behavior.Data is T b)
|
|
yield return b;
|
|
}
|
|
|
|
// The phase between the items' performance update and the power bus: what an entity commands its items to do this
|
|
// tick, read from the same liveness and stats Execute then acts on. Ship's allocation lives here.
|
|
protected virtual void UpdateThrottles() { }
|
|
|
|
public virtual void Update(float delta)
|
|
{
|
|
TurnRate = 0;
|
|
// A parented entity (a docked ship) sits on its parent, active or not: docking deactivates the ship, so this pin
|
|
// comes before the inactive return, or the ship keeps whatever pose its own physics last gave it.
|
|
if (Parent != null)
|
|
{
|
|
Position = Parent.Position;
|
|
Velocity = Parent.Velocity;
|
|
}
|
|
if (!_active) return;
|
|
|
|
var velocityBefore = Velocity;
|
|
LimitClamp = float2(0, 0);
|
|
|
|
// Mining Cut 3: a chunk target is lost the moment this entity can no longer see it -- the lost-track rule
|
|
// through the chunk path. One check of the one slot per tick, not a detection loop over chunks.
|
|
if (Target.Value.Chunk is ChunkId trackedChunk && !ChunkVisible(trackedChunk)) SetTarget(TargetRef.None);
|
|
TargetRange = Target.Value.Chunk is ChunkId rangedChunk
|
|
? length(Position.xz - Zone.ChunkPose(rangedChunk.Field, rangedChunk.Index).xy)
|
|
: Target.Value.Entity == null ? -1 : length(Position - Target.Value.Entity.Position);
|
|
|
|
var localSecurityLevel = Zone.GetSecurityLevel(Position.xz);
|
|
if (CurrentSecurityLevel.Value != localSecurityLevel) CurrentSecurityLevel.Value = localSecurityLevel;
|
|
|
|
foreach (var v in _visibilitySources.Keys.ToArray())
|
|
{
|
|
_visibilitySources[v] = _visibilitySources[v].WithStrength(decay(_visibilitySources[v].Strength, ItemManager.GameplaySettings.VisibilityDecay, delta));
|
|
|
|
if (_visibilitySources[v].Strength < 0.1f) _visibilitySources.Remove(v);
|
|
}
|
|
|
|
UpdateTemperature(delta);
|
|
|
|
foreach (var item in _orderedEquipment) item.UpdatePerformance();
|
|
UpdateThrottles();
|
|
|
|
if (_active)
|
|
{
|
|
foreach (var entity in Zone.Entities)
|
|
{
|
|
if (_telemetry.ContainsKey(entity) && !HoldsContact(entity)) _telemetry.Remove(entity);
|
|
RefreshStance(entity);
|
|
}
|
|
|
|
if(Cockpit != null)
|
|
{
|
|
var cockpitTemp = Cockpit.Temperature;
|
|
if (cockpitTemp > ItemManager.GameplaySettings.HeatstrokeTemperature)
|
|
{
|
|
var previous = Heatstroke;
|
|
Heatstroke = saturate(
|
|
Heatstroke +
|
|
pow(cockpitTemp - ItemManager.GameplaySettings.HeatstrokeTemperature, ItemManager.GameplaySettings.HeatstrokeExponent) *
|
|
ItemManager.GameplaySettings.HeatstrokeMultiplier * delta);
|
|
if(previous < ItemManager.GameplaySettings.SevereHeatstrokeRiskThreshold && Heatstroke > ItemManager.GameplaySettings.SevereHeatstrokeRiskThreshold)
|
|
HeatstrokeRisk.OnNext(Unit.Default);
|
|
if(Heatstroke > .99)
|
|
{
|
|
HeatstrokeDeath.OnNext(Unit.Default);
|
|
Deactivate();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Heatstroke = saturate(Heatstroke - ItemManager.GameplaySettings.HeatstrokeRecoverySpeed * delta);
|
|
}
|
|
|
|
if (cockpitTemp < ItemManager.GameplaySettings.HypothermiaTemperature)
|
|
{
|
|
var previous = Hypothermia;
|
|
Hypothermia = saturate(
|
|
Hypothermia +
|
|
pow(ItemManager.GameplaySettings.HypothermiaTemperature - cockpitTemp, ItemManager.GameplaySettings.HypothermiaExponent) *
|
|
ItemManager.GameplaySettings.HypothermiaMultiplier * delta);
|
|
if(previous < ItemManager.GameplaySettings.SevereHeatstrokeRiskThreshold && Heatstroke > ItemManager.GameplaySettings.SevereHeatstrokeRiskThreshold)
|
|
HypothermiaRisk.OnNext(Unit.Default);
|
|
if(Hypothermia > .99)
|
|
{
|
|
HypothermiaDeath.OnNext(Unit.Default);
|
|
Deactivate();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Hypothermia = saturate(Hypothermia - ItemManager.GameplaySettings.HypothermiaRecoverySpeed * delta);
|
|
}
|
|
}
|
|
|
|
for (var i = 0; i < _activeConsumables.Count; i++)
|
|
{
|
|
_activeConsumables[i].Update(delta);
|
|
if (_activeConsumables[i].RemainingDuration <= 0)
|
|
{
|
|
// Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this
|
|
// list without ever telling the resolver, leaving its generation/cache/modifier entries
|
|
// reachable (keyed by this ConsumableItemEffect instance) for the rest of the process.
|
|
// A StatModifier's attachments are keyed by its target items, not by the effect, so Forget alone
|
|
// leaves a boost attached to its targets: dispose each behaviour first.
|
|
foreach (var behavior in _activeConsumables[i].Behaviors)
|
|
if (behavior is IDisposable disposable)
|
|
disposable.Dispose();
|
|
Resolver.Forget(_activeConsumables[i]);
|
|
_activeConsumables.RemoveAt(i--);
|
|
}
|
|
}
|
|
|
|
// Cut 3 (docs/stats-and-power-cut.md §1.2): stepped once per tick, before any equipped item's
|
|
// Behaviors execute, so every IPowerConsumer's grant is decided before it acts on it.
|
|
PowerBus.Step(delta);
|
|
|
|
foreach (var equippedItem in _orderedEquipment)
|
|
{
|
|
equippedItem.Update(delta);
|
|
}
|
|
|
|
foreach (var message in Messages.Keys.ToArray())
|
|
{
|
|
Messages[message] = Messages[message] - delta;
|
|
if (Messages[message] < 0)
|
|
Messages.Remove(message);
|
|
}
|
|
}
|
|
foreach(var child in Children)
|
|
child.Update(delta);
|
|
|
|
if (Parent == null) Position.y = RideHeight(Position.xz);
|
|
|
|
Acceleration = delta > 0f ? (Velocity + LimitClamp - velocityBefore) / delta : float2(0, 0);
|
|
Manoeuvre.Observe(Acceleration, delta, ItemManager.GameplaySettings.SolutionWindow);
|
|
}
|
|
|
|
// The height above the field of the layer this kind of entity rides on (GameplaySettings): ships the ship plane,
|
|
// orbital entities the orbital layer above it.
|
|
public abstract float LayerHeight { get; }
|
|
|
|
// Where an unparented entity sits on the field at a planar point: the zone's height there plus the layer this kind of
|
|
// entity rides on. The step rides at its own time and xz, the presenter at the drawn time and its drawn xz (DrawAhead).
|
|
public float RideHeight(float2 planar, double time) => Zone.GetHeight(planar, time) + LayerHeight;
|
|
|
|
public float RideHeight(float2 planar) => RideHeight(planar, Zone.ExactTime);
|
|
|
|
private void UpdateTemperature(float delta)
|
|
{
|
|
var hullData = ItemManager.GetData(Hull) as HullData;
|
|
|
|
MaxTemp = Single.MinValue;
|
|
MinTemp = Single.MaxValue;
|
|
|
|
//float[,] newTemp = new float[hullData.Shape.Width,hullData.Shape.Height];
|
|
var radiation = 0f;
|
|
foreach (var v in hullData.Shape.Coordinates)
|
|
{
|
|
var temp = Temperature[v.x, v.y];
|
|
var totalTemp = temp / ItemManager.GameplaySettings.HeatConductionMultiplier;
|
|
var totalConductivity = 1f / ItemManager.GameplaySettings.HeatConductionMultiplier;
|
|
|
|
if (hullData.Shape[int2(v.x - 1, v.y)])
|
|
{
|
|
var conductivity = (GearOccupancy[v.x, v.y]?.Conductivity ?? 1) *
|
|
(GearOccupancy[v.x - 1, v.y]?.Conductivity ?? 1) *
|
|
(HullConductivity[v.x - 1, v.y].x ? hullData.Conductivity : 1 / hullData.Conductivity) *
|
|
(ThermalMass[v.x - 1, v.y] / ThermalMass[v.x, v.y]);
|
|
totalConductivity += conductivity;
|
|
totalTemp += Temperature[v.x - 1, v.y] * conductivity;
|
|
}
|
|
|
|
if (hullData.Shape[int2(v.x + 1, v.y)])
|
|
{
|
|
var conductivity = (GearOccupancy[v.x, v.y]?.Conductivity ?? 1) *
|
|
(GearOccupancy[v.x + 1, v.y]?.Conductivity ?? 1) *
|
|
(HullConductivity[v.x, v.y].x ? hullData.Conductivity : 1 / hullData.Conductivity) *
|
|
(ThermalMass[v.x + 1, v.y] / ThermalMass[v.x, v.y]);
|
|
totalConductivity += conductivity;
|
|
totalTemp += Temperature[v.x + 1, v.y] * conductivity;
|
|
}
|
|
|
|
|
|
if (hullData.Shape[int2(v.x, v.y - 1)])
|
|
{
|
|
var conductivity = (GearOccupancy[v.x, v.y]?.Conductivity ?? 1) *
|
|
(GearOccupancy[v.x, v.y - 1]?.Conductivity ?? 1) *
|
|
(HullConductivity[v.x, v.y - 1].y ? hullData.Conductivity : 1 / hullData.Conductivity) *
|
|
(ThermalMass[v.x, v.y - 1] / ThermalMass[v.x, v.y]);
|
|
totalConductivity += conductivity;
|
|
totalTemp += Temperature[v.x, v.y - 1] * conductivity;
|
|
}
|
|
|
|
|
|
if (hullData.Shape[int2(v.x, v.y + 1)])
|
|
{
|
|
var conductivity = (GearOccupancy[v.x, v.y]?.Conductivity ?? 1) *
|
|
(GearOccupancy[v.x, v.y + 1]?.Conductivity ?? 1) *
|
|
(HullConductivity[v.x, v.y].y ? hullData.Conductivity : 1 / hullData.Conductivity) *
|
|
(ThermalMass[v.x, v.y + 1] / ThermalMass[v.x, v.y]);
|
|
totalConductivity += conductivity;
|
|
totalTemp += Temperature[v.x, v.y + 1] * conductivity;
|
|
}
|
|
|
|
NewTemperature[v.x, v.y] = totalTemp / totalConductivity;
|
|
|
|
var r = 0f;
|
|
// For all cells on the border of the entity, radiate some heat into space, increasing the visibility of the ship
|
|
if (Parent==null && !hullData.InteriorCells[v])
|
|
{
|
|
var rad = pow(NewTemperature[v.x, v.y], ItemManager.GameplaySettings.HeatRadiationExponent) *
|
|
ItemManager.GameplaySettings.HeatRadiationMultiplier;
|
|
NewTemperature[v.x, v.y] -= rad * delta;
|
|
r += rad;
|
|
}
|
|
|
|
radiation += r;
|
|
|
|
if(float.IsNaN(NewTemperature[v.x, v.y]) || NewTemperature[v.x, v.y] < 0)
|
|
ItemManager.Log("HOUSTON, WE HAVE A PROBLEM!");
|
|
|
|
if (NewTemperature[v.x, v.y] < MinTemp)
|
|
MinTemp = NewTemperature[v.x, v.y];
|
|
|
|
if (NewTemperature[v.x, v.y] > MaxTemp)
|
|
MaxTemp = NewTemperature[v.x, v.y];
|
|
}
|
|
|
|
SetSignature(this, SignatureChannel.Thermal, radiation, null);
|
|
var swap = Temperature;
|
|
Temperature = NewTemperature;
|
|
NewTemperature = swap;
|
|
}
|
|
|
|
public void SetMessage(string message)
|
|
{
|
|
Messages[message] = ItemManager.GameplaySettings.MessageDuration;
|
|
}
|
|
}
|
|
|
|
// The consumable case: no durability, no modifiers (there is no entity's worth of equipment to modify against),
|
|
// and progress-through-duration substitutes for heat rather than being exponentiated by the stat's heat field.
|
|
// That substitution is the disagreement the map names, kept exactly as it behaved before the collapse.
|
|
public class ConsumableItemEffect : IStatContext
|
|
{
|
|
public float RemainingDuration { get; private set; }
|
|
public Entity Entity { get; }
|
|
public ConsumableItem Item { get; }
|
|
public ConsumableItemData Data { get; }
|
|
public Lot Lot { get; }
|
|
public Behavior[] Behaviors { get; }
|
|
|
|
public ConsumableItemEffect(ConsumableItem item, Entity entity)
|
|
{
|
|
Item = item;
|
|
Entity = entity;
|
|
Data = (ConsumableItemData) entity.ItemManager.GetData(item);
|
|
Lot = entity.ItemManager.GetLot(item);
|
|
RemainingDuration = Data.Duration;
|
|
|
|
Behaviors = Data.Behaviors
|
|
.Select(bd => bd.CreateInstance(this))
|
|
.ToArray();
|
|
}
|
|
|
|
public void Update(float delta)
|
|
{
|
|
foreach (var behavior in Behaviors)
|
|
if(behavior is IAlwaysUpdatedBehavior alwaysUpdatedBehavior) alwaysUpdatedBehavior.Update(delta);
|
|
|
|
foreach (var behavior in Behaviors)
|
|
{
|
|
if (!behavior.Execute(delta))
|
|
break;
|
|
}
|
|
|
|
RemainingDuration -= delta;
|
|
// Progress moves every tick; a stat with a ConsumableProgress term must recompute against it, the same
|
|
// way EquippedItem signals Heat and Durability from UpdatePerformance below.
|
|
Entity.Resolver.InvalidateSource(this, StatSource.ConsumableProgress);
|
|
}
|
|
|
|
// Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by
|
|
// this effect instance (never by Entity, so two active consumables never share an entry).
|
|
public float Evaluate(PerformanceStat stat) => Entity.Resolver.Resolve(this, stat, this);
|
|
|
|
// Cut 1 (docs/stats-and-power-cut.md): progress-through-duration is now its own StatSource
|
|
// (ConsumableProgress) rather than a hard-coded override of "heat" that applied to every stat regardless of
|
|
// its declared terms. A consumable stat that wants this must declare a ConsumableProgress term; one that
|
|
// does not gets the identity (1), same as any other context reading a term it has no source for.
|
|
public float HeatFactor(float exponent) => 1f;
|
|
public float DurabilityFactor(float exponent) => 1f;
|
|
public float ConsumableProgressFactor(float exponent) =>
|
|
pow(Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration), exponent);
|
|
public float PowerSupplyFactor(float exponent) => 1f;
|
|
// No modifiers: there is no entity's worth of equipment to modify against (comment above, unchanged by Cut 2).
|
|
public float ScaleModifier(PerformanceStat stat) => 1f;
|
|
public float ConstantModifier(PerformanceStat stat) => 0f;
|
|
}
|
|
|
|
// The equipped case: the only one with heat, live durability, and modifiers, because it is the only one with an
|
|
// entity and a running simulation behind it.
|
|
public class EquippedItem : IStatContext
|
|
{
|
|
// SortPosition (Cut 3-era, only ever set by Reactor.Order) is deleted by Cut 5 (docs/stats-and-power-cut.md
|
|
// §1.3): "EquippedItem.SortPosition must no longer influence who is fed" -- the tiered allocation pass
|
|
// replaces it, and IOrderedBehavior/Reactor.Order go with it (§7 O3).
|
|
public EquippableItem EquippableItem;
|
|
public int2 Position;
|
|
|
|
private bool _thermalOnline;
|
|
private bool _durabilityOnline;
|
|
|
|
public Behavior[] Behaviors { get; }
|
|
public Dictionary<int, BehaviorGroup> BehaviorGroups { get; }
|
|
public float Conductivity { get; }
|
|
public float ThermalPerformance { get; private set; }
|
|
public float ThermalExponent { get; }
|
|
public float DurabilityPerformance { get; private set; }
|
|
public float DurabilityExponent { get; }
|
|
public float Wear { get; private set; }
|
|
public Shape InsetShape { get; }
|
|
public Entity Entity { get; }
|
|
// The one hardpoint this item occupies, decided once at equip by the placement rule (Entity.HardpointAt);
|
|
// null for Tool gear and the hull. The item's origin cell need not lie in it: Entity.Hardpoints[,] is a
|
|
// per-cell map for armour and display, never an item lookup.
|
|
public HardpointData Hardpoint { get; }
|
|
public EquippableItemData Data { get; }
|
|
public Lot Lot { get; }
|
|
|
|
public ReactiveProperty<bool> ThermalOnline { get; } = new ReactiveProperty<bool>(false);
|
|
public ReactiveProperty<bool> DurabilityOnline { get; } = new ReactiveProperty<bool>(false);
|
|
public ReadOnlyReactiveProperty<bool> Online { get; }
|
|
internal ReactiveProperty<bool> Enabled { get; } = new ReactiveProperty<bool>(true);
|
|
public ReadOnlyReactiveProperty<bool> Active { get; }
|
|
public ItemManager ItemManager { get; }
|
|
|
|
private float oldTemperature;
|
|
public float Temperature
|
|
{
|
|
get
|
|
{
|
|
float sum = 0;
|
|
foreach (var x in InsetShape.Coordinates) sum += Entity.Temperature[x.x, x.y];
|
|
return sum/InsetShape.Coordinates.Length;
|
|
}
|
|
}
|
|
|
|
public EquippedItem(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, HardpointData hardpoint)
|
|
{
|
|
ItemManager = itemManager;
|
|
Data = ItemManager.GetData(item);
|
|
Entity = entity;
|
|
Hardpoint = hardpoint;
|
|
EquippableItem = item;
|
|
Lot = ItemManager.GetLot(item);
|
|
var quality = Lot.EffectiveQuality();
|
|
Position = position;
|
|
Conductivity = Data.Conductivity;
|
|
ThermalExponent = lerp(
|
|
ItemManager.GameplaySettings.ThermalQualityMin,
|
|
ItemManager.GameplaySettings.ThermalQualityMax,
|
|
pow(quality, ItemManager.GameplaySettings.ThermalQualityExponent));
|
|
DurabilityExponent = lerp(
|
|
ItemManager.GameplaySettings.DurabilityQualityMin,
|
|
ItemManager.GameplaySettings.DurabilityQualityMax,
|
|
pow(quality, ItemManager.GameplaySettings.DurabilityQualityExponent));
|
|
var hullData = itemManager.GetData(entity.Hull);
|
|
InsetShape = hullData.Shape.Inset(Data.Shape, position, item.Rotation);
|
|
if (Entity.Temperature != null) oldTemperature = Temperature;
|
|
|
|
Online = new ReadOnlyReactiveProperty<bool>(ThermalOnline
|
|
.CombineLatest(DurabilityOnline, (thermal, durability) => thermal && durability).DistinctUntilChanged());
|
|
Active = new ReadOnlyReactiveProperty<bool>(Enabled
|
|
.CombineLatest(Online, (enabled, online) => enabled && online).DistinctUntilChanged());
|
|
|
|
|
|
Behaviors = Data.Behaviors
|
|
.Select(bd => bd.CreateInstance(this))
|
|
.ToArray();
|
|
|
|
BehaviorGroups = Behaviors
|
|
.GroupBy(b => b.Data.Group)
|
|
.OrderBy(g => g.Key)
|
|
.ToDictionary(g => g.Key, g => new BehaviorGroup
|
|
{
|
|
Behaviors = g.ToArray()
|
|
});
|
|
|
|
foreach (var behavior in Behaviors)
|
|
{
|
|
if(behavior is IPopulationAssignment populationAssignment)
|
|
entity.PopulationAssignments.Add(populationAssignment);
|
|
}
|
|
|
|
// Cut 5 (docs/stats-and-power-cut.md §1.3, Q5 "defaulted per behaviour kind"): seed the stored tier
|
|
// exactly once, the first time this unit is ever equipped. A unit the player has already assigned a
|
|
// tier to (or one seeded on an earlier equip) keeps it -- this never runs again for that unit.
|
|
if (EquippableItem.PowerTier == PowerTiers.Unassigned)
|
|
{
|
|
var consumerTiers = Behaviors.OfType<IPowerConsumer>().Select(c => c.DefaultPowerTier).ToArray();
|
|
if (consumerTiers.Length > 0)
|
|
EquippableItem.PowerTier = consumerTiers.Min();
|
|
}
|
|
|
|
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, superseding F6): Cut
|
|
// 6's static half of this check -- refusing any PowerRequestFields stat whose own Terms named
|
|
// PowerSupply -- assumed PowerRequest read those stats the same way Execute does. It does not any more:
|
|
// EvaluateNominalPower (below, and Behavior.EvaluateNominalPower) is what every PowerRequest/RefreshReserve/
|
|
// RefreshInputCapacitor implementation now calls for a request-field stat, and it pins that stat's own
|
|
// PowerSupplyFactor to 1 regardless of what Terms the stat declares -- so a direct PowerSupply term on a
|
|
// request field can no longer make the request depend on its own answer, and the six shipped records this
|
|
// ruling exists to make legal again (RadiatorData.PumpedHeat) are not an authoring
|
|
// error to refuse. What is still refused: a request field fed a PowerSupply-tainted value through a
|
|
// *modifier chain* (StatModifier.ValidateNoPowerSupplyChain, StatModifier.cs) -- EvaluateNominalPower
|
|
// forwards ScaleModifier/ConstantModifier to the item's real, non-nominal resolver entries (same as
|
|
// ConditionRatio's NominalContext already did), so a modifier chain that reaches a power-tainted magnitude
|
|
// stat still corrupts the nominal read too. That dynamic check is unchanged and still runs from
|
|
// Initialize below via StatModifier -- it is the only surviving half of Cut 6's rule.
|
|
}
|
|
|
|
// Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by
|
|
// this EquippedItem instance, so two ships equipping the same design never share a resolved value.
|
|
public float Evaluate(PerformanceStat stat) => Entity.Resolver.Resolve(this, stat, this);
|
|
|
|
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): what this item wants at
|
|
// full power supply, not what it is currently managing -- the read every PowerRequest/RefreshReserve/
|
|
// RefreshInputCapacitor implementation uses for a stat named in StatValidation.PowerRequestFields, so a stat
|
|
// that is both what a request asks for and what it also produces (RadiatorData.PumpedHeat)
|
|
// stops being circular at the root instead of being forbidden outright. Only PowerSupply is pinned:
|
|
// Heat and Durability are NOT, because they are not the term a request would be circular through -- this
|
|
// tick's PowerBus.Step (which calls PowerRequest) always runs before this tick's grant exists, so nothing
|
|
// here could depend on an answer that has not been computed yet, and a hot or worn item honestly asking for
|
|
// less power is real degradation the ruling never objected to, only "the request depends on its own answer."
|
|
// Bypasses the resolver's (owner, stat) cache the same way ConditionRatio's NominalContext below does and for
|
|
// the same reason: the cache key is (this, stat) alone, so resolving a second, different context under that
|
|
// key would clobber whatever Execute's own real Evaluate(stat) call cached for this same tick.
|
|
public float EvaluateNominalPower(PerformanceStat stat) => stat.Evaluate(new PowerRequestContext(this));
|
|
|
|
// Real-condition view of this same item for EvaluateNominalPower above: everything but PowerSupply is
|
|
// forwarded to the real item, including ScaleModifier/ConstantModifier -- deliberately NOT pinned, so a
|
|
// modifier chain that reaches a power-tainted magnitude stat still taints a nominal read through them too.
|
|
// That is exactly the residual case StatModifier.ValidateNoPowerSupplyChain still refuses: nominalizing this
|
|
// item's own direct Terms cannot undo a value baked in by another item's modifier.
|
|
private readonly struct PowerRequestContext : IStatContext
|
|
{
|
|
private readonly EquippedItem _item;
|
|
public PowerRequestContext(EquippedItem item) => _item = item;
|
|
public Lot Lot => _item.Lot;
|
|
public float HeatFactor(float exponent) => _item.HeatFactor(exponent);
|
|
public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);
|
|
public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);
|
|
public float PowerSupplyFactor(float exponent) => 1f;
|
|
public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);
|
|
public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);
|
|
}
|
|
|
|
public float HeatFactor(float exponent) => pow(ThermalPerformance, ThermalExponent * exponent);
|
|
public float DurabilityFactor(float exponent) => pow(DurabilityPerformance, DurabilityExponent * exponent);
|
|
public float ConsumableProgressFactor(float exponent) => 1f;
|
|
// Cut 6 (docs/stats-and-power-cut.md), applied per F1 (operator ruling 2026-09-19): PerformanceStat.Evaluate
|
|
// multiplies this straight onto the resolved value rather than blending it into the Min/Max interpolation, so
|
|
// a stat with a PowerSupply term is exactly unchanged at full supply (pow(1, exponent) == 1 regardless of
|
|
// exponent) and exactly zero at zero supply (pow(0, exponent) == 0 for any exponent > 0), no matter what Min
|
|
// is -- "a continuous consumer that degrades instead of stopping" down to genuinely nothing. PowerBus writes
|
|
// PowerSupply below; the resolver invalidates this source once per tick from the same write (PowerBus.cs
|
|
// AllocateTiers), so this read is always this tick's own grant, never a stale one.
|
|
public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);
|
|
|
|
// Cut 3 (docs/stats-and-power-cut.md §1.2): PowerBus's grant ratio for this item, in [0,1], from whichever
|
|
// tick last ran its Step. Display-only and, as of Cut 6, stat-source-only: the only other reader is the
|
|
// resolver, through PowerSupplyFactor above, on behalf of whichever IPowerConsumer behaviour declared a
|
|
// PowerSupply term. Forbidden writers: nothing but PowerBus sets this. Defaults to 1 (fully supplied) for an
|
|
// item that draws no power, or before the bus has run once.
|
|
public float PowerSupply { get; internal set; } = 1f;
|
|
|
|
// Cut 2: these used to read the catalog stat's own per-entity dictionary (the leak, §0.3). A modifier now
|
|
// attaches to this entity's resolver, keyed by (this item, stat); reading it here is unchanged.
|
|
public float ScaleModifier(PerformanceStat stat) => Entity.Resolver.ScaleModifier(this, stat);
|
|
public float ConstantModifier(PerformanceStat stat) => Entity.Resolver.ConstantModifier(this, stat);
|
|
|
|
// Cut 8 (operator ask 2026-09-19): "multiply [emission] by actual performance so a broken thruster emits a
|
|
// puny flame." One ratio per (item, stat): this item's actually-resolved value against what the same item,
|
|
// same lot, same quality, same modifier stack would produce with every degradable term at its identity --
|
|
// full durability, optimal temperature, full power supply. Quality and lot are deliberately NOT part of
|
|
// "perfect": NominalContext forwards Lot/ScaleModifier/ConstantModifier unchanged and only pins Heat,
|
|
// Durability, PowerSupply and ConsumableProgress to 1, so a cheap item's honestly-lower ceiling still reads
|
|
// 1 at full health -- it is not reported as damaged for being cheap.
|
|
//
|
|
// Cost: Evaluate(stat) is the same resolver-cached read every other caller already pays for (§ above), so it
|
|
// is a dictionary hit here unless something already invalidated it this tick. The nominal side cannot reuse
|
|
// that cache -- the resolver's cache key is (owner, stat) alone, and owner is `this`, so resolving a second,
|
|
// different context under the same key would clobber the real cached value -- so it calls stat.Evaluate
|
|
// directly against NominalContext, bypassing the cache entirely. That is one pass over the stat's own Terms
|
|
// list (Thrust/Torque declare at most three: Quality, Heat or Durability, PowerSupply), each a pow() and a
|
|
// multiply, i.e. a handful of flops with no allocation (NominalContext is a readonly struct). Cheap enough to
|
|
// call once per thruster per frame, which is all presentation (ShipInstance.Update) does with it.
|
|
// No epsilon guard on a zero/near-zero nominal: math.min/max (CultMath's own DXIL-lowering semantics,
|
|
// math.cs) resolve a NaN operand to the OTHER operand, not to NaN, so saturate(0f/0f) -- the one way nominal
|
|
// degenerates to exactly 0 (a stat authored with Max <= 0 and no ScaleModifier/ConstantModifier moving it off
|
|
// that) -- is a real, tested 0f (ConditionIsZeroNotNaNWhenTheNominalValueItselfDegeneratesToZero), not NaN. A
|
|
// manual guard here would duplicate that behaviour rather than add any.
|
|
public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this)));
|
|
|
|
// Perfect-conditions view of this same item for ConditionRatio above. Everything but the three degradable
|
|
// factors is forwarded to the real item so a modifier stack or a cheap lot's lower Quality term still shapes
|
|
// "perfect" the same way it shapes "actual" -- only Heat/Durability/PowerSupply/ConsumableProgress are
|
|
// pinned to their identity (1), since those are exactly the terms condition is supposed to measure.
|
|
private readonly struct NominalContext : IStatContext
|
|
{
|
|
private readonly EquippedItem _item;
|
|
public NominalContext(EquippedItem item) => _item = item;
|
|
public Lot Lot => _item.Lot;
|
|
public float HeatFactor(float exponent) => 1f;
|
|
public float DurabilityFactor(float exponent) => 1f;
|
|
public float ConsumableProgressFactor(float exponent) => 1f;
|
|
public float PowerSupplyFactor(float exponent) => 1f;
|
|
public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);
|
|
public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);
|
|
}
|
|
|
|
public void AddHeat(float heat, bool ignoreThermalMass = false)
|
|
{
|
|
foreach(var hullCoord in InsetShape.Coordinates)
|
|
Entity.AddHeat(hullCoord, heat / InsetShape.Coordinates.Length, ignoreThermalMass);
|
|
}
|
|
|
|
public void UpdatePerformance()
|
|
{
|
|
var temp = Temperature;
|
|
ThermalPerformance = Data.Performance(temp);
|
|
Entity.Resolver.InvalidateSource(this, StatSource.Heat);
|
|
var deltaTemp = math.abs(temp - oldTemperature);
|
|
DurabilityPerformance = EquippableItem.Durability / Data.Durability;
|
|
Entity.Resolver.InvalidateSource(this, StatSource.Durability);
|
|
var performanceThreshold = Entity.Settings.ShutdownPerformance;
|
|
Wear = (1 - pow(ThermalPerformance,
|
|
(1 - pow(Lot.EffectiveQuality(), ItemManager.GameplaySettings.QualityWearExponent)) *
|
|
ItemManager.GameplaySettings.ThermalWearExponent) +
|
|
deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent
|
|
) * Data.Durability / Data.ThermalResilience;
|
|
ThermalOnline.Value = ThermalPerformance > performanceThreshold || Entity.OverrideShutdown && EquippableItem.OverrideShutdown;
|
|
DurabilityOnline.Value = !EquippableItem.IsDestroyed;
|
|
oldTemperature = temp;
|
|
}
|
|
|
|
public void Update(float delta)
|
|
{
|
|
if (Active.Value)
|
|
{
|
|
foreach (var group in BehaviorGroups.Values)
|
|
{
|
|
foreach (var behavior in group.Behaviors)
|
|
{
|
|
if (!behavior.Execute(delta))
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var behavior in Behaviors)
|
|
if(behavior is IAlwaysUpdatedBehavior alwaysUpdatedBehavior) alwaysUpdatedBehavior.Update(delta);
|
|
}
|
|
|
|
public T GetBehavior<T>() where T : class
|
|
{
|
|
foreach (var behavior in Behaviors)
|
|
if (behavior is T b)
|
|
return b;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public class EquippedCargoBay : EquippedItem
|
|
{
|
|
public readonly ReactiveDictionary<ItemInstance, int2> Cargo = new ReactiveDictionary<ItemInstance, int2>();
|
|
|
|
public readonly ItemInstance[,] Occupancy;
|
|
|
|
public readonly Dictionary<CultRecordKey, List<ItemInstance>> ItemsOfType = new Dictionary<CultRecordKey, List<ItemInstance>>();
|
|
|
|
public new readonly CargoBayData Data;
|
|
|
|
public float Mass { get; private set; }
|
|
public float ThermalMass { get; private set; }
|
|
public string Name { get; }
|
|
|
|
public Shape UnoccupiedSpace
|
|
{
|
|
get
|
|
{
|
|
var unoccupied = new Shape(Data.InteriorShape.Width, Data.InteriorShape.Height);
|
|
foreach (var v in unoccupied.AllCoordinates)
|
|
unoccupied[v] = Occupancy[v.x, v.y] == null;
|
|
return unoccupied;
|
|
}
|
|
}
|
|
|
|
public EquippedCargoBay(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, string name) : base(itemManager, item, position, entity, null)
|
|
{
|
|
Data = ItemManager.GetData(EquippableItem) as CargoBayData;
|
|
Name = name;
|
|
|
|
Mass = Data.Mass;
|
|
ThermalMass = Data.Mass * Data.SpecificHeat;
|
|
|
|
Occupancy = new ItemInstance[Data.InteriorShape.Width,Data.InteriorShape.Height];
|
|
}
|
|
|
|
// Check whether the given item will fit when its origin is placed at the given coordinate
|
|
public bool ItemFits(ItemInstance item, int2 cargoCoord)
|
|
{
|
|
var itemData = ItemManager.GetData(item);
|
|
// Check every cell of the item's shape
|
|
foreach (var i in itemData.Shape.Coordinates)
|
|
{
|
|
// If there is an item already occupying that space, it won't fit
|
|
var itemCargoCoord = cargoCoord + itemData.Shape.Rotate(i, item.Rotation);
|
|
if (!Data.InteriorShape[itemCargoCoord] || (Occupancy[itemCargoCoord.x, itemCargoCoord.y] != null && Occupancy[itemCargoCoord.x, itemCargoCoord.y] != item)) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public bool TryFindSpace(ItemInstance item)
|
|
{
|
|
if (item is SimpleCommodity simpleCommodity)
|
|
return TryFindSpace(simpleCommodity, out _);
|
|
if (item is CraftedItemInstance craftedItem)
|
|
return TryFindSpace(craftedItem, out _);
|
|
return false;
|
|
}
|
|
|
|
// Tries to find a place to put the given items in the inventory
|
|
// Will attempt to fill existing item stacks first
|
|
// Returns true only when ALL of the items have places to go
|
|
public bool TryFindSpace(SimpleCommodity item, out List<int2> positions)
|
|
{
|
|
positions = new List<int2>();
|
|
var itemData = ItemManager.GetData(item);
|
|
var remainingQuantity = item.Quantity;
|
|
|
|
// For simple commodities, search for existing item stacks to add to
|
|
foreach (var cargoItem in Cargo.Keys)
|
|
{
|
|
if (!item.Data.Key.Equals(cargoItem.Data.Key)) continue;
|
|
|
|
var cargoCommodity = (SimpleCommodity) cargoItem;
|
|
if (cargoCommodity.Quantity >= itemData.MaxStack) continue;
|
|
|
|
// Subtract remaining space in existing stack from remaining quantity
|
|
remainingQuantity -= min(itemData.MaxStack - cargoCommodity.Quantity, remainingQuantity);
|
|
positions.Add(Cargo[cargoItem]);
|
|
|
|
// If we've moved all of the items into existing stacks, no need to search for empty space!
|
|
if (remainingQuantity == 0) return true;
|
|
}
|
|
|
|
// TODO: Try alternate item rotations / use Shape.FitsWithin
|
|
// Search all the space in the cargo bay for an empty space where the item fits
|
|
foreach (var cargoCoord in Data.InteriorShape.Coordinates)
|
|
{
|
|
if (ItemFits(item, cargoCoord))
|
|
{
|
|
positions.Add(cargoCoord);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Searches the cargo bay for a position where the item will fit, returns true when found
|
|
public bool TryFindSpace(CraftedItemInstance item, out int2 position)
|
|
{
|
|
// Search all the space in the cargo bay for an empty space where the item fits
|
|
foreach (var cargoCoord in Data.InteriorShape.Coordinates)
|
|
{
|
|
if (ItemFits(item, cargoCoord))
|
|
{
|
|
position = cargoCoord;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
position = int2.zero;
|
|
return false;
|
|
}
|
|
|
|
public bool TryStore(ItemInstance item)
|
|
{
|
|
if (item is SimpleCommodity simpleCommodity)
|
|
return TryStore(simpleCommodity);
|
|
if (item is CraftedItemInstance craftedItem)
|
|
return TryStore(craftedItem);
|
|
return false;
|
|
}
|
|
|
|
public bool TryStore(ItemInstance item, int2 cargoCoord)
|
|
{
|
|
if (item is SimpleCommodity simpleCommodity)
|
|
return TryStore(simpleCommodity, cargoCoord);
|
|
if (item is CraftedItemInstance craftedItem)
|
|
return TryStore(craftedItem, cargoCoord);
|
|
return false;
|
|
}
|
|
|
|
// Attempts to store all of the given item anywhere in the inventory
|
|
// Will attempt to fill existing item stacks first
|
|
// Returns true only when ALL of the items are successfully stored
|
|
public bool TryStore(SimpleCommodity item)
|
|
{
|
|
if (!TryFindSpace(item, out var positions)) return false;
|
|
foreach (var position in positions)
|
|
{
|
|
if (TryStore(item, position)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Try to store the given commodity at the given position
|
|
// If there's a stack at the given position it will be added to
|
|
// Returns true only when ALL of the items are successfully stored
|
|
// The bay decides what quantity may sit in it: a stack below one unit or above its design's MaxStack is refused with
|
|
// the bay as it was, so no caller, loot, pickup or restore has to check for it.
|
|
public bool TryStore(SimpleCommodity item, int2 cargoCoord)
|
|
{
|
|
var itemData = ItemManager.GetData(item);
|
|
if (item.Quantity < 1 || item.Quantity > itemData.MaxStack) return false;
|
|
if (ItemFits(item, cargoCoord))
|
|
{
|
|
foreach (var p in itemData.Shape.Coordinates)
|
|
{
|
|
var pos = cargoCoord + itemData.Shape.Rotate(p, item.Rotation);
|
|
Occupancy[pos.x, pos.y] = item;
|
|
}
|
|
Cargo[item] = cargoCoord;
|
|
|
|
if(!ItemsOfType.ContainsKey(item.Data.Key))
|
|
ItemsOfType[item.Data.Key] = new List<ItemInstance>();
|
|
ItemsOfType[item.Data.Key].Add(item);
|
|
}
|
|
else if (Occupancy[cargoCoord.x, cargoCoord.y] is SimpleCommodity cargoCommodity && cargoCommodity.Data.Key.Equals(item.Data.Key))
|
|
{
|
|
if (cargoCommodity.Quantity + item.Quantity <= itemData.MaxStack)
|
|
{
|
|
cargoCommodity.Quantity += item.Quantity;
|
|
}
|
|
else
|
|
{
|
|
var quantityTransferred = itemData.MaxStack - cargoCommodity.Quantity;
|
|
item.Quantity -= quantityTransferred;
|
|
cargoCommodity.Quantity = itemData.MaxStack;
|
|
|
|
Mass += itemData.Mass * quantityTransferred;
|
|
ThermalMass += itemData.Mass * itemData.SpecificHeat * quantityTransferred;
|
|
return false;
|
|
}
|
|
}
|
|
else return false;
|
|
|
|
Mass += ItemManager.GetMass(item);
|
|
ThermalMass += ItemManager.GetThermalMass(item);
|
|
return true;
|
|
}
|
|
|
|
// Try to store the given item anywhere it will fit, returns true when the item was successfully stored
|
|
public bool TryStore(CraftedItemInstance item) => TryFindSpace(item, out var position) && TryStore(item, position);
|
|
|
|
// Try to store the given item at the given position, returns true when the item was successfully stored
|
|
public bool TryStore(CraftedItemInstance item, int2 cargoCoord)
|
|
{
|
|
if (!ItemFits(item, cargoCoord)) return false;
|
|
|
|
var itemData = ItemManager.GetData(item);
|
|
foreach (var p in itemData.Shape.Coordinates)
|
|
{
|
|
var pos = cargoCoord + itemData.Shape.Rotate(p, item.Rotation);
|
|
Occupancy[pos.x, pos.y] = item;
|
|
}
|
|
Cargo[item] = cargoCoord;
|
|
|
|
if(!ItemsOfType.ContainsKey(item.Data.Key))
|
|
ItemsOfType[item.Data.Key] = new List<ItemInstance>();
|
|
ItemsOfType[item.Data.Key].Add(item);
|
|
|
|
Mass += ItemManager.GetMass(item);
|
|
ThermalMass += ItemManager.GetThermalMass(item);
|
|
|
|
return true;
|
|
}
|
|
|
|
// The part removed: null, with the bay as it was, when the item is not here or the quantity is below one unit.
|
|
public SimpleCommodity Remove(SimpleCommodity item, int quantity)
|
|
{
|
|
if (quantity < 1) return null;
|
|
if (!Cargo.ContainsKey(item))
|
|
{
|
|
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
|
|
return null;
|
|
}
|
|
var itemData = ItemManager.GetData(item);
|
|
if(quantity >= item.Quantity)
|
|
{
|
|
foreach(var v in Data.InteriorShape.Coordinates)
|
|
if (Occupancy[v.x, v.y] == item)
|
|
Occupancy[v.x, v.y] = null;
|
|
|
|
Cargo.Remove(item);
|
|
ItemsOfType[item.Data.Key].Remove(item);
|
|
if (!ItemsOfType[item.Data.Key].Any())
|
|
ItemsOfType.Remove(item.Data.Key);
|
|
|
|
Mass -= ItemManager.GetMass(item);
|
|
ThermalMass -= ItemManager.GetThermalMass(item);
|
|
|
|
return item;
|
|
}
|
|
|
|
item.Quantity -= quantity;
|
|
Mass -= itemData.Mass * quantity;
|
|
ThermalMass -= itemData.Mass * itemData.SpecificHeat * quantity;
|
|
return new SimpleCommodity{Data = item.Data, Quantity = quantity, Rotation = item.Rotation};
|
|
}
|
|
|
|
public void Remove(CraftedItemInstance item)
|
|
{
|
|
if (!Cargo.ContainsKey(item))
|
|
{
|
|
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
|
|
return;
|
|
}
|
|
var itemData = ItemManager.GetData(item);
|
|
foreach(var v in Data.InteriorShape.Coordinates)
|
|
if (Occupancy[v.x, v.y] == item)
|
|
Occupancy[v.x, v.y] = null;
|
|
|
|
Cargo.Remove(item);
|
|
ItemsOfType[item.Data.Key].Remove(item);
|
|
if (!ItemsOfType[item.Data.Key].Any())
|
|
ItemsOfType.Remove(item.Data.Key);
|
|
|
|
Mass -= ItemManager.GetMass(item);
|
|
ThermalMass -= ItemManager.GetThermalMass(item);
|
|
}
|
|
|
|
public void Remove(ItemInstance item)
|
|
{
|
|
if (item is SimpleCommodity simpleCommodity)
|
|
Remove(simpleCommodity, simpleCommodity.Quantity);
|
|
if (item is CraftedItemInstance craftedItem)
|
|
Remove(craftedItem);
|
|
}
|
|
|
|
public bool TryTransferItem(EquippedCargoBay target, SimpleCommodity item, int quantity)
|
|
{
|
|
if (quantity < 1) return false;
|
|
if (!Cargo.ContainsKey(item))
|
|
{
|
|
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
|
|
return false;
|
|
}
|
|
|
|
var oldPos = Cargo[item];
|
|
var newItem = Remove(item, quantity);
|
|
|
|
// The part removed goes across, never the stack left behind.
|
|
if (target.TryStore(newItem)) return true;
|
|
|
|
// Whole or nothing: TryStore takes all of the part or none of it, so a refusal puts the part back where it came from.
|
|
TryStore(newItem, oldPos);
|
|
return false;
|
|
}
|
|
|
|
public bool TryTransferItem(EquippedCargoBay target, CraftedItemInstance item)
|
|
{
|
|
if (!Cargo.ContainsKey(item))
|
|
{
|
|
ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!");
|
|
return false;
|
|
}
|
|
|
|
if (!target.TryStore(item)) return false;
|
|
Remove(item);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public class EquippedDockingBay : EquippedCargoBay
|
|
{
|
|
public Ship DockedShip;
|
|
public int2 MaxSize => _data.MaxSize;
|
|
private DockingBayData _data;
|
|
public EquippedDockingBay(ItemManager itemManager, EquippableItem item, int2 position, Entity entity, string name) : base(itemManager, item, position, entity, name)
|
|
{
|
|
_data = ItemManager.GetData(EquippableItem) as DockingBayData;
|
|
}
|
|
}
|
|
|
|
public class BehaviorGroup
|
|
{
|
|
public Behavior[] Behaviors;
|
|
|
|
public T GetBehavior<T>() where T : Behavior
|
|
{
|
|
foreach (var b in Behaviors)
|
|
{
|
|
if (!(b is T s)) continue;
|
|
return s;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public T GetExposed<T>() where T : Behavior, IInteractiveBehavior
|
|
{
|
|
foreach (var b in Behaviors)
|
|
{
|
|
if (!(b is T s) || !((IInteractiveBehavior)b).Exposed) continue;
|
|
return s;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
} |