Slice Combat Depth (MC-2): enemy-variety server spine — Spitter, Swarmer, 4-type mix (DR-041)

Adds the server-authoritative mechanics for three new enemy archetypes on top of
the Grunt/Charger base, plus the weighted wave-composition that introduces them:

- Spitter: a ranged Husk variant (SpitterState) that holds a preferred range-band
  (advance/retreat/hold via EnemyAIMath.BandVelocity) and fires a telegraphed,
  dodgeable EnemyProjectile. New server EnemyProjectileMoveSystem (integrate +
  store LastStep) + EnemyProjectileDamageSystem (region-filtered swept hit-test
  rebuilt from LastStep — DR-018 anti-tunnelling; players use HitRadius, structures
  a const radius; at-most-once destroy). Concurrent-spit soft cap, soft-fail retry.
- Swarmer: marker tag + deterministic cluster spawn (1 slot = 1 pack;
  EnemyAIMath.ClusterOffset), MaxAlive counts ENTITIES so a pack defers if it
  won't fit.
- 4-type weighted mix: MixBands -> ZoneEnemyMath.WaveSlots/KindForSlot/
  PackSizeForSlot drives both the expedition director and (fork-4a) the base siege,
  with a mandatory MaxAlive cap. Legacy WaveSize/IsChargerSlot kept + parity-tested.
- Discriminator stays component-presence (no enum in Bursted systems): query-
  partition guards keep each enemy moved by exactly one EnemyAISystem pass
  (sole-Position-writer). EnemyTelegraph.IsCharger -> Kind byte for the client cue.

New authoring (Spitter/Swarmer/EnemyProjectile) + expanded director authorings with
tunable mix/cluster defaults. 13 new EditMode tests (mix composition + legacy parity,
band/cluster math, projectile move + cross-region + swept anti-tunnelling regressions);
full suite green before commit.

Dormant until the prefab/subscene wiring lands (next): the new systems guard on
TryGetSingleton/RequireForUpdate, so with no prefabs wired the new types stay inert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 20:06:56 -07:00
parent 3109b86d71
commit 56cf60cce3
34 changed files with 1204 additions and 64 deletions
@@ -0,0 +1,132 @@
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 tests for the hostile Spitter projectile systems (server-only, plain SimulationSystemGroup):
/// EnemyProjectileMoveSystem integrates + writes LastStep; EnemyProjectileDamageSystem swept-hit-tests players +
/// structures, REGION-FILTERED, appending a DamageEvent + destroying the spit at-most-once. Covers the two
/// review-mandated regressions: swept anti-TUNNELLING (a per-tick step bigger than the target radius still
/// registers) and the cross-region damage guard (an Expedition spit must not damage a Base target on its path).
/// </summary>
public class EnemyProjectileTests
{
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) MoveWorld()
{
var w = new World("EnemyProjMove");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyProjectileMoveSystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f));
return (w, g);
}
static (World, SimulationSystemGroup) DamageWorld()
{
var w = new World("EnemyProjDmg");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyProjectileDamageSystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f));
SetTick(w, 200);
return (w, g);
}
static Entity MakeSpit(EntityManager em, float3 pos, float2 dir, float speed, float range, byte region, float lastStep = 0f, float damage = 10f)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyProjectile { Direction = dir, Speed = speed, Damage = damage, Range = range, DistanceTravelled = 0f, LastStep = lastStep, Region = region });
return e;
}
static Entity MakePlayerTarget(EntityManager em, float3 pos, byte region, float radius = 0.6f)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponentData(e, new HitRadius { Value = radius });
em.AddComponentData(e, new RegionTag { Region = region });
em.AddBuffer<DamageEvent>(e);
em.AddComponent<PlayerTag>(e);
return e;
}
[Test]
public void Move_IntegratesAndStoresLastStep()
{
var (w, g) = MoveWorld();
using (w)
{
var em = w.EntityManager;
var spit = MakeSpit(em, new float3(0, 1, 0), new float2(1, 0), 10f, 5f, RegionId.Base);
g.Update(); // dt 0.1 * speed 10 = step 1
var p = em.GetComponentData<EnemyProjectile>(spit);
Assert.AreEqual(1f, p.LastStep, 1e-4f, "LastStep = Speed*dt (for the swept segment)");
Assert.AreEqual(1f, p.DistanceTravelled, 1e-4f);
Assert.AreEqual(1f, em.GetComponentData<LocalTransform>(spit).Position.x, 1e-4f, "moved along +X");
}
}
[Test]
public void Damage_HitsSameRegionPlayer_DestroysAtMostOnce()
{
var (w, g) = DamageWorld();
using (w)
{
var em = w.EntityManager;
var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base);
var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Base, lastStep: 1f);
g.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length, "same-region player takes the hit");
Assert.IsFalse(em.Exists(spit), "the spit is consumed on hit");
}
}
[Test]
public void Damage_RegionFilter_ExpeditionSpitSparesBasePlayer()
{
var (w, g) = DamageWorld();
using (w)
{
var em = w.EntityManager;
var basePlayer = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base);
var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Expedition, lastStep: 1f);
g.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(basePlayer).Length, "cross-region spit must NOT damage an off-region player");
Assert.IsTrue(em.Exists(spit), "and it is not consumed by an off-region target");
}
}
[Test]
public void Damage_SweptSegment_NoTunnelThroughSmallTarget()
{
var (w, g) = DamageWorld();
using (w)
{
var em = w.EntityManager;
// target radius 0.5 at x=5; spit now at x=10 but stepped 8 this tick (start x=2) -> segment [2..10] crosses x=5.
var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base, radius: 0.5f);
var spit = MakeSpit(em, new float3(10, 1, 0), new float2(1, 0), 80f, 50f, RegionId.Base, lastStep: 8f);
g.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length,
"swept segment hits even when the per-tick step exceeds the target radius (no tunnelling)");
}
}
}
}