From 037ff66490c96220c4fcec3461ff498b10a0d20c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 12 Jun 2026 21:51:52 -0700 Subject: [PATCH] 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 --- .../Tests/EditMode/CoreSystemsTests.cs | 181 ++++++++++++++++++ .../Tests/EditMode/CoreSystemsTests.cs.meta | 2 + .../Tests/EditMode/CyclePhaseSystemTests.cs | 57 ++++++ .../Tests/EditMode/SavePersistenceTests.cs | 23 ++- .../Tests/EditMode/StorageMathTests.cs | 45 +++++ .../Tests/EditMode/TuningConfigTests.cs | 4 + 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 Assets/_Project/Tests/EditMode/CoreSystemsTests.cs create mode 100644 Assets/_Project/Tests/EditMode/CoreSystemsTests.cs.meta diff --git a/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs b/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs new file mode 100644 index 000000000..9fb0f4c71 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs @@ -0,0 +1,181 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// END-1 — plain-Entities EditMode tests for the Engine Core server systems. : + /// a Husk that reaches the base drains integrity (the live + /// default with no singleton) and is consumed; a distant Husk is untouched; at 0 + /// the system idles (the lose-edge owns resolution). : the Core regenerates + /// exactly +1 across one regen interval ONLY in Calm, never mid-Siege, and never past Max. The lose-edge + /// itself is covered in CyclePhaseSystemTests. BaseAnchor is configured so PlotCenter == origin. + /// + public class CoreSystemsTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) + where T : unmanaged, ISystem + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + SetServerTick(world, serverTick); + return (world, group); + } + + static void SetServerTick(World world, uint tick) + { + var em = world.EntityManager; + using var q = em.CreateEntityQuery(typeof(NetworkTime)); + Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); + em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); + } + + static Entity MakeCore(EntityManager em, int current, int max) + { + var e = em.CreateEntity(typeof(CoreIntegrity)); + em.SetComponentData(e, new CoreIntegrity { Current = current, Max = max }); + return e; + } + + // PlotCenter = GridOrigin.xz + GridDims*CellSize*0.5; origin + zero dims => (0,0,0). + static void MakeBaseAnchor(EntityManager em) + { + var e = em.CreateEntity(typeof(BaseAnchor)); + em.SetComponentData(e, new BaseAnchor + { + AnchorPos = float3.zero, + GridOrigin = float3.zero, + CellSize = 1f, + GridDims = int2.zero, + }); + } + + static void MakeHusk(EntityManager em, float3 pos) + { + var e = em.CreateEntity(typeof(EnemyTag), typeof(LocalTransform)); + em.SetComponentData(e, LocalTransform.FromPosition(pos)); + } + + static Entity MakeCycle(EntityManager em, byte phase) + { + var e = em.CreateEntity(typeof(CycleState)); + em.SetComponentData(e, new CycleState { Phase = phase }); + return e; + } + + [Test] + public void CoreDamage_Breaching_Husk_Drains_And_Is_Consumed() + { + var (world, group) = MakeWorld("CoreDamage", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var core = MakeCore(em, current: 100, max: 100); + MakeBaseAnchor(em); + MakeHusk(em, new float3(0, 0, 0)); // at the Core -> breaches + MakeHusk(em, new float3(20, 0, 20)); // far -> safe + + group.Update(); + + Assert.AreEqual(90, em.GetComponentData(core).Current, + "one breaching Husk drains the default 10 integrity."); + using var hq = em.CreateEntityQuery(typeof(EnemyTag)); + Assert.AreEqual(1, hq.CalculateEntityCount(), + "the breaching Husk is consumed; the distant one survives."); + } + } + + [Test] + public void CoreDamage_Idles_When_Already_Breached() + { + var (world, group) = MakeWorld("CoreBreached", serverTick: 100); + using (world) + { + var em = world.EntityManager; + MakeCore(em, current: 0, max: 100); + MakeBaseAnchor(em); + MakeHusk(em, float3.zero); + + group.Update(); + + using var hq = em.CreateEntityQuery(typeof(EnemyTag)); + Assert.AreEqual(1, hq.CalculateEntityCount(), + "at 0 integrity CoreDamageSystem idles (the lose-edge owns resolution)."); + } + } + + [Test] + public void CoreRestore_Regens_Exactly_Once_Per_Interval_In_Calm() + { + var (world, group) = MakeWorld("CoreRegenCalm", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var core = MakeCore(em, current: 50, max: 100); + MakeCycle(em, CyclePhase.Calm); + + // Across one full default interval (18) of consecutive ticks, exactly ONE is on the regen boundary. + const uint interval = 18; + for (uint t = 100; t < 100 + interval; t++) + { + SetServerTick(world, t); + group.Update(); + } + + Assert.AreEqual(51, em.GetComponentData(core).Current, + "Calm regenerates exactly +1 across one regen interval."); + } + } + + [Test] + public void CoreRestore_Does_Not_Regen_During_Siege() + { + var (world, group) = MakeWorld("CoreNoRegenSiege", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var core = MakeCore(em, current: 50, max: 100); + MakeCycle(em, CyclePhase.Siege); + + for (uint t = 100; t < 100 + 18; t++) + { + SetServerTick(world, t); + group.Update(); + } + + Assert.AreEqual(50, em.GetComponentData(core).Current, + "no regen mid-Siege (a chipped Core heals only between sieges)."); + } + } + + [Test] + public void CoreRestore_Never_Exceeds_Max() + { + var (world, group) = MakeWorld("CoreCap", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var core = MakeCore(em, current: 100, max: 100); + MakeCycle(em, CyclePhase.Calm); + + for (uint t = 100; t < 100 + 18; t++) + { + SetServerTick(world, t); + group.Update(); + } + + Assert.AreEqual(100, em.GetComponentData(core).Current, + "regen clamps at Max."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs.meta b/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs.meta new file mode 100644 index 000000000..0c447a0d0 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0b316df3c18e66c47b2a29316eeaba0e \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs index 914524347..2894deee1 100644 --- a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs @@ -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(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(cycle).Phase, + "an overrun ends the siege -> Calm (soft loss)."); + Assert.AreEqual(3, em.GetComponentData(cycle).Charge, + "NO goal charge on a loss (you were overrun, not survived)."); + var l = em.GetBuffer(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(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(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(cycle).Phase); + Assert.AreEqual(0, em.GetComponentData(cycle).Charge, + "the loss never charges the goal across ticks."); + } + } + } } diff --git a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs index 596338c3c..81a74ec65 100644 --- a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs +++ b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs @@ -108,7 +108,7 @@ namespace ProjectM.Tests { var data = new SaveData { Structures = new[] { new StructureSave { Type = 1, CellX = 1, CellZ = 2, HP = 37f } } }; var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); - Assert.AreEqual(3, back.Version, "EB-1: new saves write v3."); + Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v4 since END-1)."); Assert.AreEqual(1, back.Structures.Length); Assert.AreEqual(37f, back.Structures[0].HP, 1e-4f, "the wounded HP round-trips through JSON."); } @@ -142,5 +142,26 @@ namespace ProjectM.Tests Assert.AreEqual(37f, p.HP, 1e-4f, "the wounded HP survives the save->staging copy."); } + [Test] + public void CoreCurrent_RoundTrips_And_Writes_Current_Version() + { + var data = new SaveData { GoalCharge = 1, GoalTarget = 10, CoreCurrent = 63 }; + var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); + Assert.AreEqual(SaveData.CurrentVersion, back.Version, "END-1: new saves write v4."); + Assert.AreEqual(63, back.CoreCurrent, "the wounded Core integrity round-trips through JSON."); + } + + [Test] + public void Pre_END1_Save_Missing_CoreCurrent_Defaults_To_Zero() + { + // A pre-END-1 save JSON lacks the CoreCurrent field -> JsonUtility defaults it to 0, which the + // born-correct spawn maps to the baked Max (the Core comes back full). Additive: no field, no break. + var back = JsonUtility.FromJson("{\"Version\":3,\"GoalCharge\":2,\"GoalTarget\":10}"); + Assert.AreEqual(0, back.CoreCurrent, "missing CoreCurrent -> 0 -> restored full at baked Max."); + Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v3 stays within the additive load floor."); + Assert.LessOrEqual(back.Version, SaveData.CurrentVersion); + } + + } } diff --git a/Assets/_Project/Tests/EditMode/StorageMathTests.cs b/Assets/_Project/Tests/EditMode/StorageMathTests.cs index 96bd4b331..61e57de4d 100644 --- a/Assets/_Project/Tests/EditMode/StorageMathTests.cs +++ b/Assets/_Project/Tests/EditMode/StorageMathTests.cs @@ -18,6 +18,51 @@ namespace ProjectM.Tests return (world, e); } + [Test] + public void DrainFraction_Removes_Floored_Fraction_Of_Each_Row() + { + var (world, e) = MakeWorld(); + try + { + var buf = world.EntityManager.GetBuffer(e); + StorageMath.Deposit(buf, 2, 100); // Ore + StorageMath.Deposit(buf, 4, 51); // Charge + StorageMath.DrainFraction(buf, 0.5f); + Assert.AreEqual(50, StorageMath.TotalOf(buf, 2), "100 -> floor(50) drained -> 50 left"); + Assert.AreEqual(26, StorageMath.TotalOf(buf, 4), "51 -> floor(25) drained -> 26 left"); + } + finally { world.Dispose(); } + } + + [Test] + public void DrainFraction_Drops_Rows_That_Hit_Zero_And_Clamps_Above_One() + { + var (world, e) = MakeWorld(); + try + { + var buf = world.EntityManager.GetBuffer(e); + StorageMath.Deposit(buf, 2, 4); + StorageMath.DrainFraction(buf, 1.5f); // clamps to 1.0 -> removes all -> row dropped + Assert.AreEqual(0, buf.Length, "a fully-drained row is removed"); + } + finally { world.Dispose(); } + } + + [Test] + public void DrainFraction_Zero_Is_NoOp() + { + var (world, e) = MakeWorld(); + try + { + var buf = world.EntityManager.GetBuffer(e); + StorageMath.Deposit(buf, 2, 10); + StorageMath.DrainFraction(buf, 0f); + Assert.AreEqual(10, StorageMath.TotalOf(buf, 2), "0 fraction drains nothing"); + } + finally { world.Dispose(); } + } + + [Test] public void Deposit_New_Item_Appends_Row() { diff --git a/Assets/_Project/Tests/EditMode/TuningConfigTests.cs b/Assets/_Project/Tests/EditMode/TuningConfigTests.cs index a79532862..e198b9589 100644 --- a/Assets/_Project/Tests/EditMode/TuningConfigTests.cs +++ b/Assets/_Project/Tests/EditMode/TuningConfigTests.cs @@ -35,6 +35,10 @@ namespace ProjectM.Tests // GruntWindup must stay the canonical Tuning const (TelegraphTests couples to it). Assert.AreEqual((float)Tuning.AttackWindupTicks, d.GruntWindupTicks, 1e-6f, "GruntWindupTicks == Tuning.AttackWindupTicks"); Assert.AreEqual(0.7f, d.StructureAggroWeight, 1e-6f, "EB-1 StructureAggroWeight default (<1 prefers structures)"); + Assert.AreEqual(10f, d.CoreDamagePerHusk, 1e-6f, "END-1 CoreDamagePerHusk default"); + Assert.AreEqual(18f, d.CoreRegenIntervalTicks, 1e-6f, "END-1 CoreRegenIntervalTicks default"); + Assert.AreEqual(0.5f, d.CoreOverrunDrainPct, 1e-6f, "END-1 CoreOverrunDrainPct default (half the ledger on a breach)"); + } [Test]