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]