Files
Project-M/Assets/_Project/Tests/EditMode/SpitterBrainTests.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

206 lines
11 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 EnemyAISystem SPITTER pass (server-only, plain SimulationSystemGroup). Covers the
/// headline ranged mechanic end-to-end: an in-band, ready Spitter commits a telegraphed wind-up then on elapse
/// spawns a spit carrying the FIRING Spitter's Region (fired from EXPEDITION so a dropped Region copy — which would
/// leave the prefab default 0 = Base — fails the assertion), aimed at the target. The HOLD-RANGE gate (DR-041) is
/// pinned by negative tests: a Spitter ADVANCING from out of band does NOT telegraph; a cornered Spitter fires
/// point-blank. The discriminator partition (no double-move) is asserted DIRECTLY (the wind-up value alone can't
/// prove it — the Spitter pass runs last and overwrites it). Soft-fail over the concurrent cap = short retry, no
/// full-cooldown burn. Plain-Entities world, faked NetworkTime + a SpitterProjectilePrefab singleton; the prefab
/// entity is Prefab-tagged so it is excluded from the live-spit count and cloned (minus the tag) on Instantiate.
/// </summary>
public class SpitterBrainTests
{
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) AiWorld(uint tick)
{
var w = new World("SpitterBrain");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyAISystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetTick(w, tick);
return (w, g);
}
static Entity MakeSpitPrefab(EntityManager em, float range = 16f)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(float3.zero));
em.AddComponentData(e, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Damage = 0f, Range = range, Region = 0 });
em.AddComponent<Prefab>(e); // excluded from the live-spit query; stripped on Instantiate
return e;
}
static void SetSpitSingleton(EntityManager em, Entity prefab, int maxLive)
{
var s = em.CreateEntity(typeof(SpitterProjectilePrefab));
em.SetComponentData(s, new SpitterProjectilePrefab { Prefab = prefab, MaxLiveProjectiles = maxLive });
}
static Entity MakeSpitter(EntityManager em, float3 pos, byte region, int windupTicks = 1, int cooldown = 60)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponent<EnemyTag>(e);
em.AddComponentData(e, new EnemyStats { MoveSpeed = 4f, AttackRange = 1.5f, AttackDamage = 8f, AttackCooldownTicks = cooldown });
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0u });
em.AddComponentData(e, new KnockbackState { Dir = default, Speed = 0f, UntilTick = 0u });
em.AddComponentData(e, new AttackWindup { WindUpUntilTick = 0u });
em.AddComponentData(e, new SpitterState { PreferredRange = 9f, RangeTolerance = 1.5f, ProjectileSpeed = 11f, CorneredRange = 3f, WindupTicks = windupTicks, NextShotTick = 0u });
em.AddComponentData(e, new RegionTag { Region = region });
return e;
}
static void MakePlayer(EntityManager em, float3 pos, byte region)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponent<PlayerTag>(e);
}
static int CountSpits(EntityManager em)
{
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
return q.CalculateEntityCount();
}
[Test]
public void Spitter_InBand_CommitsThenFires_SpitCarriesFiringRegion()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
var prefab = MakeSpitPrefab(em, range: 16f);
SetSpitSingleton(em, prefab, maxLive: 24);
// Fire from EXPEDITION (!=0): a dropped Region copy would leave 0 (Base) and fail the region assert.
MakePlayer(em, new float3(0, 1, 0), RegionId.Expedition);
var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Expedition, windupTicks: 1); // distance == PreferredRange -> in-band
g.Update(); // tick 200: in-band + ready -> commit the telegraph wind-up to 201
Assert.AreEqual(TickUtil.NonZero(201u), em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick,
"an in-band, ready Spitter commits a wind-up of SpitterState.WindupTicks (the partition itself is asserted in Spitter_IsExcludedFromGruntAndChargerPasses)");
Assert.AreEqual(0, CountSpits(em), "no spit yet — still telegraphing the dodge window");
SetTick(w, 202); // the wind-up tick (201) has now elapsed
g.Update();
Assert.AreEqual(1, CountSpits(em), "the spit fires when the wind-up elapses in-band");
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
var spit = q.GetSingleton<EnemyProjectile>();
Assert.AreEqual(RegionId.Expedition, spit.Region, "the spit carries the FIRING Spitter's region (Expedition!=0 -> a dropped copy fails this)");
Assert.Less(spit.Direction.x, 0f, "aimed back toward the player at the origin");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick, "the wind-up is cleared after firing");
}
}
[Test]
public void Spitter_OutOfBand_DoesNotCommitWindup()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
MakePlayer(em, new float3(0, 1, 0), RegionId.Base);
var spitter = MakeSpitter(em, new float3(40, 1, 0), RegionId.Base, windupTicks: 1); // dist 40 >> PreferredRange+tol -> advancing
g.Update();
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick,
"a Spitter ADVANCING from out of band must NOT telegraph/fire (the hold-range gate, DR-041)");
}
}
[Test]
public void Spitter_Cornered_CommitsWindupPointBlank()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
MakePlayer(em, new float3(0, 1, 0), RegionId.Base);
var spitter = MakeSpitter(em, new float3(2, 1, 0), RegionId.Base, windupTicks: 5); // dist 2 < CorneredRange 3 -> point-blank
g.Update();
Assert.AreNotEqual(0u, em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick,
"a cornered Spitter (target inside CorneredRange) fires point-blank rather than holding fire");
}
}
[Test]
public void Spitter_IsExcludedFromGruntAndChargerPasses()
{
var w = new World("SpitterRouting");
using (w)
{
var em = w.EntityManager;
MakeSpitter(em, new float3(9, 1, 0), RegionId.Base);
// The three EnemyAISystem pass partitions, asserted directly so a regression in any WithNone guard is caught.
using var gruntQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>() },
None = new[] { ComponentType.ReadOnly<LungeState>(), ComponentType.ReadOnly<SpitterState>() },
});
using var chargerQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>() },
None = new[] { ComponentType.ReadOnly<SpitterState>() },
});
using var spitterQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<SpitterState>() },
None = new[] { ComponentType.ReadOnly<LungeState>() },
});
Assert.AreEqual(0, gruntQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Grunt pass (WithNone<LungeState,SpitterState>)");
Assert.AreEqual(0, chargerQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Charger pass (WithNone<SpitterState>)");
Assert.AreEqual(1, spitterQ.CalculateEntityCount(), "a Spitter IS visited by exactly the Spitter pass");
}
}
[Test]
public void Spitter_OverSoftCap_SkipsFire_ShortRetryNoCooldownBurn()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
var prefab = MakeSpitPrefab(em);
SetSpitSingleton(em, prefab, maxLive: 2);
// pre-fill the live-spit pool to the cap (no Prefab tag -> counted by the soft-cap query)
for (int i = 0; i < 2; i++)
{
var s = em.CreateEntity();
em.AddComponentData(s, LocalTransform.FromPosition(new float3(i, 1, 0)));
em.AddComponentData(s, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Range = 16f, Region = RegionId.Base });
}
MakePlayer(em, new float3(0, 1, 0), RegionId.Base);
var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Base, windupTicks: 1, cooldown: 60);
g.Update(); // tick 200: in-band -> commit the wind-up to 201
SetTick(w, 202); // elapsed
g.Update(); // at the cap -> soft-fail
Assert.AreEqual(2, CountSpits(em), "at the concurrent cap the Spitter does NOT spawn another spit");
Assert.AreEqual(TickUtil.NonZero(210u), em.GetComponentData<SpitterState>(spitter).NextShotTick,
"soft-fail schedules a short retry (now+8 = 210), NOT a full cooldown (now+60 = 262)");
}
}
}
}