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>
This commit is contained in:
2026-06-21 22:58:26 -07:00
parent cf45ec82ae
commit 3109b86d71
33 changed files with 1044 additions and 161 deletions
@@ -44,6 +44,7 @@ namespace ProjectM.Tests
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponent<PlayerTag>(e);
em.AddBuffer<DamageEvent>(e);
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
return e;
}
@@ -59,6 +60,7 @@ namespace ProjectM.Tests
em.AddComponent<IsLunging>(e);
em.SetComponentEnabled<IsLunging>(e, false); // baked DISABLED on the real Charger (spawns not-lunging)
em.AddComponent<EnemyTag>(e);
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
return e;
}
@@ -173,8 +173,9 @@ namespace ProjectM.Tests
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)); // two live husks the team failed to clear
em.CreateEntity(typeof(EnemyTag));
// 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();
@@ -193,6 +194,38 @@ namespace ProjectM.Tests
}
}
[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()
{
@@ -184,8 +184,8 @@ namespace ProjectM.Tests
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 3);
em.CreateEntity(typeof(EnemyTag));
em.CreateEntity(typeof(EnemyTag));
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
group.Update();
@@ -216,8 +216,8 @@ namespace ProjectM.Tests
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0);
em.CreateEntity(typeof(EnemyTag));
em.CreateEntity(typeof(EnemyTag));
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
group.Update();
@@ -275,7 +275,7 @@ namespace ProjectM.Tests
var w = em.CreateEntity(typeof(WaveState));
em.SetComponentData(w, new WaveState { RemainingToSpawn = 5, Phase = WavePhase.Spawning });
for (int i = 0; i < 3; i++)
em.CreateEntity(typeof(EnemyTag));
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); // base husks (RegionTag defaults to Base)
group.Update();
@@ -170,5 +170,65 @@ namespace ProjectM.Tests
Assert.AreEqual(-1, idx);
}
// ---- Region-aware PickWeightedNearest (Slice 3: per-region target filter, DR-040 BLOCKER 1) ----
[Test]
public void PickWeightedNearest_Region_BaseHusk_PicksBasePlayer_IgnoringCloserExpedition()
{
using var players = new NativeList<float3>(Allocator.Temp);
using var pRegions = new NativeList<byte>(Allocator.Temp);
using var structs = new NativeList<float3>(Allocator.Temp);
using var sRegions = new NativeList<byte>(Allocator.Temp);
players.Add(new float3(10, 0, 0)); pRegions.Add(RegionId.Base); // base player, far
players.Add(new float3(3, 0, 0)); pRegions.Add(RegionId.Expedition); // expedition player, closer
EnemyAIMath.PickWeightedNearest(float3.zero, players, pRegions, structs, sRegions,
RegionId.Base, 0.7f, out bool isStruct, out int idx);
Assert.IsFalse(isStruct);
Assert.AreEqual(0, idx, "a base Husk targets the base player (idx 0), never the closer expedition player across the gap");
}
[Test]
public void PickWeightedNearest_Region_ExpeditionHusk_PicksExpeditionPlayer()
{
using var players = new NativeList<float3>(Allocator.Temp);
using var pRegions = new NativeList<byte>(Allocator.Temp);
using var structs = new NativeList<float3>(Allocator.Temp);
using var sRegions = new NativeList<byte>(Allocator.Temp);
players.Add(new float3(10, 0, 0)); pRegions.Add(RegionId.Base);
players.Add(new float3(3, 0, 0)); pRegions.Add(RegionId.Expedition);
EnemyAIMath.PickWeightedNearest(float3.zero, players, pRegions, structs, sRegions,
RegionId.Expedition, 0.7f, out bool isStruct, out int idx);
Assert.IsFalse(isStruct);
Assert.AreEqual(1, idx, "an expedition Husk targets the expedition player (idx 1)");
}
[Test]
public void PickWeightedNearest_Region_ExpeditionHusk_NoExpeditionTarget_ReturnsMinusOne()
{
using var players = new NativeList<float3>(Allocator.Temp);
using var pRegions = new NativeList<byte>(Allocator.Temp);
using var structs = new NativeList<float3>(Allocator.Temp);
using var sRegions = new NativeList<byte>(Allocator.Temp);
players.Add(new float3(3, 0, 0)); pRegions.Add(RegionId.Base); // only a base player
structs.Add(new float3(5, 0, 0)); sRegions.Add(RegionId.Base); // only a base structure
EnemyAIMath.PickWeightedNearest(float3.zero, players, pRegions, structs, sRegions,
RegionId.Expedition, 0.7f, out bool isStruct, out int idx);
Assert.AreEqual(-1, idx, "an expedition Husk with only base targets finds nothing in-region -> idles (no Core fallback)");
}
[Test]
public void PickWeightedNearest_Region_BaseHusk_StillRazesBaseStructures()
{
using var players = new NativeList<float3>(Allocator.Temp);
using var pRegions = new NativeList<byte>(Allocator.Temp);
using var structs = new NativeList<float3>(Allocator.Temp);
using var sRegions = new NativeList<byte>(Allocator.Temp);
structs.Add(new float3(0, 0, 12)); sRegions.Add(RegionId.Base);
EnemyAIMath.PickWeightedNearest(float3.zero, players, pRegions, structs, sRegions,
RegionId.Base, 0.7f, out bool isStruct, out int idx);
Assert.IsTrue(isStruct, "a base Husk still targets a base structure (the fortress-aggro path is unchanged in-region)");
Assert.AreEqual(0, idx);
}
}
}
@@ -0,0 +1,109 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
/// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4). A returning player banks flat Ore to the shared ledger
/// IFF this epoch's expedition wave was actually cleared and not yet rewarded — and never twice for the same
/// epoch (the co-op same-tick / gate-re-entry de-dup).
/// </summary>
public class ExpeditionGateRewardTests
{
static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name,
int epoch, byte clearedThisEpoch, int lastRewardedEpoch)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionGateSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
// CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state.
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), typeof(ThreatState));
em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm });
em.SetComponentData(cyc, new CycleRuntime
{
ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch,
});
em.AddBuffer<StorageEntry>(cyc);
// Zone-enemy director singleton (only RewardOre matters to the reward fold).
var dir = em.CreateEntity(typeof(ZoneEnemyDirector));
em.SetComponentData(dir, new ZoneEnemyDirector { RewardOre = 25 });
// A gate Expedition->Base sitting at the expedition origin.
var gate = em.CreateEntity(typeof(ExpeditionGate), typeof(LocalTransform));
em.SetComponentData(gate, new ExpeditionGate
{
FromRegion = RegionId.Expedition, ToRegion = RegionId.Base, Radius = 3f, ArrivalPos = new float3(0, 1, 0),
});
em.SetComponentData(gate, LocalTransform.FromPosition(new float3(1000, 1, 0)));
return (world, group, cyc);
}
static Entity MakeExpeditionPlayerAtGate(EntityManager em)
{
var e = em.CreateEntity();
em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition });
em.AddComponentData(e, LocalTransform.FromPosition(new float3(1000, 1, 0)));
em.AddComponent<PlayerTag>(e);
return e;
}
static int OreInLedger(EntityManager em, Entity cyc)
{
var buf = em.GetBuffer<StorageEntry>(cyc);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == (ushort)ResourceId.Ore) return buf[i].Count;
return 0;
}
[Test]
public void Cleared_Return_Banks_Ore_Once()
{
var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
using (world)
{
var em = world.EntityManager;
var player = MakeExpeditionPlayerAtGate(em);
group.Update(); // player walks the gate back to base -> reward
Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger");
Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(cyc).LastRewardedEpoch, "the epoch is marked rewarded");
// Force a second same-epoch return (the player is back in the expedition at the gate).
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.FromPosition(new float3(1000, 1, 0)));
group.Update(); // returns again, but the epoch was already rewarded
Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)");
}
}
[Test]
public void Uncleared_Return_Banks_Nothing()
{
var (world, group, cyc) = MakeWorld("GateRewardUncleared", epoch: 1, clearedThisEpoch: 0, lastRewardedEpoch: 0);
using (world)
{
var em = world.EntityManager;
MakeExpeditionPlayerAtGate(em);
group.Update();
Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 825422a92eb4b1d4eb4cdafc57884a01
@@ -83,6 +83,7 @@ namespace ProjectM.Tests
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponent<PlayerTag>(e);
em.AddBuffer<DamageEvent>(e);
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
return e;
}
@@ -95,6 +96,7 @@ namespace ProjectM.Tests
em.AddComponentData(e, kb);
em.AddComponentData(e, new AttackWindup());
em.AddComponent<EnemyTag>(e);
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
return e;
}
@@ -44,6 +44,7 @@ namespace ProjectM.Tests
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponent<PlayerTag>(e);
em.AddBuffer<DamageEvent>(e);
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
return e;
}
@@ -56,6 +57,7 @@ namespace ProjectM.Tests
em.AddComponentData(e, new KnockbackState());
em.AddComponentData(e, new AttackWindup());
em.AddComponent<EnemyTag>(e);
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
return e;
}
@@ -138,7 +138,10 @@ namespace ProjectM.Tests
// Three Husks still on the field with no one to clear them.
for (int i = 0; i < 3; i++)
em.CreateEntity(typeof(EnemyTag));
{
var h = em.CreateEntity(typeof(EnemyTag));
em.AddComponentData(h, new RegionTag { Region = RegionId.Base });
}
group.Update();
@@ -0,0 +1,193 @@
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");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f307978f01b668b42ba10eea2029091b
@@ -0,0 +1,60 @@
using NUnit.Framework;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-function tests for <see cref="ZoneEnemyMath"/> (no ECS world): the deterministic, save-reproducible
/// expedition-wave composition. Pins the lower bound, the per-epoch ramp, and the grunt-heavy -> charger-heavy
/// shift (grunt count held fixed; the per-epoch growth is all chargers).
/// </summary>
public class ZoneEnemyMathTests
{
[Test]
public void WaveSize_LowerBoundedAtOne()
{
Assert.AreEqual(1, ZoneEnemyMath.WaveSize(0, 0, 0), "an occupied expedition always has at least one enemy");
Assert.AreEqual(1, ZoneEnemyMath.WaveSize(-5, 0, 0), "epoch is floored at 1 internally");
}
[Test]
public void WaveSize_BaselinePlusOnePerEpoch()
{
Assert.AreEqual(5, ZoneEnemyMath.WaveSize(1, 4, 1), "epoch 1: 4 grunts + 1 charger");
Assert.AreEqual(7, ZoneEnemyMath.WaveSize(3, 4, 1), "epoch 3: baseline 5 + (3-1) ramp");
}
[Test]
public void IsChargerSlot_Epoch1_GruntsFirst_OneChargerLast()
{
// epoch 1, G=4 C=1 -> size 5, only the last slot is a charger.
Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 0, 4, 1));
Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 3, 4, 1));
Assert.IsTrue(ZoneEnemyMath.IsChargerSlot(1, 4, 4, 1));
}
[Test]
public void Composition_GruntCountFixed_ChargerShareGrowsWithEpoch()
{
AssertComposition(epoch: 1, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 1);
AssertComposition(epoch: 5, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 5);
}
static void AssertComposition(int epoch, int grunts, int chargers, int expectGrunts, int expectChargers)
{
int size = ZoneEnemyMath.WaveSize(epoch, grunts, chargers);
int g = 0, c = 0;
for (int slot = 0; slot < size; slot++)
if (ZoneEnemyMath.IsChargerSlot(epoch, slot, grunts, chargers)) c++; else g++;
Assert.AreEqual(expectGrunts, g, $"grunt count at epoch {epoch}");
Assert.AreEqual(expectChargers, c, $"charger count at epoch {epoch}");
}
[Test]
public void IsChargerSlot_Deterministic()
{
for (int slot = 0; slot < 9; slot++)
Assert.AreEqual(ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1), ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e61ab20e496e331449ba6e26440ad000