037ff66490
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>
182 lines
6.9 KiB
C#
182 lines
6.9 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// END-1 — plain-Entities EditMode tests for the Engine Core server systems. <see cref="CoreDamageSystem"/>:
|
|
/// a Husk that reaches the base <see cref="BaseGridMath.PlotCenter"/> drains integrity (the live
|
|
/// <see cref="TuningConfig"/> default with no singleton) and is consumed; a distant Husk is untouched; at 0
|
|
/// the system idles (the lose-edge owns resolution). <see cref="CoreRestoreSystem"/>: 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 <c>CyclePhaseSystemTests</c>. BaseAnchor is configured so PlotCenter == origin.
|
|
/// </summary>
|
|
public class CoreSystemsTests
|
|
{
|
|
static (World world, SimulationSystemGroup group) MakeWorld<T>(string name, uint serverTick)
|
|
where T : unmanaged, ISystem
|
|
{
|
|
var world = new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
|
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<CoreDamageSystem>("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<CoreIntegrity>(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<CoreDamageSystem>("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<CoreRestoreSystem>("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<CoreIntegrity>(core).Current,
|
|
"Calm regenerates exactly +1 across one regen interval.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void CoreRestore_Does_Not_Regen_During_Siege()
|
|
{
|
|
var (world, group) = MakeWorld<CoreRestoreSystem>("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<CoreIntegrity>(core).Current,
|
|
"no regen mid-Siege (a chipped Core heals only between sieges).");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void CoreRestore_Never_Exceeds_Max()
|
|
{
|
|
var (world, group) = MakeWorld<CoreRestoreSystem>("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<CoreIntegrity>(core).Current,
|
|
"regen clamps at Max.");
|
|
}
|
|
}
|
|
}
|
|
}
|