Game Scene Split up
This commit is contained in:
@@ -8,10 +8,13 @@ using Unity.NetCode;
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="CyclePhaseSystem"/> — the macro-loop director
|
||||
/// (Expedition → Defend → Build → next cycle). A bare world is seeded with a NetworkTime singleton and a cycle
|
||||
/// entity carrying CycleState + CycleRuntime (and optionally WaveState / GoalProgress). All timing is wrap-safe
|
||||
/// NetworkTick math; these tests pin each phase transition and the per-cycle goal-charge increment.
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="CyclePhaseSystem"/> — the PLAYER-DRIVEN
|
||||
/// run-state director (Calm ↔ Siege). A bare world is seeded with a NetworkTime singleton and a cycle entity
|
||||
/// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase
|
||||
/// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so
|
||||
/// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds
|
||||
/// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm and charges the goal once;
|
||||
/// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math.
|
||||
/// </summary>
|
||||
public class CyclePhaseSystemTests
|
||||
{
|
||||
@@ -28,111 +31,132 @@ namespace ProjectM.Tests
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeCycle(EntityManager em, byte phase, uint phaseEndTick, int cycleNumber, int defendStartWave)
|
||||
static Entity MakeCycle(EntityManager em, byte phase, int defendStartWave)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
|
||||
em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = phaseEndTick, CycleNumber = cycleNumber });
|
||||
em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 });
|
||||
em.SetComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave });
|
||||
return e;
|
||||
}
|
||||
|
||||
static void MakeWaveState(EntityManager em, int waveNumber, int remainingToSpawn)
|
||||
static void AddThreat(EntityManager em, Entity cycle, int pendingSiegeSize, uint armTick)
|
||||
{
|
||||
em.AddComponentData(cycle, new ThreatState { PendingSiegeSize = pendingSiegeSize, ArmTick = armTick });
|
||||
}
|
||||
|
||||
static Entity MakeWaveState(EntityManager em, int waveNumber, byte phase, int remainingToSpawn)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(WaveState));
|
||||
em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, RemainingToSpawn = remainingToSpawn });
|
||||
em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, Phase = phase, RemainingToSpawn = remainingToSpawn });
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Expedition_Enters_Defend_When_Timer_Due_Capturing_StartWave()
|
||||
public void Calm_Holds_When_No_PendingSiege()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleExpToDefend", serverTick: 200);
|
||||
var (world, group) = MakeWorld("CalmHolds", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Expedition, phaseEndTick: 100, cycleNumber: 1, defendStartWave: 0);
|
||||
MakeWaveState(em, waveNumber: 5, remainingToSpawn: 0);
|
||||
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
|
||||
AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
var cs = em.GetComponentData<CycleState>(cycle);
|
||||
Assert.AreEqual(CyclePhase.Defend, cs.Phase, "An expired Expedition timer enters Defend.");
|
||||
Assert.AreEqual(0u, cs.PhaseEndTick, "Defend is wave-driven, so PhaseEndTick is cleared.");
|
||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
|
||||
"With no pending siege the base stays Calm — no forced timer.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PendingSiege_Enters_Siege_And_Seeds_WaveState_Spawning_With_Exact_Size()
|
||||
{
|
||||
var (world, group) = MakeWorld("PendingSiege", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
|
||||
AddThreat(em, cycle, pendingSiegeSize: 7, armTick: 0); // armTick 0 => fire immediately
|
||||
var wave = MakeWaveState(em, waveNumber: 5, phase: WavePhase.Lull, remainingToSpawn: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(CyclePhase.Siege, em.GetComponentData<CycleState>(cycle).Phase,
|
||||
"An armed pending siege enters Siege.");
|
||||
|
||||
var w = em.GetComponentData<WaveState>(wave);
|
||||
Assert.AreEqual(WavePhase.Spawning, w.Phase,
|
||||
"WaveState is driven into Spawning (bypassing the Lull escalation recompute).");
|
||||
Assert.AreEqual(7, w.RemainingToSpawn,
|
||||
"RemainingToSpawn is the EXACT director-chosen siege size (not the escalation curve).");
|
||||
Assert.AreEqual(6, w.WaveNumber, "WaveNumber advances by one for the siege.");
|
||||
|
||||
Assert.AreEqual(5, em.GetComponentData<CycleRuntime>(cycle).DefendStartWave,
|
||||
"DefendStartWave captures the current WaveState.WaveNumber.");
|
||||
"DefendStartWave captures the pre-bump wave number.");
|
||||
Assert.AreEqual(0, em.GetComponentData<ThreatState>(cycle).PendingSiegeSize,
|
||||
"The pending siege is consumed (zeroed) so it fires exactly once.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Expedition_Holds_While_Timer_Pending()
|
||||
public void Siege_Exits_To_Calm_On_DefendCleared_And_Charges_Goal_Once()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleExpHold", serverTick: 200);
|
||||
var (world, group) = MakeWorld("SiegeClears", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Expedition, phaseEndTick: 5000, cycleNumber: 1, defendStartWave: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(CyclePhase.Expedition, em.GetComponentData<CycleState>(cycle).Phase,
|
||||
"Expedition holds until its timer is due.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Defend_Enters_Build_When_Wave_Cleared()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleDefendToBuild", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Defend, phaseEndTick: 0, cycleNumber: 1, defendStartWave: 1);
|
||||
// Wave advanced past the captured start, fully spawned, and no Husks alive (none created).
|
||||
MakeWaveState(em, waveNumber: 2, remainingToSpawn: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
var cs = em.GetComponentData<CycleState>(cycle);
|
||||
Assert.AreEqual(CyclePhase.Build, cs.Phase, "A cleared Defend wave enters Build.");
|
||||
Assert.AreNotEqual(0u, cs.PhaseEndTick, "Build is timed, so a PhaseEndTick is stamped.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_Enters_Expedition_Incrementing_Cycle_And_Goal()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleBuildToExp", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Build, phaseEndTick: 100, cycleNumber: 1, defendStartWave: 0);
|
||||
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
|
||||
em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 });
|
||||
// Wave advanced past the captured start, fully spawned, no Husks alive (none created).
|
||||
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
var cs = em.GetComponentData<CycleState>(cycle);
|
||||
Assert.AreEqual(CyclePhase.Expedition, cs.Phase, "An expired Build timer starts the next Expedition.");
|
||||
Assert.AreEqual(2, cs.CycleNumber, "CycleNumber increments on the new cycle.");
|
||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
|
||||
"A cleared siege returns to Calm.");
|
||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cycle).Charge,
|
||||
"One goal charge accrues per completed cycle (single writer).");
|
||||
"One goal charge accrues per siege survived (single writer).");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Test]
|
||||
[Test]
|
||||
public void Coop_Split_Presence_Keeps_Global_Phase_Calm()
|
||||
{
|
||||
var (world, group) = MakeWorld("CoopSplit", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
|
||||
AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0);
|
||||
|
||||
// One player out on expedition, one home — the GLOBAL phase machine must ignore presence.
|
||||
var pOut = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag));
|
||||
em.SetComponentData(pOut, new RegionTag { Region = RegionId.Expedition });
|
||||
var pHome = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag));
|
||||
em.SetComponentData(pHome, new RegionTag { Region = RegionId.Base });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
|
||||
"Split presence (one out, one home) never drives the single global phase — Expedition is per-player.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WaveNumber_Is_Synced_From_WaveState_For_The_Hud()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleWaveSync", serverTick: 200);
|
||||
var (world, group) = MakeWorld("WaveSync", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Defend, phaseEndTick: 0, cycleNumber: 1, defendStartWave: 1);
|
||||
MakeWaveState(em, waveNumber: 4, remainingToSpawn: 2);
|
||||
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
|
||||
MakeWaveState(em, waveNumber: 4, phase: WavePhase.Spawning, remainingToSpawn: 2);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(4, em.GetComponentData<CycleState>(cycle).WaveNumber,
|
||||
"CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber so the replicated-state-only HUD can show it.");
|
||||
"CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber for the replicated-state-only HUD.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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>
|
||||
/// Plain-Entities EditMode tests for the editor-only <see cref="DebugCommandReceiveSystem"/> — the
|
||||
/// server-side dev-tools RPC dispatcher. A bare world is seeded with the relevant singletons + a
|
||||
/// DebugCommandRequest (+ ReceiveRpcCommandRequest) entity. These pin that grant-resource deposits to the
|
||||
/// ledger, spawn-wave arms the pending siege, end-siege forces the wave to Lull + clears pending, and a
|
||||
/// sender-targeted teleport resolves the player from the source connection and flips its region.
|
||||
/// </summary>
|
||||
public class DebugCommandReceiveSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<DebugCommandReceiveSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeRequest(EntityManager em, byte op, int argA, int argB, Entity sourceConnection)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(DebugCommandRequest), typeof(ReceiveRpcCommandRequest));
|
||||
em.SetComponentData(e, new DebugCommandRequest { Op = op, ArgA = argA, ArgB = argB });
|
||||
em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = sourceConnection });
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GrantResource_Deposits_To_Ledger()
|
||||
{
|
||||
var (world, group) = MakeWorld("DebugGrantResource");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var ledger = em.CreateEntity(typeof(ResourceLedger), typeof(StorageEntry));
|
||||
MakeRequest(em, DebugOp.GrantResource, ResourceId.Aether, 50, Entity.Null);
|
||||
|
||||
group.Update();
|
||||
|
||||
var buf = em.GetBuffer<StorageEntry>(ledger);
|
||||
int aether = 0;
|
||||
for (int i = 0; i < buf.Length; i++)
|
||||
if (buf[i].ItemId == ResourceId.Aether) aether = buf[i].Count;
|
||||
Assert.AreEqual(50, aether, "GrantResource deposits the amount into the shared ledger.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SpawnWave_Arms_PendingSiege()
|
||||
{
|
||||
var (world, group) = MakeWorld("DebugSpawnWave");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var dir = em.CreateEntity(typeof(CycleState), typeof(ThreatState));
|
||||
em.SetComponentData(dir, new CycleState { Phase = CyclePhase.Calm });
|
||||
MakeRequest(em, DebugOp.SpawnWave, 8, 0, Entity.Null);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(8, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||
"SpawnWave arms a pending siege of the requested size.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EndSiege_Forces_WaveState_Lull_And_Clears_Pending()
|
||||
{
|
||||
var (world, group) = MakeWorld("DebugEndSiege");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var dir = em.CreateEntity(typeof(CycleState), typeof(ThreatState));
|
||||
em.SetComponentData(dir, new CycleState { Phase = CyclePhase.Siege });
|
||||
em.SetComponentData(dir, new ThreatState { PendingSiegeSize = 5, SiegeStartTick = 100 });
|
||||
var wave = em.CreateEntity(typeof(WaveState));
|
||||
em.SetComponentData(wave, new WaveState { Phase = WavePhase.Spawning, RemainingToSpawn = 3 });
|
||||
for (int i = 0; i < 2; i++)
|
||||
em.CreateEntity(typeof(EnemyTag));
|
||||
|
||||
MakeRequest(em, DebugOp.EndSiege, 0, 0, Entity.Null);
|
||||
|
||||
group.Update();
|
||||
|
||||
var w = em.GetComponentData<WaveState>(wave);
|
||||
Assert.AreEqual(WavePhase.Lull, w.Phase, "EndSiege drives the wave to Lull.");
|
||||
Assert.AreEqual(0, w.RemainingToSpawn, "EndSiege stops further spawning.");
|
||||
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize, "EndSiege clears any pending siege.");
|
||||
using var husks = em.CreateEntityQuery(typeof(EnemyTag));
|
||||
Assert.AreEqual(0, husks.CalculateEntityCount(), "EndSiege culls the remaining Husks.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Teleport_Resolves_Sender_Only()
|
||||
{
|
||||
var (world, group) = MakeWorld("DebugTeleport");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
|
||||
var connection = em.CreateEntity(typeof(NetworkId));
|
||||
em.SetComponentData(connection, new NetworkId { Value = 1 });
|
||||
|
||||
var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(RegionTag), typeof(LocalTransform));
|
||||
em.SetComponentData(player, new GhostOwner { NetworkId = 1 });
|
||||
em.SetComponentData(player, new RegionTag { Region = RegionId.Base });
|
||||
em.SetComponentData(player, LocalTransform.FromPosition(new float3(0, 1, 0)));
|
||||
|
||||
MakeRequest(em, DebugOp.Teleport, RegionId.Expedition, 0, connection);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
|
||||
"Teleport flips the SENDER's region.");
|
||||
Assert.Greater(em.GetComponentData<LocalTransform>(player).Position.x, 500f,
|
||||
"Teleport moves the sender to the expedition region (far +X).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b91e9e729b69964f8b9c2e2b1f5ec2f
|
||||
@@ -13,8 +13,8 @@ namespace ProjectM.Tests
|
||||
/// transit). A bare world is seeded with an <c>ExpeditionGate</c> (+ LocalTransform) and a player
|
||||
/// (RegionTag + LocalTransform + PlayerTag). A player whose region matches the gate's FromRegion and who is
|
||||
/// within the gate radius is transited (RegionTag flipped + LocalTransform teleported to ArrivalPos).
|
||||
/// Returning to base during the Expedition phase caps the cycle phase timer. Pins the proximity gate, the
|
||||
/// region/radius guards, and the early-return phase cap.
|
||||
/// Returning to base signals the ThreatDirector (the post-expedition retaliation source) exactly once. Pins
|
||||
/// the proximity gate, the region/radius guards, and the return signal.
|
||||
/// </summary>
|
||||
public class ExpeditionGateSystemTests
|
||||
{
|
||||
@@ -101,22 +101,24 @@ namespace ProjectM.Tests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Return_To_Base_During_Expedition_Caps_The_Phase_Timer()
|
||||
public void Return_To_Base_Signals_ThreatDirector_Once()
|
||||
{
|
||||
var (world, group) = MakeWorld("GateReturnCapWorld");
|
||||
var (world, group) = MakeWorld("GateReturnSignalWorld");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeGate(em, new float3(0, 1, 0), RegionId.Expedition, RegionId.Base, radius: 15f, arrival: new float3(0, 1, 0));
|
||||
MakePlayer(em, new float3(3, 1, 0), RegionId.Expedition);
|
||||
|
||||
var cycle = em.CreateEntity(typeof(CycleState));
|
||||
em.SetComponentData(cycle, new CycleState { Phase = CyclePhase.Expedition, PhaseEndTick = 5000, CycleNumber = 1 });
|
||||
var threat = em.CreateEntity(typeof(ThreatState));
|
||||
em.SetComponentData(threat, new ThreatState());
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(1u, em.GetComponentData<CycleState>(cycle).PhaseEndTick,
|
||||
"Returning to base mid-Expedition caps PhaseEndTick to 1 so Defend starts next tick.");
|
||||
var ts = em.GetComponentData<ThreatState>(threat);
|
||||
Assert.AreEqual(1, ts.PendingReturns,
|
||||
"Returning to base signals the ThreatDirector exactly once (the gate teleports the returner out of its radius).");
|
||||
Assert.AreEqual(1, ts.ExpeditionsCompleted, "A completed expedition is counted.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,5 +130,29 @@ namespace ProjectM.Tests
|
||||
"Health.Current must be untouched when there are no DamageEvents.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void God_Mode_Skips_All_Damage()
|
||||
{
|
||||
var (world, group) = MakeWorld("HealthApplyDamageGodModeWorld");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DebugGodMode));
|
||||
em.SetComponentData(entity, new Health { Current = 50f, Max = 50f });
|
||||
em.SetComponentEnabled<DebugGodMode>(entity, true);
|
||||
|
||||
var dmg = em.GetBuffer<DamageEvent>(entity);
|
||||
dmg.Add(new DamageEvent { Amount = 80f, SourceNetworkId = 9 });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(50f, em.GetComponentData<Health>(entity).Current, 1e-4f,
|
||||
"An enabled DebugGodMode entity ignores all damage.");
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(entity).Length,
|
||||
"The damage buffer is still drained (cleared) under god-mode.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="ThreatDirectorSystem"/> — the composite
|
||||
/// base-attack scheduler. A bare world is seeded with a NetworkTime singleton and a CycleDirector entity
|
||||
/// carrying CycleState + ThreatState + ThreatConfig. These pin the post-expedition source (a return arms a
|
||||
/// siege of the configured size, with simultaneous returns de-duped to one), that the event-siege size is the
|
||||
/// config floor — never the WaveSystem escalation curve — that the telegraph ArmTick is now + delay, and that
|
||||
/// an unattended siege auto-collapses after the timeout (no soft-lock). All timing is wrap-safe NetworkTick.
|
||||
/// </summary>
|
||||
public class ThreatDirectorSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<ThreatDirectorSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static ThreatConfig DefaultConfig() => new ThreatConfig
|
||||
{
|
||||
PostExpeditionEnabled = 1,
|
||||
PostExpeditionDelayTicks = 300,
|
||||
SizeBase = 5,
|
||||
SizePerExpeditionResource = 0,
|
||||
StartCondition = ThreatStartCondition.Immediate,
|
||||
SiegeTimeoutTicks = 3600,
|
||||
};
|
||||
|
||||
static Entity MakeDirector(EntityManager em, byte phase, ThreatState threat, ThreatConfig config)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(CycleState), typeof(ThreatState), typeof(ThreatConfig));
|
||||
em.SetComponentData(e, new CycleState { Phase = phase, CycleNumber = 1 });
|
||||
em.SetComponentData(e, threat);
|
||||
em.SetComponentData(e, config);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PostExpedition_Return_Edge_Sets_PendingSiegeSize()
|
||||
{
|
||||
var (world, group) = MakeWorld("ThreatReturn", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig());
|
||||
|
||||
group.Update();
|
||||
|
||||
var ts = em.GetComponentData<ThreatState>(dir);
|
||||
Assert.AreEqual(5, ts.PendingSiegeSize, "A return arms a siege of SizeBase Husks.");
|
||||
Assert.AreNotEqual(0u, ts.ArmTick, "The siege is armed with a telegraph tick.");
|
||||
Assert.AreEqual(0, ts.PendingReturns, "The return is consumed.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Multi_Player_Simultaneous_Return_Charges_Pending_Once()
|
||||
{
|
||||
var (world, group) = MakeWorld("ThreatMultiReturn", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 3 }, DefaultConfig());
|
||||
|
||||
group.Update();
|
||||
|
||||
var ts = em.GetComponentData<ThreatState>(dir);
|
||||
Assert.AreEqual(5, ts.PendingSiegeSize, "Three simultaneous returns still arm exactly one siege (de-dup).");
|
||||
Assert.AreEqual(0, ts.PendingReturns, "All returns are consumed.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Siege_Size_Equals_Config_Not_Escalation_Curve()
|
||||
{
|
||||
var (world, group) = MakeWorld("ThreatSizeConfig", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig());
|
||||
// A high wave number must NOT influence the event-siege size.
|
||||
var w = em.CreateEntity(typeof(WaveState));
|
||||
em.SetComponentData(w, new WaveState { WaveNumber = 30 });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(5, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||
"Event-siege size is the config SizeBase, never the WaveSystem escalation curve.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StartCondition_Immediate_Arms_Via_ArmTick()
|
||||
{
|
||||
var (world, group) = MakeWorld("ThreatArm", serverTick: 1000);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var config = DefaultConfig();
|
||||
config.PostExpeditionDelayTicks = 120;
|
||||
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, config);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(1120u, em.GetComponentData<ThreatState>(dir).ArmTick,
|
||||
"Immediate start arms the siege at now + the telegraph delay (1000 + 120).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Empty_Base_Siege_Auto_Resolves_Bounded()
|
||||
{
|
||||
var (world, group) = MakeWorld("ThreatTimeout", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var config = DefaultConfig();
|
||||
config.SiegeTimeoutTicks = 60;
|
||||
// SiegeStartTick 100, now 200 => 100 ticks elapsed > 60 timeout.
|
||||
var dir = MakeDirector(em, CyclePhase.Siege, new ThreatState { SiegeStartTick = 100 }, config);
|
||||
|
||||
var w = em.CreateEntity(typeof(WaveState));
|
||||
em.SetComponentData(w, new WaveState { RemainingToSpawn = 2, Phase = WavePhase.Spawning });
|
||||
|
||||
// Three Husks still on the field with no one to clear them.
|
||||
for (int i = 0; i < 3; i++)
|
||||
em.CreateEntity(typeof(EnemyTag));
|
||||
|
||||
group.Update();
|
||||
|
||||
using var huskQuery = em.CreateEntityQuery(typeof(EnemyTag));
|
||||
Assert.AreEqual(0, huskQuery.CalculateEntityCount(),
|
||||
"A timed-out (unattended) siege culls the remaining Husks so it can never soft-lock.");
|
||||
Assert.AreEqual(0, em.GetComponentData<WaveState>(w).RemainingToSpawn,
|
||||
"The wave stops spawning when the siege collapses.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<ThreatState>(dir).SiegeStartTick,
|
||||
"The siege clock resets after collapse.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 351a99057b08e3847b239782bfef893e
|
||||
Reference in New Issue
Block a user