2020-12-22 03:13:43 +00:00
/ * 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/. */
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
using GameCult.Caching ;
2020-04-09 10:33:16 +01:00
using System ;
using System.Collections.Generic ;
using System.Linq ;
2026-09-17 18:22:22 +02:00
using MessagePack ;
2020-12-10 23:20:38 +00:00
using UniRx ;
2026-09-14 17:39:12 +02:00
using CultMath ;
using static CultMath . math ;
using int2 = CultMath . int2 ;
2020-04-09 10:33:16 +01:00
2020-12-10 23:20:38 +00:00
public abstract class Entity
2020-04-09 10:33:16 +01:00
{
2026-10-08 15:04:16 +02:00
// Every hull cell's temperature (kelvin) when the entity is built.
public const float StartingCellTemperature = 280 ;
2020-12-10 23:20:38 +00:00
public Zone Zone ;
2021-03-20 04:20:17 +00:00
public Faction Faction ;
2026-10-07 01:13:08 +02:00
// The mark of a zone's boss, written only by ZoneGenerator and restored from the pack. RunGoal reads it.
public bool IsBoss ;
2020-12-10 23:20:38 +00:00
public EquippableItem Hull ;
2021-04-18 05:13:20 +01:00
public EquippedItem EquippedHull ;
2020-06-01 03:11:04 +01:00
2021-01-06 14:56:39 +00:00
public float3 Position ;
2026-08-10 14:57:50 +01:00
public float2 Direction = float2 ( 0 , 1 ) ;
2020-06-01 03:11:04 +01:00
public float2 Velocity ;
2026-10-08 18:26:02 +02:00
// 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
2026-10-08 19:16:10 +02:00
// Direction is never a turn. Read only by presenters that draw ahead of the step.
2026-10-08 18:26:02 +02:00
public float TurnRate ;
Evasion term: reachable spread from the thrust and turn envelope, realised by changes of vector
Every hit price gains one factor, PDeviation(Evasion, Tracking): how far the target can
and does move off the line of fire within SolutionWindow (0.75 s). Capability is Reach, an
eight-face closed form over the ship's ManoeuvreEnvelope (summed from what each live
thruster and aether drive reports with its own Execute arithmetic, read live: a thruster's
Thrust property is stale until it fires, which is why the Djinni moved 1.6 times its stats,
M25). Behaviour is ManoeuvreTrack, observed once per Entity.Update from the measured
acceleration; a steady burn, coasting and a dither much faster than the window do not
evade, and sideways speed alone does not (ruling evasion-speed). The commit price adds the
term at any flight time, so an instant weapon feels a jinking target. Forecast owns the
term for HitProbability and Inspect, which gain Evasion and PEvasion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:48:27 +02:00
// 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 ;
2026-10-07 05:39:40 +02:00
// 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 ;
2020-12-10 23:20:38 +00:00
public float [ , ] Temperature ;
2021-01-17 23:26:53 +00:00
public float [ , ] NewTemperature ;
2021-01-17 02:45:52 +00:00
public bool2 [ , ] HullConductivity ;
2020-12-27 22:18:22 +00:00
public float [ , ] ThermalMass ;
2020-06-05 04:16:33 +01:00
2020-12-19 02:37:13 +00:00
public readonly ReactiveCollection < EquippedItem > Equipment = new ReactiveCollection < EquippedItem > ( ) ;
public readonly ReactiveCollection < EquippedCargoBay > CargoBays = new ReactiveCollection < EquippedCargoBay > ( ) ;
public readonly ReactiveCollection < EquippedDockingBay > DockingBays = new ReactiveCollection < EquippedDockingBay > ( ) ;
2021-02-26 19:01:47 +00:00
public readonly ReactiveCollection < Entity > VisibleEntities = new ReactiveCollection < Entity > ( ) ;
2021-05-11 04:45:42 +01:00
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 > ( ) ;
2020-12-10 23:20:38 +00:00
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
// Runtime-only IFF overrides for testing/manual marking and grudges; not persisted in packs.
// When set for another entity, it decides IsHostileTo outright, ahead of the derived faction rules.
// Reactive so a change can update EntityHostility and dependent grudges immediately, not on the next tick.
private readonly ReactiveDictionary < Entity , bool > _iffOverrides = new ReactiveDictionary < Entity , bool > ( ) ;
2020-12-10 23:20:38 +00:00
public Entity Parent ;
public List < Entity > Children = new List < Entity > ( ) ;
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
// 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 ;
2021-01-05 02:56:17 +00:00
2026-09-19 13:02:05 +02:00
// Cut 2 (docs/fire-control-cut.md): the stored aim-point slot. Written only by TrySelectTargetItem
// below, and nulled whenever Target changes (subscribed in Activate). Do not read this field directly
// outside Entity -- read ResolvedTargetItem, which re-validates on every call, so decay (or the item
// leaving the target) drops the aim point the moment it is no longer earned, with no loop that has to
// notice and clear it.
public ReactiveProperty < EquippedItem > TargetItem = new ReactiveProperty < EquippedItem > ( ( EquippedItem ) null ) ;
2026-10-07 12:33:20 +02:00
// Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the
// hull turns only by Ship.Turn.
public float3 Aim ;
2020-06-01 03:11:04 +01:00
public string Name ;
2021-05-16 05:24:51 +01:00
// public int Population;
2026-08-10 14:57:50 +01:00
// public Dictionary<Guid, float> Personality = new Dictionary<Guid, float>();
2020-05-06 04:56:36 +01:00
2020-12-10 23:20:38 +00:00
public readonly Dictionary < string , float > Messages = new Dictionary < string , float > ( ) ;
public readonly Dictionary < object , float > VisibilitySources = new Dictionary < object , float > ( ) ;
Delete HardpointTransforms; mount direction and arc become sim data
Entity.HardpointTransforms was written only by Unity (EntityInstance.cs),
so the simulation's notion of where a gun points was a readback from a
renderer that never runs headless -- Combat.cs threw KeyNotFoundException
on the first combat tick outside Unity. Deletes the field, its writer, and
the readback branch in Behavior.Direction, leaving the item-rotation path
(the same convention reaction thrusters use, R6) as the only source of
mount direction.
Adds FireControl (ServerShared), a static class owning whether a weapon
bears on a point: MountDirection (planar, from ItemRotation), ArcFor (the
per-hardpoint HardpointData.FiringArc override, falling back to the new
GameplaySettings.FiringArc default of 120), and InArc (the planar dot-cone
test, R7 -- target height never enters it, and an arc of 360+ passes
unconditionally so a turret hardpoint needs no new type, just a number).
Combat.cs and TurretController.cs now gate AI fire through InArc instead
of the old hardcoded 0.99 dot-product test against a live Unity transform.
Settings.cs drops AgentFiringMinDot, which was declared and read by
nothing.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:37:52 +02:00
public readonly ReactiveDictionary < Entity , float > EntityInfoGathered = new ReactiveDictionary < Entity , float > ( ) ;
2021-05-02 20:59:45 +01:00
public ( List < Weapon > weapons , List < EquippedItem > items ) [ ] WeaponGroups ;
2020-12-31 19:53:16 +00:00
public List < IPopulationAssignment > PopulationAssignments = new List < IPopulationAssignment > ( ) ;
2020-12-27 22:18:22 +00:00
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
// 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: PowerBus replaces TryConsumeEnergy/CanConsumeEnergy
Deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor
draining/filling (the demand-mechanism halves of Reactor.cs:90-141); adds
PowerBus (one per Entity, stepped once per tick before equipment executes),
IPowerConsumer on the five continuous draw sites (EnergyDraw, Thruster,
AetherDrive, Radiator, ConstantWeapon), and EquippedItem.PowerSupply as the
bus's grant ratio. Reactor keeps only its heat/throttle arithmetic, now driven
by PowerBus.NetDraw instead of an accumulator any behaviour could add into.
The four instant draws (InstantWeapon's burst/shot, Sensor's ping, Shield's hit
taken) are out of scope for the bus this cut per the map; they spend capacitor
charge directly and atomically via the new Entity.TrySpendCapacitorCharge/
CanSpendCapacitorCharge, replacing TryConsumeEnergy's old partial-drain-then-
refuse bug for that path too. Named, temporary exception closed by Cut 4.
docs/stats-and-power-cut.md Cut 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:39:38 +02:00
// 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 ;
2020-12-12 03:42:58 +00:00
public EquippedItem [ , ] GearOccupancy ;
public HardpointData [ , ] Hardpoints ;
2021-01-08 12:44:10 +00:00
public float [ , ] Armor ;
2021-02-01 08:13:14 +00:00
public float [ , ] MaxArmor ;
2020-12-10 23:20:38 +00:00
2020-12-27 22:18:22 +00:00
private EquippedItem [ ] _orderedEquipment ;
2021-02-11 03:14:20 +00:00
private List < Weapon > _weapons = new List < Weapon > ( ) ;
2021-03-04 03:58:46 +00:00
private List < Radiator > _heatsinks = new List < Radiator > ( ) ;
2021-04-18 05:13:20 +01:00
private List < ConsumableItemEffect > _activeConsumables = new List < ConsumableItemEffect > ( ) ;
2021-01-05 02:56:17 +00:00
protected bool _active ;
2021-03-04 03:58:46 +00:00
private bool _heatsinksEnabled = true ;
public bool HeatsinksEnabled
{
get = > _heatsinksEnabled ;
set
{
if ( value = = _heatsinksEnabled ) return ;
_heatsinksEnabled = value ;
foreach ( var heatsink in _heatsinks )
2021-03-07 01:10:31 +00:00
heatsink . Item . Enabled . Value = value ;
2021-03-04 03:58:46 +00:00
}
}
2026-08-10 14:57:50 +01:00
public HullData HullData { get ; }
2021-03-14 03:56:49 +00:00
public EntitySettings Settings { get ; }
2021-03-07 01:10:31 +00:00
public bool OverrideShutdown { get ; set ; }
2021-04-15 04:25:22 +01:00
public float TractorPower { get ; set ; }
2021-03-04 03:58:46 +00:00
public bool Active
{
get = > _active ;
}
2020-12-27 22:18:22 +00:00
2021-02-11 03:14:20 +00:00
public IEnumerable < Weapon > Weapons
{
get = > _weapons ;
}
2021-03-06 01:04:26 +00:00
public Shield Shield { get ; private set ; }
2021-03-06 02:42:19 +00:00
public Cockpit Cockpit { get ; private set ; }
2021-03-21 01:55:25 +00:00
public Sensor Sensor { get ; private set ; }
2021-03-06 02:42:19 +00:00
public float Heatstroke { get ; private set ; }
2021-04-10 01:47:16 +01:00
public float Hypothermia { get ; private set ; }
2021-03-06 01:04:26 +00:00
2021-02-26 19:01:47 +00:00
public float TargetRange { get ; private set ; }
2021-01-27 00:05:30 +00:00
public float MaxTemp { get ; private set ; }
public float MinTemp { get ; private set ; }
2020-12-10 23:20:38 +00:00
public ItemManager ItemManager { get ; }
public int AssignedPopulation = > PopulationAssignments . Sum ( pa = > pa . AssignedPopulation ) ;
public float Mass { get ; private set ; }
public float Visibility = > VisibilitySources . Values . Sum ( ) ;
2021-04-27 16:44:44 +01:00
public Subject < Entity > IncomingHit = new Subject < Entity > ( ) ;
2021-01-08 12:44:10 +00:00
public Subject < ( int2 pos , float damage ) > ArmorDamage = new Subject < ( int2 , float ) > ( ) ;
public Subject < ( EquippedItem item , float damage ) > ItemDamage = new Subject < ( EquippedItem , float ) > ( ) ;
2021-03-07 01:10:31 +00:00
// public Subject<EquippedItem> ItemOffline = new Subject<EquippedItem>();
// public Subject<EquippedItem> ItemOnline = new Subject<EquippedItem>();
2021-01-08 12:44:10 +00:00
public Subject < float > HullDamage = new Subject < float > ( ) ;
2021-02-26 19:01:47 +00:00
public Subject < Entity > Docked = new Subject < Entity > ( ) ;
2021-03-06 02:42:19 +00:00
public Subject < Unit > HeatstrokeRisk = new Subject < Unit > ( ) ;
2021-03-09 02:26:58 +00:00
public Subject < Unit > HeatstrokeDeath = new Subject < Unit > ( ) ;
2021-04-10 01:47:16 +01:00
public Subject < Unit > HypothermiaRisk = new Subject < Unit > ( ) ;
public Subject < Unit > HypothermiaDeath = new Subject < Unit > ( ) ;
2021-04-30 11:19:25 +01:00
public Subject < Entity > TargetedBy = new Subject < Entity > ( ) ;
public ReactiveProperty < int > TargetedByCount = new ReactiveProperty < int > ( 0 ) ;
2021-05-11 04:45:42 +01:00
public ReactiveProperty < SecurityLevel > CurrentSecurityLevel = new ReactiveProperty < SecurityLevel > ( SecurityLevel . Open ) ;
public ReadOnlyReactiveProperty < bool > PresencePermitted ;
2021-01-10 03:59:47 +00:00
public UniRx . IObservable < EquippedItem > ItemDestroyed ;
public UniRx . IObservable < int2 > HullArmorDepleted ;
public UniRx . IObservable < HardpointData > HardpointArmorDepleted ;
2021-02-26 19:01:47 +00:00
public UniRx . IObservable < Weapon > WeaponDestroyed ;
2021-04-10 01:47:16 +01:00
public UniRx . IObservable < CauseOfDeath > Death ;
2021-01-08 12:44:10 +00:00
2021-02-26 19:01:47 +00:00
private List < IDisposable > _subscriptions = new List < IDisposable > ( ) ;
2021-05-11 04:45:42 +01:00
private Dictionary < Entity , List < IDisposable > > _watchedEntitySubscriptions = new Dictionary < Entity , List < IDisposable > > ( ) ;
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
private Dictionary < Entity , List < IDisposable > > _grudgeSubscriptions = 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 } ;
2021-02-26 19:01:47 +00:00
public virtual void Activate ( )
2020-07-03 01:33:42 +01:00
{
2021-03-21 01:55:25 +00:00
//ItemManager.Log($"Entity {Name} is activating!");
2021-02-26 19:01:47 +00:00
_active = true ;
2021-03-06 02:42:19 +00:00
Heatstroke = 0 ;
2021-05-14 03:03:28 +01:00
// 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 ) ;
2021-02-26 19:01:47 +00:00
foreach ( var item in Equipment )
foreach ( var behavior in item . Behaviors )
2020-07-03 01:33:42 +01:00
{
2021-02-26 19:01:47 +00:00
if ( behavior is IInitializableBehavior initializableBehavior )
initializableBehavior . Initialize ( ) ;
2020-07-03 01:33:42 +01:00
}
2021-05-11 04:45:42 +01:00
foreach ( var entity in Zone . Entities )
{
EntityInfoGathered [ entity ] = 0 ;
EntityHostility [ entity ] = IsHostileTo ( entity ) ;
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
if ( ! IsPlayerControlled ) WatchForGrudge ( entity ) ;
2021-05-11 04:45:42 +01:00
}
_subscriptions . Add ( Zone . Entities . ObserveAdd ( ) . Subscribe ( add = >
{
EntityInfoGathered [ add . Value ] = 0 ;
EntityHostility [ add . Value ] = IsHostileTo ( add . Value ) ;
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
if ( ! IsPlayerControlled ) WatchForGrudge ( add . Value ) ;
2021-05-11 04:45:42 +01:00
} ) ) ;
2021-02-26 19:01:47 +00:00
_subscriptions . Add ( Zone . Entities . ObserveRemove ( ) . Subscribe ( remove = >
{
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
if ( Target . Value . Entity = = remove . Value ) SetTarget ( TargetRef . None ) ;
2021-02-26 19:01:47 +00:00
EntityInfoGathered . Remove ( remove . Value ) ;
2021-05-11 04:45:42 +01:00
EntityHostility . Remove ( remove . Value ) ;
2021-02-26 19:01:47 +00:00
VisibleEntities . Remove ( remove . Value ) ;
2021-05-11 04:45:42 +01:00
VisibleEnemies . Remove ( remove . Value ) ;
VisibleFriendlies . Remove ( remove . Value ) ;
Scenarios Cut 1: a stance survives docking; it goes only with an entity gone for good
Every entity's zone-removal subscription dropped its IFF override for whatever
left the zone, and TryDock removes the docking ship, so a scenario stance was
lost one way (the other entity forgot; the docked ship, deactivated, kept its
own) as soon as the player docked. The removal event already tells the two
apart: TryDock parents the ship to its dock before removing it, and every
leaving-for-good path (death, warp, unload) leaves with no parent. The override
is now dropped only for an entity that leaves unparented.
Tests: a hostile stance holds both ways, including the live hostility each side
tracks, across dock and undock; a destroyed entity takes the player's stance
toward it with it. UndockingAdmitsWithNoAgent commits Soul's probe: undock
admits unpiloted, active, with no agent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:09:12 +02:00
// A stance lasts the sitting: it goes with an entity gone for good (destroyed, warped away, unloaded), not
// with a ship docking, which leaves the zone parented to its dock (TryDock) and rejoins it on undock.
if ( remove . Value . Parent = = null ) _iffOverrides . Remove ( remove . Value ) ;
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
if ( _grudgeSubscriptions . TryGetValue ( remove . Value , out var grudgeSubs ) )
{
foreach ( var s in grudgeSubs ) s . Dispose ( ) ;
_grudgeSubscriptions . Remove ( remove . Value ) ;
}
2021-02-26 19:01:47 +00:00
} ) ) ;
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
// An override changing updates this entity's hostility toward that entity immediately,
// rather than waiting for the next per-tick derived-hostility refresh.
void RefreshHostilityFromOverride ( Entity other )
{
if ( EntityHostility . ContainsKey ( other ) )
EntityHostility [ other ] = IsHostileTo ( other ) ;
}
_subscriptions . Add ( _iffOverrides . ObserveAdd ( ) . Subscribe ( add = > RefreshHostilityFromOverride ( add . Key ) ) ) ;
_subscriptions . Add ( _iffOverrides . ObserveReplace ( ) . Subscribe ( replace = > RefreshHostilityFromOverride ( replace . Key ) ) ) ;
_subscriptions . Add ( _iffOverrides . ObserveRemove ( ) . Subscribe ( remove = > RefreshHostilityFromOverride ( remove . Key ) ) ) ;
2021-05-11 04:45:42 +01:00
_subscriptions . Add ( VisibleEnemies . ObserveRemove ( ) . Subscribe ( remove = >
2021-02-26 19:01:47 +00:00
{
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
if ( Target . Value . Entity = = remove . Value ) SetTarget ( TargetRef . None ) ;
2021-02-26 19:01:47 +00:00
} ) ) ;
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
// Only an entity is ever targeted by: a chunk has no one to tell.
_subscriptions . Add ( Target . Subscribe ( target = > target . Entity ? . TargetedBy . OnNext ( this ) ) ) ;
2021-05-11 04:45:42 +01:00
_subscriptions . Add ( TargetedBy . Subscribe ( enemy = >
{
TargetedByCount . Value + + ;
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
enemy . Target . Where ( t = > t . Entity ! = this ) . Take ( 1 ) . Subscribe ( _ = > TargetedByCount . Value - - ) ;
2021-05-11 04:45:42 +01:00
} ) ) ;
//
_subscriptions . Add ( EntityInfoGathered . ObserveReplace ( ) . Subscribe ( replace = >
{
if ( replace . OldValue < ItemManager . GameplaySettings . TargetDetectionInfoThreshold & &
replace . NewValue > ItemManager . GameplaySettings . TargetDetectionInfoThreshold )
{
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 ) ;
} ) ) ;
2026-09-19 13:02:05 +02:00
// Cut 2 (docs/fire-control-cut.md): a Target change nulls the aim point. Fires immediately on
// subscribe with whatever Target already holds, which is harmless -- TargetItem starts null anyway.
_subscriptions . Add ( Target . Subscribe ( _ = > TargetItem . Value = null ) ) ;
Cut 9: the die is not a die
9.1, blocking. FireControl seeded CultMath's xorshift32 per shot and drew once.
NextFloat returns the top 24 bits of the FIRST round, and one round cannot carry
ShotId's low-bit variation into the high bits -- so every zone's hit roll spanned
about an eighth, with the threshold fixed at zone creation. A shot was a step
function of p: below the band it never hit, above it always did. An unaided
shooter hit 0 of 18,926 shots. That is the operator's "no hits registering," and
being stable per zone is why it came and went.
The seed derivation is unchanged -- still a pure function of (zone, shot id),
Cut 6b's rule -- and only its diffusion into the first output changes, via an
fmix32 finalizer. Measured across six zone seeds: hit fraction at p=.3 went from
0.0% in five of six zones to 29-31%, and at p=.6 to 58-61%, with the roll
spanning the full range.
The defect survived three cuts because nothing in the campaign measured a
distribution -- every fixture uses accuracy 1 or 0, and a constant satisfies a
determinism test perfectly. TheDieIsUniform now asserts the observed hit
fraction is within .08 of p across several zone seeds, which is the test whose
absence was the real defect.
9.2. Sigma is floored at half a cell. HullKernel approximates a continuous
Gaussian with a discrete sum over occupied cells, and that sum collapses once
sigma falls well under one cell against an aim point that is not exactly on a
cell centre -- true of every shipped hull. Past Precision ~4, pOnHull went to
zero and a better targeting system made the weapon unable to hit anything.
9.3. An active entity's Target is now always a live entity it has info on.
Cut 8's _active guard could never have fixed the operator's LockWeapon crash --
that code only runs on active entities, and cut8's own harness header says so.
The live route was dock, target dies, undock; Deactivate disposed the
subscription that nulls a stale Target and Activate reseeded EntityInfoGathered
without reconciling it. Still not a TryGetValue in LockWeapon.
229 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:33:22 +02:00
// 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 TargetItem 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 is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
// 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 ) ;
Cut 9: the die is not a die
9.1, blocking. FireControl seeded CultMath's xorshift32 per shot and drew once.
NextFloat returns the top 24 bits of the FIRST round, and one round cannot carry
ShotId's low-bit variation into the high bits -- so every zone's hit roll spanned
about an eighth, with the threshold fixed at zone creation. A shot was a step
function of p: below the band it never hit, above it always did. An unaided
shooter hit 0 of 18,926 shots. That is the operator's "no hits registering," and
being stable per zone is why it came and went.
The seed derivation is unchanged -- still a pure function of (zone, shot id),
Cut 6b's rule -- and only its diffusion into the first output changes, via an
fmix32 finalizer. Measured across six zone seeds: hit fraction at p=.3 went from
0.0% in five of six zones to 29-31%, and at p=.6 to 58-61%, with the roll
spanning the full range.
The defect survived three cuts because nothing in the campaign measured a
distribution -- every fixture uses accuracy 1 or 0, and a constant satisfies a
determinism test perfectly. TheDieIsUniform now asserts the observed hit
fraction is within .08 of p across several zone seeds, which is the test whose
absence was the real defect.
9.2. Sigma is floored at half a cell. HullKernel approximates a continuous
Gaussian with a discrete sum over occupied cells, and that sum collapses once
sigma falls well under one cell against an aim point that is not exactly on a
cell centre -- true of every shipped hull. Past Precision ~4, pOnHull went to
zero and a better targeting system made the weapon unable to hit anything.
9.3. An active entity's Target is now always a live entity it has info on.
Cut 8's _active guard could never have fixed the operator's LockWeapon crash --
that code only runs on active entities, and cut8's own harness header says so.
The live route was dock, target dies, undock; Deactivate disposed the
subscription that nulls a stale Target and Activate reseeded EntityInfoGathered
without reconciling it. Still not a TryGetValue in LockWeapon.
229 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:33:22 +02:00
2021-05-02 20:59:45 +01:00
if ( WeaponGroups . All ( wg = > ! wg . items . Any ( ) ) )
GenerateWeaponGroups ( ) ;
2020-07-03 01:33:42 +01:00
}
2020-05-25 03:05:30 +01:00
2026-09-19 13:02:05 +02:00
// Cut 2 (docs/fire-control-cut.md): the only writer of TargetItem. Accepts null (clearing the aim
// point) or an item belonging to the current Target that FireControl.IsRevealed resolves true for this
// entity as observer. Returns whether the write took effect; a rejected non-null selection leaves
// TargetItem unchanged.
public bool TrySelectTargetItem ( EquippedItem item )
{
if ( item = = null )
{
TargetItem . Value = null ;
return true ;
}
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
// 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 ) )
2026-09-19 13:02:05 +02:00
return false ;
TargetItem . Value = item ;
return true ;
}
// The read path for TargetItem (see the field's own comment): null once the aimed item is no longer
// revealed to this entity, or no longer belongs to the current Target, even though nothing wrote
// TargetItem.Value at the moment that became true. Re-checked on every call; never cached.
public EquippedItem ResolvedTargetItem = >
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
TargetItem . Value ! = null & & Target . Value . Entity = = TargetItem . Value . Entity & & FireControl . IsRevealed ( this , TargetItem . Value )
2026-09-19 13:02:05 +02:00
? TargetItem . Value
: null ;
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
// 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.
public 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. 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), capped at 1. A ping adds nothing that
// lasts, so only the passive gain enters.
public float ChunkInfo ( ChunkId chunk )
{
var visibility = Zone . ChunkVisibility ( chunk ) ;
if ( visibility < = 0f ) return 0f ;
var position = Zone . ChunkPose ( chunk . Field , chunk . Index ) . xy ;
var rate = 0f ;
var sensors = 0 ;
foreach ( var sensor in ActiveSensors ( ) )
{
rate + = sensor . PassiveRate ( visibility , position ) ;
sensors + + ;
}
return sensors = = 0 ? 0f : saturate ( 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 ;
2026-10-01 13:02:26 +02:00
// Mining Cut 3 (Q13 A): the reach for picking a chunk -- the longest range among this entity's active weapons
2026-10-03 15:02:51 +02:00
// 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.
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
public void VisibleChunksInReach ( List < ChunkId > into )
{
2026-10-03 15:02:51 +02:00
into . Clear ( ) ;
2026-10-05 23:38:20 +02:00
Zone . Targets . Within ( MiningSearch ( ) , _targetCandidates ) ;
2026-10-03 15:02:51 +02:00
foreach ( var candidate in _targetCandidates )
if ( candidate . Target . Chunk is ChunkId chunk & & ChunkVisible ( chunk ) )
into . Add ( chunk ) ;
into . Sort ( ByIndexThenField ) ;
}
2026-10-05 23:38:20 +02:00
// 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.
public bool TargetUnderReticle ( )
{
2026-10-07 12:33:20 +02:00
var look = float2 ( Aim . x , Aim . z ) ;
2026-10-05 23:38:20 +02:00
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.
public 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.
public bool TargetNext ( ) = > CycleTarget ( false ) ;
// The reverse: the next one nearer, wrapping to the farthest; with nothing targeted, the farthest.
public 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 ;
}
2026-10-03 15:02:51 +02:00
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 ;
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
}
// Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables.
private IEnumerable < Sensor > ActiveSensors ( ) = >
Equipment . Where ( item = > item . Active . Value ) . SelectMany ( item = > item . Behaviors ) . OfType < Sensor > ( )
. Concat ( _activeConsumables . SelectMany ( effect = > effect . Behaviors ) . OfType < Sensor > ( ) ) ;
2026-09-25 16:16:53 +02:00
// 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
2026-09-25 18:33:23 +02:00
// 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.
2026-09-25 16:16:53 +02:00
public float ArmorAbsorb ( int2 cell , float damage )
Fire control Cut 3: hit authority moves to FireControl
Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the
raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and
Lightning) and replaces it with a single owner in the same cut: FireControl
now decides whether a shot hits, Zone owns the pending-shot lifecycle, and
Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the
result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies
nothing.
FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor
* pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the
weapon's payload and the shooter's resolved targeting stats at the instant the
trigger is pulled (Q6), computes the predicted intercept through the one
shared PredictedIntercept function (CombatState now calls the same function
instead of duplicating first_order_intercept), and queues a PendingShot.
FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon --
deviation from the predicted intercept, measured once, live, at that instant
-- and applies it at ArrivalTime. A shot whose flight is shorter than the
horizon commits and resolves in the same tick it fires. Rolls draw from the
shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc
or otherwise gated shot draws nothing.
Player fire is arc-gated through the same predicate AI and turret fire use
(Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every
shooter's trigger passes through), closing Q2.
Adds GameplaySettings.CommitHorizon, SchematicCellSize and
AgentMinHitProbability (first guesses; the headless suite is the tuning
harness, operator play smoke is the arbiter).
FORK reported, not worked around silently: HullCollider.SendHit/SendSplash
cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs,
ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them
from their own live Physics queries -- that's Cut 4's migration. They fire
into the void today: nothing subscribes to Hit/Splash any more for the four
weapon kinds this cut owns.
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the
gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its
evasion window, short-flight degradation, the frozen-payload snapshot, aimed
hits, the item-then-hull damage order, the planar penetration march and the
shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are
updated for the new AI gate and OnFire(int) signature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
2026-09-25 05:55:59 +02:00
var d = damage ;
Fire control Cut 3: hit authority moves to FireControl
Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the
raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and
Lightning) and replaces it with a single owner in the same cut: FireControl
now decides whether a shot hits, Zone owns the pending-shot lifecycle, and
Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the
result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies
nothing.
FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor
* pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the
weapon's payload and the shooter's resolved targeting stats at the instant the
trigger is pulled (Q6), computes the predicted intercept through the one
shared PredictedIntercept function (CombatState now calls the same function
instead of duplicating first_order_intercept), and queues a PendingShot.
FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon --
deviation from the predicted intercept, measured once, live, at that instant
-- and applies it at ArrivalTime. A shot whose flight is shorter than the
horizon commits and resolves in the same tick it fires. Rolls draw from the
shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc
or otherwise gated shot draws nothing.
Player fire is arc-gated through the same predicate AI and turret fire use
(Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every
shooter's trigger passes through), closing Q2.
Adds GameplaySettings.CommitHorizon, SchematicCellSize and
AgentMinHitProbability (first guesses; the headless suite is the tuning
harness, operator play smoke is the arbiter).
FORK reported, not worked around silently: HullCollider.SendHit/SendSplash
cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs,
ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them
from their own live Physics queries -- that's Cut 4's migration. They fire
into the void today: nothing subscribes to Hit/Splash any more for the four
weapon kinds this cut owns.
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the
gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its
evasion window, short-flight degradation, the frozen-payload snapshot, aimed
hits, the item-then-hull damage order, the planar penetration march and the
shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are
updated for the new AI gate and OnFire(int) signature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
2026-09-25 05:55:59 +02:00
if ( d > 0f )
Fire control Cut 3: hit authority moves to FireControl
Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the
raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and
Lightning) and replaces it with a single owner in the same cut: FireControl
now decides whether a shot hits, Zone owns the pending-shot lifecycle, and
Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the
result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies
nothing.
FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor
* pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the
weapon's payload and the shooter's resolved targeting stats at the instant the
trigger is pulled (Q6), computes the predicted intercept through the one
shared PredictedIntercept function (CombatState now calls the same function
instead of duplicating first_order_intercept), and queues a PendingShot.
FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon --
deviation from the predicted intercept, measured once, live, at that instant
-- and applies it at ArrivalTime. A shot whose flight is shorter than the
horizon commits and resolves in the same tick it fires. Rolls draw from the
shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc
or otherwise gated shot draws nothing.
Player fire is arc-gated through the same predicate AI and turret fire use
(Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every
shooter's trigger passes through), closing Q2.
Adds GameplaySettings.CommitHorizon, SchematicCellSize and
AgentMinHitProbability (first guesses; the headless suite is the tuning
harness, operator play smoke is the arbiter).
FORK reported, not worked around silently: HullCollider.SendHit/SendSplash
cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs,
ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them
from their own live Physics queries -- that's Cut 4's migration. They fire
into the void today: nothing subscribes to Hit/Splash any more for the four
weapon kinds this cut owns.
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the
gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its
evasion window, short-flight degradation, the frozen-payload snapshot, aimed
hits, the item-then-hull damage order, the planar penetration march and the
shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are
updated for the new AI gate and OnFire(int) signature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
2026-09-25 05:55:59 +02:00
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 ) ;
}
Fire control Cut 3: hit authority moves to FireControl
Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the
raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and
Lightning) and replaces it with a single owner in the same cut: FireControl
now decides whether a shot hits, Zone owns the pending-shot lifecycle, and
Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the
result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies
nothing.
FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor
* pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the
weapon's payload and the shooter's resolved targeting stats at the instant the
trigger is pulled (Q6), computes the predicted intercept through the one
shared PredictedIntercept function (CombatState now calls the same function
instead of duplicating first_order_intercept), and queues a PendingShot.
FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon --
deviation from the predicted intercept, measured once, live, at that instant
-- and applies it at ArrivalTime. A shot whose flight is shorter than the
horizon commits and resolves in the same tick it fires. Rolls draw from the
shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc
or otherwise gated shot draws nothing.
Player fire is arc-gated through the same predicate AI and turret fire use
(Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every
shooter's trigger passes through), closing Q2.
Adds GameplaySettings.CommitHorizon, SchematicCellSize and
AgentMinHitProbability (first guesses; the headless suite is the tuning
harness, operator play smoke is the arbiter).
FORK reported, not worked around silently: HullCollider.SendHit/SendSplash
cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs,
ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them
from their own live Physics queries -- that's Cut 4's migration. They fire
into the void today: nothing subscribes to Hit/Splash any more for the four
weapon kinds this cut owns.
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the
gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its
evasion window, short-flight degradation, the frozen-payload snapshot, aimed
hits, the item-then-hull damage order, the planar penetration march and the
shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are
updated for the new AI gate and OnFire(int) signature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
2026-09-25 16:16:53 +02:00
return d ;
}
2026-09-25 17:01:05 +02:00
// 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`
2026-09-25 18:33:23 +02:00
// 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
2026-09-26 01:17:19 +02:00
// 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.
2026-09-25 17:01:05 +02:00
public void ItemAbsorb ( EquippedItem item , Span < float > incoming )
2026-09-25 16:16:53 +02:00
{
2026-09-25 17:01:05 +02:00
var total = 0f ;
for ( var i = 0 ; i < incoming . Length ; i + + ) total + = incoming [ i ] ;
if ( total < = 0.1f ) return ;
2026-09-25 16:16:53 +02:00
2026-09-25 17:01:05 +02:00
var before = item . EquippableItem . Durability ;
var absorbed = min ( total , before ) ;
item . EquippableItem . Durability = max ( before - absorbed , 0f ) ;
2026-09-25 18:33:23 +02:00
var fraction = absorbed / total ;
2026-09-25 17:01:05 +02:00
for ( var i = 0 ; i < incoming . Length ; i + + )
2026-09-25 05:55:59 +02:00
{
2026-09-25 17:01:05 +02:00
if ( incoming [ i ] > 0f ) ItemDamage . OnNext ( ( item , incoming [ i ] ) ) ;
incoming [ i ] - = incoming [ i ] * fraction ;
Fire control Cut 3: hit authority moves to FireControl
Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the
raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and
Lightning) and replaces it with a single owner in the same cut: FireControl
now decides whether a shot hits, Zone owns the pending-shot lifecycle, and
Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the
result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies
nothing.
FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor
* pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the
weapon's payload and the shooter's resolved targeting stats at the instant the
trigger is pulled (Q6), computes the predicted intercept through the one
shared PredictedIntercept function (CombatState now calls the same function
instead of duplicating first_order_intercept), and queues a PendingShot.
FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon --
deviation from the predicted intercept, measured once, live, at that instant
-- and applies it at ArrivalTime. A shot whose flight is shorter than the
horizon commits and resolves in the same tick it fires. Rolls draw from the
shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc
or otherwise gated shot draws nothing.
Player fire is arc-gated through the same predicate AI and turret fire use
(Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every
shooter's trigger passes through), closing Q2.
Adds GameplaySettings.CommitHorizon, SchematicCellSize and
AgentMinHitProbability (first guesses; the headless suite is the tuning
harness, operator play smoke is the arbiter).
FORK reported, not worked around silently: HullCollider.SendHit/SendSplash
cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs,
ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them
from their own live Physics queries -- that's Cut 4's migration. They fire
into the void today: nothing subscribes to Hit/Splash any more for the four
weapon kinds this cut owns.
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the
gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its
evasion window, short-flight degradation, the frozen-payload snapshot, aimed
hits, the item-then-hull damage order, the planar penetration march and the
shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are
updated for the new AI gate and OnFire(int) signature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
2026-09-25 05:55:59 +02:00
}
// 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 )
Fire control Cut 3: hit authority moves to FireControl
Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the
raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and
Lightning) and replaces it with a single owner in the same cut: FireControl
now decides whether a shot hits, Zone owns the pending-shot lifecycle, and
Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the
result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies
nothing.
FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor
* pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the
weapon's payload and the shooter's resolved targeting stats at the instant the
trigger is pulled (Q6), computes the predicted intercept through the one
shared PredictedIntercept function (CombatState now calls the same function
instead of duplicating first_order_intercept), and queues a PendingShot.
FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon --
deviation from the predicted intercept, measured once, live, at that instant
-- and applies it at ArrivalTime. A shot whose flight is shorter than the
horizon commits and resolves in the same tick it fires. Rolls draw from the
shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc
or otherwise gated shot draws nothing.
Player fire is arc-gated through the same predicate AI and turret fire use
(Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every
shooter's trigger passes through), closing Q2.
Adds GameplaySettings.CommitHorizon, SchematicCellSize and
AgentMinHitProbability (first guesses; the headless suite is the tuning
harness, operator play smoke is the arbiter).
FORK reported, not worked around silently: HullCollider.SendHit/SendSplash
cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs,
ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them
from their own live Physics queries -- that's Cut 4's migration. They fire
into the void today: nothing subscribes to Hit/Splash any more for the four
weapon kinds this cut owns.
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the
gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its
evasion window, short-flight degradation, the frozen-payload snapshot, aimed
hits, the item-then-hull damage order, the planar penetration march and the
shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are
updated for the new AI gate and OnFire(int) signature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
2026-09-25 05:55:59 +02:00
Hull . Durability - = damage ;
HullDamage . OnNext ( damage ) ;
Fire control Cut 3: hit authority moves to FireControl
Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the
raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and
Lightning) and replaces it with a single owner in the same cut: FireControl
now decides whether a shot hits, Zone owns the pending-shot lifecycle, and
Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the
result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies
nothing.
FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor
* pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the
weapon's payload and the shooter's resolved targeting stats at the instant the
trigger is pulled (Q6), computes the predicted intercept through the one
shared PredictedIntercept function (CombatState now calls the same function
instead of duplicating first_order_intercept), and queues a PendingShot.
FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon --
deviation from the predicted intercept, measured once, live, at that instant
-- and applies it at ArrivalTime. A shot whose flight is shorter than the
horizon commits and resolves in the same tick it fires. Rolls draw from the
shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc
or otherwise gated shot draws nothing.
Player fire is arc-gated through the same predicate AI and turret fire use
(Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every
shooter's trigger passes through), closing Q2.
Adds GameplaySettings.CommitHorizon, SchematicCellSize and
AgentMinHitProbability (first guesses; the headless suite is the tuning
harness, operator play smoke is the arbiter).
FORK reported, not worked around silently: HullCollider.SendHit/SendSplash
cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs,
ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them
from their own live Physics queries -- that's Cut 4's migration. They fire
into the void today: nothing subscribes to Hit/Splash any more for the four
weapon kinds this cut owns.
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the
gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its
evasion window, short-flight degradation, the frozen-payload snapshot, aimed
hits, the item-then-hull damage order, the planar penetration march and the
shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are
updated for the new AI gate and OnFire(int) signature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
}
2026-09-24 18:55:53 +02:00
// Cut 12.1 (docs/fire-control-cut.md): the one schematic-frame owner. Maps a world-planar vector into this
2026-09-25 05:55:59 +02:00
// entity's own schematic frame -- x = starboard, y = bow -- so FireControl.Apply's lane walk and
2026-09-26 01:17:19 +02:00
// FireControl.Detonate's per-entity point conversion both read the same transform instead of each carrying
// their own copy of forward/right.
2026-09-24 18:55:53 +02:00
public float2 ToSchematic ( float2 worldPlanar )
{
var forward = normalize ( Direction ) ;
var right = float2 ( forward . y , - forward . x ) ;
return float2 ( dot ( worldPlanar , right ) , dot ( worldPlanar , forward ) ) ;
}
2026-09-26 01:17:19 +02:00
// 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 ;
}
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
// Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null)
// until this entity detects them, mirroring the existing detection model (VisibleEntities, driven
// by EntityInfoGathered crossing TargetDetectionInfoThreshold). Detected but not yet hostility-rated
// by the other entity also reads as unknown.
public bool? PerceivedStanceOf ( Entity other ) = >
VisibleEntities . Contains ( other ) & & other . EntityHostility . TryGetValue ( this , out var hostile )
? hostile
: ( bool? ) null ;
// Non-player entities hold a grudge: once another entity's stance toward THIS one turns hostile
// (its own override or its derived rule) WHILE this entity detects it, this entity sets a sticky
// hostile override on it back, event-driven off that entity's own EntityHostility changes and off
// this entity's own detection (VisibleEntities) picking up an already-hostile entity. It never
// forgives on its own; only a future utility-evaluation pass is meant to lift a grudge. Leaving the
// zone clears overrides (see the Zone.Entities removal subscription above), so a grudge is also
// cleared then -- a known gap, flagged as a follow-up rather than solved here.
private void WatchForGrudge ( Entity other )
{
void CheckGrudge ( bool otherIsHostileToThis )
{
if ( otherIsHostileToThis & & VisibleEntities . Contains ( other ) & & ! _iffOverrides . ContainsKey ( other ) )
SetIff ( other , true ) ;
}
var subscriptions = new List < IDisposable >
{
other . EntityHostility . ObserveAdd ( )
. Where ( add = > add . Key = = this )
. Subscribe ( add = > CheckGrudge ( add . Value ) ) ,
other . EntityHostility . ObserveReplace ( )
. Where ( replace = > replace . Key = = this )
. Subscribe ( replace = > CheckGrudge ( replace . NewValue ) ) ,
// Detecting an entity whose stance toward this one is already hostile grudges it immediately.
VisibleEntities . ObserveAdd ( )
. Where ( add = > add . Value = = other )
. Subscribe ( _ = >
{
if ( other . EntityHostility . TryGetValue ( this , out var hostile ) )
CheckGrudge ( hostile ) ;
} )
} ;
_grudgeSubscriptions [ other ] = subscriptions ;
if ( other . EntityHostility . TryGetValue ( this , out var currentlyHostile ) )
CheckGrudge ( currentlyHostile ) ;
}
2021-02-26 19:01:47 +00:00
public virtual void Deactivate ( )
{
2021-03-21 01:55:25 +00:00
//ItemManager.Log($"Entity {Name} is deactivating!");
2021-02-26 19:01:47 +00:00
foreach ( var s in _subscriptions ) s . Dispose ( ) ;
_subscriptions . Clear ( ) ;
2021-05-11 04:45:42 +01:00
foreach ( var ss in _watchedEntitySubscriptions . Values ) foreach ( var s in ss ) s . Dispose ( ) ;
_watchedEntitySubscriptions . Clear ( ) ;
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
foreach ( var ss in _grudgeSubscriptions . Values ) foreach ( var s in ss ) s . Dispose ( ) ;
_grudgeSubscriptions . Clear ( ) ;
2021-02-26 19:01:47 +00:00
_active = false ;
EntityInfoGathered . Clear ( ) ;
VisibleEntities . Clear ( ) ;
2021-05-11 04:45:42 +01:00
VisibleEnemies . Clear ( ) ;
VisibleFriendlies . Clear ( ) ;
2021-02-26 19:01:47 +00:00
}
2021-01-05 02:56:17 +00:00
2021-03-14 03:56:49 +00:00
public Entity ( ItemManager itemManager , Zone zone , EquippableItem hull , EntitySettings settings )
2020-04-09 10:33:16 +01:00
{
2026-09-17 18:22:22 +02:00
Settings = MessagePackSerializer . Deserialize < EntitySettings > ( MessagePackSerializer . Serialize ( settings ) ) ;
2020-12-10 23:20:38 +00:00
ItemManager = itemManager ;
2020-05-07 03:53:54 +01:00
Zone = zone ;
2020-04-23 03:31:17 +01:00
Hull = hull ;
2026-08-10 14:57:50 +01:00
HullData = itemManager . GetData ( hull ) as HullData ;
Name = HullData . Name ;
Cut 3: PowerBus replaces TryConsumeEnergy/CanConsumeEnergy
Deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor
draining/filling (the demand-mechanism halves of Reactor.cs:90-141); adds
PowerBus (one per Entity, stepped once per tick before equipment executes),
IPowerConsumer on the five continuous draw sites (EnergyDraw, Thruster,
AetherDrive, Radiator, ConstantWeapon), and EquippedItem.PowerSupply as the
bus's grant ratio. Reactor keeps only its heat/throttle arithmetic, now driven
by PowerBus.NetDraw instead of an accumulator any behaviour could add into.
The four instant draws (InstantWeapon's burst/shot, Sensor's ping, Shield's hit
taken) are out of scope for the bus this cut per the map; they spend capacitor
charge directly and atomically via the new Entity.TrySpendCapacitorCharge/
CanSpendCapacitorCharge, replacing TryConsumeEnergy's old partial-drain-then-
refuse bug for that path too. Named, temporary exception closed by Cut 4.
docs/stats-and-power-cut.md Cut 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:39:38 +02:00
PowerBus = new PowerBus ( this ) ;
2021-01-08 12:44:10 +00:00
MapEntity ( ) ;
2021-05-02 20:59:45 +01:00
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 > ( ) ) ;
2021-01-10 03:59:47 +00:00
ItemDestroyed = ItemDamage . Where ( x = > x . item . EquippableItem . Durability < . 01f ) . Select ( x = > x . item ) ;
2021-02-26 19:01:47 +00:00
WeaponDestroyed = ItemDestroyed . Select ( x = > x . Behaviors . FirstOrDefault ( b = > b is Weapon ) as Weapon ) . Where ( x = > x ! = null ) ;
2021-01-10 03:59:47 +00:00
HullArmorDepleted = ArmorDamage . Where ( x = > Armor [ x . pos . x , x . pos . y ] < . 01f ) . Select ( x = > x . pos ) ;
2021-04-10 01:47:16 +01:00
Death = HullDamage . Where ( _ = > Hull . Durability < . 01f ) . Select ( _ = > CauseOfDeath . HullDestroyed )
. Merge ( HeatstrokeDeath . Select ( _ = > CauseOfDeath . Heatstroke ) )
. Merge ( HypothermiaDeath . Select ( _ = > CauseOfDeath . Hypothermia ) )
2026-09-29 07:36:04 +02:00
. 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 ) ;
2021-02-26 19:01:47 +00:00
2021-05-14 03:03:28 +01:00
//CurrentSecurityLevel.Value = SecurityLevel.Open;
2021-05-11 04:45:42 +01:00
}
2021-04-30 11:19:25 +01:00
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
// 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.
public void SetIff ( Entity other , bool? hostile )
{
if ( hostile . HasValue ) _iffOverrides [ other ] = hostile . Value ;
else _iffOverrides . Remove ( other ) ;
}
2021-05-11 04:45:42 +01:00
public bool IsHostileTo ( Entity other , bool recursive = false )
{
Add manual IFF overrides, event-driven grudges, and detection-gated stance
Entity.SetIff(other, hostile?) is the one owner of a runtime-only, per-entity
override (a ReactiveDictionary, not persisted in packs). IsHostileTo checks it
first, but only for the top-level (non-recursive) query: the derived rule's
internal reciprocity check (recursive=true) skips overrides entirely, so one
entity's override can never leak into another entity's own derived hostility.
An override change refreshes EntityHostility immediately via a dedicated
subscription, so dependents (weapons, HUD, grudges) never need a tick to see
it. Overrides are cleared when an entity leaves the zone (existing removal
subscription).
Non-player entities (ships not IsPlayerShip, stations, turrets) hold a grudge:
WatchForGrudge subscribes to another entity's EntityHostility changes toward
this one, plus this entity's own VisibleEntities.ObserveAdd, so a hostile
stance grudges immediately whether the marking or the detection happens
second -- purely event-driven, no per-tick polling. A grudge only takes while
this entity currently detects the marker (VisibleEntities), and once set it
never re-evaluates (sticky; forgiveness is out of scope until a utility-
evaluation pass exists). Player-controlled entities never grudge automatically.
PerceivedStanceOf(other) exposes another entity's stance toward this one for
UI/AI, gated the same way: unknown (null) until this entity detects them.
Known follow-up: leaving the zone clears overrides, which also clears a
grudge. Left as a known gap per the operator's ruling, not solved here.
Of the per-tick derived-hostility refresh's inputs (Entity.cs ~955-964),
PresencePermitted is already a ReactiveProperty; Faction and zone ownership
are not, so converting that refresh to fully event-driven is future work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:00:41 +02:00
// An override decides only the stance of the entity that holds it. The recursive=true calls
// below are the derived rule asking "is the other entity hostile to me" purely to compute ITS
// OWN reciprocal stance; skipping the override check there stops one entity's override (e.g. a
// player going neutral) from leaking into another entity's derived hostility.
if ( ! recursive & & _iffOverrides . TryGetValue ( other , out var overrideHostile ) )
return overrideHostile ;
2021-05-11 04:45:42 +01:00
if ( Faction = = null )
return ! recursive & & other . Faction ! = null & & other . IsHostileTo ( this , true ) ;
// TODO: Inter-faction hostility
// When the entity faction owns the zone, they are hostile to trespassers or those hostile to them
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
if ( Faction = = Zone . GalaxyZone . Owner )
2021-05-14 03:03:28 +01:00
return recursive ? ! ( other . PresencePermitted ? . Value ? ? true ) : ! ( other . PresencePermitted ? . Value ? ? true ) | | other . IsHostileTo ( this , true ) ;
2021-05-11 04:45:42 +01:00
return ! recursive & & other . IsHostileTo ( this , true ) ;
2021-02-26 19:01:47 +00:00
}
2021-05-11 04:45:42 +01:00
// TODO: Inter-faction relationships
2021-05-12 01:41:18 +01:00
public FactionRelationship GetFactionRelationship ( Faction faction )
{
if ( faction = = null )
return FactionRelationship . Neutral ;
if ( this is Ship { IsPlayerShip : true } )
2021-05-14 03:03:28 +01:00
return Zone . Galaxy . FactionRelationships [ faction ] ;
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
return faction = = Faction ? FactionRelationship . Beloved : FactionRelationship . Neutral ;
2021-05-12 01:41:18 +01:00
}
2021-05-12 00:15:11 +01:00
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 ;
2021-05-11 04:45:42 +01:00
2021-04-18 05:13:20 +01:00
public void ActivateConsumable ( ConsumableItem item )
{
_activeConsumables . Add ( new ConsumableItemEffect ( item , this ) ) ;
}
2026-10-09 20:17:47 +02:00
// Derived, never written: true iff an active consumable effect carries a ThrottleLock.
public bool ThrottleLocked = > _activeConsumables . Any ( e = > e . Behaviors . OfType < ThrottleLock > ( ) . Any ( ) ) ;
2026-08-10 14:57:50 +01:00
public ConsumableItemEffect FindActiveConsumable ( ConsumableItemData data )
2026-07-13 16:19:35 +01:00
{
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
return _activeConsumables . FirstOrDefault ( ac = > ac . Data = = data ) ;
2026-06-16 07:01:50 +01:00
}
2026-08-10 14:57:50 +01:00
public bool CanActivateConsumable ( ConsumableItemData data )
2026-06-16 07:18:14 +01:00
{
2026-08-10 14:57:50 +01:00
return data . Stackable | | FindActiveConsumable ( data ) = = null ;
2026-07-13 16:19:35 +01:00
}
2026-08-10 14:57:50 +01:00
public bool TryActivateConsumable ( ConsumableItemData data )
2026-06-16 07:18:14 +01:00
{
2026-08-10 14:57:50 +01:00
if ( ! CanActivateConsumable ( data ) ) return false ;
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
var key = ItemManager . ItemData . RefOf < ItemData > ( data ) . Key ;
var bay = FindItemInCargo ( key ) ;
2026-06-14 07:20:06 +01:00
if ( bay = = null ) return false ;
2026-08-10 14:57:50 +01:00
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
var item = ( ConsumableItem ) bay . ItemsOfType [ key ] . First ( ) ;
2026-06-14 07:20:06 +01:00
ActivateConsumable ( item ) ;
bay . Remove ( item ) ;
return true ;
}
2021-01-08 12:44:10 +00:00
private void MapEntity ( )
2020-06-05 04:16:33 +01:00
{
2026-08-10 14:57:50 +01:00
var hullData = ItemManager . GetData ( Hull ) as HullData ;
2026-10-03 14:57:27 +02:00
EquippedHull = new EquippedItem ( ItemManager , Hull , int2 . zero , this , null ) ;
2021-04-18 05:13:20 +01:00
Equipment . Add ( EquippedHull ) ;
2026-09-30 12:52:44 +02:00
// A bare hull, one TryEquip never ran on, still updates: its ordered equipment is the hull alone.
_orderedEquipment = Equipment . ToArray ( ) ;
2026-08-10 14:57:50 +01:00
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 )
2020-06-05 04:16:33 +01:00
{
2020-12-10 23:20:38 +00:00
foreach ( var hardpointCoord in hardpoint . Shape . Coordinates )
{
var hullCoord = hardpoint . Position + hardpointCoord ;
2020-12-12 03:42:58 +00:00
Hardpoints [ hullCoord . x , hullCoord . y ] = hardpoint ;
2020-12-10 23:20:38 +00:00
}
2020-07-03 01:33:42 +01:00
}
2026-08-10 14:57:50 +01:00
var cellCount = hullData . Shape . Coordinates . Length ;
foreach ( var v in hullData . Shape . Coordinates )
2021-02-01 08:13:14 +00:00
{
2026-08-10 14:57:50 +01:00
Armor [ v . x , v . y ] = hullData . Armor ;
MaxArmor [ v . x , v . y ] = hullData . Armor ;
2021-02-01 08:13:14 +00:00
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 ;
}
2026-10-08 15:04:16 +02:00
Temperature [ v . x , v . y ] = StartingCellTemperature ;
2026-08-10 14:57:50 +01:00
ThermalMass [ v . x , v . y ] = hullData . Mass * hullData . SpecificHeat / cellCount ;
2021-02-01 08:13:14 +00:00
}
2026-08-10 14:57:50 +01:00
GearOccupancy = new EquippedItem [ hullData . Shape . Width , hullData . Shape . Height ] ;
2020-05-06 04:56:36 +01:00
}
2021-05-02 20:59:45 +01:00
public void GenerateWeaponGroups ( )
2021-02-26 19:01:47 +00:00
{
foreach ( var group in Weapons
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
. GroupBy ( w = > w . Item . EquippableItem . Data . Key )
2021-04-18 05:13:20 +01:00
. OrderBy ( wg = > wg . Average ( w = > w . Range ) )
2021-02-26 19:01:47 +00:00
. Select ( ( weapons , index ) = > ( weapons , index ) ) )
{
2021-05-02 20:59:45 +01:00
WeaponGroups [ group . index ] . weapons = group . weapons . ToList ( ) ;
WeaponGroups [ group . index ] . items = group . weapons . Select ( w = > w . Item ) . ToList ( ) ;
2021-02-26 19:01:47 +00:00
}
}
2021-02-01 08:13:14 +00:00
public void AddHeat ( int2 position , float heat , bool ignoreThermalMass = false )
2020-05-06 04:56:36 +01:00
{
2021-02-01 08:13:14 +00:00
if ( ignoreThermalMass )
Temperature [ position . x , position . y ] + = heat ;
else
Temperature [ position . x , position . y ] + = heat / ThermalMass [ position . x , position . y ] ;
2020-12-31 19:53:16 +00:00
}
2020-06-11 02:55:11 +01:00
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
public int CountItemsInCargo ( CultRecordKey itemDataID )
2020-12-31 19:53:16 +00:00
{
int sum = 0 ;
foreach ( var x in CargoBays )
2020-07-03 01:33:42 +01:00
{
2026-08-10 14:57:50 +01:00
if ( x . ItemsOfType . ContainsKey ( itemDataID ) )
2020-12-31 19:53:16 +00:00
{
2026-08-10 14:57:50 +01:00
foreach ( var i in x . ItemsOfType [ itemDataID ] ) sum + = i is SimpleCommodity simpleCommodity ? simpleCommodity . Quantity : 1 ;
2020-12-31 19:53:16 +00:00
}
2020-07-03 01:33:42 +01:00
}
2020-06-05 04:16:33 +01:00
2020-12-31 19:53:16 +00:00
return sum ;
2020-12-27 22:18:22 +00:00
}
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
public EquippedCargoBay FindItemInCargo ( CultRecordKey itemDataID )
2026-06-14 23:09:49 +01:00
{
2026-08-10 14:57:50 +01:00
return CargoBays . FirstOrDefault ( c = > c . ItemsOfType . ContainsKey ( itemDataID ) ) ;
2026-06-14 23:09:49 +01:00
}
2021-03-20 04:20:17 +00:00
public Shape UnoccupiedSpace
{
get
{
2026-08-10 14:57:50 +01:00
var emptyShape = new Shape ( HullData . Shape . Width , HullData . Shape . Height ) ;
foreach ( var v in HullData . Shape . Coordinates )
2021-03-20 04:20:17 +00:00
{
2026-09-11 23:23:19 +02:00
// 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 )
2021-03-20 04:20:17 +00:00
emptyShape [ v ] = true ;
}
return emptyShape ;
}
}
2020-12-10 23:20:38 +00:00
// Attempts to move a given number of items of the given type to the target Entity
// Returns the number of items successfully transferred
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
public int TryTransferItems ( Entity target , CultRecordKey itemDataID , int quantity )
2020-06-10 02:45:10 +01:00
{
2020-12-10 23:20:38 +00:00
int quantityTransferred = 0 ;
while ( quantityTransferred < quantity )
{
2026-08-10 14:57:50 +01:00
EquippedCargoBay originInventory = CargoBays . FirstOrDefault ( c = > c . ItemsOfType . ContainsKey ( itemDataID ) ) ;
2020-12-10 23:20:38 +00:00
if ( originInventory = = null ) break ;
2026-08-10 14:57:50 +01:00
var itemInstance = originInventory . ItemsOfType [ itemDataID ] [ 0 ] ;
2020-12-10 23:20:38 +00:00
if ( itemInstance is SimpleCommodity simpleCommodity )
{
2026-08-10 14:57:50 +01:00
var targetQuantity = min ( simpleCommodity . Quantity , quantity - quantityTransferred ) ;
2020-12-19 02:37:13 +00:00
if ( ! target . CargoBays . Any ( c = > originInventory . TryTransferItem ( c , simpleCommodity , targetQuantity ) ) )
2020-12-10 23:20:38 +00:00
{
quantityTransferred + = targetQuantity - simpleCommodity . Quantity ;
break ;
}
quantityTransferred + = targetQuantity ;
}
else if ( itemInstance is CraftedItemInstance craftedItemInstance )
{
2020-12-19 02:37:13 +00:00
if ( ! target . CargoBays . Any ( c = > originInventory . TryTransferItem ( c , craftedItemInstance ) ) )
2020-12-10 23:20:38 +00:00
break ;
quantityTransferred + + ;
}
}
2020-06-10 02:45:10 +01:00
2020-12-10 23:20:38 +00:00
return quantityTransferred ;
2020-06-05 04:16:33 +01:00
}
2021-03-04 03:58:46 +00:00
public EquippableItem TryUnequip ( EquippedItem item )
2020-06-05 04:16:33 +01:00
{
2021-03-04 03:58:46 +00:00
// Don't allow unequipping when the entity is active
if ( _active ) return null ;
2020-12-10 23:20:38 +00:00
if ( item . EquippableItem = = null )
{
ItemManager . Log ( "Attempted to remove equipped item with no equippable item on it! This should be impossible!" ) ;
return null ;
}
2020-06-05 04:16:33 +01:00
2020-12-10 23:20:38 +00:00
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 ;
}
2020-06-05 04:16:33 +01:00
2020-12-19 02:37:13 +00:00
CargoBays . Remove ( cargoBay ) ;
2020-12-10 23:20:38 +00:00
}
2020-12-19 02:37:13 +00:00
Equipment . Remove ( item ) ;
Cut 5: priority tiers replace PowerBus's single shared GrantRatio
PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their
per-behaviour-kind defaults: Radiator is Critical (the closest thing this game
has to life support -- a starved radiator cascades into wear and shutdown
across the whole hull), Shield is High, Thruster/AetherDrive are Medium,
ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility.
IPowerConsumer.DefaultPowerTier lets each behaviour own its own default.
EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the
unit rather than the design; EquippedItem's constructor seeds a kind default
the first time a unit is equipped, only when the field is still
PowerTiers.Unassigned, and never again -- a tier is a stored choice, not
something the bus re-derives.
PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in
full before the next tier sees anything left, an empty tier passes its whole
share down, and within a tier every consumer rations by the same
grant/demand fraction so unequal requests still divide proportionally. A new
TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to
read starvation from directly, instead of reconstructing it from every item's
PowerSupply.
Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its
only implementer): the bus already steps before any equipment's Execute, so
equip/behaviour order never decided who got fed even before this cut, and
nothing else needed the interface (subject to the map's O3 operator check).
docs/stats-and-power-cut.md Cut 5 / §1.3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
_orderedEquipment = Equipment . ToArray ( ) ;
2020-12-10 23:20:38 +00:00
2026-08-10 14:57:50 +01:00
var hullData = ItemManager . GetData ( Hull ) as HullData ;
var itemData = ItemManager . GetData ( item . EquippableItem ) ;
foreach ( var i in hullData . Shape . Coordinates )
2021-01-17 02:45:52 +00:00
if ( GearOccupancy [ i . x , i . y ] = = item )
2020-12-27 22:18:22 +00:00
{
2026-08-10 14:57:50 +01:00
ThermalMass [ i . x , i . y ] - = ItemManager . GetThermalMass ( item . EquippableItem ) / itemData . Shape . Coordinates . Length ;
2021-01-17 02:45:52 +00:00
GearOccupancy [ i . x , i . y ] = null ;
2020-12-27 22:18:22 +00:00
}
2026-08-10 14:57:50 +01:00
Mass - = itemData . Mass ;
2021-02-11 03:14:20 +00:00
foreach ( var b in item . Behaviors )
2021-03-02 23:02:19 +00:00
{
2021-02-11 03:14:20 +00:00
if ( b is Weapon weapon )
2026-09-14 22:30:40 +02:00
{
2021-03-02 23:02:19 +00:00
_weapons . Remove ( weapon ) ;
2026-09-14 22:30:40 +02:00
foreach ( var group in WeaponGroups ) { group . items . Remove ( item ) ; group . weapons . Remove ( weapon ) ; }
}
2021-03-06 01:04:26 +00:00
if ( b is Radiator heatsink )
2021-03-04 03:58:46 +00:00
_heatsinks . Remove ( heatsink ) ;
2021-03-06 01:04:26 +00:00
if ( b is Shield )
Shield = null ;
2021-03-06 02:42:19 +00:00
if ( b is Cockpit )
Cockpit = null ;
2021-03-21 01:55:25 +00:00
if ( b is Sensor )
Sensor = null ;
2021-03-02 23:02:19 +00:00
}
2020-06-05 04:16:33 +01:00
2026-09-18 20:41:42 +02:00
// 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 ) ;
2020-12-10 23:20:38 +00:00
return item . EquippableItem ;
2020-06-05 04:16:33 +01:00
}
2026-10-03 14:57:27 +02:00
// 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 )
2020-06-05 04:16:33 +01:00
{
2026-10-03 14:57:27 +02:00
hardpoint = null ;
2020-12-10 23:20:38 +00:00
// Items without specific hardpoints on the ship can be freely rotated and placed anywhere
2026-08-10 14:57:50 +01:00
if ( itemData . HardpointType = = HardpointType . Tool )
2020-12-10 23:20:38 +00:00
{
// Check every cell of the item's shape
2026-08-10 14:57:50 +01:00
foreach ( var i in itemData . Shape . Coordinates )
2020-12-10 23:20:38 +00:00
{
2026-09-12 23:22:36 +02:00
// 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.
2026-08-10 14:57:50 +01:00
var itemCoord = hullCoord + itemData . Shape . Rotate ( i , item . Rotation ) ;
2026-09-12 23:22:36 +02:00
if ( ! hullData . InteriorCells [ itemCoord ] | | GearOccupancy [ itemCoord . x , itemCoord . y ] ! = null )
2021-01-17 02:45:52 +00:00
return false ;
2020-12-10 23:20:38 +00:00
}
}
else
{
2026-10-03 14:57:27 +02:00
hardpoint = HardpointAt ( itemData , hullData , hullCoord ) ;
2020-12-10 23:20:38 +00:00
if ( hardpoint = = null ) return false ;
2026-08-10 14:57:50 +01:00
item . Rotation = hardpoint . Rotation ;
2020-12-10 23:20:38 +00:00
}
return true ;
2020-06-05 04:16:33 +01:00
}
2026-09-30 23:13:37 +02:00
// 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 ;
}
2020-12-10 23:20:38 +00:00
// 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 )
2020-06-05 04:16:33 +01:00
{
2021-03-04 03:58:46 +00:00
// Don't allow equipping while deployed
if ( _active ) return false ;
2026-08-10 14:57:50 +01:00
var itemData = ItemManager . GetData ( item ) ;
var hullData = ItemManager . GetData ( Hull ) as HullData ;
2026-10-03 14:57:27 +02:00
return ItemFits ( itemData , hullData , item , hullCoord , out _ ) ;
2020-06-05 04:16:33 +01:00
}
2020-12-10 23:20:38 +00:00
public bool TryFindSpace ( EquippableItem item , out int2 hullCoord )
2020-06-05 04:16:33 +01:00
{
2021-03-04 03:58:46 +00:00
// Don't allow equipping while deployed
if ( _active )
{
2026-08-10 14:57:50 +01:00
hullCoord = int2 . zero ;
2026-06-14 04:56:01 +01:00
return false ;
}
2026-08-10 14:57:50 +01:00
var itemData = ItemManager . GetData ( item ) ;
var hullData = ItemManager . GetData ( Hull ) as HullData ;
2020-05-11 01:17:10 +01:00
2020-12-10 23:20:38 +00:00
// Tools and thermal equipment can be installed anywhere on the ship
// Search the whole ship for somewhere the item will fit
2026-08-10 14:57:50 +01:00
if ( itemData . HardpointType = = HardpointType . Tool )
2020-12-10 23:20:38 +00:00
{
2026-08-10 14:57:50 +01:00
foreach ( var hullCoord2 in hullData . InteriorCells . Coordinates )
2020-12-10 23:20:38 +00:00
{
2026-10-03 14:57:27 +02:00
if ( ItemFits ( itemData , hullData , item , hullCoord2 , out _ ) )
2020-12-10 23:20:38 +00:00
{
hullCoord = hullCoord2 ;
return true ;
}
}
}
2020-05-06 04:56:36 +01:00
2020-12-10 23:20:38 +00:00
// 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
2020-06-05 04:16:33 +01:00
{
2026-10-03 14:57:27 +02:00
// 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.
2026-08-10 14:57:50 +01:00
foreach ( var hardpoint in hullData . Hardpoints )
2020-12-10 23:20:38 +00:00
{
2026-08-10 14:57:50 +01:00
if ( hardpoint . Type = = itemData . HardpointType )
2020-12-10 23:20:38 +00:00
{
2026-10-03 14:57:27 +02:00
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 + + )
2020-12-10 23:20:38 +00:00
{
2026-09-30 23:13:37 +02:00
var hullCoord2 = hardpoint . Position + int2 ( x , y ) ;
2026-10-03 14:57:27 +02:00
if ( ItemFits ( itemData , hullData , item , hullCoord2 , out _ ) )
2020-12-10 23:20:38 +00:00
{
hullCoord = hullCoord2 ;
return true ;
}
}
}
}
2020-06-05 04:16:33 +01:00
}
2020-04-28 01:50:50 +01:00
2026-08-10 14:57:50 +01:00
hullCoord = int2 . zero ;
2020-12-10 23:20:38 +00:00
return false ;
2020-04-28 01:50:50 +01:00
}
2020-12-10 23:20:38 +00:00
// 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 )
2020-06-02 07:01:03 +01:00
{
2021-03-04 03:58:46 +00:00
// Don't allow equipping while deployed
if ( _active ) return false ;
2026-08-10 14:57:50 +01:00
var itemData = ItemManager . GetData ( item ) ;
var hullData = ItemManager . GetData ( Hull ) as HullData ;
2020-12-10 23:20:38 +00:00
2026-10-03 14:57:27 +02:00
if ( ! ItemFits ( itemData , hullData , item , hullCoord , out var hardpoint ) ) return false ;
2020-06-05 04:16:33 +01:00
2021-01-06 14:56:39 +00:00
EquippedItem equippedItem ;
2026-08-10 14:57:50 +01:00
if ( itemData . HardpointType = = HardpointType . Tool )
2020-12-10 23:20:38 +00:00
{
2026-08-10 14:57:50 +01:00
if ( itemData is CargoBayData )
2020-12-10 23:20:38 +00:00
{
2026-08-10 14:57:50 +01:00
if ( itemData is DockingBayData )
2020-12-19 02:37:13 +00:00
{
2020-12-27 22:18:22 +00:00
equippedItem = new EquippedDockingBay ( ItemManager , item , hullCoord , this , $"{Name} Docking Bay {DockingBays.Count + 1}" ) ;
2020-12-19 02:37:13 +00:00
DockingBays . Add ( ( EquippedDockingBay ) equippedItem ) ;
}
else
{
2020-12-27 22:18:22 +00:00
equippedItem = new EquippedCargoBay ( ItemManager , item , hullCoord , this , $"{Name} Cargo Bay {CargoBays.Count + 1}" ) ;
2020-12-19 02:37:13 +00:00
CargoBays . Add ( ( EquippedCargoBay ) equippedItem ) ;
}
2020-12-10 23:20:38 +00:00
}
else
{
2026-10-03 14:57:27 +02:00
equippedItem = new EquippedItem ( ItemManager , item , hullCoord , this , null ) ;
2020-12-19 02:37:13 +00:00
Equipment . Add ( equippedItem ) ;
2020-12-10 23:20:38 +00:00
}
}
2020-06-02 07:01:03 +01:00
else
2020-06-05 04:16:33 +01:00
{
2026-10-03 14:57:27 +02:00
equippedItem = new EquippedItem ( ItemManager , item , hullCoord , this , hardpoint ) ;
2020-12-19 02:37:13 +00:00
Equipment . Add ( equippedItem ) ;
2021-01-06 14:56:39 +00:00
}
2021-03-03 01:53:23 +00:00
foreach ( var b in equippedItem . Behaviors )
{
if ( b is Weapon weapon )
_weapons . Add ( weapon ) ;
2021-03-04 03:58:46 +00:00
if ( b is Radiator heatsink )
_heatsinks . Add ( heatsink ) ;
2021-03-06 01:04:26 +00:00
if ( b is Shield shield )
Shield = shield ;
2021-03-06 02:42:19 +00:00
if ( b is Cockpit cockpit )
Cockpit = cockpit ;
2021-03-21 01:55:25 +00:00
if ( b is Sensor sensor )
Sensor = sensor ;
2021-03-03 01:53:23 +00:00
}
2021-03-07 01:10:31 +00:00
// equippedItem.OnOnline += () => ItemOnline.OnNext(equippedItem);
// equippedItem.OnOffline += () => ItemOffline.OnNext(equippedItem);
2020-12-10 23:20:38 +00:00
2026-08-10 14:57:50 +01:00
foreach ( var i in itemData . Shape . Coordinates )
2021-01-06 14:56:39 +00:00
{
2026-08-10 14:57:50 +01:00
var occupiedCoord = hullCoord + itemData . Shape . Rotate ( i , item . Rotation ) ;
2021-01-06 14:56:39 +00:00
// TODO: Track thermal mass of cargo bay contents as reactive property
2026-08-10 14:57:50 +01:00
ThermalMass [ occupiedCoord . x , occupiedCoord . y ] + = ItemManager . GetThermalMass ( item ) / itemData . Shape . Coordinates . Length ;
2021-01-17 02:45:52 +00:00
GearOccupancy [ occupiedCoord . x , occupiedCoord . y ] = equippedItem ;
2020-06-05 04:16:33 +01:00
}
2021-01-06 14:56:39 +00:00
2026-08-10 14:57:50 +01:00
Mass + = itemData . Mass ;
Cut 5: priority tiers replace PowerBus's single shared GrantRatio
PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their
per-behaviour-kind defaults: Radiator is Critical (the closest thing this game
has to life support -- a starved radiator cascades into wear and shutdown
across the whole hull), Shield is High, Thruster/AetherDrive are Medium,
ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility.
IPowerConsumer.DefaultPowerTier lets each behaviour own its own default.
EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the
unit rather than the design; EquippedItem's constructor seeds a kind default
the first time a unit is equipped, only when the field is still
PowerTiers.Unassigned, and never again -- a tier is a stored choice, not
something the bus re-derives.
PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in
full before the next tier sees anything left, an empty tier passes its whole
share down, and within a tier every consumer rations by the same
grant/demand fraction so unequal requests still divide proportionally. A new
TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to
read starvation from directly, instead of reconstructing it from every item's
PowerSupply.
Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its
only implementer): the bus already steps before any equipment's Execute, so
equip/behaviour order never decided who got fed even before this cut, and
nothing else needed the interface (subject to the map's O3 operator check).
docs/stats-and-power-cut.md Cut 5 / §1.3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
_orderedEquipment = Equipment . ToArray ( ) ;
2021-01-06 14:56:39 +00:00
return true ;
2020-12-19 02:37:13 +00:00
}
2020-06-05 04:16:33 +01:00
2020-12-19 02:37:13 +00:00
public EquippedDockingBay TryDock ( Ship ship )
{
2025-07-18 00:21:33 +01:00
//if (!IsDockingPermitted(ship.GetFactionRelationship(Faction), CurrentSecurityLevel.Value)) return null;
2021-05-12 00:15:11 +01:00
2020-12-19 02:37:13 +00:00
var bay = DockingBays . FirstOrDefault ( x = > x . DockedShip = = null ) ;
if ( bay ! = null )
{
bay . DockedShip = ship ;
ship . SetParent ( this ) ;
Zone . Entities . Remove ( ship ) ;
2021-03-21 01:55:25 +00:00
ship . Deactivate ( ) ;
2021-02-26 19:01:47 +00:00
ship . Docked . OnNext ( this ) ;
2020-12-19 02:37:13 +00:00
}
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 ( ) ;
2026-09-30 12:32:59 +02:00
Zone . Admit ( ship , piloted : false ) ;
2020-12-19 02:37:13 +00:00
return true ;
2020-06-05 04:16:33 +01:00
}
Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.
InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.
Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00
// 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").
2021-03-02 23:02:19 +00:00
2021-01-17 02:45:52 +00:00
private void AddChild ( Entity entity )
2020-06-05 04:16:33 +01:00
{
Mass + = entity . Mass ;
2020-12-10 23:20:38 +00:00
Children . Add ( entity ) ;
2020-06-05 04:16:33 +01:00
}
2020-06-02 07:01:03 +01:00
2021-01-17 02:45:52 +00:00
private void RemoveChild ( Entity entity )
2020-06-05 04:16:33 +01:00
{
Mass - = entity . Mass ;
2020-12-10 23:20:38 +00:00
Children . Remove ( entity ) ;
2020-06-02 07:01:03 +01:00
}
2020-12-01 00:16:11 +00:00
public void SetParent ( Entity parent )
{
2020-12-10 23:20:38 +00:00
Parent = parent ;
2020-12-01 00:16:11 +00:00
parent . AddChild ( this ) ;
}
public void RemoveParent ( )
{
2020-12-10 23:20:38 +00:00
if ( Parent = = null )
2020-12-01 00:16:11 +00:00
return ;
2020-12-10 23:20:38 +00:00
Parent . RemoveChild ( this ) ;
Parent = null ;
2020-12-01 00:16:11 +00:00
}
2020-06-02 07:01:03 +01:00
2021-04-18 05:13:20 +01:00
public T GetBehavior < T > ( ) where T : Behavior
2020-06-02 07:01:03 +01:00
{
2020-12-19 02:37:13 +00:00
foreach ( var equippedItem in Equipment )
2020-12-27 22:18:22 +00:00
if ( equippedItem . Behaviors ! = null )
foreach ( var behavior in equippedItem . Behaviors )
if ( behavior is T b )
return b ;
2020-12-10 23:20:38 +00:00
return null ;
}
2020-06-05 04:16:33 +01:00
2021-04-18 05:13:20 +01:00
public IEnumerable < T > GetBehaviors < T > ( ) where T : Behavior
2020-12-10 23:20:38 +00:00
{
2020-12-19 02:37:13 +00:00
foreach ( var equippedItem in Equipment )
2020-12-27 22:18:22 +00:00
if ( equippedItem . Behaviors ! = null )
foreach ( var behavior in equippedItem . Behaviors )
if ( behavior is T b )
yield return b ;
2020-12-10 23:20:38 +00:00
}
2020-06-05 04:16:33 +01:00
2026-08-10 14:57:50 +01:00
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 ;
}
2020-12-10 23:20:38 +00:00
public virtual void Update ( float delta )
{
2026-10-08 18:26:02 +02:00
TurnRate = 0 ;
2026-09-22 00:50:57 +02:00
if ( ! _active ) return ;
Evasion term: reachable spread from the thrust and turn envelope, realised by changes of vector
Every hit price gains one factor, PDeviation(Evasion, Tracking): how far the target can
and does move off the line of fire within SolutionWindow (0.75 s). Capability is Reach, an
eight-face closed form over the ship's ManoeuvreEnvelope (summed from what each live
thruster and aether drive reports with its own Execute arithmetic, read live: a thruster's
Thrust property is stale until it fires, which is why the Djinni moved 1.6 times its stats,
M25). Behaviour is ManoeuvreTrack, observed once per Entity.Update from the measured
acceleration; a steady burn, coasting and a dither much faster than the window do not
evade, and sideways speed alone does not (ruling evasion-speed). The commit price adds the
term at any flight time, so an instant weapon feels a jinking target. Forecast owns the
term for HitProbability and Inspect, which gain Evasion and PEvasion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:48:27 +02:00
var velocityBefore = Velocity ;
2026-10-07 05:39:40 +02:00
LimitClamp = float2 ( 0 , 0 ) ;
2026-08-10 14:57:50 +01:00
var hullData = ItemManager . GetData ( Hull ) as HullData ;
2021-01-25 05:53:36 +00:00
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
// 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 ) ;
2026-08-10 14:57:50 +01:00
var localSecurityLevel = Zone . GetSecurityLevel ( Position . xz ) ;
2021-05-11 04:45:42 +01:00
if ( CurrentSecurityLevel . Value ! = localSecurityLevel ) CurrentSecurityLevel . Value = localSecurityLevel ;
2021-01-25 05:53:36 +00:00
foreach ( var v in VisibilitySources . Keys . ToArray ( ) )
{
Delete AetheriaMath in favour of CultMath
AetheriaMath predates the CultMath swap and re-implemented helpers CultMath
now owns. Callers use CultMath directly: decay (Ship, AetherDrive, Entity),
damp (VelocityConversion, FieldTester, FieldDriver), first_order_intercept
(TurretController, Combat, GuidedProjectile), quadratic_bezier (FieldDriver)
and smootherstep (Ship). Parameter order matches in every case, and those
formulas have the same float evaluation order, so behaviour is unchanged.
Galaxy's commented-out segment check names distance_to_segment.
The other overloads had no callers and are dropped without a CultMath
counterpart being needed: the double Damp overloads, FindDistanceToSegment,
GetCatmullRomPosition, GetCubicSplinePosition, Smoothstep(x), and the
Deg2Rad/Rad2Deg constants.
AetherDb census, factions and hardpoint-fit are byte-identical to the
pre-cut captures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:02:41 +02:00
VisibilitySources [ v ] = decay ( VisibilitySources [ v ] , ItemManager . GameplaySettings . VisibilityDecay , delta ) ;
2021-05-12 00:15:11 +01:00
2021-04-12 04:20:52 +01:00
if ( VisibilitySources [ v ] < 0.1f ) VisibilitySources . Remove ( v ) ;
2021-01-25 05:53:36 +00:00
}
2021-02-26 19:01:47 +00:00
UpdateTemperature ( delta ) ;
2021-04-13 01:50:42 +01:00
foreach ( var item in _orderedEquipment ) item . UpdatePerformance ( ) ;
2021-02-26 19:01:47 +00:00
if ( _active )
{
2021-05-12 00:15:11 +01:00
foreach ( var entity in Zone . Entities )
{
var previousHostility = EntityHostility [ entity ] ;
var newHostility = IsHostileTo ( entity ) ;
if ( newHostility ! = previousHostility )
EntityHostility [ entity ] = newHostility ;
}
2021-03-09 02:26:58 +00:00
if ( Cockpit ! = null )
{
2021-04-18 05:13:20 +01:00
var cockpitTemp = Cockpit . Temperature ;
if ( cockpitTemp > ItemManager . GameplaySettings . HeatstrokeTemperature )
2021-03-09 02:26:58 +00:00
{
var previous = Heatstroke ;
2026-08-10 14:57:50 +01:00
Heatstroke = saturate (
2021-03-09 02:26:58 +00:00
Heatstroke +
2026-08-10 14:57:50 +01:00
pow ( cockpitTemp - ItemManager . GameplaySettings . HeatstrokeTemperature , ItemManager . GameplaySettings . HeatstrokeExponent ) *
2021-03-09 02:26:58 +00:00
ItemManager . GameplaySettings . HeatstrokeMultiplier * delta ) ;
if ( previous < ItemManager . GameplaySettings . SevereHeatstrokeRiskThreshold & & Heatstroke > ItemManager . GameplaySettings . SevereHeatstrokeRiskThreshold )
HeatstrokeRisk . OnNext ( Unit . Default ) ;
2021-04-10 01:47:16 +01:00
if ( Heatstroke > . 99 )
2021-03-09 02:26:58 +00:00
{
HeatstrokeDeath . OnNext ( Unit . Default ) ;
Deactivate ( ) ;
}
}
else
{
2026-08-10 14:57:50 +01:00
Heatstroke = saturate ( Heatstroke - ItemManager . GameplaySettings . HeatstrokeRecoverySpeed * delta ) ;
2021-03-09 02:26:58 +00:00
}
2021-04-18 05:13:20 +01:00
if ( cockpitTemp < ItemManager . GameplaySettings . HypothermiaTemperature )
2021-04-10 01:47:16 +01:00
{
var previous = Hypothermia ;
2026-08-10 14:57:50 +01:00
Hypothermia = saturate (
2021-04-10 01:47:16 +01:00
Hypothermia +
2026-08-10 14:57:50 +01:00
pow ( ItemManager . GameplaySettings . HypothermiaTemperature - cockpitTemp , ItemManager . GameplaySettings . HypothermiaExponent ) *
2021-04-10 01:47:16 +01:00
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
{
2026-08-10 14:57:50 +01:00
Hypothermia = saturate ( Hypothermia - ItemManager . GameplaySettings . HypothermiaRecoverySpeed * delta ) ;
2021-04-10 01:47:16 +01:00
}
2021-03-09 02:26:58 +00:00
}
2021-04-18 05:13:20 +01:00
for ( var i = 0 ; i < _activeConsumables . Count ; i + + )
{
_activeConsumables [ i ] . Update ( delta ) ;
2026-09-18 20:41:42 +02:00
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.
Resolver . Forget ( _activeConsumables [ i ] ) ;
_activeConsumables . RemoveAt ( i - - ) ;
}
2021-04-18 05:13:20 +01:00
}
Cut 3: PowerBus replaces TryConsumeEnergy/CanConsumeEnergy
Deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor
draining/filling (the demand-mechanism halves of Reactor.cs:90-141); adds
PowerBus (one per Entity, stepped once per tick before equipment executes),
IPowerConsumer on the five continuous draw sites (EnergyDraw, Thruster,
AetherDrive, Radiator, ConstantWeapon), and EquippedItem.PowerSupply as the
bus's grant ratio. Reactor keeps only its heat/throttle arithmetic, now driven
by PowerBus.NetDraw instead of an accumulator any behaviour could add into.
The four instant draws (InstantWeapon's burst/shot, Sensor's ping, Shield's hit
taken) are out of scope for the bus this cut per the map; they spend capacitor
charge directly and atomically via the new Entity.TrySpendCapacitorCharge/
CanSpendCapacitorCharge, replacing TryConsumeEnergy's old partial-drain-then-
refuse bug for that path too. Named, temporary exception closed by Cut 4.
docs/stats-and-power-cut.md Cut 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:39:38 +02:00
// 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 ) ;
2021-02-26 19:01:47 +00:00
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 )
{
2026-08-10 14:57:50 +01:00
Position = Parent . Position ;
Velocity = Parent . Velocity ;
2021-02-26 19:01:47 +00:00
}
2026-08-10 14:57:50 +01:00
else Position . y = Zone . GetHeight ( Position . xz ) + hullData . GridOffset ;
Evasion term: reachable spread from the thrust and turn envelope, realised by changes of vector
Every hit price gains one factor, PDeviation(Evasion, Tracking): how far the target can
and does move off the line of fire within SolutionWindow (0.75 s). Capability is Reach, an
eight-face closed form over the ship's ManoeuvreEnvelope (summed from what each live
thruster and aether drive reports with its own Execute arithmetic, read live: a thruster's
Thrust property is stale until it fires, which is why the Djinni moved 1.6 times its stats,
M25). Behaviour is ManoeuvreTrack, observed once per Entity.Update from the measured
acceleration; a steady burn, coasting and a dither much faster than the window do not
evade, and sideways speed alone does not (ruling evasion-speed). The commit price adds the
term at any flight time, so an instant weapon feels a jinking target. Forecast owns the
term for HitProbability and Inspect, which gain Evasion and PEvasion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:48:27 +02:00
2026-10-07 05:39:40 +02:00
Acceleration = delta > 0f ? ( Velocity + LimitClamp - velocityBefore ) / delta : float2 ( 0 , 0 ) ;
Evasion term: reachable spread from the thrust and turn envelope, realised by changes of vector
Every hit price gains one factor, PDeviation(Evasion, Tracking): how far the target can
and does move off the line of fire within SolutionWindow (0.75 s). Capability is Reach, an
eight-face closed form over the ship's ManoeuvreEnvelope (summed from what each live
thruster and aether drive reports with its own Execute arithmetic, read live: a thruster's
Thrust property is stale until it fires, which is why the Djinni moved 1.6 times its stats,
M25). Behaviour is ManoeuvreTrack, observed once per Entity.Update from the measured
acceleration; a steady burn, coasting and a dither much faster than the window do not
evade, and sideways speed alone does not (ruling evasion-speed). The commit price adds the
term at any flight time, so an instant weapon feels a jinking target. Forecast owns the
term for HitProbability and Inspect, which gain Evasion and PEvasion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:48:27 +02:00
Manoeuvre . Observe ( Acceleration , delta , ItemManager . GameplaySettings . SolutionWindow ) ;
2021-02-26 19:01:47 +00:00
}
private void UpdateTemperature ( float delta )
{
2026-08-10 14:57:50 +01:00
var hullData = ItemManager . GetData ( Hull ) as HullData ;
2021-01-25 05:53:36 +00:00
2021-01-27 00:05:30 +00:00
MaxTemp = Single . MinValue ;
MinTemp = Single . MaxValue ;
2026-08-10 14:57:50 +01:00
//float[,] newTemp = new float[hullData.Shape.Width,hullData.Shape.Height];
2020-12-27 22:18:22 +00:00
var radiation = 0f ;
2026-08-10 14:57:50 +01:00
foreach ( var v in hullData . Shape . Coordinates )
2020-12-27 22:18:22 +00:00
{
var temp = Temperature [ v . x , v . y ] ;
2021-01-27 00:05:30 +00:00
var totalTemp = temp / ItemManager . GameplaySettings . HeatConductionMultiplier ;
var totalConductivity = 1f / ItemManager . GameplaySettings . HeatConductionMultiplier ;
2020-12-27 22:18:22 +00:00
2026-08-10 14:57:50 +01:00
if ( hullData . Shape [ int2 ( v . x - 1 , v . y ) ] )
2021-01-27 00:05:30 +00:00
{
var conductivity = ( GearOccupancy [ v . x , v . y ] ? . Conductivity ? ? 1 ) *
( GearOccupancy [ v . x - 1 , v . y ] ? . Conductivity ? ? 1 ) *
2026-08-10 14:57:50 +01:00
( HullConductivity [ v . x - 1 , v . y ] . x ? hullData . Conductivity : 1 / hullData . Conductivity ) *
2021-01-27 00:05:30 +00:00
( ThermalMass [ v . x - 1 , v . y ] / ThermalMass [ v . x , v . y ] ) ;
totalConductivity + = conductivity ;
totalTemp + = Temperature [ v . x - 1 , v . y ] * conductivity ;
}
2021-01-17 02:45:52 +00:00
2026-08-10 14:57:50 +01:00
if ( hullData . Shape [ int2 ( v . x + 1 , v . y ) ] )
2021-01-27 00:05:30 +00:00
{
var conductivity = ( GearOccupancy [ v . x , v . y ] ? . Conductivity ? ? 1 ) *
( GearOccupancy [ v . x + 1 , v . y ] ? . Conductivity ? ? 1 ) *
2026-08-10 14:57:50 +01:00
( HullConductivity [ v . x , v . y ] . x ? hullData . Conductivity : 1 / hullData . Conductivity ) *
2021-01-27 00:05:30 +00:00
( ThermalMass [ v . x + 1 , v . y ] / ThermalMass [ v . x , v . y ] ) ;
totalConductivity + = conductivity ;
totalTemp + = Temperature [ v . x + 1 , v . y ] * conductivity ;
}
2026-08-10 14:57:50 +01:00
if ( hullData . Shape [ int2 ( v . x , v . y - 1 ) ] )
2021-01-27 00:05:30 +00:00
{
var conductivity = ( GearOccupancy [ v . x , v . y ] ? . Conductivity ? ? 1 ) *
( GearOccupancy [ v . x , v . y - 1 ] ? . Conductivity ? ? 1 ) *
2026-08-10 14:57:50 +01:00
( HullConductivity [ v . x , v . y - 1 ] . y ? hullData . Conductivity : 1 / hullData . Conductivity ) *
2021-01-27 00:05:30 +00:00
( ThermalMass [ v . x , v . y - 1 ] / ThermalMass [ v . x , v . y ] ) ;
totalConductivity + = conductivity ;
totalTemp + = Temperature [ v . x , v . y - 1 ] * conductivity ;
}
2020-12-27 22:18:22 +00:00
2026-08-10 14:57:50 +01:00
if ( hullData . Shape [ int2 ( v . x , v . y + 1 ) ] )
2021-01-27 00:05:30 +00:00
{
var conductivity = ( GearOccupancy [ v . x , v . y ] ? . Conductivity ? ? 1 ) *
( GearOccupancy [ v . x , v . y + 1 ] ? . Conductivity ? ? 1 ) *
2026-08-10 14:57:50 +01:00
( HullConductivity [ v . x , v . y ] . y ? hullData . Conductivity : 1 / hullData . Conductivity ) *
2021-01-27 00:05:30 +00:00
( ThermalMass [ v . x , v . y + 1 ] / ThermalMass [ v . x , v . y ] ) ;
totalConductivity + = conductivity ;
totalTemp + = Temperature [ v . x , v . y + 1 ] * conductivity ;
}
2020-12-27 22:18:22 +00:00
2021-01-27 00:05:30 +00:00
NewTemperature [ v . x , v . y ] = totalTemp / totalConductivity ;
var r = 0f ;
2020-12-27 22:18:22 +00:00
// For all cells on the border of the entity, radiate some heat into space, increasing the visibility of the ship
2026-08-10 14:57:50 +01:00
if ( Parent = = null & & ! hullData . InteriorCells [ v ] )
2020-12-27 22:18:22 +00:00
{
2026-08-10 14:57:50 +01:00
var rad = pow ( NewTemperature [ v . x , v . y ] , ItemManager . GameplaySettings . HeatRadiationExponent ) *
2021-03-01 01:53:56 +00:00
ItemManager . GameplaySettings . HeatRadiationMultiplier ;
NewTemperature [ v . x , v . y ] - = rad * delta ;
r + = rad ;
2020-12-27 22:18:22 +00:00
}
2021-01-27 00:05:30 +00:00
radiation + = r ;
2021-01-17 02:45:52 +00:00
2021-01-31 03:52:32 +00:00
if ( float . IsNaN ( NewTemperature [ v . x , v . y ] ) | | NewTemperature [ v . x , v . y ] < 0 )
2021-01-17 02:45:52 +00:00
ItemManager . Log ( "HOUSTON, WE HAVE A PROBLEM!" ) ;
2021-01-27 00:05:30 +00:00
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 ] ;
2020-12-27 22:18:22 +00:00
}
VisibilitySources [ this ] = radiation ;
2021-01-17 23:26:53 +00:00
var swap = Temperature ;
Temperature = NewTemperature ;
NewTemperature = swap ;
2020-06-02 07:01:03 +01:00
}
2020-12-10 23:20:38 +00:00
public void SetMessage ( string message )
2020-04-28 01:50:50 +01:00
{
2020-12-10 23:20:38 +00:00
Messages [ message ] = ItemManager . GameplaySettings . MessageDuration ;
2020-06-02 07:01:03 +01:00
}
2020-12-10 23:20:38 +00:00
}
Cut 0: collapse the three disagreeing stat evaluate bodies into one
ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and
EquippedItem.Evaluate each hand-computed the same quality/durability/heat/
modifier arithmetic with small, undocumented disagreements (docs/stats-and-
power-cut.md Sec 0.2). Collapse the arithmetic into one function,
PerformanceStat.Evaluate(IStatContext), fed by three IStatContext
implementations: EquippedItem (the only one with heat, live durability and
modifiers), ConsumableItemEffect (progress substitutes for heat,
unconditionally, matching its prior behaviour exactly) and a new
UnequippedStatContext in ItemManager (no heat, no modifiers, by name).
No behaviour change beyond the two disagreements the cut map names as
defects. Each context's original NaN handling is preserved exactly
(unequipped throws with diagnostic detail; equipped/consumable fall back to
Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so
the ~81 existing call sites are untouched.
Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before
new tests are added; a real-catalog probe over GameData/Aetheria.cc found
the new unequipped path reproduces the old formula exactly across 3123
(item, quality, durability, stat) cases; Unity batchmode compiles with 0
error CS and the Cut 1/2/3 shield probes still pass; AetherDb census,
factions, dangling, station-fit, loadout 1 and hardpoint-fit are
byte-identical before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
// 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
2021-04-18 05:13:20 +01:00
{
public float RemainingDuration { get ; private set ; }
public Entity Entity { get ; }
public ConsumableItem Item { get ; }
2026-08-10 14:57:50 +01:00
public ConsumableItemData Data { get ; }
2026-09-17 04:16:23 +02:00
public Lot Lot { get ; }
2021-04-18 05:13:20 +01:00
public Behavior [ ] Behaviors { get ; }
public ConsumableItemEffect ( ConsumableItem item , Entity entity )
{
Item = item ;
Entity = entity ;
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
Data = ( ConsumableItemData ) entity . ItemManager . GetData ( item ) ;
2026-09-17 04:16:23 +02:00
Lot = entity . ItemManager . GetLot ( item ) ;
2026-08-10 14:57:50 +01:00
RemainingDuration = Data . Duration ;
2021-04-18 05:13:20 +01:00
2026-08-10 14:57:50 +01:00
Behaviors = Data . Behaviors
. Select ( bd = > bd . CreateInstance ( this ) )
. ToArray ( ) ;
2026-07-13 16:19:35 +01:00
}
2021-04-18 05:13:20 +01:00
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 ;
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
// 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 ) ;
2021-04-18 05:13:20 +01:00
}
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
// 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 0: collapse the three disagreeing stat evaluate bodies into one
ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and
EquippedItem.Evaluate each hand-computed the same quality/durability/heat/
modifier arithmetic with small, undocumented disagreements (docs/stats-and-
power-cut.md Sec 0.2). Collapse the arithmetic into one function,
PerformanceStat.Evaluate(IStatContext), fed by three IStatContext
implementations: EquippedItem (the only one with heat, live durability and
modifiers), ConsumableItemEffect (progress substitutes for heat,
unconditionally, matching its prior behaviour exactly) and a new
UnequippedStatContext in ItemManager (no heat, no modifiers, by name).
No behaviour change beyond the two disagreements the cut map names as
defects. Each context's original NaN handling is preserved exactly
(unequipped throws with diagnostic detail; equipped/consumable fall back to
Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so
the ~81 existing call sites are untouched.
Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before
new tests are added; a real-catalog probe over GameData/Aetheria.cc found
the new unequipped path reproduces the old formula exactly across 3123
(item, quality, durability, stat) cases; Unity batchmode compiles with 0
error CS and the Cut 1/2/3 shield probes still pass; AetherDb census,
factions, dangling, station-fit, loadout 1 and hardpoint-fit are
byte-identical before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// 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 ;
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
// No modifiers: there is no entity's worth of equipment to modify against (comment above, unchanged by Cut 2).
Cut 0: collapse the three disagreeing stat evaluate bodies into one
ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and
EquippedItem.Evaluate each hand-computed the same quality/durability/heat/
modifier arithmetic with small, undocumented disagreements (docs/stats-and-
power-cut.md Sec 0.2). Collapse the arithmetic into one function,
PerformanceStat.Evaluate(IStatContext), fed by three IStatContext
implementations: EquippedItem (the only one with heat, live durability and
modifiers), ConsumableItemEffect (progress substitutes for heat,
unconditionally, matching its prior behaviour exactly) and a new
UnequippedStatContext in ItemManager (no heat, no modifiers, by name).
No behaviour change beyond the two disagreements the cut map names as
defects. Each context's original NaN handling is preserved exactly
(unequipped throws with diagnostic detail; equipped/consumable fall back to
Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so
the ~81 existing call sites are untouched.
Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before
new tests are added; a real-catalog probe over GameData/Aetheria.cc found
the new unequipped path reproduces the old formula exactly across 3123
(item, quality, durability, stat) cases; Unity batchmode compiles with 0
error CS and the Cut 1/2/3 shield probes still pass; AetherDb census,
factions, dangling, station-fit, loadout 1 and hardpoint-fit are
byte-identical before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
public float ScaleModifier ( PerformanceStat stat ) = > 1f ;
public float ConstantModifier ( PerformanceStat stat ) = > 0f ;
2021-04-18 05:13:20 +01:00
}
Cut 0: collapse the three disagreeing stat evaluate bodies into one
ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and
EquippedItem.Evaluate each hand-computed the same quality/durability/heat/
modifier arithmetic with small, undocumented disagreements (docs/stats-and-
power-cut.md Sec 0.2). Collapse the arithmetic into one function,
PerformanceStat.Evaluate(IStatContext), fed by three IStatContext
implementations: EquippedItem (the only one with heat, live durability and
modifiers), ConsumableItemEffect (progress substitutes for heat,
unconditionally, matching its prior behaviour exactly) and a new
UnequippedStatContext in ItemManager (no heat, no modifiers, by name).
No behaviour change beyond the two disagreements the cut map names as
defects. Each context's original NaN handling is preserved exactly
(unequipped throws with diagnostic detail; equipped/consumable fall back to
Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so
the ~81 existing call sites are untouched.
Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before
new tests are added; a real-catalog probe over GameData/Aetheria.cc found
the new unequipped path reproduces the old formula exactly across 3123
(item, quality, durability, stat) cases; Unity batchmode compiles with 0
error CS and the Cut 1/2/3 shield probes still pass; AetherDb census,
factions, dangling, station-fit, loadout 1 and hardpoint-fit are
byte-identical before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
// 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
2020-12-10 23:20:38 +00:00
{
Cut 5: priority tiers replace PowerBus's single shared GrantRatio
PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their
per-behaviour-kind defaults: Radiator is Critical (the closest thing this game
has to life support -- a starved radiator cascades into wear and shutdown
across the whole hull), Shield is High, Thruster/AetherDrive are Medium,
ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility.
IPowerConsumer.DefaultPowerTier lets each behaviour own its own default.
EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the
unit rather than the design; EquippedItem's constructor seeds a kind default
the first time a unit is equipped, only when the field is still
PowerTiers.Unassigned, and never again -- a tier is a stored choice, not
something the bus re-derives.
PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in
full before the next tier sees anything left, an empty tier passes its whole
share down, and within a tier every consumer rations by the same
grant/demand fraction so unequal requests still divide proportionally. A new
TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to
read starvation from directly, instead of reconstructing it from every item's
PowerSupply.
Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its
only implementer): the bus already steps before any equipment's Execute, so
equip/behaviour order never decided who got fed even before this cut, and
nothing else needed the interface (subject to the map's O3 operator check).
docs/stats-and-power-cut.md Cut 5 / §1.3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
// 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).
2021-01-17 02:45:52 +00:00
public EquippableItem EquippableItem ;
public int2 Position ;
2021-02-26 19:01:47 +00:00
2021-03-07 01:10:31 +00:00
private bool _thermalOnline ;
private bool _durabilityOnline ;
2021-04-30 06:39:57 +01:00
public WwiseMetaSoundBank SoundBank ;
2020-12-10 23:20:38 +00:00
2021-04-18 05:13:20 +01:00
public Behavior [ ] Behaviors { get ; }
public Dictionary < int , BehaviorGroup > BehaviorGroups { get ; }
2021-01-17 02:45:52 +00:00
public float Conductivity { get ; }
2021-03-07 01:10:31 +00:00
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 ; }
2020-12-27 22:18:22 +00:00
public Shape InsetShape { get ; }
2021-03-07 01:10:31 +00:00
public Entity Entity { get ; }
2026-10-03 14:57:27 +02:00
// 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 ; }
2026-08-10 14:57:50 +01:00
public EquippableItemData Data { get ; }
2026-09-17 04:16:23 +02:00
public Lot Lot { get ; }
2020-12-27 22:18:22 +00:00
2021-03-07 01:10:31 +00:00
public ReactiveProperty < bool > ThermalOnline { get ; } = new ReactiveProperty < bool > ( false ) ;
public ReactiveProperty < bool > DurabilityOnline { get ; } = new ReactiveProperty < bool > ( false ) ;
public ReadOnlyReactiveProperty < bool > Online { get ; }
public ReactiveProperty < bool > Enabled { get ; } = new ReactiveProperty < bool > ( true ) ;
public ReadOnlyReactiveProperty < bool > Active { get ; }
2021-03-16 16:35:23 +00:00
public ItemManager ItemManager { get ; }
2021-01-10 03:59:47 +00:00
2021-04-30 06:39:57 +01:00
public Subject < uint > AudioEvents { get ; } = new Subject < uint > ( ) ;
public Subject < ( uint id , float v ) > AudioParameters { get ; } = new Subject < ( uint id , float v ) > ( ) ;
public Dictionary < uint , float > AudioParameterValues { get ; } = new Dictionary < uint , float > ( ) ;
2021-06-04 14:49:00 +02:00
private float oldTemperature ;
2020-12-27 22:18:22 +00:00
public float Temperature
{
get
{
float sum = 0 ;
2021-03-07 01:10:31 +00:00
foreach ( var x in InsetShape . Coordinates ) sum + = Entity . Temperature [ x . x , x . y ] ;
2020-12-27 22:18:22 +00:00
return sum / InsetShape . Coordinates . Length ;
}
}
2021-04-30 06:39:57 +01:00
public void FireAudioEvent ( uint eventId , bool skipVerify = false )
{
if ( SoundBank ! = null & & ( skipVerify | | SoundBank . IncludedEvents . Any ( o = > o . Id = = eventId ) ) )
AudioEvents . OnNext ( eventId ) ;
}
public void FireAudioEvent ( WwiseSoundBinding soundBinding )
{
FireAudioEvent ( soundBinding . PlayEvent ) ;
}
public void PlaySound ( WwiseLoopingSoundBinding soundBinding )
{
FireAudioEvent ( soundBinding . PlayEvent ) ;
}
public void StopSound ( WwiseLoopingSoundBinding soundBinding )
{
FireAudioEvent ( soundBinding . StopEvent ) ;
}
public void FireAudioEvent ( WeaponAudioEvent weaponAudioEvent )
{
if ( SoundBank = = null ) return ;
var eventObject = SoundBank . GetEvent ( weaponAudioEvent ) ;
if ( eventObject = = null )
{
ItemManager . Log ( $"Attempted to trigger {Enum.GetName(typeof(WeaponAudioEvent), weaponAudioEvent)} weapon audio event, but the soundbank doesn't support it!" ) ;
return ;
}
FireAudioEvent ( eventObject . Id , true ) ;
}
public void FireAudioEvent ( ChargedWeaponAudioEvent weaponAudioEvent )
{
if ( SoundBank = = null ) return ;
var eventObject = SoundBank . GetEvent ( weaponAudioEvent ) ;
if ( eventObject = = null )
{
ItemManager . Log ( $"Attempted to trigger {Enum.GetName(typeof(ChargedWeaponAudioEvent), weaponAudioEvent)} weapon audio event, but the soundbank doesn't support it!" ) ;
return ;
}
FireAudioEvent ( eventObject . Id , true ) ;
}
public void SetAudioParameter ( uint id , float v , bool skipVerify = false )
{
if ( SoundBank ! = null & & ( skipVerify | | SoundBank . GameParameters . Any ( o = > o . Id = = id ) ) )
{
AudioParameterValues [ id ] = v ;
AudioParameters . OnNext ( ( id , v ) ) ;
}
}
// public void SetAudioParameter(WwiseParameterBinding binding)
// {
// FireAudioEvent(binding.Parameter);
// }
public void SetAudioParameter ( SpecialAudioParameter p , float v )
{
if ( SoundBank = = null ) return ;
var metaObject = SoundBank . GetParameter ( p ) ;
if ( metaObject = = null )
{
ItemManager . Log ( $"Attempted to set {Enum.GetName(typeof(ChargedWeaponAudioEvent), p)} audio parameter, but the soundbank doesn't support it!" ) ;
return ;
}
SetAudioParameter ( metaObject . Id , v , true ) ;
}
2020-12-10 23:20:38 +00:00
2026-10-03 14:57:27 +02:00
public EquippedItem ( ItemManager itemManager , EquippableItem item , int2 position , Entity entity , HardpointData hardpoint )
2020-04-09 10:33:16 +01:00
{
2021-03-16 16:35:23 +00:00
ItemManager = itemManager ;
2026-08-10 14:57:50 +01:00
Data = ItemManager . GetData ( item ) ;
2021-03-07 01:10:31 +00:00
Entity = entity ;
2026-10-03 14:57:27 +02:00
Hardpoint = hardpoint ;
2020-12-10 23:20:38 +00:00
EquippableItem = item ;
2026-09-17 04:16:23 +02:00
Lot = ItemManager . GetLot ( item ) ;
2020-12-10 23:20:38 +00:00
Position = position ;
2026-08-10 14:57:50 +01:00
Conductivity = Data . Conductivity ;
ThermalExponent = lerp (
2021-03-22 05:22:31 +00:00
ItemManager . GameplaySettings . ThermalQualityMin ,
ItemManager . GameplaySettings . ThermalQualityMax ,
2026-09-17 04:16:23 +02:00
pow ( Lot . Quality , ItemManager . GameplaySettings . ThermalQualityExponent ) ) ;
2026-08-10 14:57:50 +01:00
DurabilityExponent = lerp (
2021-03-22 05:22:31 +00:00
ItemManager . GameplaySettings . DurabilityQualityMin ,
ItemManager . GameplaySettings . DurabilityQualityMax ,
2026-09-17 04:16:23 +02:00
pow ( Lot . Quality , ItemManager . GameplaySettings . DurabilityQualityExponent ) ) ;
2026-08-10 14:57:50 +01:00
var hullData = itemManager . GetData ( entity . Hull ) ;
InsetShape = hullData . Shape . Inset ( Data . Shape , position , item . Rotation ) ;
2021-06-04 14:49:00 +02:00
if ( Entity . Temperature ! = null ) oldTemperature = Temperature ;
2020-12-31 19:53:16 +00:00
2021-03-07 01:10:31 +00:00
Online = new ReadOnlyReactiveProperty < bool > ( ThermalOnline
. CombineLatest ( DurabilityOnline , ( thermal , durability ) = > thermal & & durability ) . DistinctUntilChanged ( ) ) ;
Active = new ReadOnlyReactiveProperty < bool > ( Enabled
. CombineLatest ( Online , ( enabled , online ) = > enabled & & online ) . DistinctUntilChanged ( ) ) ;
2026-08-10 14:57:50 +01:00
Behaviors = Data . Behaviors
. Select ( bd = > bd . CreateInstance ( this ) )
. ToArray ( ) ;
2021-01-25 05:53:36 +00:00
2020-12-31 19:53:16 +00:00
BehaviorGroups = Behaviors
2026-08-10 14:57:50 +01:00
. GroupBy ( b = > b . Data . Group )
2021-01-25 05:53:36 +00:00
. OrderBy ( g = > g . Key )
. ToDictionary ( g = > g . Key , g = > new BehaviorGroup
{
2021-03-07 01:10:31 +00:00
Behaviors = g . ToArray ( )
2021-01-25 05:53:36 +00:00
} ) ;
2020-12-31 19:53:16 +00:00
foreach ( var behavior in Behaviors )
{
if ( behavior is IPopulationAssignment populationAssignment )
entity . PopulationAssignments . Add ( populationAssignment ) ;
}
Cut 5: priority tiers replace PowerBus's single shared GrantRatio
PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their
per-behaviour-kind defaults: Radiator is Critical (the closest thing this game
has to life support -- a starved radiator cascades into wear and shutdown
across the whole hull), Shield is High, Thruster/AetherDrive are Medium,
ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility.
IPowerConsumer.DefaultPowerTier lets each behaviour own its own default.
EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the
unit rather than the design; EquippedItem's constructor seeds a kind default
the first time a unit is equipped, only when the field is still
PowerTiers.Unassigned, and never again -- a tier is a stored choice, not
something the bus re-derives.
PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in
full before the next tier sees anything left, an empty tier passes its whole
share down, and within a tier every consumer rations by the same
grant/demand fraction so unequal requests still divide proportionally. A new
TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to
read starvation from directly, instead of reconstructing it from every item's
PowerSupply.
Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its
only implementer): the bus already steps before any equipment's Execute, so
equip/behaviour order never decided who got fed even before this cut, and
nothing else needed the interface (subject to the map's O3 operator check).
docs/stats-and-power-cut.md Cut 5 / §1.3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
// 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 ( ) ;
}
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it
docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the
resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5)
as an identity everywhere. This lands the two things Cut 6 actually adds.
The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead
of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this
tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n).
PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the
missing half of the wiring named in the cut map -- so the resolver actually recomputes when
the grant moves, using the same per-source generation scheme Heat/Durability already use
(unconditional, at most once per tick, never touching a stat that did not declare the
source).
The request-independence rule: a stat that decides a power request may not depend on power
supply, directly or through a modifier chain. Direct dependency is checked statically
(StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of
(BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own
PowerRequest evaluates as its request, a compile-time fact rather than something authored
per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at
equip (EquippedItem's constructor) as defense against the two named bypasses that write a
catalog document without going through Upsert. The modifier-chain half is dynamic
(StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked
through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle
check, and refused at Initialize (equip time) if it is power-tainted and targets a known
request stat, naming both items.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
2026-09-19 06:58:57 +02:00
// 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
Delete the aether drive: behaviour, union 12, hardpoint type, aggregates, settings, presenters, prefab
AetherDriveData and AetherDrive, Union(12) (now a retired-do-not-reuse comment, as 26 is),
HardpointType.AetherDrive (last member, nothing shifts), Ship's drive sets, aggregates and
Axis write, the five request-field rows, GameplaySettings Aether multipliers, ShipInstance's
drive particles, SchematicDisplay's RPM display and AetherDriveUi, the undock refusal's drive
clause, the drive prefab and material, and AetherDb's drive roles and brownout row. The
design is kept at parked/aether-drive.
Adds EveryShippedShipHullHasATopSpeed. ARPG.unity still holds stale serialized references to
the removed SchematicDisplay fields and the RPM Display object; the next commit removes them.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 14:04:28 +02:00
// ruling exists to make legal again (RadiatorData.PumpedHeat) are not an authoring
2026-09-19 06:58:57 +02:00
// 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.
2020-12-27 22:18:22 +00:00
}
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
// 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 ) ;
Cut 0: collapse the three disagreeing stat evaluate bodies into one
ItemManager.Evaluate (unequipped), ConsumableItemEffect.Evaluate and
EquippedItem.Evaluate each hand-computed the same quality/durability/heat/
modifier arithmetic with small, undocumented disagreements (docs/stats-and-
power-cut.md Sec 0.2). Collapse the arithmetic into one function,
PerformanceStat.Evaluate(IStatContext), fed by three IStatContext
implementations: EquippedItem (the only one with heat, live durability and
modifiers), ConsumableItemEffect (progress substitutes for heat,
unconditionally, matching its prior behaviour exactly) and a new
UnequippedStatContext in ItemManager (no heat, no modifiers, by name).
No behaviour change beyond the two disagreements the cut map names as
defects. Each context's original NaN handling is preserved exactly
(unequipped throws with diagnostic detail; equipped/consumable fall back to
Min). Behavior.Evaluate(PerformanceStat) keeps its name and signature, so
the ~81 existing call sites are untouched.
Verified: dotnet test tests/Aetheria.Shared.Tests unchanged at 61/61 before
new tests are added; a real-catalog probe over GameData/Aetheria.cc found
the new unequipped path reproduces the old formula exactly across 3123
(item, quality, durability, stat) cases; Unity batchmode compiles with 0
error CS and the Cut 1/2/3 shield probes still pass; AetherDb census,
factions, dangling, station-fit, loadout 1 and hardpoint-fit are
byte-identical before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:22 +02:00
2026-09-19 06:58:57 +02:00
// 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
Delete the aether drive: behaviour, union 12, hardpoint type, aggregates, settings, presenters, prefab
AetherDriveData and AetherDrive, Union(12) (now a retired-do-not-reuse comment, as 26 is),
HardpointType.AetherDrive (last member, nothing shifts), Ship's drive sets, aggregates and
Axis write, the five request-field rows, GameplaySettings Aether multipliers, ShipInstance's
drive particles, SchematicDisplay's RPM display and AetherDriveUi, the undock refusal's drive
clause, the drive prefab and material, and AetherDb's drive roles and brownout row. The
design is kept at parked/aether-drive.
Adds EveryShippedShipHullHasATopSpeed. ARPG.unity still holds stale serialized references to
the removed SchematicDisplay fields and the RPM Display object; the next commit removes them.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 14:04:28 +02:00
// 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:
2026-09-19 06:58:57 +02:00
// 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 ) ;
}
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
public float HeatFactor ( float exponent ) = > pow ( ThermalPerformance , ThermalExponent * exponent ) ;
public float DurabilityFactor ( float exponent ) = > pow ( DurabilityPerformance , DurabilityExponent * exponent ) ;
public float ConsumableProgressFactor ( float exponent ) = > 1f ;
2026-09-19 05:03:31 +02:00
// 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
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it
docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the
resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5)
as an identity everywhere. This lands the two things Cut 6 actually adds.
The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead
of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this
tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n).
PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the
missing half of the wiring named in the cut map -- so the resolver actually recomputes when
the grant moves, using the same per-source generation scheme Heat/Durability already use
(unconditional, at most once per tick, never touching a stat that did not declare the
source).
The request-independence rule: a stat that decides a power request may not depend on power
supply, directly or through a modifier chain. Direct dependency is checked statically
(StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of
(BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own
PowerRequest evaluates as its request, a compile-time fact rather than something authored
per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at
equip (EquippedItem's constructor) as defense against the two named bypasses that write a
catalog document without going through Upsert. The modifier-chain half is dynamic
(StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked
through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle
check, and refused at Initialize (equip time) if it is power-tainted and targets a known
request stat, naming both items.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
// 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 ) ;
2026-06-21 09:37:54 +01:00
Cut 3: PowerBus replaces TryConsumeEnergy/CanConsumeEnergy
Deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor
draining/filling (the demand-mechanism halves of Reactor.cs:90-141); adds
PowerBus (one per Entity, stepped once per tick before equipment executes),
IPowerConsumer on the five continuous draw sites (EnergyDraw, Thruster,
AetherDrive, Radiator, ConstantWeapon), and EquippedItem.PowerSupply as the
bus's grant ratio. Reactor keeps only its heat/throttle arithmetic, now driven
by PowerBus.NetDraw instead of an accumulator any behaviour could add into.
The four instant draws (InstantWeapon's burst/shot, Sensor's ping, Shield's hit
taken) are out of scope for the bus this cut per the map; they spend capacitor
charge directly and atomically via the new Entity.TrySpendCapacitorCharge/
CanSpendCapacitorCharge, replacing TryConsumeEnergy's old partial-drain-then-
refuse bug for that path too. Named, temporary exception closed by Cut 4.
docs/stats-and-power-cut.md Cut 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:39:38 +02:00
// Cut 3 (docs/stats-and-power-cut.md §1.2): PowerBus's grant ratio for this item, in [0,1], from whichever
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it
docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the
resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5)
as an identity everywhere. This lands the two things Cut 6 actually adds.
The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead
of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this
tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n).
PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the
missing half of the wiring named in the cut map -- so the resolver actually recomputes when
the grant moves, using the same per-source generation scheme Heat/Durability already use
(unconditional, at most once per tick, never touching a stat that did not declare the
source).
The request-independence rule: a stat that decides a power request may not depend on power
supply, directly or through a modifier chain. Direct dependency is checked statically
(StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of
(BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own
PowerRequest evaluates as its request, a compile-time fact rather than something authored
per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at
equip (EquippedItem's constructor) as defense against the two named bypasses that write a
catalog document without going through Upsert. The modifier-chain half is dynamic
(StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked
through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle
check, and refused at Initialize (equip time) if it is power-tainted and targets a known
request stat, naming both items.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
// 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.
Cut 3: PowerBus replaces TryConsumeEnergy/CanConsumeEnergy
Deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor
draining/filling (the demand-mechanism halves of Reactor.cs:90-141); adds
PowerBus (one per Entity, stepped once per tick before equipment executes),
IPowerConsumer on the five continuous draw sites (EnergyDraw, Thruster,
AetherDrive, Radiator, ConstantWeapon), and EquippedItem.PowerSupply as the
bus's grant ratio. Reactor keeps only its heat/throttle arithmetic, now driven
by PowerBus.NetDraw instead of an accumulator any behaviour could add into.
The four instant draws (InstantWeapon's burst/shot, Sensor's ping, Shield's hit
taken) are out of scope for the bus this cut per the map; they spend capacitor
charge directly and atomically via the new Entity.TrySpendCapacitorCharge/
CanSpendCapacitorCharge, replacing TryConsumeEnergy's old partial-drain-then-
refuse bug for that path too. Named, temporary exception closed by Cut 4.
docs/stats-and-power-cut.md Cut 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:39:38 +02:00
public float PowerSupply { get ; internal set ; } = 1f ;
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
// 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 ) ;
2021-03-07 01:10:31 +00:00
2026-09-19 04:41:13 +02:00
// 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 ) ;
}
2021-02-01 08:13:14 +00:00
public void AddHeat ( float heat , bool ignoreThermalMass = false )
2020-12-27 22:18:22 +00:00
{
foreach ( var hullCoord in InsetShape . Coordinates )
2021-03-07 01:10:31 +00:00
Entity . AddHeat ( hullCoord , heat / InsetShape . Coordinates . Length , ignoreThermalMass ) ;
2020-04-09 10:33:16 +01:00
}
2021-04-13 01:50:42 +01:00
public void UpdatePerformance ( )
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
{
2021-03-07 01:10:31 +00:00
var temp = Temperature ;
2026-08-10 14:57:50 +01:00
ThermalPerformance = Data . Performance ( temp ) ;
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
Entity . Resolver . InvalidateSource ( this , StatSource . Heat ) ;
2026-08-10 14:57:50 +01:00
var deltaTemp = math . abs ( temp - oldTemperature ) ;
DurabilityPerformance = EquippableItem . Durability / Data . Durability ;
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
Entity . Resolver . InvalidateSource ( this , StatSource . Durability ) ;
2021-03-14 03:56:49 +00:00
var performanceThreshold = Entity . Settings . ShutdownPerformance ;
2026-08-10 14:57:50 +01:00
Wear = ( 1 - pow ( ThermalPerformance ,
2026-09-17 04:16:23 +02:00
( 1 - pow ( Lot . Quality , ItemManager . GameplaySettings . QualityWearExponent ) ) *
2021-06-04 14:49:00 +02:00
ItemManager . GameplaySettings . ThermalWearExponent ) +
Cut 2: give every entity its own StatResolver, delete the catalog leak
PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.
StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.
A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
deltaTemp * ItemManager . GameplaySettings . DeltaTempWearExponent
2026-08-10 14:57:50 +01:00
) * Data . Durability / Data . ThermalResilience ;
2021-03-07 01:10:31 +00:00
ThermalOnline . Value = ThermalPerformance > performanceThreshold | | Entity . OverrideShutdown & & EquippableItem . OverrideShutdown ;
DurabilityOnline . Value = EquippableItem . Durability > . 01f ;
2021-06-04 14:49:00 +02:00
oldTemperature = temp ;
2021-04-13 01:50:42 +01:00
}
public void Update ( float delta )
{
2026-08-10 14:57:50 +01:00
foreach ( var audioStat in Data . AudioStats )
2021-04-30 06:39:57 +01:00
{
SetAudioParameter ( audioStat . Parameter , Evaluate ( audioStat . Stat ) ) ;
}
2021-03-07 01:10:31 +00:00
if ( Active . Value )
2020-12-10 23:20:38 +00:00
{
2021-01-25 05:53:36 +00:00
foreach ( var group in BehaviorGroups . Values )
2020-12-10 23:20:38 +00:00
{
2021-01-10 03:59:47 +00:00
foreach ( var behavior in group . Behaviors )
{
if ( ! behavior . Execute ( delta ) )
break ;
}
2020-12-10 23:20:38 +00:00
}
}
2021-04-21 01:49:07 +01:00
foreach ( var behavior in Behaviors )
if ( behavior is IAlwaysUpdatedBehavior alwaysUpdatedBehavior ) alwaysUpdatedBehavior . Update ( delta ) ;
2021-03-02 23:02:19 +00:00
}
2021-04-21 01:49:07 +01:00
public T GetBehavior < T > ( ) where T : class
2021-03-02 23:02:19 +00:00
{
foreach ( var behavior in Behaviors )
if ( behavior is T b )
return b ;
return null ;
2020-05-11 01:17:10 +01:00
}
2020-12-10 23:20:38 +00:00
}
2020-05-11 01:17:10 +01:00
2020-12-10 23:20:38 +00:00
public class EquippedCargoBay : EquippedItem
{
public readonly ReactiveDictionary < ItemInstance , int2 > Cargo = new ReactiveDictionary < ItemInstance , int2 > ( ) ;
public readonly ItemInstance [ , ] Occupancy ;
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
public readonly Dictionary < CultRecordKey , List < ItemInstance > > ItemsOfType = new Dictionary < CultRecordKey , List < ItemInstance > > ( ) ;
2026-06-14 04:47:02 +01:00
2026-08-10 14:57:50 +01:00
public new readonly CargoBayData Data ;
2020-12-10 23:20:38 +00:00
public float Mass { get ; private set ; }
public float ThermalMass { get ; private set ; }
2020-12-19 02:37:13 +00:00
public string Name { get ; }
2021-03-20 04:20:17 +00:00
public Shape UnoccupiedSpace
{
get
{
2026-08-10 14:57:50 +01:00
var unoccupied = new Shape ( Data . InteriorShape . Width , Data . InteriorShape . Height ) ;
2021-03-20 04:20:17 +00:00
foreach ( var v in unoccupied . AllCoordinates )
unoccupied [ v ] = Occupancy [ v . x , v . y ] = = null ;
return unoccupied ;
}
}
2026-10-03 14:57:27 +02:00
public EquippedCargoBay ( ItemManager itemManager , EquippableItem item , int2 position , Entity entity , string name ) : base ( itemManager , item , position , entity , null )
2020-05-11 01:17:10 +01:00
{
2026-08-10 14:57:50 +01:00
Data = ItemManager . GetData ( EquippableItem ) as CargoBayData ;
2020-12-19 02:37:13 +00:00
Name = name ;
2020-12-10 23:20:38 +00:00
2026-08-10 14:57:50 +01:00
Mass = Data . Mass ;
ThermalMass = Data . Mass * Data . SpecificHeat ;
2020-12-10 23:20:38 +00:00
2026-08-10 14:57:50 +01:00
Occupancy = new ItemInstance [ Data . InteriorShape . Width , Data . InteriorShape . Height ] ;
2020-05-11 01:17:10 +01:00
}
2020-12-10 23:20:38 +00:00
// Check whether the given item will fit when its origin is placed at the given coordinate
2020-12-20 03:04:52 +00:00
public bool ItemFits ( ItemInstance item , int2 cargoCoord )
2020-05-28 04:50:40 +01:00
{
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
var itemData = ItemManager . GetData ( item ) ;
2020-12-10 23:20:38 +00:00
// Check every cell of the item's shape
2026-08-10 14:57:50 +01:00
foreach ( var i in itemData . Shape . Coordinates )
2020-12-10 23:20:38 +00:00
{
// If there is an item already occupying that space, it won't fit
2026-08-10 14:57:50 +01:00
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 ;
2020-12-10 23:20:38 +00:00
}
return true ;
2020-05-28 04:50:40 +01:00
}
2021-03-04 03:58:46 +00:00
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 ;
}
2020-05-28 04:50:40 +01:00
2020-12-10 23:20:38 +00:00
// 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 )
2020-05-28 04:50:40 +01:00
{
2020-12-10 23:20:38 +00:00
positions = new List < int2 > ( ) ;
2026-08-10 14:57:50 +01:00
var itemData = ItemManager . GetData ( item ) ;
2020-12-10 23:20:38 +00:00
var remainingQuantity = item . Quantity ;
// For simple commodities, search for existing item stacks to add to
foreach ( var cargoItem in Cargo . Keys )
{
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
if ( ! item . Data . Key . Equals ( cargoItem . Data . Key ) ) continue ;
2020-12-10 23:20:38 +00:00
var cargoCommodity = ( SimpleCommodity ) cargoItem ;
2026-08-10 14:57:50 +01:00
if ( cargoCommodity . Quantity > = itemData . MaxStack ) continue ;
2020-12-10 23:20:38 +00:00
// Subtract remaining space in existing stack from remaining quantity
2026-08-10 14:57:50 +01:00
remainingQuantity - = min ( itemData . MaxStack - cargoCommodity . Quantity , remainingQuantity ) ;
2020-12-10 23:20:38 +00:00
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 ;
}
2021-03-20 04:20:17 +00:00
// TODO: Try alternate item rotations / use Shape.FitsWithin
2020-12-10 23:20:38 +00:00
// Search all the space in the cargo bay for an empty space where the item fits
2026-08-10 14:57:50 +01:00
foreach ( var cargoCoord in Data . InteriorShape . Coordinates )
2020-12-10 23:20:38 +00:00
{
if ( ItemFits ( item , cargoCoord ) )
{
positions . Add ( cargoCoord ) ;
return true ;
}
}
return false ;
2020-05-28 04:50:40 +01:00
}
2021-03-04 03:58:46 +00:00
// 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
2026-08-10 14:57:50 +01:00
foreach ( var cargoCoord in Data . InteriorShape . Coordinates )
2021-03-04 03:58:46 +00:00
{
if ( ItemFits ( item , cargoCoord ) )
{
position = cargoCoord ;
return true ;
}
}
2026-08-10 14:57:50 +01:00
position = int2 . zero ;
2021-03-04 03:58:46 +00:00
return false ;
}
2021-01-27 16:14:41 +00:00
public bool TryStore ( ItemInstance item )
{
if ( item is SimpleCommodity simpleCommodity )
return TryStore ( simpleCommodity ) ;
if ( item is CraftedItemInstance craftedItem )
return TryStore ( craftedItem ) ;
return false ;
}
2020-05-28 04:50:40 +01:00
2020-12-20 03:04:52 +00:00
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 ;
}
2020-12-10 23:20:38 +00:00
// 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 )
2020-04-09 10:33:16 +01:00
{
2020-12-10 23:20:38 +00:00
TryFindSpace ( item , out var positions ) ;
foreach ( var position in positions )
2020-05-26 01:12:06 +01:00
{
2020-12-10 23:20:38 +00:00
if ( TryStore ( item , position ) ) return true ;
2020-05-26 01:12:06 +01:00
}
2020-12-10 23:20:38 +00:00
return false ;
2020-04-09 10:33:16 +01:00
}
2020-12-10 23:20:38 +00:00
// 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 )
2020-04-09 10:33:16 +01:00
{
2026-08-10 14:57:50 +01:00
var itemData = ItemManager . GetData ( item ) ;
2020-12-10 23:20:38 +00:00
if ( ItemFits ( item , cargoCoord ) )
2020-06-05 04:16:33 +01:00
{
2026-08-10 14:57:50 +01:00
foreach ( var p in itemData . Shape . Coordinates )
2020-06-12 01:57:15 +01:00
{
2026-08-10 14:57:50 +01:00
var pos = cargoCoord + itemData . Shape . Rotate ( p , item . Rotation ) ;
2020-12-10 23:20:38 +00:00
Occupancy [ pos . x , pos . y ] = item ;
}
Cargo [ item ] = cargoCoord ;
2020-06-05 04:16:33 +01:00
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
if ( ! ItemsOfType . ContainsKey ( item . Data . Key ) )
ItemsOfType [ item . Data . Key ] = new List < ItemInstance > ( ) ;
ItemsOfType [ item . Data . Key ] . Add ( item ) ;
2020-12-10 23:20:38 +00:00
}
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
else if ( Occupancy [ cargoCoord . x , cargoCoord . y ] is SimpleCommodity cargoCommodity & & cargoCommodity . Data . Key . Equals ( item . Data . Key ) )
2020-12-10 23:20:38 +00:00
{
2026-08-10 14:57:50 +01:00
if ( cargoCommodity . Quantity + item . Quantity < = itemData . MaxStack )
2020-12-10 23:20:38 +00:00
{
cargoCommodity . Quantity + = item . Quantity ;
2020-06-12 01:57:15 +01:00
}
2020-12-10 23:20:38 +00:00
else
2020-06-12 01:57:15 +01:00
{
2026-08-10 14:57:50 +01:00
var quantityTransferred = itemData . MaxStack - cargoCommodity . Quantity ;
2020-12-10 23:20:38 +00:00
item . Quantity - = quantityTransferred ;
2026-08-10 14:57:50 +01:00
cargoCommodity . Quantity = itemData . MaxStack ;
2020-12-10 23:20:38 +00:00
2026-08-10 14:57:50 +01:00
Mass + = itemData . Mass * quantityTransferred ;
ThermalMass + = itemData . Mass * itemData . SpecificHeat * quantityTransferred ;
2020-12-10 23:20:38 +00:00
return false ;
2020-06-12 01:57:15 +01:00
}
2020-12-10 23:20:38 +00:00
}
else return false ;
2021-03-16 16:35:23 +00:00
Mass + = ItemManager . GetMass ( item ) ;
ThermalMass + = ItemManager . GetThermalMass ( item ) ;
2020-12-10 23:20:38 +00:00
return true ;
}
2020-06-12 01:57:15 +01:00
2020-12-10 23:20:38 +00:00
// 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 ;
2026-08-10 14:57:50 +01:00
var itemData = ItemManager . GetData ( item ) ;
foreach ( var p in itemData . Shape . Coordinates )
2020-05-04 00:07:36 +01:00
{
2026-08-10 14:57:50 +01:00
var pos = cargoCoord + itemData . Shape . Rotate ( p , item . Rotation ) ;
2020-12-10 23:20:38 +00:00
Occupancy [ pos . x , pos . y ] = item ;
2020-05-04 00:07:36 +01:00
}
2020-12-10 23:20:38 +00:00
Cargo [ item ] = cargoCoord ;
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
if ( ! ItemsOfType . ContainsKey ( item . Data . Key ) )
ItemsOfType [ item . Data . Key ] = new List < ItemInstance > ( ) ;
ItemsOfType [ item . Data . Key ] . Add ( item ) ;
2020-12-10 23:20:38 +00:00
2021-03-16 16:35:23 +00:00
Mass + = ItemManager . GetMass ( item ) ;
ThermalMass + = ItemManager . GetThermalMass ( item ) ;
2020-12-10 23:20:38 +00:00
return true ;
}
2020-05-11 01:17:10 +01:00
2020-12-10 23:20:38 +00:00
public SimpleCommodity Remove ( SimpleCommodity item , int quantity )
{
if ( ! Cargo . ContainsKey ( item ) )
2020-06-06 03:21:03 +01:00
{
2021-03-16 16:35:23 +00:00
ItemManager . Log ( "Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!" ) ;
2020-12-10 23:20:38 +00:00
return null ;
2020-06-06 03:21:03 +01:00
}
2026-08-10 14:57:50 +01:00
var itemData = ItemManager . GetData ( item ) ;
2020-12-10 23:20:38 +00:00
if ( quantity > = item . Quantity )
2020-06-06 03:21:03 +01:00
{
2026-08-10 14:57:50 +01:00
foreach ( var v in Data . InteriorShape . Coordinates )
2020-12-20 03:04:52 +00:00
if ( Occupancy [ v . x , v . y ] = = item )
Occupancy [ v . x , v . y ] = null ;
2020-12-10 23:20:38 +00:00
Cargo . Remove ( item ) ;
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
ItemsOfType [ item . Data . Key ] . Remove ( item ) ;
if ( ! ItemsOfType [ item . Data . Key ] . Any ( ) )
ItemsOfType . Remove ( item . Data . Key ) ;
2020-12-10 23:20:38 +00:00
2021-03-16 16:35:23 +00:00
Mass - = ItemManager . GetMass ( item ) ;
ThermalMass - = ItemManager . GetThermalMass ( item ) ;
2020-12-10 23:20:38 +00:00
return item ;
2020-06-06 03:21:03 +01:00
}
2020-04-29 23:36:00 +01:00
2020-12-10 23:20:38 +00:00
item . Quantity - = quantity ;
2026-08-10 14:57:50 +01:00
Mass - = itemData . Mass * quantity ;
ThermalMass - = itemData . Mass * itemData . SpecificHeat * quantity ;
return new SimpleCommodity { Data = item . Data , Quantity = quantity , Rotation = item . Rotation } ;
2020-05-29 23:06:35 +01:00
}
2020-12-10 23:20:38 +00:00
public void Remove ( CraftedItemInstance item )
2020-04-29 23:36:00 +01:00
{
2020-12-10 23:20:38 +00:00
if ( ! Cargo . ContainsKey ( item ) )
{
2021-03-16 16:35:23 +00:00
ItemManager . Log ( "Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!" ) ;
2020-12-10 23:20:38 +00:00
return ;
}
2026-08-10 14:57:50 +01:00
var itemData = ItemManager . GetData ( item ) ;
foreach ( var v in Data . InteriorShape . Coordinates )
2020-12-20 03:04:52 +00:00
if ( Occupancy [ v . x , v . y ] = = item )
Occupancy [ v . x , v . y ] = null ;
2020-12-10 23:20:38 +00:00
Cargo . Remove ( item ) ;
Move the ServerShared data model onto CultLib's CultCache
Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and
GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also
checks the Caching project exists.
Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject]
and integer [Key]s on the Q7 concrete types (the eight ItemData leaves,
Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the
four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout).
SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName].
ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose
[MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout
moves into ServerShared so AetheriaStores can route it.
References: Guid and DatabaseLink<T> members become CultRecordRef<T>;
Guid-keyed maps become ref-keyed with [CultReference(..., many: true)];
ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits,
bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey.
Identity is the record key: ID comparisons become reference or key
comparisons. StatModifierData.RequireBehavior is a simple type name.
AetheriaStores.Open composes the catalog (read-only unless writable), run
and player stores, one home per type. CultRecordRefs carries IsSet, a
null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert.
ItemManager.GetData is the one item resolution path. MathResolver is
declared by [assembly: CultCacheFormatterResolver].
Inspector attributes: text, range and asset path map onto CultLib's
CultInspector attributes (asset path only on string members); every
replaced Aetheria attribute keeps [Inspectable] for the runtime panel.
Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project,
which follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
ItemsOfType [ item . Data . Key ] . Remove ( item ) ;
if ( ! ItemsOfType [ item . Data . Key ] . Any ( ) )
ItemsOfType . Remove ( item . Data . Key ) ;
2020-12-10 23:20:38 +00:00
2021-03-16 16:35:23 +00:00
Mass - = ItemManager . GetMass ( item ) ;
ThermalMass - = ItemManager . GetThermalMass ( item ) ;
2020-04-29 23:36:00 +01:00
}
2020-12-20 03:04:52 +00:00
public void Remove ( ItemInstance item )
{
if ( item is SimpleCommodity simpleCommodity )
Remove ( simpleCommodity , simpleCommodity . Quantity ) ;
if ( item is CraftedItemInstance craftedItem )
Remove ( craftedItem ) ;
}
2020-12-10 23:20:38 +00:00
public bool TryTransferItem ( EquippedCargoBay target , SimpleCommodity item , int quantity )
2020-04-29 23:36:00 +01:00
{
2020-12-10 23:20:38 +00:00
if ( ! Cargo . ContainsKey ( item ) )
{
2021-03-16 16:35:23 +00:00
ItemManager . Log ( "Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!" ) ;
2020-12-10 23:20:38 +00:00
return false ;
}
2020-04-30 02:54:31 +01:00
2020-12-10 23:20:38 +00:00
var oldPos = Cargo [ item ] ;
var newItem = Remove ( item , quantity ) ;
if ( target . TryStore ( item ) ) return true ;
// Failed to transfer full quantity, move the remaining items back to their old slot
TryStore ( newItem , oldPos ) ;
return false ;
}
public bool TryTransferItem ( EquippedCargoBay target , CraftedItemInstance item )
{
if ( ! Cargo . ContainsKey ( item ) )
{
2021-03-16 16:35:23 +00:00
ItemManager . Log ( "Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!" ) ;
2020-12-10 23:20:38 +00:00
return false ;
}
if ( ! target . TryStore ( item ) ) return false ;
Remove ( item ) ;
return true ;
2020-04-29 23:36:00 +01:00
}
2020-06-02 07:01:03 +01:00
}
2020-12-19 02:37:13 +00:00
public class EquippedDockingBay : EquippedCargoBay
{
public Ship DockedShip ;
2026-08-10 14:57:50 +01:00
public int2 MaxSize = > _data . MaxSize ;
private DockingBayData _data ;
2020-12-27 22:18:22 +00:00
public EquippedDockingBay ( ItemManager itemManager , EquippableItem item , int2 position , Entity entity , string name ) : base ( itemManager , item , position , entity , name )
2020-12-19 02:37:13 +00:00
{
2026-08-10 14:57:50 +01:00
_data = ItemManager . GetData ( EquippableItem ) as DockingBayData ;
2020-12-19 02:37:13 +00:00
}
}
2020-06-05 04:16:33 +01:00
public class BehaviorGroup
2020-06-02 07:01:03 +01:00
{
2021-04-18 05:13:20 +01:00
public Behavior [ ] Behaviors ;
2021-01-25 05:53:36 +00:00
2021-04-18 05:13:20 +01:00
public T GetBehavior < T > ( ) where T : Behavior
2021-01-25 05:53:36 +00:00
{
foreach ( var b in Behaviors )
{
if ( ! ( b is T s ) ) continue ;
return s ;
}
return null ;
}
2021-04-18 05:13:20 +01:00
public T GetExposed < T > ( ) where T : Behavior , IInteractiveBehavior
2021-01-25 05:53:36 +00:00
{
foreach ( var b in Behaviors )
{
if ( ! ( b is T s ) | | ! ( ( IInteractiveBehavior ) b ) . Exposed ) continue ;
return s ;
}
return null ;
}
2020-06-05 04:16:33 +01:00
//public IAnalogBehavior Axis;
2026-08-10 14:57:50 +01:00
}