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:
commit
f1c8afcb83
5 changed files with 629 additions and 21 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
244
tests/Aetheria.Shared.Tests/ShieldReserveTests.cs
Normal file
244
tests/Aetheria.Shared.Tests/ShieldReserveTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
218
tests/mutation_tests_shield_reserve.py
Normal file
218
tests/mutation_tests_shield_reserve.py
Normal 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())
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue