Merge the authored shield reserve and its broken state

Capacity, refill duration and restore duration are authored on ShieldData.
A hit the reserve cannot cover breaks the shield: the hit passes through in
full, the reserve empties, and it restores from zero on the restore
duration, so the punish window is the same every time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-19 02:47:35 +02:00
commit f1c8afcb83
5 changed files with 629 additions and 21 deletions

View file

@ -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)

View file

@ -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;

View file

@ -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<ItemData>(design),
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
};
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<HullData>("Skiff"));
var ship = new Ship(items, zone, hull, new EntitySettings());
var reactorData = cache.GetByName<GearData>("Reactor");
((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorGeneration);
Assert.True(ship.TryEquip(Mint(cache, items, reactorData)));
var shieldData = cache.GetByName<GearData>("Shield");
Assert.True(ship.TryEquip(Mint(cache, items, shieldData)));
zone.Entities.Add(ship);
ship.Activate();
return (ship, ship.GetBehavior<Shield>());
}
// 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));
}
}

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <Type.Member>]... [apply]");
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [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<GearData>().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<ConsumableItemData>().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<ShieldData>().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;
}
}