6.1 (Soul finding 6): Commit builds a local CultMath.Random from (Zone.CombatSeed,
shot.ShotId) instead of reading and writing the shared ItemManager.Random stream.
Zone exposes CombatSeed (the same expression that already seeds its own _random),
so a shot's outcome is a pure function of zone identity and shot id -- immune to
whatever else drew from the shared stream in between.
6.2 (Soul finding 5): airburst is wired into the simulation instead of back into
Projectile.cs. PendingShot gains BurstPosition/BurstRadius, frozen at Fire time
from PredictedIntercept and the weapon's authored WeaponItemData.AirburstRange
when WeaponModifiers carries Airburst. FireControl.Step resolves such a shot via
Splash instead of Apply, never both. Projectile.cs/ProjectileManager.cs drop the
dead AirburstDistance/AirburstRange fields and write-back.
Tests: FireControlCut6Tests.cs pins both rules (208 baseline + 5 new, all green).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's pass over the whole campaign confirmed eleven findings. Seven were code
defects of one shape -- a rule implemented on one path and left off the path
beside it -- and this is all seven.
5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of
zero, and Commit drops the zero branch that turned a Tracking-less shooter
into a hard wall. Unaided fire was meant to be really, really bad; it was
334 shots and no hits against a target drifting at 5 u/s.
5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into
ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that
recharges mid-flight cannot retroactively survive. Splash already did this;
the discrete roll never did, which made the whole shield-break mechanic
unreachable for every weapon in the catalog.
5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A
side-mounted beam was firing forward.
5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into
InArc, which used to normalize the zero vector into NaN. The trigger said
fire and the probability said impossible.
5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops
concentrating hits onto a dead subsystem's footprint.
5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal
lived only in Unity's loot-drop subscription, so headless nothing ever left
the zone, shots landed on corpses and no fight could end.
5.7 PendingShot drops PredictedIntercept and FlightTime, written and read
nowhere.
208 tests pass (197 plus eleven new).
Known defect, recorded rather than hidden: the committed mutation harness is
unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs,
leaving live mutations in the working tree -- including its own 5.1 mutation,
which is how a green suite briefly reported two failures. Its verdicts are
therefore evidence only where re-run from a verified-clean tree. Two anchors
(Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and
5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written
but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's
word, and the suite result above was taken by hand from a hash-verified tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FireControl.Fire takes an optional damageOverride so a continuous weapon can
queue a shot for less than its full Damage. FireControl.Splash is the one new
rule this cut adds: an unconditional area effect (mines, airburst) applying
the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic
over the directional half of each target's own hull that faces the blast --
the rule moved verbatim from the Splash subscription EntityInstance.cs
carried before Cut 3 deleted it (git show b7743789^).
ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval
(new setting) for Damage*interval, through the same FireControl.Fire/Step
pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight
commits and resolves in the same tick it fires (R4's short-flight
degradation). A beam is a sequence of rolls, not a continuous truth.
Fixes a real defect found while testing the above: both InstantWeapon.Execute
(Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl.
Fire(...))` inline. C#'s null-conditional short-circuits the whole
expression, argument included, whenever the event has no subscriber -- true
of any headless run with no Unity EntityInstance wired up
(docs/headless-playground-cut.md) -- so a shot silently never fired. Both
now evaluate FireControl.Fire into a local before the null-conditional
invoke.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
LoadoutGenerator.FillInterior equips a targeting-system product,
required: true, on any entity EquipHardpoints already gave a weapon --
Q4's "not an upgrade" ruling made structural. CombatState now selects
the target's most dangerous revealed weapon item each tick through the
same IsRevealed predicate and TrySelectTargetItem writer the player path
uses, instead of never touching TargetItem at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TargetingSystem is required interior Tool gear: Accuracy/Resolution/
Precision/Tracking are PerformanceStats reached through the usual
Evaluate path, so lot quality, wear, heat and PowerSupply all apply
(a starved system rolls worse, never switches off). FireControl.Accuracy/
Resolution/Precision fall back to GameplaySettings.UnaidedAccuracy (new,
authored low per Q4) whenever an entity has no targeting system or its
own is destroyed/disabled.
FireControl.IsRevealed derives R5's reveal rule from EntityInfoGathered:
non-hull equipment ranks hardpoint-mounted first, then by size, and
reveals between TargetArmorInfoThreshold and TargetGearInfoThreshold --
the first code to read either setting. Entity.TargetItem/
TrySelectTargetItem/ResolvedTargetItem give both the player and AI one
shared, revoke-on-decay aim-point slot with a single writer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A power request may not depend on power supply (Cut 6/F6), and continuous
consumers brown out through a power term on their own performance stats
(Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the
same stat, making six shipped catalog records illegal and leaving six
BrownoutTests cases skipped.
Resolved per the operator's ruling: a power request is evaluated
nominally -- what the item wants at full supply, not what it is currently
managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's
own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability
stay live, since they are not the term a request would be circular
through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor
implementation now reads a StatValidation.PowerRequestFields stat through
it, so the same stat can carry a brownout curve and be a request field
without the request depending on its own answer.
StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the
rule, forbidding the term outright) is deleted -- nominal evaluation
removes the circularity at the root instead. Only the dynamic
modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still
refuses anything, since EvaluateNominalPower forwards ScaleModifier/
ConstantModifier unchanged (real, not nominal).
Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new
regression tests (a direct EvaluateNominalPower probe, and a two-tick
Radiator demand check) backed by a new mutation harness. Updates the four
PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded
static refusal to instead assert acceptance. GameData/Aetheria.cc is
unchanged and still validates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
F1 power supply multiplies the resolved value (operator ruling), so zero
supply means zero and a flat stat still responds. F2 the bus agrees with
Active in both directions. F3 an empty tier reports no demand instead of
full supply, and sub-epsilon demand is rationed and subtracted. F4/F5 the
shield restores while inactive and CanTakeHit no longer mutates. F6/F7 the
validator names every stat a request reads and the tools validate before
writing. F8 re-anchors two dead mutation harnesses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CultRecordRefs.Upsert is the one function every catalog write is supposed to
go through, but tools/AetherDb/Program.cs's Dangling, ShieldMigrate and
BrownoutMigrate commands each repair an existing record in place and must
preserve its key -- Upsert's own cache.UpsertAsync(document) cannot take a
caller-supplied key, so all three wrote raw and unvalidated through
`db.Cache.Commit(batch => batch.Upsert(document.GetType(), document, key))`.
`brownout-migrate apply` is how the shipped catalog's PowerSupply terms were
actually authored, unvalidated, the whole time.
Extracted Upsert's validation body into CultRecordRefs.Validate(object), and
call it directly from all three tool commands immediately before their own
identity-preserving commit -- the same rules, invoked where Upsert itself
cannot reach. The comment above StatValidation naming the write-bypass holes
(landed with F6) now names all four instead of two: CultCache Studio's
generic editor (still open -- not this repository's to patch) plus the three
tool commands above (closed).
Pinned in tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs:
ValidateRefusesAnEquippableItemWithAZeroSpanHeatRangeTheSameWayUpsertWould and
ValidateRefusesAPowerRequestCarryingAPowerSupplyTermTheSameWayUpsertWould pin
that the extracted method actually refuses what Upsert would (fail under a
reverted Validate that is a no-op); ValidateIsANoOpForADocumentTypeItDoesNotGovern
pins that a document type it has no opinion about doesn't crash it.
Verified tools/AetherDb/AetherDb.csproj still builds clean against the pinned
CultLib worktree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StatValidation.PowerRequestFields registered only each consumer's own
top-level request field (Thruster.EnergyUsage, Shield.Capacity, and so on),
on the premise that PowerRequest reads exactly one PerformanceStat. That was
false for five of the eight consumers: Radiator.PowerRequest also reads
PumpedHeat and WasteHeat (its own early-out gate), AetherDrive.PowerRequest
also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its
whole spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also
reads RefillDuration/RestoreDuration, InstantWeapon.PowerRequest (via
RefreshInputCapacitor) also reads Cooldown/Count, and Sensor.PowerRequest
also reads PingCooldown. Any of those could carry a PowerSupply term and sail
straight past ValidateNoPowerSupplyOnRequest, corrupting the very request it
feeds -- exactly Soul's reproduced "OK Disperser" oscillation
(SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates).
Audited directly against each PowerRequest method's own body (and the
RefreshReserve/RefreshInputCapacitor helpers it calls into), not guessed from
the behaviour's public surface.
This correctly refuses the real catalog now: `GameData/Aetheria.cc` carries
exactly six violations --
cold like my heart RadiatorData.PumpedHeat
Arctica RadiatorData.PumpedHeat
Skiron RadiatorData.PumpedHeat
OK Disperser RadiatorData.PumpedHeat
Iapyx RadiatorData.PumpedHeat
Traction AetherDriveData.Torque
-- re-authoring them (moving the PowerSupply term off the now-covered field,
or redesigning Radiator/AetherDrive's own PowerRequest to read a
nominal/undegraded value instead) is catalog and design work for Self/roles
authoring, not done here.
BrownoutTests.cs's own Radiator/AetherDrive brownout fixtures hit the same
wall -- they authored a PowerSupply term on PumpedHeat/Torque to test Cut 7's
"curve on the performance stat," which is now correctly refused since both
behaviours' PowerRequest reads that same stat. Neither behaviour has an
untouched field left to curve, so there is currently no valid catalog shape
that gives them a working brownout curve without also feeding it back into
their own request -- a real, scoped conflict between Cut 6 and Cut 7 for
these two consumers. The six affected tests are marked Skip with a header
comment explaining the conflict rather than silently reworked or deleted;
Thruster and ConstantWeapon are unaffected (their own PowerRequest never
reads the separate field their tests curve).
Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs:
UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm and
UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm -- both fail
under the reverted (top-level-field-only) registry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
F4: a hit that breaks the shield usually also cooks it (TakeHit's own
AddHeat), which can knock the item thermally offline in the same tick. The
reserve's recharge and its Broken-clearing check lived inside Execute, which
Entity.Update only runs while Item.Active is true -- so a shield broken by a
hit that also shuts it down could never come back, no matter how long
RestoreDuration allows for. That logic now lives in Shield.Update
(IAlwaysUpdatedBehavior), which Entity.Update calls every tick unconditionally
(Cooldown.cs already relies on the same interface for the same reason).
F5: CanTakeHit broke the shield and drained the reserve as a side effect of
the query itself, on the premise that every caller queries it exactly once
per hit. That premise was false: RaycastAll/SphereCastAll/OverlapSphere hand
every caller under Assets/Scripts/Gameplay/Weapons both the shield collider
and the hull collider for one shot, so the same shield's CanTakeHit was
queried twice per hit. CanTakeHit is now a pure read (Broken + the reserve's
own CanSpend); the mutation moved to a new Shield.Break(), idempotent, called
by each weapon file exactly where it already decided to route a hit past the
shield instead of absorbing it.
Pinned in tests/Aetheria.Shared.Tests/ShieldReserveTests.cs:
RestorationProceedsWhileTheItemIsInactive (F4),
CanTakeHitNeverMutatesRegardlessOfHowManyTimesItIsCalled and
BreakIsIdempotentAcrossRepeatedCalls (F5). Updated every existing test that
relied on CanTakeHit's old mutating side effect to call Break() explicitly,
matching the new caller pattern.
Cannot compile-verify Assets/Scripts/Gameplay/Weapons/*.cs here (UnityEngine,
Unity batchmode unavailable in this environment) -- reviewed by hand for
brace/control-flow correctness; Self or a Unity batchmode pass should confirm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AllocateTiers treated any tier whose summed demand was at or below 1e-4 as
"fully met" (ratio 1) without ever subtracting it from `remaining`. Two bugs:
a tier with no consumers equipped in it at all read as 1f, the exact "fully
supplied" signal a UI is meant to trust, even on a fully blacked-out ship
(measured: Critical 0 demand reads "fine" right next to a genuinely starved
High); and a tier with a real but tiny demand (down to 5e-5) was granted out
of nothing and never charged against the ship's actual generation, letting a
lower tier get overfed by the sliver the higher one was never billed for.
AllocateTiers now tracks which tiers actually have a consumer separately from
their summed demand. A tier with no consumer reports the new PowerBus.NoDemand
sentinel (NaN), never 1f. A tier with a consumer whose own request truly is
zero this tick is trivially satisfied (ratio 1, nothing to be short of). Any
other demand, however small, goes through the same grant/remaining arithmetic
as every other tier.
Pinned in tests/Aetheria.Shared.Tests/PowerTierTests.cs:
SubEpsilonDemandGetsNoGrantAtTotalBlackout,
HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining, and
EmptyTierAtBlackoutReportsNoDemandNotFullySupplied -- all fail under the
reverted (<=1e-4 free-pass) mutation. Updated
ATierWithNoDemandPassesItsShareDown's own empty-tier assertion, which had
pinned the bug itself ("empty tiers read as fully met").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PowerBus.Step billed every equipped IPowerConsumer's request unconditionally
and admitted a reactor on Item.Online alone, but Entity.Update only ever runs
a behaviour's Execute when the item is Active (Enabled && Online). A consumer
the player switched off never executes and never spends anything, yet was
still billed, starving live consumers and draining capacitors for nobody. A
reactor switched off stayed Online and kept generating even though
Reactor.Execute (which produces the heat receipt) never ran for it: free,
heat-free power.
Both directions now gate on the same predicate the rest of Entity uses:
item.Active.Value.
Pinned in tests/Aetheria.Shared.Tests/PowerBusTests.cs:
DisabledConsumerContributesNoDemand and DisabledReactorGeneratesNoPower --
both fail under the reverted (Online-only / unconditional) mutation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
32 of 51 designs declare roles, 183 stats point at one, 28 of 37 products
author matching role quality. Adds the validation that refuses a stat
naming a role its design lacks, and roles-migrate alongside the other
catalog tools. Product means are placeholders pending operator review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PerformanceStat.Evaluate folded PowerSupply into the same Min/Max lerp factor
as Heat/Durability/Quality, so zero supply bottomed out at Min instead of 0
(measured: a radiator at zero power still pumped 19-25% of full; three of Cut
7's eight authored terms had Min == Max and were completely inert, since
lerp(x, x, anything) == x).
PowerSupply terms are now accumulated into their own multiplier and applied to
the fully-interpolated, fully-modified value instead of to the interpolation
factor. The term still lives in PerformanceStat.Terms -- it is still authored
per stat and still censused/validated the same way -- it is just no longer
blended into `factor`. Corrected the comments that stated the old premise
(Radiator.cs, Entity.cs, ItemData.cs's IStatContext.PowerSupplyFactor doc).
Pinned in tests/Aetheria.Shared.Tests/PowerCurveTests.cs:
APowerSupplyTermedStatWithNonzeroMinResolvesToExactlyZeroAtZeroSupply and
AMinEqualsMaxStatStillRespondsToItsPowerSupplyTerm -- both fail under the
reverted (lerp-based) mutation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StatValidation.ValidateRoleUsage walks every PerformanceStat field on every
behaviour and refuses a StatSource.Quality term whose Role is not in the
design's own declared Roles -- wired into AetheriaStores.Open and
CultRecordRefs.Upsert alongside the existing heat/modifier/power-supply
checks. Lot.QualityForRole already falls back to workmanship for a role an
old lot never rolled (forward-compat for a role added after minting); this
is the other direction: a term naming a role the design never declared at
all is an authoring mistake, not a lot predating the role, and must fail
loudly.
RoleAuthoringTests.cs pins three rules with both a positive and negative
control: Upsert refuses an absent-role term but accepts a declared one, and
two products of one design with different ProductRole means mint lots that
resolve a role-termed stat to different, deterministic values.
tests/mutation_tests_stats_power_cut8.py (this repo's own numbering follows
cut7.py's brownout work) mutates ValidateRoleUsage in both directions
(never-refuse, always-refuse) and QualityForRole's fallback, plus
ItemManager.CreateLot's mean read -- all six behave as declared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator ask (2026-09-19): thruster particles only reflected thrust intent,
so a cooked or starved thruster looked identical to a healthy one.
EquippedItem.ConditionRatio (Entity.cs) resolves a stat's actual value
against what the same item, same lot, same modifiers would produce with
Heat/Durability/PowerSupply pinned to their identity (NominalContext) --
quality and lot are not part of "perfect", so a cheap item still reads 1
at full health. The nominal side bypasses the resolver's cache (which is
keyed only by (owner, stat) and would otherwise collide with the real
cached value) with one direct Evaluate call, a handful of flops.
Thruster.Condition and AetherDrive.Condition expose that ratio against
each behaviour's own governing stat (Thrust, Torque) for presentation to
read next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/stats-and-power-target.md: "Continuous consumers brown out through a
power supply curve on their performance stats." Cut 6 wired the curve
(EquippedItem.PowerSupplyFactor) but every continuous consumer still gated
its own effect on Item.PowerSupply >= 1f, so an 80% grant produced nothing
instead of 80% performance.
- Thruster/AetherDrive: gate narrowed from ">= 1f" to a true-zero floor
(> 1e-4f, PowerBus's own epsilon); Thrust/Torque are plain Evaluate()
reads, so a curved stat already degrades once the full-grant demand is
dropped.
- Radiator: the gate is deleted outright. PumpedHeat is already a curved
Evaluate() read; waste heat is unaffected, so a starved radiator now falls
behind instead of refusing to pump, and zero supply already zeroes
PumpedHeat via the curve with no special case needed.
- ConstantWeapon: the weapon no longer safes itself off (_firing = false)
on a partial grant -- only true zero still stops it. Damage already
degrades via base.Execute's own Evaluate() read.
- EnergyDraw: has no performance stat of its own -- its Execute return
value IS the effect (gates its BehaviorGroup). Narrowed the same way:
a partial grant still passes, only true zero closes the gate.
InputCapacitor and the three instant consumers (InstantWeapon, Sensor,
Shield) are untouched: all-or-nothing on a partial charge still holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Operator ruling, 2026-09-19, supersedes Cut 4's derived reserve (EnergyUsage refilling
every second, flagged as a guess). ShieldData now authors the reserve size and two
recharge speeds directly; EnergyUsage stays the per-damage-point cost multiplier.
A hit the reserve cannot fully cover now breaks the shield instead of a silent,
charge-preserving refusal. Two follow-up rulings settle the fork the first left open:
the breaking hit passes through in full (no partial absorption -- TakeHit never runs
for it), and the reserve empties to zero on break so RestoreDuration means the same
thing every time regardless of how full the shield was.
InputCapacitorTests' shield fixture now authors the three new fields explicitly --
without them the reserve defaults to zero capacity and breaks on every hit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
F5: ItemData.cs's comment claimed every tool, test and migration writes
through CultRecordRefs.Upsert. tools/AetherDb/Program.cs's Dangling command
does not -- it commits repaired records through CultCache's own batch API,
keyed explicitly to preserve identity, bypassing the validating extension.
That path can land an EquippableItemData or ConsumableItemData with an
invalid heat response or an unresolvable stat modifier reference, refused
only the next time someone reopens the file. Named as a second exception
alongside Studio's already-documented one, since routing the fix through the
generic Upsert<T> extension would need a way to change an existing record's
key-preserving write path that this pass did not have visibility into --
correcting the comment is the honest option available now.
F6: nothing invalidates StatSource.Quality (no InvalidateSource call for it
anywhere). Harmless today because EquippedItem.Lot is set once in the
constructor and never reassigned. Sec 0b designs an upgrade as a lot swap the
resolver must see; recorded at the read site so the cut that adds the repoint
finds the obligation waiting for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StatModifier.Initialize recomputed _targets fresh every (re)activation but
never reset _applied, so after deactivate -> unequip the target -> equip a
replacement -> activate, Update saw `_executed && _applied` (both still true
from the stale attachment) and neither branch of its latch fired again --
the modifier silently stopped reaching anything. Differential evidence: the
same scenario resolved 6 pre-cut and 1 at Cut 2's HEAD.
Chose the ownership fix over the one-line flag reset: Initialize is the
entity's own re-activation hook, called exactly once per (re)activation for
every StatModifier on it, so it is the one place that already knows a
transition is happening. Detaching whatever this modifier was attached to
before recomputing _targets removes the split authority between Initialize
(which decides targets) and Update (which was silently also deciding
attachment state) instead of leaving Update to infer a transition it was
never told about.
F8: Behaviors.cs's `StatOwner` property had no production caller -- it was
added only to make one mutation test's mutant expressible. Deleted; the
mutation test now uses `Entity` (a real symbol) as its wrong-owner stand-in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StatResolver's _cache, _modifiers and _generations were keyed by owner and
nothing ever removed an entry, contradicting Sec 0b's promise that a resolver
entry is "destroyed at unequip / entity teardown." A ConsumableItemEffect that
expired still had a generations entry, and an unequipped EquippedItem's entries
lived on for the rest of the process -- the same shape of leak the catalog
object had before Cut 2, one level down.
Forget(owner) drops every entry for that owner across all three dictionaries;
Entity.TryUnequip and the consumable-expiry branch in Entity.Update now call it.
Also adds GenerationOwnerCount/CacheEntryCount/ModifierEntryCount, a test-only
surface a mutation test pins the retained-set claim against.
F3: InvalidateSource's comment claimed a stat "recomputes only when a term's
source changes." Entity.cs's UpdatePerformance calls it unconditionally every
tick for Heat and Durability, regardless of whether the value moved, so the
true invariant is "at most once per tick per (item, stat)." Corrected, and
noted Quality as the one source this scheme still meaningfully defends
against a real no-op tick (see F6 in ItemData.cs).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
f4d5659d landed with StatValidation's OptimalTemperature-bounds check replaced by
`if (false)`: the mutation harness was running concurrently against this same file
and a `git add`/`git commit` caught it mid-mutation. Restores the real check. Caught
because the harness's own "not yet reached" scars in doctrine are what prompted a
by-hand recheck of the just-committed diff rather than trusting the terminal output
that said 80/80 green a moment earlier.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response
fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a
plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and
the Performance() comment that already claimed this was enforced. The writable-path
hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs),
the one function every tool, test and migration script writes catalog documents
through, validates before a write reaches disk instead of waiting for the next Open().
CultCache Studio's generic editor bypasses Upsert and still has no per-document
validation hook -- that hole is CultLib's, not this repo's, and stays named in the code
rather than silently assumed closed.
EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the
same shape HeatResponseTests already hand-authors) because dozens of existing test
fixtures across the suite build designs that never cared about heat and defaulted into
the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal
without this would have required touching every one of those fixtures instead of the
root cause.
Four new mutations pin S7's survivors, each with its own dedicated test rather than a
coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f
outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring
its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the
exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own
runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes
it unobservable at every input, verified by an exhaustive numeric sweep -- and is
recorded as such rather than forced into a fake kill.
80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Cut 2 of docs/addressables-cut.md. The asset move (git mv
Resources/{Prefabs,Schematics,Sprites/Icons} -> Content/...) and the
UnityHelpers.cs deletion already landed in 1fdf8151, picked up by a
concurrent session sharing this working tree; this commit is the rest
of Cut 2 landing on top of that shared state, atomically:
- Bump CultLib to cultlib-unity-v1.0.60 / caching-unity-v1.4.0 (both
45c2f40) in Packages/manifest.json, packages-lock.json and
Directory.Build.props, together with the 8 ServerShared
[CultInspectorAssetPath] usages becoming [CultInspectorAssetGuid] -
the .NET and Unity sides move together or nothing compiles.
- Assets/Scripts/EngineAssets.cs replaces UnityHelpers: loads an
Addressables key (bare GUID or guid[subAsset]) synchronously,
loading the GameObject and taking the component for the four sites
that load a component. ActionBarSlot, EntityInstance, ShipInstance
and ZoneRenderer call it instead of Resources.Load/UnityHelpers; the
inline "Assets/Resources/" prefix strips are gone with them.
CultCacheDrawers' schematic preview goes through
AssetDatabase.GUIDToAssetPath first.
- Assets/Scripts/Editor/EngineAssetCheck.cs validates every stored
[CultInspectorAssetGuid] value: 32 lowercase hex, resolves to a file,
is addressable, and satisfies its member's consumer-type contract
(Texture2D for icons/logos/schematics, a GameObject with the right
component for prefabs) - table-driven, so an attributed member with
no row fails loudly instead of being skipped.
- GameData/Aetheria.cc: the scratch migration (Aetheria.Shared + the
pinned CultLib revision, a throwaway console app, never landed)
walked all 138 attributed field slots across 180 documents,
rewriting the 30 non-empty Resources paths to their .meta GUIDs -
108 stayed empty, 0 unresolved, matching the map's census exactly.
Verification: dotnet test tests/Aetheria.Shared.Tests 61/61 green
against CultLib 45c2f40; Unity 6000.3.24f1 batchmode compiles clean;
EngineAssetCheck.Run passes all 30 stored references; the map's
negative greps are clean; tests/mutation_tests.py's 5 mutations (wrong
type, non-addressable, random guid, an old path, and an untabled
attributed member) are all caught and named, the control case and the
post-revert recompile stay clean, and the catalog/source are restored
byte-identical afterward.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entity's constructor now takes a MessagePack round-trip copy of the settings
it is given, so no entity can alias GameplaySettings.DefaultEntitySettings.
That makes ActionGameManager.NewEntitySettings and EntitySerializer.Unpack's
null-coalescing copy redundant; both are deleted, and TradeMenu buys a ship
straight off the template (Entity's ctor copies it).
EntitiesDoNotShareDefaultEntitySettings pins the invariant: writing
ShutdownPerformance on one ship must not move the default for a sibling or
the template (settings-globals-cut.md, Cut 0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ActionGameManager.Sensitivity was a Unity-serialized public float2, but
CultMath's float2 isn't Unity-serializable, so ARPG.unity's stored value
loaded as zero and mouse look went dead. Sensitivity is a player
preference, so it belongs in PlayerInputSettings (key 2) with the prior
default (-0.001, 0.001) as a field initializer, so a pre-existing player
store missing the key still deserializes to the working default instead
of zero. ActionGameManager now reads PlayerSettings.InputSettings.Sensitivity
directly; no cached copy on the MonoBehaviour. Adds a pinning test that
simulates an old store lacking key 2 and asserts the default, plus a
round-trip test for a non-default value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fork L (docs/headless-playground-cut.md): pickup, impact absorption, melee
blade and propulsion are separate item capabilities that a future simulation
will own; presentations subscribe and decide nothing. This stands up the wire
shape only -- AbsorbEvent/GrabEvent/MeleeEvent/ThrustEvent plus a per-entity
CapabilityEvents source (UniRx Subject<T> per kind, matching the idiom
already used on Entity) -- with no publisher wired up yet. Compiles into the
headless Aetheria.Shared build with no Unity dependency.
AbsorbEvent carries the hit's DamageType (operator, 2026-09-17) so a future
presenter can vary visuals per type; the damage-type mask itself belongs to
the absorb capability's own data and is out of scope here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Weapon.StanceAllowsFire is the one shared gate: true with no target, else the
shooter's own Entity.IsHostileTo(Target) -- not the target's stance toward the
shooter. It lives on the Weapon base class so every weapon subtype and every
caller (player input, TurretController, AI) goes through the same rule.
Wired at the two places weapons actually start dealing damage:
- InstantWeapon.Trigger (the base for InstantWeapon, AutoWeapon, ChargedWeapon
and LockWeapon/Launcher, all of which route real firing through Trigger).
- ConstantWeapon.Execute's continuous-beam loop, which stops firing (and
raises OnStopFiring) the moment the shooter's stance disallows it.
Also fixes LockWeapon's lock-building condition, which read the wrong
direction (target.IsHostileTo(shooter) instead of shooter.IsHostileTo(target))
-- the same backwards check as the HUD lock indicator, fixed alongside this in
ActionGameManager.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
ZoneGenerator now spawns NeutralWandererCount ships (default 2, authored on
ZoneGenerationSettings) from a galaxy faction that is neither the zone owner
nor its nearest faction, using the zone's existing seeded Random so placement
stays deterministic per zone. None spawn when no faction qualifies. The
faction-pool rule is pulled into ZoneGenerator.EligibleWandererFactions so it
is unit-testable without running the full generation pipeline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The manufacturer does not belong to a design (operator ruling); it lives on
FactionProductData and branding is derived, never stored on the design.
- Delete ItemData.cs key 3 (Manufacturer), with a retired-key comment.
- AetherDb census now attributes a design's maker to the first product (in
record-key order) that sells it, matching ItemManager.Brand's rule; a
design no product sells shows "(none)".
- AetheriaStoresTests' catalog fixture moves its Manufacturer reference onto
a FactionProductData selling the design, and FactionIsASingletonInstance
reads it from there.
- mutation_tests.py gets a paired mutation for that fixture wiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator ruling (docs/item-provenance-cut.md Progress header, 2026-09-17):
delete EntityPack.PersistedBehaviors, IPersistentBehavior,
PersistentBehaviorData and MarkerPersistentBehaviorData outright rather than
keep patching around a feature with no production writer. Key(5) is retired
and left unused; other MessagePack keys are untouched.
Removes the pack/unpack code that read and wrote the field, the feature-only
test ReopenedPackKeepsPersistedBehaviors and its BarePack initializer, and the
matching "not yet reached" mutation notes in tests/mutation_tests.py.
ReopenSurvivesAGeneratedShipWithEntities is kept: it pins the reopen defect
class against a real generated ship, independent of this feature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clear removes every record IsRunRecord names, and RunTypes has included
ProvenanceLedger since Cut B; the comment listed only the four earlier
run record types.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dictionary<int2, PersistentBehaviorData[]> has no hash-resistant comparer
under CultCache's UntrustedData security, so any run store holding an
entity threw MessagePackSerializationException on reopen, even when the
dictionary was empty. EntityPack.PersistedBehaviors (key 5, unchanged)
becomes (int2 position, PersistentBehaviorData[] data)[], matching
Equipment/CargoBays/DockingBays. Pack writes pairs; Unpack builds a local
lookup before restoring behaviors.
PersistentBehaviorData had no concrete leaf and no Union, so nothing could
ever exercise its wire format; added MarkerPersistentBehaviorData as the
one placeholder leaf so ReopenedPackKeepsPersistedBehaviors can round-trip
real data ahead of a real IPersistentBehavior implementer landing.
Tests: RunSaveTests.BarePack uses an empty pairs array.
Added ReopenedPackKeepsPersistedBehaviors (a hand-built pack with two
positions, each with one persisted behavior, round-trips through commit
and reopen) and ReopenSurvivesAGeneratedShipWithEntities (a real ship from
LoadoutGenerator against a catalog fixture, committed into a SavedZone and
reopened, pinning the defect at the layer Soul's probe found it).
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots go
green; 36 of 36 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Quality, Ingredients and Product leave CraftedItemInstance; a unit now
carries only a LotId into the run-scoped ProvenanceLedger. ItemManager
gains the one lot-minting path (CreateLot/CreateInstance(int)), GetLot,
and Brand(), which derives a maker and product from provenance instead
of a stored field. RunSave persists the ledger pruned to what the saved
zones' entities still reach; the live ledger is never pruned, so a
floor pickup collected after a save still resolves. CopyBuild is gone:
matching hardpoints now share the same lot instead of copying values.
PropertiesPanel shows the derived brand where the design's manufacturer
used to be. AetherDb's census flags duplicate (maker, design) products
and its save command checks every crafted instance's lot against a real
run store.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut A of docs/item-provenance-cut.md. TradeMenuDebug.cs was dead (its
script GUID appears in no scene/prefab/asset and no other .cs names the
type). ItemManager.Instantiate and CreateInstance(SimpleCommodityData,
int) re-rolled quality and dropped provenance on every load; nothing
outside EntitySerializer called them once the instantiate parameter is
gone. EntitySerializer.Unpack/Restore no longer thread an instantiate
flag, so Temperature and Armor are restored unconditionally like every
other caller already got. The starting ship and spawnturret now keep
what the generator built instead of re-minting.
Verification: Aetheria.Shared, tools/AetherDb build green against
CultLib a0813c6; tests/Aetheria.Shared.Tests 29 passed (unchanged); the
four negative greps in the map are empty. Unity compile is still owed
(the editor is open on this project; batchmode was not run per
instructions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LoadoutGenerator.IsAvailable's comment claimed a caller passes it to
Loadouts.Materialize. No game path materializes a preset yet. The comment now
says a spawner is to pass it, and that none does yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening the process cache's catalog writable in the editor was the wrong cut.
It made catalog read-only only by convention, so a stray catalog write in play
succeeded and a later flush could land play-mutated instances in Aetheria.cc.
- The process cache opens the catalog read-only in every build again, so the
missing-global check runs for it as it did before db83cd29. The writable
empty-seed exemption from ab4ced0f stays for AetherDb and fixtures.
- Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It
opens its own short-lived cache over the catalog, writable, commits the one
preset conditionally on its key and disposes it. No other cache or instance
is touched. The running game sees the preset only after the catalog reloads.
CultLib at a0813c6 allows this: a read-only single-file store takes no lock
and holds no handle, and the writer's commit takes the exclusive sidecar
<path>.lock and replaces the file atomically.
- Capture refuses, writing nothing, when Aetheria.cc is missing (it never
creates the catalog), when a preset of that name exists under another key
(CultCache does not reject duplicate names on write), or when the key holds a
preset and replace is not given.
- The console refuses a command with any character outside letters, digits,
spaces and hyphens instead of stripping it, so "Mk.II" can no longer become
"MkII". capturepreset refuses a second argument other than replace, so an
unquoted capturepreset My Ship refuses instead of saving "My".
- The capture log line says the session sees the preset after a reload, and
that a Studio session opened earlier overwrites captured presets on save.
- Tests: capture while the process cache holds the catalog read-only leaves it
unchanged; a missing catalog refuses and creates nothing; a same name under a
Studio-minted key refuses, with or without replace; a game-shaped read-only
open missing a global throws. The existing preset tests now commit through
the catalog path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Globals are owned by lifecycle, so the cache must never run without one,
however the catalog is opened. Before this, opening the catalog writable
skipped the check entirely. The editor now opens the catalog writable, so a
missing global went silent in play mode.
AetheriaStores.Open now skips the check only for a writable catalog that
has no records: a seed store being authored from nothing, as the test
fixtures do. A populated catalog is always checked, and so is a read-only
empty one. Nothing else relied on the old skip: the game has no catalog
globals, AetherDb opens the populated Aetheria.cc, and every writable test
reopen already holds its fixture global.
WritablePopulatedCatalogMissingGlobalIsLoud removes the global from a
populated catalog and checks that a writable reopen throws. It fails when
the old writable-skip condition is restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>