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:
@@ -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
|
||||
Reference in New Issue
Block a user