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>
194 lines
8.9 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|
|
}
|