From da5a71003062839a383695a0d455b4c47044cee7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 05:03:31 +0200 Subject: [PATCH 1/7] F1: power supply multiplies the resolved stat, it does not interpolate 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 --- .../ServerShared/Behaviors/Radiator.cs | 6 ++- Assets/Scripts/ServerShared/Entity.cs | 8 ++-- Assets/Scripts/ServerShared/ItemData.cs | 26 +++++++++--- .../Aetheria.Shared.Tests/PowerCurveTests.cs | 42 +++++++++++++++++++ 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs index d7a9a7c58..d09b5382a 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs @@ -90,8 +90,10 @@ public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior // plain Evaluate() read, so a PumpedHeat stat carrying a PowerSupply term already pumps less under a // partial grant -- waste heat below is unaffected by the curve, so a starved radiator falls behind and // the ship heats up, which is the reduced-performance failure the ruling asks for instead of the pump - // simply refusing to run. At true zero supply PumpedHeat itself resolves to 0 (pow(0, exponent) == 0), - // so "produces nothing" already falls out of the curve below without a special case here. + // simply refusing to run. F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PowerSupply is a + // multiplier PerformanceStat.Evaluate applies to the whole resolved value, not a term blended into the + // Min/Max interpolation, so at true zero supply PumpedHeat resolves to exactly 0 regardless of Min -- so + // "produces nothing" already falls out of Evaluate() without a special case here. var pumpedHeat = PumpedHeat * max(itemTemperature - _data.TemperatureFloor, 0); // Radiator temperature is below temperature floor, stop executing diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 0d53c1020..53f4deb66 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1371,9 +1371,11 @@ public class EquippedItem : IStatContext public float HeatFactor(float exponent) => pow(ThermalPerformance, ThermalExponent * exponent); public float DurabilityFactor(float exponent) => pow(DurabilityPerformance, DurabilityExponent * exponent); public float ConsumableProgressFactor(float exponent) => 1f; - // Cut 6 (docs/stats-and-power-cut.md): the brownout curve. A stat with a PowerSupply term degrades as this - // tick's grant falls below 1 and is exactly unchanged at full supply (pow(1, exponent) == 1 regardless of - // exponent) -- the operator's "a continuous consumer that degrades instead of stopping." PowerBus writes + // Cut 6 (docs/stats-and-power-cut.md), applied per F1 (operator ruling 2026-09-19): PerformanceStat.Evaluate + // multiplies this straight onto the resolved value rather than blending it into the Min/Max interpolation, so + // a stat with a PowerSupply term is exactly unchanged at full supply (pow(1, exponent) == 1 regardless of + // exponent) and exactly zero at zero supply (pow(0, exponent) == 0 for any exponent > 0), no matter what Min + // is -- "a continuous consumer that degrades instead of stopping" down to genuinely nothing. PowerBus writes // PowerSupply below; the resolver invalidates this source once per tick from the same write (PowerBus.cs // AllocateTiers), so this read is always this tick's own grant, never a stale one. public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent); diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 8f318d9d0..8dd1e79ae 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -587,9 +587,9 @@ public interface IStatContext // Progress through a consumable effect's duration, standing in for "condition" on a consumable the way heat // does for equipped gear. Only ConsumableItemEffect has one; every other context is the identity (1). float ConsumableProgressFactor(float exponent); - // Cut 6 (the power bus) wires this to a real brownout curve. Until then no catalog stat declares a - // PowerSupply term, and every context answers the identity so the enum member can exist now without a - // resolver to back it. + // Cut 6 (the power bus) wires this to the bus's grant ratio. F1 (docs/stats-and-power-cut.md, operator + // ruling 2026-09-19): this is not one of the terms blended into the Min/Max interpolation -- PerformanceStat + // applies it as a separate multiplier on the fully-resolved value, so zero supply always resolves to zero. float PowerSupplyFactor(float exponent); float ScaleModifier(PerformanceStat stat); float ConstantModifier(PerformanceStat stat); @@ -643,11 +643,28 @@ public class PerformanceStat // this cut, because pow(x, 0) == 1 regardless of x. Callers keep their own NaN handling: an unequipped read // throws with diagnostic detail, an equipped or consumable read falls back to Min. That disagreement is not // named as a defect, so it is not touched here. + // + // F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PowerSupply is not folded into the + // interpolation factor with every other source. Heat, Durability and Quality all describe "how worn/hot/well + // made is this part" -- a part that is still there, just less good at its job, so blending toward Min is + // right for them. Power is not a degradation of the part; it is whether the part is receiving anything to + // work with at all, so it multiplies the fully-interpolated, fully-modified value instead: a PowerSupply term + // still lives in this stat's own Terms list (it is still authored per stat, still censused, still refused on + // a request stat by ValidateNoPowerSupplyOnRequest below), it is just applied as a separate multiplier rather + // than contributing to `factor`. Consequences named in the ruling: PowerSupply == 0 makes the whole + // expression 0 regardless of Min (pow(0, exponent) == 0 for any exponent > 0), and a Min == Max stat still + // responds to a PowerSupply term because the multiplier no longer has to move a degenerate interpolation. public float Evaluate(IStatContext context) { var factor = 1f; + var powerMultiplier = 1f; foreach (var term in Terms) { + if (term.Source == StatSource.PowerSupply) + { + powerMultiplier *= context.PowerSupplyFactor(term.Exponent); + continue; + } factor *= term.Source switch { // F6 (docs/stats-and-power-cut.md Cut 2 Soul pass): nothing ever calls Resolver.InvalidateSource @@ -662,11 +679,10 @@ public class PerformanceStat StatSource.Heat => context.HeatFactor(term.Exponent), StatSource.Durability => context.DurabilityFactor(term.Exponent), StatSource.ConsumableProgress => context.ConsumableProgressFactor(term.Exponent), - StatSource.PowerSupply => context.PowerSupplyFactor(term.Exponent), _ => throw new ArgumentOutOfRangeException(nameof(term.Source), term.Source, $"Unknown StatSource on {Min}-{Max} stat") }; } - return lerp(Min, Max, factor) * context.ScaleModifier(this) + context.ConstantModifier(this); + return (lerp(Min, Max, factor) * context.ScaleModifier(this) + context.ConstantModifier(this)) * powerMultiplier; } } diff --git a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs index 5141291a6..da67ff0e8 100644 --- a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs @@ -145,6 +145,48 @@ public sealed class PowerCurveTests : IDisposable Assert.Equal(100f, engine.Evaluate(curve), 3); } + // --- F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): "power supply multiplies, it does not + // --- interpolate." Before this fix PowerSupply was one more term feeding PerformanceStat's Min/Max lerp, so + // --- zero supply bottomed out at Min, not 0 -- Soul measured a radiator still pumping 19-25% of full heat at + // --- zero power. Min here is 1000 (nonzero), so lerp(1000, 4000, pow(0, 2)) would read 1000 under the old + // --- code; the multiplier form must read exactly 0 regardless of Min. --- + [Fact] + public void APowerSupplyTermedStatWithNonzeroMinResolvesToExactlyZeroAtZeroSupply() + { + var curve = new PerformanceStat { Min = 1000, Max = 4000, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 2 } } }; + using var cache = OpenCatalog(curve); + var ship = BuildShip(cache, reactorCharge: 0); // no generation, no stored charge -> demand of 100 is unmet + + ship.Update(1f); + + var engine = ship.Equipment.Single(e => e.Data.Name == "Engine"); + Assert.Equal(0f, engine.PowerSupply, 4); + Assert.Equal(0f, engine.Evaluate(curve), 3); // NOT 1000 (Min) -- the old lerp-based bug's exact number + } + + // --- The ruling's other named consequence: "a stat whose Min equals its Max still responds" -- three of Cut + // --- 7's eight authored terms were inert for exactly this reason, because lerp(x, x, anything) == x. The + // --- multiplier form has no interpolation to degenerate, so it must still move. --- + [Fact] + public void AMinEqualsMaxStatStillRespondsToItsPowerSupplyTerm() + { + var curve = new PerformanceStat { Min = 2500, Max = 2500, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cacheStarved = OpenCatalog(curve); + var starved = BuildShip(cacheStarved, reactorCharge: 0); + starved.Update(1f); + var starvedEngine = starved.Equipment.Single(e => e.Data.Name == "Engine"); + + var curve2 = new PerformanceStat { Min = 2500, Max = 2500, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cacheFed = OpenCatalog(curve2); + var fed = BuildShip(cacheFed, reactorCharge: 1000); + fed.Update(1f); + var fedEngine = fed.Equipment.Single(e => e.Data.Name == "Engine"); + + Assert.Equal(0f, starvedEngine.Evaluate(curve), 3); // was inert (always 2500) before this fix + Assert.Equal(2500f, fedEngine.Evaluate(curve2), 3); + Assert.NotEqual(fedEngine.Evaluate(curve2), starvedEngine.Evaluate(curve)); + } + // A small, deliberately fake context (StatResolverTests' own pattern) so the "no cost when undeclared" rule // is provable without any catalog, entity or item plumbing -- and so it can count exactly how many times the // resolver ever asked for the power-supply factor. From 881415d5e330ee698b2c3699bbac49c152b6accb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 05:06:31 +0200 Subject: [PATCH 2/7] F2: gate the bus's reactors and consumers on Active, not Online 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 --- Assets/Scripts/ServerShared/PowerBus.cs | 14 ++++++- tests/Aetheria.Shared.Tests/PowerBusTests.cs | 42 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/PowerBus.cs b/Assets/Scripts/ServerShared/PowerBus.cs index 6942688ae..36d27c41f 100644 --- a/Assets/Scripts/ServerShared/PowerBus.cs +++ b/Assets/Scripts/ServerShared/PowerBus.cs @@ -93,14 +93,24 @@ public class PowerBus { switch (behavior) { - case Reactor reactor when reactor.Item.Online.Value: + // F2 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this must agree with the one predicate + // Entity.Update actually gates Execute on (Active.Value, Entity.cs -- Enabled && Online), in both + // directions. Gating on Online alone let a reactor the player switched off (Enabled = false, so + // Active is false but Online stays true) keep generating here even though Reactor.Execute -- the + // behaviour that produces the heat receipt -- never runs for it: free, heat-free power. Capacitor + // has no IPowerConsumer/generation role and is unconditionally a store regardless of its own + // item's state (mirrors pre-existing behaviour; nothing in the ruling asks for that to change). + case Reactor reactor when item.Active.Value: reactors.Add(reactor); break; case Capacitor capacitor: capacitors.Add(capacitor); break; } - if (behavior is IPowerConsumer consumer) + // The other direction of the same rule: a consumer switched off or shut down (Active.Value false) + // never runs its Execute, so it must never be billed either -- otherwise a disabled item's demand + // starves every live consumer and drains the capacitors for energy nobody spends. + if (behavior is IPowerConsumer consumer && item.Active.Value) { // Cut 5 (docs/stats-and-power-cut.md §1.3): the item's own stored choice wins once it has one; // PowerTiers.Unassigned only survives past EquippedItem's constructor for a consumer added to diff --git a/tests/Aetheria.Shared.Tests/PowerBusTests.cs b/tests/Aetheria.Shared.Tests/PowerBusTests.cs index 07384a0bc..1fb04274d 100644 --- a/tests/Aetheria.Shared.Tests/PowerBusTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerBusTests.cs @@ -208,4 +208,46 @@ public sealed class PowerBusTests : IDisposable Assert.Equal(ship.PowerBus.GrantRatio, drainOne.PowerSupply, 5); Assert.Equal(ship.PowerBus.GrantRatio, drainTwo.PowerSupply, 5); } + + // --- F2 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "one predicate, both directions." Step used to + // --- collect every equipped IPowerConsumer's request unconditionally, but Entity.Update only ever runs a + // --- behaviour's Execute when the item is Active (Enabled && Online) -- so a consumer the player switched + // --- off (Enabled = false) never executes, never spends anything, and yet was still billed here, starving + // --- every live consumer and draining the capacitors for energy nobody spent. A disabled consumer must + // --- contribute nothing to TotalDemand at all. --- + [Fact] + public void DisabledConsumerContributesNoDemand() + { + using var cache = OpenCatalog(); + var (ship, _, _, capacitor) = BuildShip(cache, reactorCharge: 0, startingCapacitorCharge: 100, reactorFirst: true); + var drain = EquipDrain(cache, ship, rate: 50); + drain.Enabled.Value = false; + Assert.False(drain.Active.Value); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalDemand, 3); + Assert.Equal(100f, capacitor.Charge, 3); // nothing was spent on a request nobody will ever execute + } + + // --- The other direction of the same rule: PowerBus.Step gated a reactor on Item.Online alone, but + // --- Reactor.Execute (the behaviour that actually produces the heat receipt) only runs when Item.Active -- + // --- Enabled && Online -- is true. A reactor the player switched off stays Online (thermal/durability are + // --- fine) but is not Active, so it must not generate here either, or it supplies the whole ship for free + // --- with no heat cost at all. --- + [Fact] + public void DisabledReactorGeneratesNoPower() + { + using var cache = OpenCatalog(); + var (ship, _, reactor, _) = BuildShip(cache, reactorCharge: 100, startingCapacitorCharge: 0, reactorFirst: true); + reactor.Item.Enabled.Value = false; + Assert.True(reactor.Item.Online.Value); + Assert.False(reactor.Item.Active.Value); + EquipDrain(cache, ship, rate: 100); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalGeneration, 3); + Assert.Equal(0f, ship.PowerBus.TotalGrant, 3); + } } From f36effd70ba5b0755aef01d251ad00c8073e1d15 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 05:15:30 +0200 Subject: [PATCH 3/7] F3: an empty tier is not a satisfied tier, and sub-epsilon demand pays its way 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 --- Assets/Scripts/ServerShared/PowerBus.cs | 33 +++++++++-- tests/Aetheria.Shared.Tests/PowerTierTests.cs | 58 ++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/PowerBus.cs b/Assets/Scripts/ServerShared/PowerBus.cs index 36d27c41f..488ea07d2 100644 --- a/Assets/Scripts/ServerShared/PowerBus.cs +++ b/Assets/Scripts/ServerShared/PowerBus.cs @@ -52,16 +52,25 @@ public class PowerBus // TierGrantRatio or the item's own PowerSupply for that. public float GrantRatio { get; private set; } = 1f; + // F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the sentinel AllocateTiers writes for a tier with no + // consumers equipped in it at all. Distinct from 1f -- an empty tier has not been "fully supplied," there was + // nothing to supply, and reporting it as 1f is exactly the fully-satisfied signal a UI is meant to read as + // "this tier is fine," on a ship that may be fully blacked out. A reader must treat NaN as "nothing to report + // here," not as satisfied or starved. + public const float NoDemand = float.NaN; + // Cut 5: this tick's within-tier grant ratio, indexed by PowerTiers.Critical..Utility. 1f means that tier's - // demand was fully met (including a tier with no demand at all, per the array's own default-init); anything - // below 1f is exactly the "starved" state a future UI (Cut 8) reads instead of reconstructing it from + // demand was fully met; NoDemand (NaN) means the tier has no consumers at all (see NoDemand above); anything + // else below 1f is exactly the "starved" state a future UI (Cut 8) reads instead of reconstructing it from // per-item PowerSupply. A tier below a starved one is always 0f -- §1.3's "a tier boundary does not leak". public float[] TierGrantRatio { get; } = InitFullTiers(); + // Before the bus has ever run a Step, there is no grant information at all yet -- not "fully supplied," + // "nothing to report" (NoDemand), same as an empty tier reads once Step does run. private static float[] InitFullTiers() { var ratios = new float[PowerTiers.Count]; - for (var i = 0; i < ratios.Length; i++) ratios[i] = 1f; + for (var i = 0; i < ratios.Length; i++) ratios[i] = NoDemand; return ratios; } @@ -157,14 +166,28 @@ public class PowerBus private void AllocateTiers(List draws) { var tierDemand = new float[PowerTiers.Count]; - foreach (var draw in draws) tierDemand[draw.Tier] += draw.Request; + // F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): demand alone cannot tell "no consumer is + // equipped in this tier" from "a real consumer is equipped here and its own request happens to be 0 (or + // effectively 0) this tick" -- both sum to the same tierDemand. Only the first is actually empty; the + // second still has an item reading this tier's ratio back as its own PowerSupply, and that item is owed + // a real answer (trivially 1: it asked for nothing, so nothing it could be short of), not NoDemand. + var tierHasConsumer = new bool[PowerTiers.Count]; + foreach (var draw in draws) { tierDemand[draw.Tier] += draw.Request; tierHasConsumer[draw.Tier] = true; } var remaining = TotalGrant; var ratios = TierGrantRatio; for (var tier = 0; tier < PowerTiers.Count; tier++) { + // A tier with genuinely no consumers is not satisfied, it is empty: report NoDemand, not the "fully + // supplied" signal 1f. Nothing to subtract from `remaining` either way. + if (!tierHasConsumer[tier]) { ratios[tier] = NoDemand; continue; } var demand = tierDemand[tier]; - if (demand <= 1e-4f) { ratios[tier] = 1f; continue; } + // This used to short-circuit ANY demand at or below 1e-4 the same way as a genuinely empty tier, + // which both reported a grant nobody made (an item reading ratio 1 while the ship generates nothing, + // down to Soul's own probe at 5e-5) and leaked that tier's unsubtracted share to every lower one. A + // consumer requesting truly nothing (demand <= 0f exactly) is trivially fully met; anything above + // that, however tiny, is rationed like any other tier. + if (demand <= 0f) { ratios[tier] = 1f; continue; } var grant = min(demand, remaining); ratios[tier] = saturate(grant / demand); remaining -= grant; diff --git a/tests/Aetheria.Shared.Tests/PowerTierTests.cs b/tests/Aetheria.Shared.Tests/PowerTierTests.cs index 85605c61c..50f16b530 100644 --- a/tests/Aetheria.Shared.Tests/PowerTierTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerTierTests.cs @@ -158,8 +158,11 @@ public sealed class PowerTierTests : IDisposable Assert.Equal(.25f, utility.PowerSupply, 3); Assert.Equal(.25f, ship.PowerBus.GrantRatio, 3); + // F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): an empty tier reports PowerBus.NoDemand (NaN), + // not the "fully met" signal 1f -- there was nothing here to satisfy, and its share still passed down + // (proven by utility's own .25 above, not by a since-corrected "1f" reading on the empty tiers). for (var tier = 0; tier < PowerTiers.Utility; tier++) - Assert.Equal(1f, ship.PowerBus.TierGrantRatio[tier], 3); // empty tiers read as fully met, never starved + Assert.True(float.IsNaN(ship.PowerBus.TierGrantRatio[tier]), $"tier {tier} should report NoDemand, read {ship.PowerBus.TierGrantRatio[tier]}"); } // --- "Rules worth pinning" #4: "reactor throttling changes what is available and therefore which tiers @@ -221,4 +224,57 @@ public sealed class PowerTierTests : IDisposable Assert.Equal(.6f, mover.PowerSupply, 3); } + + // --- F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "a sub-epsilon demand is granted out of + // --- nothing." A tier whose real demand is genuinely tiny (5e-5, below the old 1e-4 free-pass threshold) is + // --- not empty -- it must be rationed at a total blackout exactly like any other tier: PowerSupply 0, not + // --- the old code's unconditional 1. --- + [Fact] + public void SubEpsilonDemandGetsNoGrantAtTotalBlackout() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 0); + var tiny = EquipDrain(cache, ship, rate: 5e-5f, tier: PowerTiers.Critical); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalGrant, 6); + Assert.Equal(0f, tiny.PowerSupply, 4); // NOT 1f -- there is no power to grant + } + + // --- The other half of the same bug: a higher tier's sub-epsilon demand must still be subtracted from + // --- `remaining`, or a lower tier is overfed by the sliver the higher tier was never charged for. Critical's + // --- own tiny request (5e-5) and Utility's real one (10) together exceed the 10 generated by exactly that + // --- sliver; the two tiers' actual delivered energy must never exceed what the ship made. --- + [Fact] + public void HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 10); + var critical = EquipDrain(cache, ship, rate: 5e-5f, tier: PowerTiers.Critical); + var utility = EquipDrain(cache, ship, rate: 10f, tier: PowerTiers.Utility); + + ship.Update(1f); + + var delivered = 5e-5f * critical.PowerSupply + 10f * utility.PowerSupply; + Assert.True(delivered <= ship.PowerBus.TotalGrant + 1e-6f, + $"delivered {delivered} against a total grant of {ship.PowerBus.TotalGrant}"); + } + + // --- The first named bug, directly: "an empty tier reports fully supplied." Only a Utility consumer is + // --- equipped and the ship is fully blacked out -- Critical must not read as 1f ("fine"), it must read as + // --- PowerBus.NoDemand, because there is nothing in Critical to be fine or starved. --- + [Fact] + public void EmptyTierAtBlackoutReportsNoDemandNotFullySupplied() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 0); + EquipDrain(cache, ship, rate: 10f, tier: PowerTiers.Utility); + + ship.Update(1f); + + Assert.True(float.IsNaN(ship.PowerBus.TierGrantRatio[PowerTiers.Critical]), + $"empty Critical tier should report NoDemand at blackout, read {ship.PowerBus.TierGrantRatio[PowerTiers.Critical]}"); + Assert.Equal(0f, ship.PowerBus.TierGrantRatio[PowerTiers.Utility], 3); // Utility itself is genuinely starved + } } From 95d6fd3b14fac6c875bd364dfa0f516dbf7963f3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 05:32:29 +0200 Subject: [PATCH 4/7] F4/F5: shield restore survives inactivity, and CanTakeHit stops mutating 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 --- .../Scripts/Gameplay/Weapons/ConstantLaser.cs | 13 ++- .../Gameplay/Weapons/GuidedProjectile.cs | 13 ++- Assets/Scripts/Gameplay/Weapons/Laser.cs | 13 ++- Assets/Scripts/Gameplay/Weapons/Lightning.cs | 24 +++- Assets/Scripts/Gameplay/Weapons/Mine.cs | 18 ++- Assets/Scripts/Gameplay/Weapons/Projectile.cs | 14 ++- .../Scripts/ServerShared/Behaviors/Shield.cs | 68 +++++++---- .../ShieldReserveTests.cs | 106 ++++++++++++++++-- 8 files changed, 221 insertions(+), 48 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs b/Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs index aded241fb..002b4a367 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs @@ -76,10 +76,19 @@ public class ConstantLaser : MonoBehaviour var shield = hit.collider.GetComponent(); if (shield) { - if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue; + var shieldBehavior = shield.Entity.Shield; + var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; + var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); + // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read + // once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit + // is actually decided to route past it (to the hull collider RaycastAll returns further down + // this same loop) instead of being absorbed. The hull branch below re-queries CanTakeHit safely + // -- it is side-effect-free -- and will see Broken already true. + if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); + if (!shieldAbsorbs) continue; if (shield.Entity != SourceEntity) { - shield.Entity.Shield.TakeHit(DamageType, Damage); + shieldBehavior.TakeHit(DamageType, Damage); shield.ShowHit(hit.point, sqrt(Damage)); LineRenderer.SetPosition(1, hit.point); hitFound = true; diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index 16005ca3d..079644a8d 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -155,10 +155,19 @@ public class GuidedProjectile : MonoBehaviour var shield = hit.collider.GetComponent(); if (shield) { - if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue; + var shieldBehavior = shield.Entity.Shield; + var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; + var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); + // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- + // read once into shieldAbsorbs, and break the shield exactly once, right here, at the point + // this hit is actually decided to route past it (to the hull collider RaycastAll returns + // further down this same loop) instead of being absorbed. The hull branch below re-queries + // CanTakeHit safely -- it is side-effect-free -- and will see Broken already true. + if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); + if (!shieldAbsorbs) continue; if (shield.Entity != SourceEntity) { - shield.Entity.Shield.TakeHit(DamageType, Damage); + shieldBehavior.TakeHit(DamageType, Damage); shield.ShowHit(hit.point, sqrt(Damage)); } } diff --git a/Assets/Scripts/Gameplay/Weapons/Laser.cs b/Assets/Scripts/Gameplay/Weapons/Laser.cs index a111de91d..0c010c8a2 100644 --- a/Assets/Scripts/Gameplay/Weapons/Laser.cs +++ b/Assets/Scripts/Gameplay/Weapons/Laser.cs @@ -42,10 +42,19 @@ public class Laser : MonoBehaviour var shield = hit.collider.GetComponent(); if (shield) { - if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue; + var shieldBehavior = shield.Entity.Shield; + var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; + var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); + // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read + // once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit + // is actually decided to route past it (to the hull collider RaycastAll returns further down + // this same loop) instead of being absorbed. The hull branch below re-queries CanTakeHit safely + // -- it is side-effect-free -- and will see Broken already true. + if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); + if (!shieldAbsorbs) continue; if (shield.Entity != SourceEntity) { - shield.Entity.Shield.TakeHit(DamageType, Damage); + shieldBehavior.TakeHit(DamageType, Damage); shield.ShowHit(hit.point, sqrt(Damage)); LineRenderer.SetPosition(1, hit.point); hitFound = true; diff --git a/Assets/Scripts/Gameplay/Weapons/Lightning.cs b/Assets/Scripts/Gameplay/Weapons/Lightning.cs index da2a4cb7e..641d44212 100644 --- a/Assets/Scripts/Gameplay/Weapons/Lightning.cs +++ b/Assets/Scripts/Gameplay/Weapons/Lightning.cs @@ -35,14 +35,26 @@ public class Lightning : MonoBehaviour foreach (var hit in hits) { var shield = hit.collider.GetComponent(); - if (shield && (shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) + if (shield) { - if (shield.Entity == Source.Entity) continue; - LightningCompute.OnLeaderComplete = () => + var shieldBehavior = shield.Entity.Shield; + var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; + var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); + // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read + // once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit + // is actually decided to route past it (to the hull collider SphereCastAll returns as a + // separate entry further down this same loop) instead of being absorbed. The hull branch below + // re-queries CanTakeHit safely -- it is side-effect-free -- and will see Broken already true. + if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); + if (shieldAbsorbs) { - shield.Entity.Shield.TakeHit(DamageType, Damage); - shield.ShowHit(hit.point, sqrt(Damage)); - }; + if (shield.Entity == Source.Entity) continue; + LightningCompute.OnLeaderComplete = () => + { + shieldBehavior.TakeHit(DamageType, Damage); + shield.ShowHit(hit.point, sqrt(Damage)); + }; + } } var hull = hit.collider.GetComponent(); if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) diff --git a/Assets/Scripts/Gameplay/Weapons/Mine.cs b/Assets/Scripts/Gameplay/Weapons/Mine.cs index 7c0699d75..3807270d3 100644 --- a/Assets/Scripts/Gameplay/Weapons/Mine.cs +++ b/Assets/Scripts/Gameplay/Weapons/Mine.cs @@ -92,10 +92,22 @@ public class Mine : MonoBehaviour foreach (var collider in Physics.OverlapSphere(position, BlastRange, 1 | (1 << 17))) { var shield = collider.GetComponent(); - if (shield && (shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) + if (shield) { - shield.Entity.Shield.TakeHit(DamageType, Damage); - shield.ShowHit(position, sqrt(Damage)); + var shieldBehavior = shield.Entity.Shield; + var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; + var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); + // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read + // once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit + // is actually decided to route past it (to the hull collider OverlapSphere returns as a + // separate entry in this same loop) instead of being absorbed. The hull branch below re-queries + // CanTakeHit safely -- it is side-effect-free -- and will see Broken already true. + if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); + if (shieldAbsorbs) + { + shieldBehavior.TakeHit(DamageType, Damage); + shield.ShowHit(position, sqrt(Damage)); + } } var hull = collider.GetComponent(); if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index f93e2eb82..8e6972c4d 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -61,10 +61,20 @@ public class Projectile : MonoBehaviour var hull = hit.collider.GetComponent(); if (shield) { - if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue; + var shieldBehavior = shield.Entity.Shield; + var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; + var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); + // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- + // read once into shieldAbsorbs, and break the shield exactly once, right here, at the point + // this hit is actually decided to route past it (to the hull collider RaycastAll returns + // as a separate entry further down this same loop) instead of being absorbed. The hull + // branch below re-queries CanTakeHit safely -- it is side-effect-free -- and will see Broken + // already true. + if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); + if (!shieldAbsorbs) continue; if (shield.Entity != SourceEntity) { - shield.Entity.Shield.TakeHit(DamageType, Damage*DirectHitDamageMultiplier); + shieldBehavior.TakeHit(DamageType, Damage*DirectHitDamageMultiplier); shield.ShowHit(hit.point, sqrt(Damage * DirectHitDamageMultiplier)); } } diff --git a/Assets/Scripts/ServerShared/Behaviors/Shield.cs b/Assets/Scripts/ServerShared/Behaviors/Shield.cs index 0818f0377..1dfe421f4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Shield.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Shield.cs @@ -41,7 +41,7 @@ public class ShieldData : BehaviorData } } -public class Shield : Behavior, IProgressBehavior, IPowerConsumer +public class Shield : Behavior, IProgressBehavior, IPowerConsumer, IAlwaysUpdatedBehavior { public float Efficiency { get; private set; } public float EnergyUsage { get; private set; } @@ -56,9 +56,11 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer // // Operator's second ruling, same date, on the fork the first ruling left open: the breaking hit passes // through in full -- no partial absorption. The reserve does not spend the portion it could have covered; - // TakeHit is never called for this hit at all (CanTakeHit's false branch below is the caller's sole signal - // to route the whole hit to the hull instead -- see every caller under Assets/Scripts/Gameplay/Weapons). - // The break is a distinct event the player feels, not a discount on an overkill hit. + // TakeHit is never called for this hit at all -- CanTakeHit's false return is the caller's signal to route + // the whole hit to the hull instead, and Break() below (F5, Soul pass 2026-09-19) is what the caller invokes + // at that same point to actually break the shield, since CanTakeHit itself is now a pure query (see every + // caller under Assets/Scripts/Gameplay/Weapons). The break is a distinct event the player feels, not a + // discount on an overkill hit. // // A second question the operator asked me to settle and state: what happens to whatever charge the reserve // held at the moment it broke. Chosen: emptied by the break, not left at its pre-hit charge. A reserve that @@ -116,36 +118,62 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer Capacity = Evaluate(_data.Capacity); RefillDuration = Evaluate(_data.RefillDuration); RestoreDuration = Evaluate(_data.RestoreDuration); - // This tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item - // instance never reaches PowerBus.Step (see CanTakeHit/TakeHit) and so never accrues charge. - if (Item != null) - { - _reserve.AddCharge(_reserve.RequestedFill(dt) * Item.PowerSupply); - // Restored to full on the restore duration: back up. - if (Broken && _reserve.Charge >= _reserve.Capacity - .01f) Broken = false; - } return true; } + // F4 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the reserve's own recharge, and clearing Broken + // once it is full, used to live in Execute above -- which Entity.Update only calls while Item.Active is + // true. A hit that breaks the shield usually also cooks it (AddHeat in TakeHit below), which can knock the + // item thermally offline in the same moment it breaks -- Execute then stops running for as long as the item + // stays offline, so the shield could never come back no matter how long RestoreDuration allows for. Restoring + // must not depend on whether the item is currently Active, so it moved to IAlwaysUpdatedBehavior.Update, + // which Entity.Update calls every tick unconditionally (Cooldown.cs already relies on the same interface for + // exactly this reason -- see Entity.cs's own alwaysUpdatedBehavior loop, outside the `if (Active.Value)` + // block Execute lives in). PowerBus (F2) only bills and refreshes Item.PowerSupply while the item is Active, + // so while offline this keeps recharging at the last grant it actually received rather than freezing dead or + // fabricating a fresh one -- the punish window still runs down on its own clock instead of being held + // hostage by an unrelated shutdown. + public void Update(float dt) + { + // Item is non-null-checked the same way Execute's old version was: a null-Item (consumable-hosted) + // instance never reaches PowerBus.Step and so never accrues charge -- see CanTakeHit's own comment. + if (Item == null) return; + RefreshReserve(); + _reserve.AddCharge(_reserve.RequestedFill(dt) * Item.PowerSupply); + // Restored to full on the restore duration: back up. + if (Broken && _reserve.Charge >= _reserve.Capacity - .01f) Broken = false; + } + // Cut 4 (docs/stats-and-power-cut.md, Cut 4): a hit taken, not a chosen activation, drawing from the // shield's own continuous reserve instead of the entity's shared bus capacitors -- closing Cut 3's named, // temporary exception. A consumable-hosted instance (Item == null) has no PowerBus entry, so nothing would // ever fill this reserve; bypass it rather than starving such an instance forever. // - // Operator ruling, 2026-09-19: a broken shield absorbs nothing (Broken short-circuits to false). A hit the - // reserve cannot fully cover breaks the shield as a side effect of this same check -- see the Broken field - // comment above for why that mutation lives here rather than in TakeHit. Every caller queries CanTakeHit - // exactly once per hit and only calls TakeHit when it returns true (Assets/Scripts/Gameplay/Weapons/*), so - // this is not a repeated-query hazard in practice. Draining the reserve on break (rather than leaving its - // pre-hit charge, see the Broken field comment) happens here too, in the same atomic decision. + // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this used to mutate -- breaking the shield and + // draining the reserve -- as a side effect of the query itself, on the premise that "every caller queries + // CanTakeHit exactly once per hit." That premise was false: RaycastAll returns both the shield collider and + // the hull collider for one shot, and every caller under Assets/Scripts/Gameplay/Weapons queries this twice + // per hit (once deciding whether the shield collider absorbs it, once deciding whether the hull collider is + // exposed). A caller must be able to ask without consequence, so this is now a pure read: Broken and the + // reserve's own CanSpend, nothing more. See Break() below for where the mutation moved. public bool CanTakeHit(DamageType type, float damage) { if (Item == null) return true; if (Broken) return false; - if (_reserve.CanSpend(damage * EnergyUsage)) return true; + return _reserve.CanSpend(damage * EnergyUsage); + } + + // F5: the mutation CanTakeHit used to perform when it returned false, now a caller invokes explicitly at the + // one point damage is actually applied -- specifically, the point a caller decides to route a hit past this + // shield (to the hull) instead of absorbing it, which is also the only point that decision is made at all, + // so this is naturally called at most once per hit despite the double CanTakeHit query above. Idempotent + // (a no-op once Broken is already true) as a defense-in-depth against a caller structure that cannot + // guarantee single-call discipline, not as licence to call it more than once on purpose. + public void Break() + { + if (Item == null || Broken) return; Broken = true; _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge - return false; } public void TakeHit(DamageType type, float damage) diff --git a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs index b05fa55eb..9c3afb11b 100644 --- a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs +++ b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs @@ -15,6 +15,12 @@ using Xunit; // broke. This file pins all of that; InputCapacitorTests.cs keeps pinning the surviving Cut 4 rules (a hit // within the reserve may draw a partial amount; TrySpend/AddCharge atomicity) against the same class. Each rule // here has a matching mutation in tests/mutation_tests_shield_reserve.py. +// +// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit no longer breaks the shield as a side +// effect of the query -- it did, on the false premise that every caller queries it exactly once per hit, but +// RaycastAll hands every real caller both the shield and the hull collider for one shot, so it was queried +// twice. Every test below that means to simulate a breaking hit now calls Shield.Break() explicitly, the same +// way Assets/Scripts/Gameplay/Weapons/* now does at the point it decides to route a hit past the shield. public sealed class ShieldReserveTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-shieldreserve-" + Guid.NewGuid().ToString("N")); @@ -113,8 +119,9 @@ public sealed class ShieldReserveTests : IDisposable // Proves the hit actually drew the reserve down (not a free absorb that left Charge at 5): a second hit // that would have fit inside the original 5 (4, with clear margin either side of the exact 2 left, to // stay clear of float rounding at the boundary) no longer fits inside what remains. Per the ruling that - // any refusal is itself a break, this also puts the shield into Broken. + // any refusal is itself a break, Break() (the caller's own next step, F5) puts the shield into Broken. Assert.False(shield.CanTakeHit(DamageType.Kinetic, 4f)); + shield.Break(); Assert.True(shield.Broken); } @@ -128,6 +135,7 @@ public sealed class ShieldReserveTests : IDisposable Assert.False(shield.Broken); Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // exceeds the 5 held + shield.Break(); Assert.True(shield.Broken); } @@ -145,7 +153,8 @@ public sealed class ShieldReserveTests : IDisposable using var cache = OpenCatalog(); var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); ship.Update(1f); // Charge = 5 - Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // breaks; TakeHit must not run for this hit + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break; TakeHit must not run for this hit + shield.Break(); ship.Update(3f); // from zero: 6 of 10 -- not yet full. From a surviving 5 (TakeHit ran, refused, kept it // untouched): 5 + 6 clamped to 10 -- already full. @@ -163,7 +172,8 @@ public sealed class ShieldReserveTests : IDisposable using var cache = OpenCatalog(); var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); ship.Update(1f); // Charge = 5 - Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // breaks, reserve now 0 + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break, reserve now emptied by Break() + shield.Break(); Assert.True(shield.Broken); ship.Update(1f); // restoring at 2/s -> 2 of 10, still short of full -- still Broken @@ -185,7 +195,8 @@ public sealed class ShieldReserveTests : IDisposable using var cache = OpenCatalog(); var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); ship.Update(1f); // Charge = 5 - Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // breaks with 5 charge in play + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break with 5 charge in play + shield.Break(); ship.Update(3f); // from zero: 6 of 10 -- not yet full. From a surviving 5: would already be full. Assert.True(shield.Broken); @@ -204,7 +215,8 @@ public sealed class ShieldReserveTests : IDisposable using var cache = OpenCatalog(); var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); ship.Update(1f); // Charge = 5 - Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // breaks, reserve now 0 + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break, reserve now emptied by Break() + shield.Break(); ship.Update(2f); // RefillDuration's span -- must not be enough to restore at RestoreDuration's rate Assert.True(shield.Broken); @@ -231,14 +243,86 @@ public sealed class ShieldReserveTests : IDisposable var (halfShip, halfShield) = BuildShipWithShield(halfCache, reactorGeneration: 2.5f); // half of the 5/s request halfShip.Update(1f); // granted half -> Charge = 2.5 - // Check the affordable amount FIRST: any refused CanTakeHit call breaks the shield (the ruling this - // file pins throughout), which would zero the reserve and make every later call read false regardless - // of grant. The half-grant reserve affords roughly its half share (2, with margin either side of the - // exact 2.5 to stay clear of float rounding at the boundary); only then do we spend the one refusal - // this test needs on a hit big enough that even the full-grant reserve could not have covered it more - // than four-fold over, to show the two really do differ. + // F5: CanTakeHit is a pure query now, so calling it more than once no longer risks the shield breaking + // itself out from under a later assertion -- these three reads can be in any order. The half-grant + // reserve affords roughly its half share (2, with margin either side of the exact 2.5 to stay clear of + // float rounding at the boundary); the final check is a hit big enough that even the full-grant reserve + // could not have covered it more than four-fold over, to show the two really do differ. Assert.True(halfShield.CanTakeHit(DamageType.Kinetic, 2f)); Assert.True(fullShield.CanTakeHit(DamageType.Kinetic, 4f)); Assert.False(halfShield.CanTakeHit(DamageType.Kinetic, 4f)); } + + // --- F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "make the query a query." Calling CanTakeHit + // --- any number of times, in any mix of true/false answers, must never move the reserve or set Broken -- + // --- exactly what every real caller under Assets/Scripts/Gameplay/Weapons now relies on (RaycastAll hands + // --- it both the shield and the hull collider for one shot, so it is queried twice per hit). --- + [Fact] + public void CanTakeHitNeverMutatesRegardlessOfHowManyTimesItIsCalled() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 3f)); + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 3f)); // asked again -- still true, nothing spent + Assert.False(shield.Broken); + + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would refuse -- exceeds the 5 held + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // asked again -- still false, still not broken + Assert.False(shield.Broken); + + // The reserve itself never moved either: a hit well within the original 5 still fits. + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 4f)); + } + + // --- The mutation side of the same split: Break() is what a caller now invokes explicitly, and it must be + // --- safe to call more than once for one hit (a caller structure that cannot guarantee single-call + // --- discipline, per the six weapon files' own double-query shape) -- idempotent, not a double-drain or a + // --- crash on an already-empty reserve. --- + [Fact] + public void BreakIsIdempotentAcrossRepeatedCalls() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + + shield.Break(); + shield.Break(); // called again for the same hit (e.g. both the shield- and hull-collider branches) + shield.Break(); + Assert.True(shield.Broken); + + // Restores exactly on RestoreDuration from zero, same as a single Break() call -- proving the repeats + // did not re-empty an already-restoring reserve or otherwise disturb the clock. + ship.Update(3f); + Assert.True(shield.Broken); + ship.Update(2f); // total 5s -- exactly RestoreDuration + Assert.False(shield.Broken); + } + + // --- F4 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "restoration must not depend on the item being + // --- active." A hit that breaks the shield usually also cooks it, which can knock the item Active.Value + // --- false in the same moment (thermal/durability shutdown) -- Entity only runs Execute (and, pre-F4, the + // --- unbreak check that lived inside it) while Active is true, so the shield could never come back no + // --- matter how long it stayed offline. Simulate that here directly (Enabled = false covers every reason + // --- Active can go false; the mechanism doesn't care which) and confirm the reserve keeps restoring and + // --- clears Broken on schedule regardless. --- + [Fact] + public void RestorationProceedsWhileTheItemIsInactive() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break + shield.Break(); + Assert.True(shield.Broken); + + shield.Item.Enabled.Value = false; + Assert.False(shield.Item.Active.Value); + + // RestoreDuration (5s) entirely while inactive -- if restoration depended on Active (Execute-gated, the + // pre-F4 shape), none of this would move the reserve at all and Broken would still be true. + ship.Update(5f); + Assert.False(shield.Broken); + } } From 02e2ca9556650c788a21f6ce5873130eaf8067a6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 05:47:14 +0200 Subject: [PATCH 5/7] F6: name every field a power request actually reads, not just its top-level one 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 --- Assets/Scripts/ServerShared/ItemData.cs | 65 ++++++++++++++----- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 34 ++++++++-- .../Aetheria.Shared.Tests/PowerCurveTests.cs | 51 +++++++++++++++ 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 8dd1e79ae..30ae61f00 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -688,17 +688,23 @@ public class PerformanceStat // Cut 1's loud refusal: the heat-response shape (min, max, optimum, plateau width) must describe a coherent // range. Runs at catalog load (AetheriaStores.Open) and at every catalog write that goes through -// CultRecordRefs.Upsert (AetheriaStores.cs). That is not, in fact, every catalog write in this repository: two -// named holes exist, not one. +// CultRecordRefs.Upsert (AetheriaStores.cs). That is not, in fact, every catalog write in this repository. F7 +// (docs/stats-and-power-cut.md, Soul pass 2026-09-19) found four holes where the two named here left off, not +// one: // - CultCache Studio's generic document editor writes straight through CultCache, bypassing Upsert, and Studio -// exposes no per-document validation hook to attach to yet. -// - tools/AetherDb/Program.cs's Dangling command (:271-274) lands its repaired records through -// `db.Cache.Commit(batch => batch.Upsert(document.GetType(), document, key))` -- CultCache's own batch API, -// keyed explicitly to preserve the record's existing identity, not the validating extension method above. A -// dangling-ref fixup can therefore land an EquippableItemData or ConsumableItemData with an invalid heat -// response or an unresolvable stat modifier reference, and `Open` would refuse it only the next time someone -// reopens the file, not at the moment `apply` writes it. Both gaps are named here rather than silently assumed -// closed. +// exposes no per-document validation hook to attach to yet. Still open -- Studio is not this repository's to +// patch from here. +// - tools/AetherDb/Program.cs's Dangling, ShieldMigrate and BrownoutMigrate commands each repair existing +// records in place and so cannot go through Upsert (its cache.UpsertAsync(document) does not accept a +// caller-supplied key -- see AetheriaStores.cs's own Validate comment); each lands its changes through +// `db.Cache.Commit(batch => batch.Upsert(document.GetType(), document, key))`, CultCache's own batch API, +// keyed explicitly to preserve the record's existing identity. `brownout-migrate apply` is how the shipped +// catalog's PowerSupply terms were actually written (Cut 7), and all three now call +// `AetheriaStores.Validate(document)` immediately before that commit -- the same checks Upsert runs, just +// invoked directly since the identity-preserving write path cannot call Upsert itself. Closed: a dangling-ref +// fixup, a shield migration or a brownout migration can no longer land an EquippableItemData or +// ConsumableItemData with an invalid heat response, an unresolvable stat modifier reference, or a power +// request that depends on its own supply. public static class StatValidation { public static void ValidateHeatResponse(EquippableItemData data) @@ -773,24 +779,47 @@ public static class StatValidation // Cut 6 (docs/stats-and-power-cut.md): "a stat that decides a power request may not depend on power supply, // directly or through a modifier chain, and no stat may depend on itself." IPowerConsumer.PowerRequest(dt) - // never hands the bus a number it invented on the spot -- each implementation evaluates exactly one fixed - // PerformanceStat field on its own BehaviorData (Thruster reads EnergyUsage, EnergyDraw reads EnergyDraw, and - // so on). That field is a compile-time fact about the C# type, not something authored per catalog instance, - // so it is named here once -- the same (type, field) address ResolveStatField already uses for - // StatReference -- rather than adding a second reflection idiom or a per-instance flag. Naming it here (not a - // new interface member on IPowerConsumer) means the check needs no Behavior instance, no EquippedItem and no - // Entity: it can run over the catalog's own Data objects, at load and at Upsert, before anything is ever - // equipped. + // never hands the bus a number it invented on the spot -- each implementation evaluates a fixed set of + // PerformanceStat fields on its own BehaviorData. Those fields are a compile-time fact about the C# type, not + // something authored per catalog instance, so they are named here once -- the same (type, field) address + // ResolveStatField already uses for StatReference -- rather than adding a second reflection idiom or a + // per-instance flag. Naming it here (not a new interface member on IPowerConsumer) means the check needs no + // Behavior instance, no EquippedItem and no Entity: it can run over the catalog's own Data objects, at load + // and at Upsert, before anything is ever equipped. + // + // F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this used to name only each consumer's top-level + // request field ("Thruster reads EnergyUsage, EnergyDraw reads EnergyDraw, and so on") on the premise that + // PowerRequest reads exactly one PerformanceStat. That premise was false for five of the eight consumers -- + // Radiator.PowerRequest also reads PumpedHeat and WasteHeat (to decide whether the pump can even run this + // tick), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its + // whole rotor spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration and + // RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown and Count, and + // Sensor.PowerRequest (via RefreshInputCapacitor) also reads PingCooldown. Every one of those was free to + // carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest below, corrupting the + // request it feeds exactly the way the rule exists to forbid. Audited directly against each PowerRequest + // method's own body (and RefreshReserve/RefreshInputCapacitor, which PowerRequest calls into) rather than + // guessed from the behaviour's public surface. public static readonly (Type Type, string Field)[] PowerRequestFields = { (typeof(EnergyDrawData), nameof(EnergyDrawData.EnergyDraw)), (typeof(ThrusterData), nameof(ThrusterData.EnergyUsage)), (typeof(ConstantWeaponData), nameof(WeaponData.Energy)), (typeof(InstantWeaponData), nameof(WeaponData.Energy)), + (typeof(InstantWeaponData), nameof(InstantWeaponData.Cooldown)), + (typeof(InstantWeaponData), nameof(InstantWeaponData.Count)), (typeof(SensorData), nameof(SensorData.PingEnergy)), + (typeof(SensorData), nameof(SensorData.PingCooldown)), (typeof(ShieldData), nameof(ShieldData.Capacity)), + (typeof(ShieldData), nameof(ShieldData.RefillDuration)), + (typeof(ShieldData), nameof(ShieldData.RestoreDuration)), (typeof(RadiatorData), nameof(RadiatorData.EnergyUsage)), + (typeof(RadiatorData), nameof(RadiatorData.PumpedHeat)), + (typeof(RadiatorData), nameof(RadiatorData.WasteHeat)), (typeof(AetherDriveData), nameof(AetherDriveData.EnergyDraw)), + (typeof(AetherDriveData), nameof(AetherDriveData.Torque)), + (typeof(AetherDriveData), nameof(AetherDriveData.LambdaMultiplier)), + (typeof(AetherDriveData), nameof(AetherDriveData.MaximumRpm)), + (typeof(AetherDriveData), nameof(AetherDriveData.PassiveCoupling)), }; // The direct half of the rule: a request stat's own declared Terms must not name PowerSupply. Runs wherever diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index c90330721..fe5b57cb2 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -17,6 +17,23 @@ using static CultMath.math; // follows the authored exponent (not accidentally linear), and an instant item (InputCapacitor's all-or-nothing // rule, Cut 4) is untouched by any of it. Each rule has a matching mutation in // tests/mutation_tests_stats_power_cut7.py. +// +// F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) surfaced a real conflict this file's Radiator and +// AetherDrive fixtures happened to sit on top of, unnoticed because the pre-F6 registry only named each +// consumer's own top-level request field. Radiator.PowerRequest's own early-out reads PumpedHeat AND WasteHeat +// (to decide whether the pump can keep up this tick); AetherDrive.PowerRequest's spin-up arithmetic reads +// Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling. Cut 7 asks for the brownout curve to live on exactly +// the stats these two behaviours actually curve (PumpedHeat, Torque) -- but Cut 6 forbids a PowerSupply term on +// any stat a request reads, on pain of the exact oscillation Soul measured against the shipped catalog's own "OK +// Disperser" radiator (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates, docs/stats-and-power-cut.md). Every +// field either behaviour's PowerRequest touches is now correctly refused for a PowerSupply term, and neither has +// an untouched field left to curve instead -- there is no valid catalog shape today that gives Radiator or +// AetherDrive a working brownout curve without also feeding it back into their own request. The six tests below +// that authored one are marked Skip rather than deleted or silently reworked to hide the gap: reconciling Cut +// 6 and Cut 7 for these two behaviours (most likely by having PowerRequest read a nominal/undegraded value +// instead of the live curved one) is real, scoped work for a future pass, not a fix a registry correction should +// smuggle in. Thruster and ConstantWeapon are unaffected -- their own PowerRequest reads only EnergyUsage/Energy, +// never the separate Thrust/Damage field their tests curve. public sealed class BrownoutTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-brownout-" + Guid.NewGuid().ToString("N")); @@ -177,7 +194,9 @@ public sealed class BrownoutTests : IDisposable return (drive.Rpm.x, drive); } - [Fact] + // F6: Torque is now a registered AetherDrive request field (PowerRequest's spin-up arithmetic reads it) -- + // see this file's header comment. Curved(...) on Torque is no longer an authorable catalog shape. + [Fact(Skip = "F6 (docs/stats-and-power-cut.md): Torque is a request field now; a PowerSupply term on it is correctly refused. See file header.")] public void AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp() { var (full, _) = RunAetherDrive(reactorCharge: 1000); // demand ~50, generation far exceeds it -> ratio 1 @@ -186,7 +205,7 @@ public sealed class BrownoutTests : IDisposable Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); } - [Fact] + [Fact(Skip = "F6 (docs/stats-and-power-cut.md): Torque is a request field now; a PowerSupply term on it is correctly refused. See file header.")] public void AetherDriveAtFullGrantIsUnchangedFromToday() { var (full, drive) = RunAetherDrive(reactorCharge: 1000); @@ -194,7 +213,7 @@ public sealed class BrownoutTests : IDisposable Assert.True(full > 0f); } - [Fact] + [Fact(Skip = "F6 (docs/stats-and-power-cut.md): Torque is a request field now; a PowerSupply term on it is correctly refused. See file header.")] public void AetherDriveAtZeroGrantProducesNothing() { var (zero, drive) = RunAetherDrive(reactorCharge: 0); @@ -221,7 +240,10 @@ public sealed class BrownoutTests : IDisposable return (radiator.RadiatorTemperature - before, radiator); } - [Fact] + // F6: PumpedHeat and WasteHeat are now registered Radiator request fields (PowerRequest's own early-out + // reads both) -- see this file's header comment. Curved(...) on PumpedHeat is no longer an authorable + // catalog shape. + [Fact(Skip = "F6 (docs/stats-and-power-cut.md): PumpedHeat is a request field now; a PowerSupply term on it is correctly refused. See file header.")] public void RadiatorAtHalfGrantPumpsReducedNotZeroNotFullHeat() { var (full, _) = RunRadiator(reactorCharge: 1000); // demand 50, generation far exceeds it -> ratio 1 @@ -230,7 +252,7 @@ public sealed class BrownoutTests : IDisposable Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); } - [Fact] + [Fact(Skip = "F6 (docs/stats-and-power-cut.md): PumpedHeat is a request field now; a PowerSupply term on it is correctly refused. See file header.")] public void RadiatorAtFullGrantIsUnchangedFromToday() { var (full, radiator) = RunRadiator(reactorCharge: 1000); @@ -238,7 +260,7 @@ public sealed class BrownoutTests : IDisposable Assert.True(full > 0f); } - [Fact] + [Fact(Skip = "F6 (docs/stats-and-power-cut.md): PumpedHeat is a request field now; a PowerSupply term on it is correctly refused. See file header.")] public void RadiatorAtZeroGrantProducesNothing() { var (zero, radiator) = RunRadiator(reactorCharge: 0); diff --git a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs index da67ff0e8..e5701b623 100644 --- a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs @@ -295,6 +295,57 @@ public sealed class PowerCurveTests : IDisposable Assert.Contains(nameof(ThrusterData.EnergyUsage), error.Message); } + // --- F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the registry used to name only each consumer's + // --- top-level request field, so a PowerSupply term on a stat a request reads only indirectly -- here, + // --- Radiator.PowerRequest's own early-out gate on PumpedHeat, exactly the shape Soul reproduced against + // --- the shipped catalog's "OK Disperser" (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates) -- sailed + // --- straight through Upsert. PumpedHeat is now in PowerRequestFields, so this must be refused. --- + [Fact] + public void UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm() + { + var badPumpedHeat = new PerformanceStat { Min = 1000, Max = 4000, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + + var error = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "BadRadiator", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new RadiatorData + { + PumpedHeat = badPumpedHeat, WasteHeat = Constant(100), EnergyUsage = Constant(6), + Emissivity = Constant(.5f), ThermalMass = Constant(100), TemperatureFloor = 0 + } } + })); + Assert.Contains("BadRadiator", error.Message); + Assert.Contains(nameof(RadiatorData.PumpedHeat), error.Message); + } + + // --- The same hole on the other consumer the ruling names by field: Shield.PowerRequest calls RefreshReserve, + // --- which reads RefillDuration (or RestoreDuration while broken) to size the reserve's own fill rate -- a + // --- PowerSupply term there makes the reserve's own refill speed depend on how much of it was already + // --- granted, the same self-reference the rule exists to forbid. --- + [Fact] + public void UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm() + { + var badRefillDuration = new PerformanceStat { Min = 2, Max = 2, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + + var error = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "BadShield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ShieldData + { + Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(100), + RefillDuration = badRefillDuration, RestoreDuration = Constant(5) + } } + })); + Assert.Contains("BadShield", error.Message); + Assert.Contains(nameof(ShieldData.RefillDuration), error.Message); + } + // --- Cut 6 verification bullet 3: "the same refusal through a modifier chain, at equip time, naming both // --- items." Drain's own EnergyDraw declares no PowerSupply term (so Upsert/Open both accept it on its own), // --- but Booster's StatModifier targets it with a magnitude that DOES carry one -- the request would inherit From e3e228e0369cc5f6b71222066e85619266fb5797 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 05:47:31 +0200 Subject: [PATCH 6/7] F7: route AetherDb's identity-preserving repairs through the same validation 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 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 27 ++++++++--- .../AetheriaStoresTests.cs | 48 +++++++++++++++++++ tools/AetherDb/Program.cs | 15 ++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 47e935999..641e431e6 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -69,12 +69,16 @@ public static class CultRecordRefs new CultRecordRef(cache.TryGetHandle(document)?.Key ?? throw new InvalidOperationException($"This cache holds no such {document.GetType().Name}.")); - // Writes a document to its home store; the cache mints its key the first time. Every catalog write in this - // repository (tools, tests, migration scripts) goes through this one function, so it is where R-heat's - // validation closes the gap AetheriaStores.Open leaves: Open only checks what is already on disk when a - // session starts, not what a session then writes. Validating here means an invalid heat response is refused - // before the write ever reaches disk, not just the next time someone reopens the file. - public static CultRecordRef Upsert(this CultCache cache, T document) where T : class + // F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the validation body itself, pulled out of Upsert + // below so it has exactly one owner. Every catalog write in this repository was supposed to go through + // Upsert, but three of tools/AetherDb/Program.cs's own migration commands (Dangling, ShieldMigrate, + // BrownoutMigrate) cannot: they repair an EXISTING record in place and must preserve its key, which Upsert's + // own cache.UpsertAsync(document) does not support (Upsert mints or resolves a key from the document itself, + // not from a caller-supplied one). Rather than leave those three writing raw and unvalidated -- as they did + // before this fix -- they call this method directly, immediately before their own identity-preserving + // batch.Upsert(document.GetType(), document, key) commit, so the same rules run either way. Takes `object` + // (not a generic T) because that is what those three tools' own (Document, Key) change lists are typed as. + public static void Validate(object document) { if (document is EquippableItemData data) { @@ -87,6 +91,17 @@ public static class CultRecordRefs StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors); StatValidation.ValidateNoPowerSupplyOnRequest(consumable.Name, consumable.Behaviors); } + } + + // Writes a document to its home store; the cache mints its key the first time. Every catalog write in this + // repository (tools, tests, migration scripts) goes through this one function, or through Validate above + // directly when the caller must preserve an existing key (see Validate's own comment) -- so this is where + // R-heat's validation closes the gap AetheriaStores.Open leaves: Open only checks what is already on disk + // when a session starts, not what a session then writes. Validating here means an invalid heat response is + // refused before the write ever reaches disk, not just the next time someone reopens the file. + public static CultRecordRef Upsert(this CultCache cache, T document) where T : class + { + Validate(document); return new CultRecordRef(cache.UpsertAsync(document).Result.Key); } } diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index 1542950f0..9f2d906d2 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -149,6 +149,54 @@ public sealed class AetheriaStoresTests : IDisposable Assert.Contains("aetheria.tests.catalogglobal", error.Message); } + // --- F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "there are at least four [write bypasses]." + // --- tools/AetherDb/Program.cs's Dangling, ShieldMigrate and BrownoutMigrate commands repair an existing + // --- record in place through `cache.Commit(batch => batch.Upsert(document.GetType(), document, key))` -- + // --- CultCache's own batch API, keyed explicitly to preserve identity -- because CultRecordRefs.Upsert's own + // --- cache.UpsertAsync(document) cannot take a caller-supplied key. That is real, not an excuse to skip + // --- validation: all three now call CultRecordRefs.Validate(document) directly, immediately before their own + // --- commit. This pins that the extracted method actually refuses what Upsert would have, proving the tools' + // --- direct call is wired to the same rule rather than a hollow copy. --- + [Fact] + public void ValidateRefusesAnEquippableItemWithAZeroSpanHeatRangeTheSameWayUpsertWould() + { + var badDesign = new GearData + { + Name = "BadDangling", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 100, MaximumTemperature = 100, OptimalTemperature = 100, PlateauWidth = 0 + }; + var error = Assert.Throws(() => CultRecordRefs.Validate(badDesign)); + Assert.Contains("BadDangling", error.Message); + } + + // The same direct-call path must also catch F6's rule (a power request stat may not carry a PowerSupply + // term), not only R-heat -- a repair tool landing a bad ThrusterData.EnergyUsage is exactly as dangerous as + // one landing a bad heat response. + [Fact] + public void ValidateRefusesAPowerRequestCarryingAPowerSupplyTermTheSameWayUpsertWould() + { + var badEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + var badDesign = new GearData + { + Name = "BadMigratedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ThrusterData { EnergyUsage = badEnergy } } + }; + var error = Assert.Throws(() => CultRecordRefs.Validate(badDesign)); + Assert.Contains("BadMigratedThruster", error.Message); + Assert.Contains(nameof(ThrusterData.EnergyUsage), error.Message); + } + + // A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be + // a pure no-op, not a crash -- the three tools' change lists carry `object`, and not every migration touches + // an equippable design (e.g. a future repair over FactionProductData). + [Fact] + public void ValidateIsANoOpForADocumentTypeItDoesNotGovern() + { + var record = Record.Exception(() => CultRecordRefs.Validate(new Faction { Name = "Unrelated" })); + Assert.Null(record); + } + private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); private static string[] SchemaNames(string path) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 3b17c8b67..16284326a 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -270,6 +270,11 @@ public static class Program return 0; } + // F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this repair must preserve the record's existing + // key, which the validating CultRecordRefs.Upsert extension does not support (see AetheriaStores.cs's own + // Validate comment) -- so it calls the same validation directly, right before the identity-preserving + // write, instead of writing raw and unvalidated the way this line used to. + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); db.Cache.Commit(batch => { foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); @@ -552,6 +557,11 @@ public static class Program return 0; } + // F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this repair must preserve the record's existing + // key, which the validating CultRecordRefs.Upsert extension does not support (see AetheriaStores.cs's own + // Validate comment) -- so it calls the same validation directly, right before the identity-preserving + // write, instead of writing raw and unvalidated the way this line used to. + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); db.Cache.Commit(batch => { foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); @@ -643,6 +653,11 @@ public static class Program return 0; } + // F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this repair must preserve the record's existing + // key, which the validating CultRecordRefs.Upsert extension does not support (see AetheriaStores.cs's own + // Validate comment) -- so it calls the same validation directly, right before the identity-preserving + // write, instead of writing raw and unvalidated the way this line used to. + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); db.Cache.Commit(batch => { foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); From 8dddda4d80ad7af0b3a6d8fc70edb6b2737f7b6b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 06:02:08 +0200 Subject: [PATCH 7/7] F8: re-anchor cut3/cut4 mutation harnesses after later cuts moved their code Both scripts aborted with "anchor must occur exactly once, found 0" -- later cuts moved the code their anchors pinned: - cut3's fourth mutation anchored PowerBus.Step's old flat "write GrantRatio to every consumer" loop, which Cut 5 replaced with AllocateTiers' own tiered closing loop (draw.Item.PowerSupply = ratios[draw.Tier]). Re-anchored to that loop; the rule (every draw gets written, not just the first) and its test (PowerBusTests.EveryConsumingItemReadsTheSameGrantRatio, a single-tier fixture) are unchanged. - cut4's shield mutation anchored CanTakeHit's old single-expression body (`return Item == null || _reserve.CanSpend(...)`), which the 2026-09-19 shield reserve ruling split into an Item-null check, a Broken check and this CanSpend call as its own line, and F5 (this same pass) then made the whole method a pure query. Re-anchored to the CanSpend line; the rule (a partial draw is allowed, not gated on covering the whole reserve) and its test are unchanged. Both scripts now run clean end to end against the pinned CultLib worktree: every mutation kills its named test, the no-op control does not. Scope note: mutation_tests_stats_power_cut0/1/2 also abort on stale anchors (spot-checked while re-anchoring cut3/cut4, then the check was cancelled partway through cut1 once the pattern was clear) -- not touched here, as F8 named only cut3 and cut4. Co-Authored-By: Claude Opus 5 --- tests/mutation_tests_stats_power_cut3.py | 24 +++++++++++++++--------- tests/mutation_tests_stats_power_cut4.py | 13 ++++++++++--- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/tests/mutation_tests_stats_power_cut3.py b/tests/mutation_tests_stats_power_cut3.py index 47142254e..b4184918c 100644 --- a/tests/mutation_tests_stats_power_cut3.py +++ b/tests/mutation_tests_stats_power_cut3.py @@ -75,20 +75,26 @@ MUTATIONS: list[Mutation] = [ # --- Cut 3 verification bullet 3 (§0.5): "two ships with identical loadouts fitted in opposite equip order # --- get identical grants. That is the death of the hidden priority." The invariant that makes equip order - # --- unable to matter is that every consuming item reads back the SAME GrantRatio; restricting the write to - # --- only the first equipped consumer reintroduces exactly the "whoever equipped first" priority the cut - # --- kills, just relocated from SortPosition into this loop. --- + # --- unable to matter is that every consuming item reads back its own tier's GrantRatio; restricting the + # --- write to only the first draw found reintroduces exactly the "whoever equipped first" priority the cut + # --- kills, just relocated from SortPosition into this loop. + # --- + # --- F8 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) re-anchor: Cut 3's own flat "write GrantRatio to + # --- every consumer" loop (Step) was replaced by Cut 5's tiered AllocateTiers, whose closing loop writes each + # --- draw's own tier ratio instead -- the anchor below follows that move; the rule and the test it's pinned + # --- against (EveryConsumingItemReadsTheSameGrantRatio, a single-tier fixture) are unchanged. --- Mutation( - rule="every consuming item must be written the same GrantRatio, not just the first one found", + rule="every consuming item must be written its own tier's GrantRatio, not just the first draw found", file="Assets/Scripts/ServerShared/PowerBus.cs", anchor=( - " foreach (var item in _entity.Equipment)\n" - " if (item.Behaviors.Any(b => b is IPowerConsumer))\n" - " item.PowerSupply = GrantRatio;" + " foreach (var draw in draws)\n" + " {\n" + " draw.Item.PowerSupply = ratios[draw.Tier];" ), mutated=( - " var firstConsumer = _entity.Equipment.FirstOrDefault(item => item.Behaviors.Any(b => b is IPowerConsumer));\n" - " if (firstConsumer != null) firstConsumer.PowerSupply = GrantRatio;" + " foreach (var draw in draws.Take(1))\n" + " {\n" + " draw.Item.PowerSupply = ratios[draw.Tier];" ), test="PowerBusTests.EveryConsumingItemReadsTheSameGrantRatio", expect="red", diff --git a/tests/mutation_tests_stats_power_cut4.py b/tests/mutation_tests_stats_power_cut4.py index ea6b0c92a..e61f973f1 100644 --- a/tests/mutation_tests_stats_power_cut4.py +++ b/tests/mutation_tests_stats_power_cut4.py @@ -116,12 +116,19 @@ MUTATIONS: list[Mutation] = [ # --- Shield is the map's named exception: a hit is not a chosen activation, so its reserve is a continuous # --- one that may absorb an arbitrary fraction of a hit's cost, unlike a weapon's shot (always spent # --- whole). Requiring a hit to cover the WHOLE reserve before absorbing anything collapses that - # --- distinction back into an activation buffer, which the map explicitly says not to give it. --- + # --- distinction back into an activation buffer, which the map explicitly says not to give it. + # --- + # --- F8 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) re-anchor: the shield reserve ruling + # --- (2026-09-19) gave Shield a Broken state, splitting CanTakeHit's old single-expression body + # --- (`return Item == null || _reserve.CanSpend(...)`) into an Item-null check, a Broken check, and this + # --- CanSpend call as its own line; F5 (the same Soul pass) then made the whole method a pure query. The + # --- rule under test -- CanSpend reads the hit's own cost, not a full-reserve requirement -- is unchanged + # --- and still reachable from this same line. --- Mutation( rule="the shield reserve must allow a partial draw, not require a full reserve before absorbing a hit", file="Assets/Scripts/ServerShared/Behaviors/Shield.cs", - anchor=" return Item == null || _reserve.CanSpend(damage * EnergyUsage);", - mutated=" return Item == null || _reserve.CanSpend(_reserve.Capacity);", + anchor=" return _reserve.CanSpend(damage * EnergyUsage);", + mutated=" return _reserve.CanSpend(_reserve.Capacity);", test="InputCapacitorTests.ShieldReserveAllowsAPartialDrawButRefusesAHitItCannotFullyCover", expect="red", ),