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