From b0e67cb36fea898781cbbda00a47e60ac50d532c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 02:46:17 +0200 Subject: [PATCH 1/3] Shield reserve is authored: Capacity/RefillDuration/RestoreDuration, and a broken state 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 --- .../Scripts/ServerShared/Behaviors/Shield.cs | 94 +++++++++++++++---- .../InputCapacitorTests.cs | 8 +- 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Shield.cs b/Assets/Scripts/ServerShared/Behaviors/Shield.cs index 6b2797d76..8960f11a1 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Shield.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Shield.cs @@ -10,12 +10,27 @@ using static CultMath.math; [Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] public class ShieldData : BehaviorData { - [Inspectable, JsonProperty("efficiency"), Key(1), RuntimeInspectable] + [Inspectable, JsonProperty("efficiency"), Key(1), RuntimeInspectable] public PerformanceStat Efficiency = new PerformanceStat(); - [Inspectable, JsonProperty("energy"), Key(2), RuntimeInspectable] + [Inspectable, JsonProperty("energy"), Key(2), RuntimeInspectable] public PerformanceStat EnergyUsage = new PerformanceStat(); - + + // Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): supersedes Cut + // 4's derived sizing (reserve = EnergyUsage, refilling every second). The reserve is now an authored energy + // pool, sized independently of EnergyUsage (which stays the per-damage-point cost multiplier). + [Inspectable, JsonProperty("capacity"), Key(3), RuntimeInspectable] + public PerformanceStat Capacity = new PerformanceStat(); + + // Seconds to go from empty to full while the shield is up (holding, not broken). + [Inspectable, JsonProperty("refillDuration"), Key(4), RuntimeInspectable] + public PerformanceStat RefillDuration = new PerformanceStat(); + + // Seconds to go from empty to full while the shield is broken. This is the punish window: how long a hit + // that overwhelms the reserve leaves the hull exposed. + [Inspectable, JsonProperty("restoreDuration"), Key(5), RuntimeInspectable] + public PerformanceStat RestoreDuration = new PerformanceStat(); + public override Behavior CreateInstance(EquippedItem item) { return new Shield(this, item); @@ -30,18 +45,39 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer { public float Efficiency { get; private set; } public float EnergyUsage { get; private set; } + public float Capacity { get; private set; } + public float RefillDuration { get; private set; } + public float RestoreDuration { get; private set; } + + // Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): the state Cut 4's + // reserve never had. A hit the reserve cannot fully cover breaks the shield instead of being silently + // refused with its charge intact. Persists tick to tick (unlike Efficiency/EnergyUsage/Capacity above, + // which are re-evaluated fresh every Execute): it is the shield's own history, not a derived stat. + // + // 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. + // + // 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 + // broke at 9/10 and kept that charge would refill to full in a heartbeat under RestoreDuration (which is + // sized for a full recharge, not a top-up) -- the punish window would only ever bite on a shield broken + // from near-empty. Zeroing it makes RestoreDuration mean the same thing every time a shield breaks, + // regardless of how close to full it was when the hit that broke it landed. + public bool Broken { get; private set; } private ShieldData _data; - // Cut 4 (docs/stats-and-power-cut.md, Cut 4): "a shield hit is not an activation the player chose, so its - // 'instant' draw is really a continuous reserve. Name it as such rather than giving it an activation - // buffer." Unlike InstantWeapon/Sensor above, TrySpend below is never called with exactly Capacity -- a hit - // can partially draw the reserve, same as the old direct capacitor spend could. Shield has no Energy/ - // Cooldown pair to derive a size from (only EnergyUsage, a per-damage-point multiplier), so this is a - // judgment call, not a formula the map states: the reserve's Capacity and Rate both default to EnergyUsage - // itself, refilling in full every second (Cooldown's implicit identity value -- the same "absent means - // identity" convention InputCapacitor and PerformanceStat itself already use). Flagged for operator review - // against real catalog EnergyUsage magnitudes rather than presented as spec'd. + // Operator ruling, 2026-09-19: reserve capacity is now an authored number (Capacity), independent of + // EnergyUsage (which stays the per-damage-point cost multiplier applied in CanTakeHit/TakeHit below). Rate + // is derived the same way InputCapacitor already derives every other consumer's rate (Capacity/duration, + // UpdateStats' own cooldown-to-rate formula) -- just fed RefillDuration while up and RestoreDuration while + // Broken, so refilling from partial and restoring from broken are two different speeds through the one + // mechanism every other IPowerConsumer already uses. A partial bus grant (Item.PowerSupply < 1) stretches + // either duration proportionally for free: AddCharge below is scaled by PowerSupply same as every other + // consumer, so a half grant simply takes twice as long to fill regardless of which duration is active. private readonly InputCapacitor _reserve = new InputCapacitor(); public Shield(ShieldData data, EquippedItem item) : base(data, item) @@ -53,15 +89,16 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer _data = data; } - // Cut 4: capacity/rate, resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls - // PowerRequest below before this behaviour's own Execute runs this tick). + // Resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls PowerRequest below before + // this behaviour's own Execute runs this tick), reading Broken as it stood at the end of the previous tick. private void RefreshReserve() { - var energyUsage = Evaluate(_data.EnergyUsage); - _reserve.UpdateStats(energyUsage, cooldown: 1f); + var capacity = Evaluate(_data.Capacity); + var duration = Broken ? Evaluate(_data.RestoreDuration) : Evaluate(_data.RefillDuration); + _reserve.UpdateStats(capacity, cooldown: duration, capacityOverride: capacity); } - // Cut 4: the request PowerBus needs before Execute runs. + // The request PowerBus needs before Execute runs. public float PowerRequest(float dt) { RefreshReserve(); @@ -72,10 +109,17 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer { Efficiency = Evaluate(_data.Efficiency); EnergyUsage = Evaluate(_data.EnergyUsage); - // Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item + 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; } @@ -83,9 +127,21 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer // 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. public bool CanTakeHit(DamageType type, float damage) { - return Item == null || _reserve.CanSpend(damage * EnergyUsage); + if (Item == null) return true; + if (Broken) return false; + if (_reserve.CanSpend(damage * EnergyUsage)) return true; + 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/InputCapacitorTests.cs b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs index dacccc6ab..f9a05459e 100644 --- a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs +++ b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs @@ -65,7 +65,13 @@ public sealed class InputCapacitorTests : IDisposable { Name = "Shield", 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/RefillDuration/RestoreDuration authored explicitly (shield reserve ruling, 2026-09-19): + // ShieldData no longer derives its reserve size from EnergyUsage, so a fixture that only authors + // Efficiency/EnergyUsage now gets a zero-capacity reserve that breaks on every hit. Capacity = 1 + // reproduces this fixture's pre-ruling reserve size exactly (EnergyUsage was 1); durations are + // unused by the tests below (they never provoke a break here) but must be nonzero so RefreshReserve + // never divides by a zero duration. + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1), RefillDuration = Constant(1), RestoreDuration = Constant(1) } } }); cache.FlushAsync().Wait(); return cache; From ad3a3d40cb3390c107ca351eda714ce4768e7745 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 02:46:26 +0200 Subject: [PATCH 2/3] Pin the shield reserve ruling: 7 rules, each with a mutation ShieldReserveTests.cs covers what Cut 4's InputCapacitorTests.cs cannot: a hit within the reserve is absorbed and spends charge; a hit beyond it breaks the shield; the breaking hit is not partially absorbed; a broken shield absorbs nothing (gated on Broken itself, not merely on the reserve reading empty); a break empties the reserve regardless of pre-hit charge; a broken shield restores on RestoreDuration and not RefillDuration; a partial bus grant stretches the refill proportionally. mutation_tests_shield_reserve.py mutates each rule's own line and confirms the named test goes red for exactly that reason, plus a no-op control. All 8 (7 rules + control) verified against the pinned CultLib worktree. Co-Authored-By: Claude Opus 5 --- .../ShieldReserveTests.cs | 244 ++++++++++++++++++ tests/mutation_tests_shield_reserve.py | 218 ++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/ShieldReserveTests.cs create mode 100644 tests/mutation_tests_shield_reserve.py diff --git a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs new file mode 100644 index 000000000..b05fa55eb --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): supersedes Cut 4's +// derived reserve sizing. ShieldData now authors Capacity, RefillDuration and RestoreDuration directly; the +// shield gains a Broken state a hit it cannot fully cover falls into, rather than being silently refused with +// its charge intact. Two further operator rulings on the fork the first left open: the breaking hit passes +// through in full (no partial absorption -- TakeHit is never called for it), and the reserve empties to zero on +// break so the restore duration means the same thing every time regardless of how full the shield was when it +// 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. +public sealed class ShieldReserveTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-shieldreserve-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public ShieldReserveTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // Same fixture shape as InputCapacitorTests.OpenCatalog: a Reactor whose generation BuildShip sets per test, + // and a Shield authoring Capacity = 10, RefillDuration = 2 (Rate = 5/s while up), RestoreDuration = 5 + // (Rate = 2/s while broken) -- the two durations deliberately far apart so a test that used the wrong one + // would be caught well outside rounding noise. EnergyUsage = 1 keeps damage == reserve cost, so test + // arithmetic reads directly in damage units. + private CultCache OpenCatalog() + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ReactorData + { + Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) + } } + }); + cache.Upsert(new GearData + { + Name = "Shield", 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(10), RefillDuration = Constant(2), RestoreDuration = Constant(5) + } } + }); + cache.FlushAsync().Wait(); + return cache; + } + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + private (Ship ship, Shield shield) BuildShipWithShield(CultCache cache, float reactorGeneration) + { + var ledger = new ProvenanceLedger(); + var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hull = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorGeneration); + Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); + + var shieldData = cache.GetByName("Shield"); + Assert.True(ship.TryEquip(Mint(cache, items, shieldData))); + + zone.Entities.Add(ship); + ship.Activate(); + + return (ship, ship.GetBehavior()); + } + + // Rule: a hit within the reserve is absorbed and spends charge. Capacity = 10, RefillDuration = 2 -> Rate = + // 5/s; a generous reactor (20/s) grants the full request every tick, so a 1s warm-up tick banks 5 charge. + // A 3-damage hit (cost 3, EnergyUsage = 1) is well inside that -- absorbed -- and spends charge rather than + // leaving it untouched: a second hit that needs more than the 2 left (2.5) is refused, proving the first + // hit actually drew the reserve down instead of being a free pass. + [Fact] + public void HitWithinReserveIsAbsorbedAndSpendsCharge() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // reserve Rate = 5/s, granted in full -> Charge = 5 + + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 3f)); + shield.TakeHit(DamageType.Kinetic, 3f); // Charge: 5 -> 2 + + // 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. + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 4f)); + Assert.True(shield.Broken); + } + + // Rule: a hit beyond the reserve breaks the shield. + [Fact] + public void HitBeyondReserveBreaksTheShield() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + + Assert.False(shield.Broken); + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // exceeds the 5 held + Assert.True(shield.Broken); + } + + // Rule: the breaking hit passes through in full -- no partial absorption. Operator's second ruling on the + // fork the first left open: TakeHit must never be called for this hit at all (every caller treats + // CanTakeHit's false branch as "route the whole hit to the hull instead"), so the reserve must not have + // spent, or banked, any part of the 5 it held. Pinned by timing rather than a single large tick (a big + // enough single Update always tops up to Capacity regardless of where it started, because RequestedFill + // caps at Capacity-Charge and hides the difference): if TakeHit had run for the breaking hit, its own + // TrySpend is atomic and cost (6) exceeds the charge held (5), so TrySpend would refuse and leave that 5 + // charge in place, un-emptied -- restoring measurably faster than a genuinely emptied reserve. + [Fact] + public void BreakingHitIsNotPartiallyAbsorbed() + { + 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 + + 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. + Assert.True(shield.Broken); + } + + // Rule: a broken shield absorbs nothing -- gated on Broken itself, not merely on the reserve happening to + // read empty (a break always leaves it empty right away, so a hit against a *freshly* broken shield would + // still fail on reserve arithmetic alone and would not catch a missing Broken check). Restore partway (1s + // at Rate = 2/s -> 2 of 10) so the reserve genuinely holds enough to cover a small hit on arithmetic alone, + // and confirm the shield still refuses it while Broken remains true. + [Fact] + public void BrokenShieldAbsorbsNothing() + { + 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.True(shield.Broken); + + ship.Update(1f); // restoring at 2/s -> 2 of 10, still short of full -- still Broken + Assert.True(shield.Broken); + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 1f)); // reserve arithmetic alone would allow this + } + + // Rule: a break empties the reserve to zero -- the restore duration means the same thing every time, + // regardless of how full the shield was when the breaking hit landed. Pinned by timing, not by a boundary + // CanTakeHit call (which would short-circuit on Broken alone and not actually distinguish the two + // hypotheses): break with 5 of 10 charge still in play, at RestoreDuration = 5 (Rate = 2/s). If that + // leftover 5 survived the break, only (10-5)/2 = 2.5s more would be needed to reach Capacity, so by 3s + // post-break the shield would already have restored. If the break truly zeroed the reserve, 3s only banks + // 6 of 10 and the shield is still Broken; a further 2s (5s total, RestoreDuration exactly) is what actually + // restores it from zero. + [Fact] + public void BreakEmptiesTheReserveRegardlessOfChargeHeldAtTheMoment() + { + 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 + + ship.Update(3f); // from zero: 6 of 10 -- not yet full. From a surviving 5: would already be full. + Assert.True(shield.Broken); + + ship.Update(2f); // total 5s since the break -- exactly RestoreDuration, from zero + Assert.False(shield.Broken); + } + + // Rule: a broken shield returns to full on the restore duration, not the refill duration. RefillDuration = + // 2 (Rate 5/s) is far shorter than RestoreDuration = 5 (Rate 2/s); ticking for exactly RefillDuration's + // span while broken must NOT be enough to restore (that would mean the refill rate leaked into the broken + // path), but ticking for the full RestoreDuration must be. + [Fact] + public void BrokenShieldRestoresOnRestoreDurationNotRefillDuration() + { + 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 + + ship.Update(2f); // RefillDuration's span -- must not be enough to restore at RestoreDuration's rate + Assert.True(shield.Broken); + + ship.Update(3f); // total 5s since the break -- exactly RestoreDuration + Assert.False(shield.Broken); + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 10f)); // and genuinely full, not just "not broken" + } + + // Rule: a partial grant from the bus stretches both durations proportionally -- the bus still rations + // everything by one fraction (no tiers yet). A reactor generating half the reserve's Rate * 1 demand grants + // exactly half every tick, so the reserve should read as having banked half of what a fully-supplied + // reactor would over the same span. Pinned by comparing two ships built with different reactor generation + // rather than reading raw charge (Shield exposes no such accessor -- CanTakeHit's own boundary is the + // observable this suite uses throughout). + [Fact] + public void PartialGrantStretchesTheRefillDurationProportionally() + { + using var fullCache = OpenCatalog(); + var (fullShip, fullShield) = BuildShipWithShield(fullCache, reactorGeneration: 20); // covers demand (5/s) twice over + fullShip.Update(1f); // granted in full -> Charge = 5 + + using var halfCache = OpenCatalog(); + 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. + Assert.True(halfShield.CanTakeHit(DamageType.Kinetic, 2f)); + Assert.True(fullShield.CanTakeHit(DamageType.Kinetic, 4f)); + Assert.False(halfShield.CanTakeHit(DamageType.Kinetic, 4f)); + } +} diff --git a/tests/mutation_tests_shield_reserve.py b/tests/mutation_tests_shield_reserve.py new file mode 100644 index 000000000..79fc2d462 --- /dev/null +++ b/tests/mutation_tests_shield_reserve.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Mutation tests for the shield reserve ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19). + +Supersedes Cut 4's derived reserve sizing (mutation_tests_stats_power_cut4.py's own shield rule, still intact +and still pinned there against InputCapacitorTests.cs -- a hit within the reserve may draw a partial amount). +This script covers what changed on top of that: ShieldData now authors Capacity/RefillDuration/RestoreDuration +directly, a hit the reserve cannot fully cover breaks the shield instead of a silent, charge-preserving refusal, +the breaking hit itself passes through with no partial absorption, the reserve empties to zero on break (not +left at its pre-hit charge), and a broken shield restores on RestoreDuration, never RefillDuration. Each rule +mutates one exact, unique anchor of source text and asserts the named test in ShieldReserveTests.cs goes red for +exactly that reason. A no-op control mutation proves the read/replace/restore path itself is transparent. + +Usage: + python tests/mutation_tests_shield_reserve.py --cultlib-root + +Only touches files under this repository; every mutation is reversed before the script exits, including on +failure. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" +SHIELD_CS = "Assets/Scripts/ServerShared/Behaviors/Shield.cs" + + +@dataclass +class Mutation: + rule: str + file: str + anchor: str + mutated: str + test: str + expect: str # "red" (mutant must be killed) or "green" (the no-op control) + + +MUTATIONS: list[Mutation] = [ + # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- + Mutation( + rule="control (no-op round trip)", + file=SHIELD_CS, + anchor=" public void TakeHit(DamageType type, float damage)", + mutated=" public void TakeHit(DamageType type, float damage)", + test="ShieldReserveTests.HitWithinReserveIsAbsorbedAndSpendsCharge", + expect="green", + ), + + # --- A hit within the reserve is absorbed and spends charge: TakeHit must actually draw the reserve down, + # --- not just gate on CanTakeHit and leave Charge untouched. --- + Mutation( + rule="a hit within the reserve must spend charge, not just be permitted", + file=SHIELD_CS, + anchor=" if (Item != null) _reserve.TrySpend(damage * EnergyUsage);", + mutated=" if (Item != null) { }", + test="ShieldReserveTests.HitWithinReserveIsAbsorbedAndSpendsCharge", + expect="red", + ), + + # --- A hit beyond the reserve breaks the shield: refusing the hit is not enough on its own -- Broken must + # --- actually flip, or the shield is just Cut 4's silent refusal again with new authored numbers. --- + Mutation( + rule="a hit beyond the reserve must set Broken, not just refuse", + file=SHIELD_CS, + anchor=( + " if (_reserve.CanSpend(damage * EnergyUsage)) return true;\n" + " Broken = true;" + ), + mutated=" if (_reserve.CanSpend(damage * EnergyUsage)) return true;", + test="ShieldReserveTests.HitBeyondReserveBreaksTheShield", + expect="red", + ), + + # --- Operator's second ruling: the breaking hit passes through in full, no partial absorption -- TakeHit + # --- (and its TrySpend/AddHeat) must never run for the hit that broke the shield. Routing the breaking hit + # --- through TakeHit instead of a direct break-and-empty absorbs/processes it, which the ruling forbids. + # --- Caught by the restore-timing assertion: TrySpend's own atomicity refuses (cost > charge) and leaves + # --- Charge at its pre-hit value instead of the emptied reserve. + Mutation( + rule="the breaking hit must not be absorbed via TakeHit -- it passes through untouched", + file=SHIELD_CS, + anchor=( + " Broken = true;\n" + " _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge\n" + " return false;" + ), + mutated=( + " Broken = true;\n" + " TakeHit(type, damage);\n" + " return false;" + ), + test="ShieldReserveTests.BreakingHitIsNotPartiallyAbsorbed", + expect="red", + ), + + # --- A broken shield absorbs nothing -- gated on Broken itself, not on the reserve happening to read empty. + # --- Deleting the Broken short-circuit lets a partially-restored (but still Broken) reserve answer purely + # --- on its own charge arithmetic, absorbing a hit it should still be refusing outright. + Mutation( + rule="a broken shield must refuse every hit regardless of reserve arithmetic", + file=SHIELD_CS, + anchor=( + " if (Item == null) return true;\n" + " if (Broken) return false;" + ), + mutated=" if (Item == null) return true;", + test="ShieldReserveTests.BrokenShieldAbsorbsNothing", + expect="red", + ), + + # --- A break empties the reserve to zero, not left at its pre-hit charge -- the restore duration must mean + # --- the same thing every time. Removing the drain leaves whatever charge the reserve held at the moment + # --- it broke, letting a shield that broke nearly full restore far faster than one that broke empty. + Mutation( + rule="a break must empty the reserve, not leave its pre-hit charge intact", + file=SHIELD_CS, + anchor=" Broken = true;\n _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge", + mutated=" Broken = true;", + test="ShieldReserveTests.BreakEmptiesTheReserveRegardlessOfChargeHeldAtTheMoment", + expect="red", + ), + + # --- A broken shield restores on RestoreDuration, not RefillDuration -- the two must stay genuinely + # --- separate speeds through the one InputCapacitor mechanism. Feeding RefillDuration in both branches + # --- collapses the punish window: a shield would come back exactly as fast broken as it refills while up. + Mutation( + rule="a broken shield must derive its rate from RestoreDuration, not RefillDuration", + file=SHIELD_CS, + anchor=" var duration = Broken ? Evaluate(_data.RestoreDuration) : Evaluate(_data.RefillDuration);", + mutated=" var duration = Evaluate(_data.RefillDuration);", + test="ShieldReserveTests.BrokenShieldRestoresOnRestoreDurationNotRefillDuration", + expect="red", + ), + + # --- A partial grant from the bus stretches both durations proportionally -- Shield must scale its banked + # --- charge by Item.PowerSupply like every other IPowerConsumer, not assume it always receives the whole + # --- request regardless of what the bus actually granted. + Mutation( + rule="the reserve must only bank the fraction PowerBus actually granted, not the whole request", + file=SHIELD_CS, + anchor=" _reserve.AddCharge(_reserve.RequestedFill(dt) * Item.PowerSupply);", + mutated=" _reserve.AddCharge(_reserve.RequestedFill(dt));", + test="ShieldReserveTests.PartialGrantStretchesTheRefillDurationProportionally", + expect="red", + ), +] + + +def replace_unique(path: Path, anchor: str, mutated: str) -> None: + original = path.read_text(encoding="utf-8") + count = original.count(anchor) + if count != 1: + raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") + path.write_text(original.replace(anchor, mutated), encoding="utf-8") + + +def run_test(cultlib_root: str, test: str) -> tuple[int, str]: + proc = subprocess.run( + ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", + "--filter", f"FullyQualifiedName~{test}"], + capture_output=True, text=True, timeout=300, + ) + return proc.returncode, proc.stdout + proc.stderr + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--cultlib-root", required=True) + args = parser.parse_args() + + failures: list[str] = [] + originals: dict[str, str] = {} + for m in MUTATIONS: + if m.file not in originals: + originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") + + def restore_all() -> None: + for file, text in originals.items(): + (REPO_ROOT / file).write_text(text, encoding="utf-8") + + try: + for m in MUTATIONS: + print(f"\n=== {m.rule} (expect {m.expect}) ===") + path = REPO_ROOT / m.file + replace_unique(path, m.anchor, m.mutated) + code, output = run_test(args.cultlib_root, m.test) + passed = "Passed!" in output and code == 0 + if m.expect == "green": + if not passed: + failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") + else: + print(f"OK: {m.test} passed under a no-op mutation, as expected") + else: + if passed: + failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") + else: + print(f"OK: {m.test} went red under the mutation, as expected") + # Restore this file immediately so the next mutation starts from a clean tree. + path.write_text(originals[m.file], encoding="utf-8") + finally: + restore_all() + + if failures: + print("\n=== FAILURES ===") + for f in failures: + print(f"- {f}") + return 1 + + print("\nAll mutations behaved as declared.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 7d60405f32836f0b57a65fd6d86380d065236435 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 02:46:38 +0200 Subject: [PATCH 3/3] Add shield-migrate: authors Capacity/RefillDuration/RestoreDuration for catalog shields Catalog migration for the shield reserve ruling (docs/stats-and-power-cut.md). Scans every GearData- and ConsumableItemData-hosted ShieldData, derives Capacity from EnergyUsage (a per-damage-point multiplier, not an energy quantity -- capacity = 10 raw damage points x max(1, EnergyUsage) x cell count) and flat starting durations (2s refill, 12s restore), and prints a derivation table. Dry run unless passed "apply"; writes nothing if commit fails partway (single Cache.Commit batch). GameData/Aetheria.cc is unchanged by this commit: the shipped catalog holds zero ShieldData records (the pre-Cut-4 probe's 348-instance PerformanceStat table never lists ShieldData among its owners either -- consistent with GameData/Aetheria.cc being the post-breach bare-minimum rebuild, not the full pre-breach consumer set). shield-migrate reports "0 shield designs" against it today and will migrate real designs as soon as any are authored. Co-Authored-By: Claude Opus 5 --- tools/AetherDb/Program.cs | 86 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 25e704998..9a6290bf2 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -29,8 +29,9 @@ public static class Program case "dangling": return Dangling(args.Skip(1).ToArray()); case "settings": return Settings(); case "settings-dump": return SettingsDump(); + case "shield-migrate": return ShieldMigrate(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply]"); return 1; } } @@ -474,4 +475,87 @@ public static class Program Console.WriteLine($"\n{failures} hulls failed to generate a loadout (seed {seed})"); return failures; } + + // Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): ShieldData now + // authors Capacity/RefillDuration/RestoreDuration directly instead of deriving the reserve from EnergyUsage + // at runtime. Every existing shield design's PerformanceStat default (Min = Max = 0) reads as an authored + // zero reserve post-migration -- CanTakeHit would break on the very first hit -- so this command must land + // in the same commit as the schema change. Dry run unless passed "apply", which alone opens the catalog + // writable; every record must derive cleanly or nothing is written (TEMP: prints the derivation table for + // operator review either way). + private static int ShieldMigrate(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + + // ShieldData nests inside either host's Behaviors list (GearData: an equipped item with a hardpoint + // Shape to size from; ConsumableItemData: a consumable effect with no Shape -- cells reads 0 there, and + // the derivation below floors it to 1). Both are scanned because the schema change applies to every + // ShieldData record, not just equipped ones. + var hosts = db.Cache.GetAll().Select(g => (Name: g.Name, Cells: g.Shape.Coordinates.Length, Document: (object) g, Key: db.Cache.RefOf(g).Key, Behaviors: g.Behaviors)) + .Concat(db.Cache.GetAll().Select(c => (Name: c.Name, Cells: 0, Document: (object) c, Key: db.Cache.RefOf(c).Key, Behaviors: c.Behaviors))); + var shields = hosts + .Select(h => (h.Name, h.Cells, h.Document, h.Key, Shield: h.Behaviors.OfType().FirstOrDefault())) + .Where(p => p.Shield != null) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .ToArray(); + + Console.WriteLine($"{shields.Length} shield designs\n"); + Console.WriteLine($"{"design",-24} {"cells",5} {"energy",7} {"capacity",9} {"refill",7} {"restore",8} derived from"); + var changed = new List<(object Document, CultRecordKey Key)>(); + var alreadyAuthored = 0; + foreach (var (name, cells, document, key, shield) in shields) + { + if (shield.Capacity.Max > 0f || shield.RefillDuration.Max > 0f || shield.RestoreDuration.Max > 0f) + { + alreadyAuthored++; + Console.WriteLine($"{name,-24} {cells,5} {shield.EnergyUsage.Max,7:0.##} {shield.Capacity.Max,9:0.##} " + + $"{shield.RefillDuration.Max,7:0.##} {shield.RestoreDuration.Max,8:0.##} (already authored, left alone)"); + continue; + } + + // EnergyUsage is a per-damage-point cost multiplier, not an energy quantity of its own -- Cut 4's + // mistake was treating it as a reserve size. A reserve needs to be sized in damage points, so this + // divides back out: a shield with EnergyUsage 1 costs 1 energy per point of damage, so a Capacity of + // (BaseAbsorption * EnergyUsage) buys BaseAbsorption points of raw damage before breaking, scaling + // with the item's own cost multiplier the same way the old runtime-derived Capacity did (Capacity = + // EnergyUsage) but sized for a real fight instead of one hit. BaseAbsorption = 10 raw damage points, + // then scaled by the item's own cell count (larger reserved capacitors in a bigger hull slot) with a + // floor of 1 cell so a consumable-hosted shield (no Shape, Cells = 0) or a malformed 0-cell fixture + // does not zero the result. + const float baseAbsorption = 10f; + var energyUsage = shield.EnergyUsage.Max; + var capacity = baseAbsorption * Math.Max(1f, energyUsage) * Math.Max(1, cells); + + // Refill (holding) is the fast lever; restore (broken) is the punish window and is authored several + // times slower so breaking a shield costs real time regardless of how it broke. 2s/12s are flat + // starting points (no other authored duration on ShieldData to scale from), not derived from any + // per-design number -- flagged for operator tuning against real combat pacing, same footing Cut 4 + // flagged its own guess on. + const float refillDuration = 2f; + const float restoreDuration = 12f; + + shield.Capacity = new PerformanceStat { Min = capacity, Max = capacity }; + shield.RefillDuration = new PerformanceStat { Min = refillDuration, Max = refillDuration }; + shield.RestoreDuration = new PerformanceStat { Min = restoreDuration, Max = restoreDuration }; + + Console.WriteLine($"{name,-24} {cells,5} {energyUsage,7:0.##} {capacity,9:0.##} " + + $"{refillDuration,7:0.##} {restoreDuration,8:0.##} {baseAbsorption:0.#} x max(1,energy) x cells; flat durations"); + changed.Add((document, key)); + } + + Console.WriteLine($"\n{changed.Count} designs migrated, {alreadyAuthored} already authored (left alone)"); + if (changed.Count == 0) return 0; + if (!apply) + { + Console.WriteLine($"Dry run. Pass \"apply\" to land {changed.Count} changed records."); + return 0; + } + + db.Cache.Commit(batch => + { + foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); + }); + Console.WriteLine($"Landed {changed.Count} changed records in Aetheria.cc"); + return 0; + } }