/* 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 Equipment = new ReactiveCollection(); public readonly ReactiveCollection CargoBays = new ReactiveCollection(); public readonly ReactiveCollection DockingBays = new ReactiveCollection(); public readonly ReactiveCollection VisibleEntities = new ReactiveCollection(); // The cache of IsHostileTo for each zone entity: written only by RefreshStance, refreshed each Update. public readonly ReactiveDictionary EntityHostility = new ReactiveDictionary(); public readonly ReactiveCollection VisibleEnemies = new ReactiveCollection(); public readonly ReactiveCollection VisibleFriendlies = new ReactiveCollection(); // 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 _iffOverrides = new ReactiveDictionary(); // 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 _telemetry = new Dictionary(); public Entity Parent; public List Children = new List(); // 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 _target = new ReactiveProperty(TargetRef.None); public IReadOnlyReactiveProperty 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 Personality = new Dictionary(); public readonly Dictionary Messages = new Dictionary(); private readonly Dictionary _visibilitySources = new Dictionary(); // 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 _visibilityView; public IReadOnlyDictionary VisibilitySources => _visibilityView ??= new ReadOnlyDictionary(_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()) 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 _itemClaims = new Dictionary(); 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 EntityInfoGathered = new ReactiveDictionary(); public (List weapons, List items)[] WeaponGroups; public List PopulationAssignments = new List(); // 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 _weapons = new List(); private List _heatsinks = new List(); private List _activeConsumables = new List(); protected bool _active; private bool _heatsinksEnabled = true; public bool HeatsinksEnabled { get => _heatsinksEnabled; 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; set; } // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. public float TractorCommand { get; internal set; } // The beam's spun-up state: Pickup's, or 0 without one. public float TractorPower => GetBehavior()?.Power ?? 0f; public bool Active { get => _active; } public IEnumerable 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 IncomingHit = new Subject(); // 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 ItemOffline = new Subject(); // public Subject ItemOnline = new Subject(); public Subject HullDamage = new Subject(); public Subject Docked = new Subject(); public Subject HeatstrokeRisk = new Subject(); public Subject HeatstrokeDeath = new Subject(); public Subject HypothermiaRisk = new Subject(); public Subject HypothermiaDeath = new Subject(); // 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 _paintInbox = new List(); private readonly List _incomingPaints = new List(); public IReadOnlyList 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 _returnInbox = new List(); private readonly List _pingReturns = new List(); internal IReadOnlyList 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 _emissions = new List(); // 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> _reveals = new Dictionary>(); // 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> _seenDestroyed = new Dictionary>(); private readonly List _endedItems = new List(); private readonly List _agedSubjects = new List(); public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; public UniRx.IObservable ItemDestroyed; public UniRx.IObservable HullArmorDepleted; public UniRx.IObservable HardpointArmorDepleted; public UniRx.IObservable WeaponDestroyed; public UniRx.IObservable Death; private List _subscriptions = new List(); private Dictionary> _watchedEntitySubscriptions = new Dictionary>(); // 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(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 { // 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 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 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 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 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 _targetCandidates = new List(); private static readonly Comparison 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 _activeSensors = new List(); private List 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 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(); 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(); 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 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 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 RevealsOf(Entity subject) => _reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty(); // 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 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 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 InFittedOrder(Entity subject, IEnumerable 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 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 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 LayoutOf(Entity subject) => ContactOf(subject).Profile?.Hardpoints ?? (IReadOnlyList) Array.Empty(); // 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(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 weapons, List items)[itemManager.GameplaySettings.WeaponGroupCount]; for(int i=0; i(), new List()); 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()!=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 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().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 kind) => CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType() .Select(item => ItemManager.GetData(item) as ConsumableItemData) .FirstOrDefault(data => data != null && data.Behaviors.Any(b => kind(b))); public bool HasActiveConsumable(Predicate 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(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; } } // Attempts to move a given number of items of the given type to the target Entity // Returns the number of items successfully transferred public int TryTransferItems(Entity target, CultRecordKey itemDataID, int quantity) { int quantityTransferred = 0; while (quantityTransferred < quantity) { EquippedCargoBay originInventory = CargoBays.FirstOrDefault(c => c.ItemsOfType.ContainsKey(itemDataID)); if (originInventory == null) break; var itemInstance = originInventory.ItemsOfType[itemDataID][0]; if (itemInstance is SimpleCommodity simpleCommodity) { var targetQuantity = min(simpleCommodity.Quantity, quantity - quantityTransferred); if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, simpleCommodity, targetQuantity))) { quantityTransferred += targetQuantity - simpleCommodity.Quantity; break; } quantityTransferred += targetQuantity; } else if (itemInstance is CraftedItemInstance craftedItemInstance) { if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, craftedItemInstance))) break; quantityTransferred++; } } return quantityTransferred; } 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; 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; } public T GetBehavior() where T : Behavior { foreach (var equippedItem in Equipment) if(equippedItem.Behaviors != null) foreach (var behavior in equippedItem.Behaviors) if (behavior is T b) return b; return null; } public IEnumerable GetBehaviors() 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 GetBehaviorData() 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; 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 = Parent.Position; Velocity = Parent.Velocity; } else 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 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 ThermalOnline { get; } = new ReactiveProperty(false); public ReactiveProperty DurabilityOnline { get; } = new ReactiveProperty(false); public ReadOnlyReactiveProperty Online { get; } public ReactiveProperty Enabled { get; } = new ReactiveProperty(true); public ReadOnlyReactiveProperty 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(ThermalOnline .CombineLatest(DurabilityOnline, (thermal, durability) => thermal && durability).DistinctUntilChanged()); Active = new ReadOnlyReactiveProperty(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().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() where T : class { foreach (var behavior in Behaviors) if (behavior is T b) return b; return null; } } public class EquippedCargoBay : EquippedItem { public readonly ReactiveDictionary Cargo = new ReactiveDictionary(); public readonly ItemInstance[,] Occupancy; public readonly Dictionary> ItemsOfType = new Dictionary>(); 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 positions) { positions = new List(); 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 public bool TryStore(SimpleCommodity item, int2 cargoCoord) { var itemData = ItemManager.GetData(item); 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(); 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(); ItemsOfType[item.Data.Key].Add(item); Mass += ItemManager.GetMass(item); ThermalMass += ItemManager.GetThermalMass(item); return true; } public SimpleCommodity Remove(SimpleCommodity item, int quantity) { 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 (!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); if (target.TryStore(item)) return true; // Failed to transfer full quantity, move the remaining items back to their old slot 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() where T : Behavior { foreach (var b in Behaviors) { if (!(b is T s)) continue; return s; } return null; } public T GetExposed() where T : Behavior, IInteractiveBehavior { foreach (var b in Behaviors) { if (!(b is T s) || !((IInteractiveBehavior)b).Exposed) continue; return s; } return null; } }