Files
Project-M/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs
kronic 3109b86d71 Slice 3: Expedition Combat Spine — epoch-seeded zone waves (DR-040)
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>
2026-06-21 22:58:26 -07:00

194 lines
8.9 KiB
C#

using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Collections;
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 server-only <see cref="ZoneEnemyDirectorSystem"/>. A bare world is
/// seeded with NetworkTime, a CycleDirector entity (CycleState + CycleRuntime) and a zone-enemy director
/// (ZoneEnemyDirector + ZoneEnemyState + a Prefab-tagged enemy in the ZoneEnemyPrefab buffer). Pins: it spawns
/// only while a player is OUT in the expedition AND the base is Calm; tags spawns RegionTag{Expedition} +
/// ZoneEnemyTag at the deterministic ring origin (Scale preserved); and marks CycleRuntime.ClearedThisEpoch on a
/// real clear.
/// </summary>
public class ZoneEnemyDirectorSystemTests
{
static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name, uint serverTick, byte phase, int epoch)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ZoneEnemyDirectorSystem>());
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) });
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
em.SetComponentData(cyc, new CycleState { Phase = phase });
em.SetComponentData(cyc, new CycleRuntime { ExpeditionEpoch = epoch });
return (world, group, cyc);
}
static Entity MakeZonePrefab(EntityManager em)
{
var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag));
em.SetComponentData(e, LocalTransform.Identity); // Scale = 1 so WithPosition keeps it
em.AddComponent<Prefab>(e);
return e;
}
static Entity MakeDirector(EntityManager em, Entity grunt, Entity charger,
int maxAlive, int gruntsPerWave, int chargersPerWave,
uint nextSpawnTick, int remainingToSpawn, int seededEpoch, uint spawnCounter)
{
var e = em.CreateEntity(typeof(ZoneEnemyDirector), typeof(ZoneEnemyState));
em.SetComponentData(e, new ZoneEnemyDirector
{
MaxAlive = maxAlive, RingRadius = 14f, RingSlots = 10, SpawnIntervalTicks = 10,
GruntsPerWave = gruntsPerWave, ChargersPerWave = chargersPerWave, RewardOre = 25,
});
em.SetComponentData(e, new ZoneEnemyState
{
SpawnCounter = spawnCounter, RemainingToSpawn = remainingToSpawn,
NextSpawnTick = nextSpawnTick, SeededEpoch = seededEpoch,
});
var buf = em.AddBuffer<ZoneEnemyPrefab>(e);
buf.Add(new ZoneEnemyPrefab { Prefab = grunt });
buf.Add(new ZoneEnemyPrefab { Prefab = charger });
return e;
}
static Entity MakeExpeditionPlayer(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition });
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponent<PlayerTag>(e);
return e;
}
static int ZoneCount(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag));
return q.CalculateEntityCount();
}
[Test]
public void Spawns_Expedition_Tagged_Enemy_When_Occupied_And_Calm()
{
var (world, group, _) = MakeWorld("ZoneSpawn", serverTick: 100, phase: CyclePhase.Calm, epoch: 1);
using (world)
{
var em = world.EntityManager;
var grunt = MakeZonePrefab(em);
var charger = MakeZonePrefab(em);
var dir = MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0,
nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0);
MakeExpeditionPlayer(em, new float3(1000, 1, 0));
group.Update();
Assert.AreEqual(1, ZoneCount(em), "one zone enemy spawns this tick");
using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(RegionTag));
var arr = q.ToComponentDataArray<RegionTag>(Allocator.Temp);
Assert.AreEqual(RegionId.Expedition, arr[0].Region, "the spawn is tagged RegionTag{Expedition}");
arr.Dispose();
var zs = em.GetComponentData<ZoneEnemyState>(dir);
Assert.AreEqual(1u, zs.SpawnCounter, "spawn counter advanced");
Assert.AreEqual(1, zs.RemainingToSpawn, "wave size 2 seeded, 1 spawned -> 1 remaining");
}
}
[Test]
public void Spawn_Lands_On_Expedition_Ring_Origin_With_Scale_Preserved()
{
var (world, group, _) = MakeWorld("ZoneRing", serverTick: 100, phase: CyclePhase.Calm, epoch: 1);
using (world)
{
var em = world.EntityManager;
var grunt = MakeZonePrefab(em);
var charger = MakeZonePrefab(em);
MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0,
nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0);
MakeExpeditionPlayer(em, new float3(1000, 1, 0));
group.Update();
using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(LocalTransform));
var arr = q.ToComponentDataArray<LocalTransform>(Allocator.Temp);
// origin = base(0,1,0) + (1000,0,0); ring slot 0 of a 10-slot radius-14 ring -> +X.
Assert.AreEqual(1014f, arr[0].Position.x, 1e-2f, "deterministic ring slot 0 at the expedition origin (+radius on X)");
Assert.AreEqual(1f, arr[0].Scale, 1e-3f, "baked Scale preserved (WithPosition, not FromPosition)");
arr.Dispose();
}
}
[Test]
public void Does_Not_Spawn_When_No_Expedition_Player()
{
var (world, group, _) = MakeWorld("ZoneEmpty", serverTick: 100, phase: CyclePhase.Calm, epoch: 1);
using (world)
{
var em = world.EntityManager;
var grunt = MakeZonePrefab(em);
var charger = MakeZonePrefab(em);
MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0,
nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0);
// no expedition player out there
group.Update();
Assert.AreEqual(0, ZoneCount(em), "nobody out in the expedition -> nothing spawns");
}
}
[Test]
public void Does_Not_Spawn_During_Base_Siege()
{
var (world, group, _) = MakeWorld("ZoneSiege", serverTick: 100, phase: CyclePhase.Siege, epoch: 1);
using (world)
{
var em = world.EntityManager;
var grunt = MakeZonePrefab(em);
var charger = MakeZonePrefab(em);
MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0,
nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0);
MakeExpeditionPlayer(em, new float3(1000, 1, 0));
group.Update();
Assert.AreEqual(0, ZoneCount(em), "the expedition wave pauses while the base is under siege (Calm-only spawning)");
}
}
[Test]
public void Cleared_Wave_Marks_ClearedThisEpoch()
{
var (world, group, cyc) = MakeWorld("ZoneCleared", serverTick: 100, phase: CyclePhase.Calm, epoch: 1);
using (world)
{
var em = world.EntityManager;
var grunt = MakeZonePrefab(em);
var charger = MakeZonePrefab(em);
// already seeded this epoch + fully spawned (RemainingToSpawn 0) + no live zone enemies.
MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0,
nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 1, spawnCounter: 2);
MakeExpeditionPlayer(em, new float3(1000, 1, 0));
group.Update();
Assert.AreEqual((byte)1, em.GetComponentData<CycleRuntime>(cyc).ClearedThisEpoch,
"wave fully spawned + no live zone enemies -> a real clear is marked");
}
}
}
}