3109b86d71
Reactivate the dormant Expedition region as a procedural combat venue.
v1 loop: walk the gate -> fight an epoch-seeded enemy wave in the
expedition -> clear -> return -> flat Ore reward (once per epoch) ->
escalated retaliation base siege.
- New sim types: ZoneEnemyTag, ZoneEnemyDirector (+ ZoneEnemyPrefab
buffer), ZoneEnemyState, ZoneEnemyMath (grunt->charger composition
by epoch). ZoneEnemyDirectorSystem (server, Burst): drip-spawns the
wave at a deterministic ring under a MaxAlive cap while a player is
out and the base is Calm; marks ClearedThisEpoch on a real clear.
[UpdateAfter(ExpeditionFieldSystem)] only (avoids a sort cycle).
- BLOCKER 1: EnemyAISystem region-filters target selection (player +
structure snapshots gain parallel region lists; no base structures /
no Core fallback for expedition husks).
- BLOCKER 3: WaveSystem, ThreatDirectorSystem timeout cull, and
CyclePhaseSystem DefendCleared + Core-breach cull all count/cull
RegionTag{Base} husks only (the breach cull was caught region-blind
by the post-impl review: a base breach wiped the live expedition
wave and spuriously paid the reward).
- BLOCKER 4: reward de-duped via CycleRuntime.LastRewardedEpoch +
ClearedThisEpoch; ExpeditionGateSystem deposits RewardOre once/epoch.
- ExpeditionFieldSystem teardown also culls zone enemies + region-
guards the clutter loop. Subscene wired with the director + roster.
368/368 EditMode green + clean netcode Play smoke. Docs: DR-040 ->
built, session log, CLAUDE.md cross-region tag-reaudit rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
13 KiB
C#
253 lines
13 KiB
C#
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="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
|
|
{
|
|
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
|
{
|
|
var world = new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<CyclePhaseSystem>());
|
|
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 Entity MakeCycle(EntityManager em, byte phase, int defendStartWave)
|
|
{
|
|
var e = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
|
|
em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 });
|
|
em.SetComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave });
|
|
return e;
|
|
}
|
|
|
|
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, Phase = phase, RemainingToSpawn = remainingToSpawn });
|
|
return e;
|
|
}
|
|
|
|
[Test]
|
|
public void Calm_Holds_When_No_PendingSiege()
|
|
{
|
|
var (world, group) = MakeWorld("CalmHolds", serverTick: 200);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
|
|
AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0);
|
|
|
|
group.Update();
|
|
|
|
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 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 Siege_Exits_To_Calm_On_DefendCleared_And_Charges_Goal_Once()
|
|
{
|
|
var (world, group) = MakeWorld("SiegeClears", 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 });
|
|
// Wave advanced past the captured start, fully spawned, no Husks alive (none created).
|
|
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0);
|
|
|
|
group.Update();
|
|
|
|
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 siege survived (single writer).");
|
|
}
|
|
}
|
|
|
|
[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("WaveSync", serverTick: 200);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
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 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);
|
|
// two live BASE husks the team failed to clear (RegionTag defaults to Region 0 = Base)
|
|
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
|
|
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 Base_Overrun_Disperses_Base_Husks_But_Spares_Expedition_Husks()
|
|
{
|
|
// Slice 3 regression: a BASE Core breach must NOT wipe an in-progress EXPEDITION wave (both share
|
|
// EnemyTag but live in different regions). A region-blind cull would also spuriously trip the zone
|
|
// director's aliveZone==0 clear/reward edge on the player's return.
|
|
var (world, group) = MakeWorld("BaseOverrunSparesExpedition", 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), typeof(RegionTag)); // BASE husk (RegionTag defaults to Region 0 = Base)
|
|
var exp = em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
em.SetComponentData(exp, new RegionTag { Region = RegionId.Expedition }); // a husk out on the expedition
|
|
|
|
group.Update();
|
|
|
|
Assert.IsTrue(em.Exists(exp), "the expedition husk survives a base Core breach.");
|
|
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
|
|
Assert.AreEqual(1, huskQ.CalculateEntityCount(),
|
|
"only the BASE husk is dispersed by the breach; the in-progress expedition wave is untouched.");
|
|
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(exp).Region,
|
|
"the survivor is the expedition-region husk.");
|
|
}
|
|
}
|
|
|
|
[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.");
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|