Tests: END-1 Core drain/regen/lose-edge + persistence v4 (330/330 EditMode)

CoreSystemsTests (new): a breaching Husk drains + is consumed; idles at 0;
regen fires once per interval in Calm only; no regen mid-Siege; caps at Max.
CyclePhaseSystemTests: the soft-loss overrun edge ends the siege, drains the
ledger, despawns husks, withholds the goal charge, and resolves once.
StorageMathTests: DrainFraction floors per row, drops zeroed rows, clamps.
SavePersistenceTests: CoreCurrent round-trips at v4; a pre-END-1 save with no
CoreCurrent defaults to 0 (-> born full); the v3->v4 version pin updated.
TuningConfig golden pin extended with the 3 Core defaults.

See DR-034.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:51:52 -07:00
parent 60e1e21dd3
commit 037ff66490
6 changed files with 311 additions and 1 deletions
@@ -158,5 +158,62 @@ namespace ProjectM.Tests
"CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber for the replicated-state-only HUD.");
}
}
[Test]
public void Siege_Overrun_Ends_Siege_Drains_Ledger_Despawns_Husks_No_Goal_Charge()
{
var (world, group) = MakeWorld("SiegeOverrun", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
em.AddComponentData(cycle, new GoalProgress { Charge = 3, Target = 10 });
em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100, OverrunTick = 0 }); // breached
var ledger = em.AddBuffer<StorageEntry>(cycle);
ledger.Add(new StorageEntry { ItemId = 2, Count = 100 });
ledger.Add(new StorageEntry { ItemId = 4, Count = 40 });
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3);
em.CreateEntity(typeof(EnemyTag)); // two live husks the team failed to clear
em.CreateEntity(typeof(EnemyTag));
group.Update();
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
"an overrun ends the siege -> Calm (soft loss).");
Assert.AreEqual(3, em.GetComponentData<GoalProgress>(cycle).Charge,
"NO goal charge on a loss (you were overrun, not survived).");
var l = em.GetBuffer<StorageEntry>(cycle);
Assert.AreEqual(50, l[0].Count, "ledger row 1 drained 50% (100 -> 50).");
Assert.AreEqual(20, l[1].Count, "ledger row 2 drained 50% (40 -> 20).");
Assert.AreNotEqual(0u, em.GetComponentData<CoreIntegrity>(cycle).OverrunTick,
"the overrun pulse is stamped for the HUD flash.");
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(0, huskQ.CalculateEntityCount(),
"remaining husks are despawned (the siege disperses).");
}
}
[Test]
public void Overrun_Resolves_Once_Then_Stays_Calm_Without_Recharging()
{
var (world, group) = MakeWorld("OverrunOnce", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 });
em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100 });
em.AddBuffer<StorageEntry>(cycle);
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0);
group.Update();
group.Update(); // second tick: Calm branch -> must not re-resolve or charge
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase);
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cycle).Charge,
"the loss never charges the goal across ticks.");
}
}
}
}