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; 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()) 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; + } }