Files
Project-M/Assets/_Project/Tests/EditMode/ChargerTests.cs
T
kronic 3109b86d71 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>
2026-06-21 22:58:26 -07:00

193 lines
9.8 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>
/// Plain-Entities EditMode tests for the MC-1 Charger branch in EnemyAISystem. A Husk variant baked with
/// LungeState commits to a fixed-direction lunge on wind-up elapse (UNLIKE the Grunt, it does NOT cancel when
/// the target leaves range — the commit is the punishable tell), deals contact damage if it connects, and
/// staggers (extends EnemyAttackCooldown + clears the lunge + opens a telemetry whiff window) if it overshoots
/// or wall-stops. Knockback cancels an in-flight lunge so EnemyAISystem stays the SOLE Position writer.
/// </summary>
public class ChargerTests
{
static void SetServerTick(World world, uint tick)
{
var em = world.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 world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static Entity MakePlayer(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
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;
}
static Entity MakeCharger(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 12f, AttackCooldownTicks = 36 });
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
em.AddComponentData(e, new KnockbackState());
em.AddComponentData(e, new AttackWindup());
em.AddComponentData(e, new LungeState());
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;
}
[Test]
public void Commit_Fires_Even_When_Target_Left_Range()
{
var (world, group) = MakeWorld("ChargerCommit", 200);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0)); // far out of AttackRange (1.6)
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick
group.Update(); // tick 200
var lunge = em.GetComponentData<LungeState>(charger);
Assert.AreNotEqual(0u, lunge.UntilTick, "Charger commits the lunge even with the target out of range (no cancel-on-leave-range).");
Assert.Greater(lunge.Dir.x, 0.5f, "Lunge direction is locked toward the target at commit (+X).");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(charger).WindUpUntilTick, "The wind-up clears on commit.");
}
}
[Test]
public void Overshoot_Whiff_Staggers_And_Opens_A_Punish_Window()
{
var (world, group) = MakeWorld("ChargerWhiff", 206);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(-10, 1, 0)); // player is behind; the lunge goes +X, never connects
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring lunge
em.CreateEntity(typeof(DevTelemetry)); // so the whiff telemetry increment is observable
group.Update(); // tick 206 > 205 -> lunge timer elapsed without landing -> overshoot whiff
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "A whiffed lunge is cleared.");
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<EnemyAttackCooldown>(charger).NextAttackTick,
"An overshoot whiff extends the attack cooldown by the stagger window (the punish window).");
using var tq = em.CreateEntityQuery(typeof(DevTelemetry));
Assert.AreEqual(1u, tq.GetSingleton<DevTelemetry>().ChargerWhiffWindowsOpened, "A whiff opens one telemetry punish window.");
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<LungeState>(charger).StaggerUntilTick,
"The whiff stamps the scoreable StaggerUntilTick window (ChargerWhiffPunishesLanded source).");
}
}
[Test]
public void Knockback_Cancels_An_InFlight_Lunge()
{
var (world, group) = MakeWorld("ChargerKnockback", 305);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 }); // mid-lunge +X
em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 }); // recoil -X
group.Update(); // tick 305: knockback (until 315) wins
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick,
"Knockback cancels the in-flight lunge (no two-writer contention on Position).");
Assert.Less(em.GetComponentData<LocalTransform>(charger).Position.x, 0f,
"The recoiling Charger moved along its knockback direction (-X), not its lunge direction.");
}
}
[Test]
public void Commit_Enables_IsLunging()
{
var (world, group) = MakeWorld("ChargerIsLungingCommit", 200);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(3, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick -> commit
Assert.IsFalse(em.IsComponentEnabled<IsLunging>(charger), "Charger spawns not-lunging (baked DISABLED).");
group.Update(); // tick 200: commit the lunge
Assert.AreNotEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "Sanity: the lunge committed.");
Assert.IsTrue(em.IsComponentEnabled<IsLunging>(charger),
"The replicated mid-lunge cue is ENABLED while a committed lunge is live (.WithPresent visits the disabled entity to write the bit).");
}
}
[Test]
public void Whiff_Disables_IsLunging()
{
var (world, group) = MakeWorld("ChargerIsLungingWhiff", 206);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(-10, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring
em.SetComponentEnabled<IsLunging>(charger, true); // was mid-lunge
group.Update(); // tick 206 > 205 -> overshoot whiff clears the lunge
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "Sanity: the whiffed lunge cleared.");
Assert.IsFalse(em.IsComponentEnabled<IsLunging>(charger), "The cue clears the tick the lunge ends (whiff).");
}
}
[Test]
public void Knockback_Disables_IsLunging()
{
var (world, group) = MakeWorld("ChargerIsLungingKnockback", 305);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 });
em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 });
em.SetComponentEnabled<IsLunging>(charger, true); // mid-lunge before the knockback
group.Update(); // tick 305: knockback cancels the lunge (UntilTick -> 0) via the mid-body continue path
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "Sanity: knockback cancelled the lunge.");
Assert.IsFalse(em.IsComponentEnabled<IsLunging>(charger),
"The cue clears when knockback cancels the lunge (covers the mid-body continue exit path).");
}
}
}
}