Files
Project-M/Assets/_Project/Tests/EditMode/SwarmerClusterSpawnTests.cs
T
kronic e32dadbc66 Slice Combat Depth (MC-3 + wiring + review fixes): Spitter aim-line + player-hit punch, rigged enemies, in-band gate (DR-041)
Completes the Combat Depth slice on top of the MC-2 server spine (56cf60cce):

MC-3 impact juice (client, observe-only):
- 7 FeelConfig fields + ResetDefaults; magnitude-scaled player-dealt-hit camera
  PunchFov on the enemy-Health-decrease edge (camera-only hit-stop, never timeScale).
- Spitter Kind==2 aim-LANE telegraph (BuildLaneMesh) — reads baked SpitterState
  client-side, falls back to a fixed length. True freeze + material flash deferred.

Content / wiring:
- SpitterProjectilePrefabAuthoring (the SpitterProjectilePrefab singleton).
- Both directors rebuilt to a 4-entry KIND-INDEXED roster [Grunt,Charger,Spitter,
  Swarmer] + mix/MaxAlive config + the SpitterProjectileConfig singleton in the subscene.
- Real rigged models: EnemySpitter (re-skinned Kaiju, ranged poker) + EnemySwarmerUndead
  (Undead-Werewolf, fast/low-HP); grunt/charger keep Werewolf/ChargerMuscle. EnemySpit =
  ownerless interpolated ghost (no Health, no collider).

Post-impl adversarial review fixes (wf_febdcfdb-665):
- [MED] in-band fire gate: the Spitter committed its telegraph from ANY range (fired while
  advancing from far). Now commits only when sInBand || sCornered (gives CorneredRange a
  real read site) — a Spitter out-of-band holds fire and repositions.
- [LOW] EnemyProjectileDamageSystem early-returns on !ServerTick.IsValid (sibling parity).
- [LOW] EnemyAuthoring bake-time guard: errors if a prefab composes both Charger+Spitter
  (would match zero AI passes -> never move).
- [LOW] tests: Spitter brain fires from Expedition (kills the Base==0 region false-green);
  a direct partition-exclusion test replaces the order-masked claim; added out-of-band +
  cornered negative tests.

388/388 EditMode green + two Play smokes (clean boot, fire, swept-hit, region, server==
client; rigged Kaiju spitter bakes + fires with zero console errors). Accepted as-is
(documented in DR-041): global spit soft-cap, co-op punch attribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:08:59 -07:00

115 lines
5.7 KiB
C#

using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// MC-2 system tests for the SWARMER cluster spawn in the base-siege WaveSystem (fork 4a). A swarmer composition
/// slot must instantiate a whole PACK in one tick (EnemyAIMath.ClusterOffset) while consuming exactly ONE wave
/// SLOT, and MaxAlive must count ENTITIES — a pack that won't fit is DEFERRED (slot kept) rather than partially
/// spawned (the review-flagged slot-vs-entity accounting). Plain-Entities world, server WaveSystem registered
/// directly, faked NetworkTime; the 4-entry [Grunt,Charger,Spitter,Swarmer] roster is Prefab-tagged so the
/// instances (and only the instances) count as live EnemyTag ghosts.
/// </summary>
public class SwarmerClusterSpawnTests
{
static void SetTick(World w, uint tick)
{
var em = w.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World, SimulationSystemGroup) WaveWorld(uint tick)
{
var w = new World("SwarmerCluster");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<WaveSystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetTick(w, tick);
return (w, g);
}
// Director with a swarmer-only band so slot 0 is unambiguously a swarmer pack. The 4-entry roster aliases
// dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack.
static Entity MakeDirector(EntityManager em, int swarmerSlotBase, int packSize, int maxAlive)
{
// Create the 4 prefab entities FIRST: every em.CreateEntity is a structural change, so a DynamicBuffer
// handle grabbed before them would be invalidated (the bug this ordering avoids). The roster aliases
// dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack.
var prefabs = new Entity[4];
for (int i = 0; i < 4; i++)
{
var p = em.CreateEntity();
em.AddComponentData(p, LocalTransform.FromPosition(float3.zero));
em.AddComponent<EnemyTag>(p);
em.AddComponent<Prefab>(p);
prefabs[i] = p;
}
var dir = em.CreateEntity();
em.AddComponentData(dir, new WaveDirector
{
RingRadius = 10f, RingSlots = 8, BaseCount = 0, CountPerWave = 0,
SpawnIntervalTicks = 1, LullTicks = 1, MaxAlive = maxAlive,
ChargerBase = 0, SpitterBase = 0, SwarmerSlotBase = swarmerSlotBase,
ChargerPerEpoch = 0, SpitterPerEpoch = 0, SwarmerSlotPerEpoch = 0,
SwarmerPackSize = packSize, SwarmerPackPerEpoch = 0, ClusterTightRadius = 2.5f,
});
em.AddComponentData(dir, new WaveState { WaveNumber = 0, Phase = WavePhase.Lull, NextActionTick = 0u, RemainingToSpawn = 0, SpawnCounter = 0 });
// AddBuffer LAST (after every structural change on dir), then populate with no further structural change.
var buf = em.AddBuffer<WaveEnemyPrefab>(dir);
for (int i = 0; i < 4; i++) buf.Add(new WaveEnemyPrefab { Prefab = prefabs[i] });
return dir;
}
static int CountEnemies(EntityManager em)
{
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyTag>());
return q.CalculateEntityCount();
}
[Test]
public void Swarmer_Slot_SpawnsWholePack_ConsumesOneSlot()
{
var (w, g) = WaveWorld(200);
using (w)
{
var em = w.EntityManager;
var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 12);
g.Update(); // Lull -> start wave: RemainingToSpawn = WaveSlots(1) = 1 swarmer slot
Assert.AreEqual(1, em.GetComponentData<WaveState>(dir).RemainingToSpawn, "one swarmer SLOT this wave");
g.Update(); // Spawning -> the pack lands in one tick
Assert.AreEqual(4, CountEnemies(em), "the whole pack spawns in a single tick");
var st = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(1, st.SpawnCounter, "exactly ONE slot consumed for the pack");
Assert.AreEqual(0, st.RemainingToSpawn, "the swarmer slot is done");
}
}
[Test]
public void Swarmer_PackOverMaxAlive_Defers_KeepsSlot()
{
var (w, g) = WaveWorld(200);
using (w)
{
var em = w.EntityManager;
var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 3); // pack(4) > cap(3)
g.Update(); // start wave
g.Update(); // try to spawn -> 0 + 4 > 3 -> defer (don't partially spawn, don't consume the slot)
Assert.AreEqual(0, CountEnemies(em), "a pack that won't fit MaxAlive is NOT partially spawned");
var st = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(0, st.SpawnCounter, "the slot is NOT consumed when deferred");
Assert.AreEqual(1, st.RemainingToSpawn, "the swarmer slot remains pending");
}
}
}
}