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

206 lines
9.1 KiB
C#

using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="ThreatDirectorSystem"/> — the composite
/// base-attack scheduler. A bare world is seeded with a NetworkTime singleton and a CycleDirector entity
/// carrying CycleState + ThreatState + ThreatConfig. These pin the post-expedition source (a return arms a
/// siege of the configured size, with simultaneous returns de-duped to one), that the event-siege size is the
/// config floor — never the WaveSystem escalation curve — that the telegraph ArmTick is now + delay, and that
/// an unattended siege auto-collapses after the timeout (no soft-lock). All timing is wrap-safe NetworkTick.
/// </summary>
public class ThreatDirectorSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ThreatDirectorSystem>());
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) });
return (world, group);
}
static ThreatConfig DefaultConfig() => new ThreatConfig
{
PostExpeditionEnabled = 1,
PostExpeditionDelayTicks = 300,
SizeBase = 5,
SizePerExpeditionResource = 0,
StartCondition = ThreatStartCondition.Immediate,
SiegeTimeoutTicks = 3600,
};
static Entity MakeDirector(EntityManager em, byte phase, ThreatState threat, ThreatConfig config)
{
var e = em.CreateEntity(typeof(CycleState), typeof(ThreatState), typeof(ThreatConfig));
em.SetComponentData(e, new CycleState { Phase = phase, CycleNumber = 1 });
em.SetComponentData(e, threat);
em.SetComponentData(e, config);
return e;
}
[Test]
public void PostExpedition_Return_Edge_Sets_PendingSiegeSize()
{
var (world, group) = MakeWorld("ThreatReturn", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig());
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(5, ts.PendingSiegeSize, "A return arms a siege of SizeBase Husks.");
Assert.AreNotEqual(0u, ts.ArmTick, "The siege is armed with a telegraph tick.");
Assert.AreEqual(0, ts.PendingReturns, "The return is consumed.");
}
}
[Test]
public void Multi_Player_Simultaneous_Return_Charges_Pending_Once()
{
var (world, group) = MakeWorld("ThreatMultiReturn", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 3 }, DefaultConfig());
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(5, ts.PendingSiegeSize, "Three simultaneous returns still arm exactly one siege (de-dup).");
Assert.AreEqual(0, ts.PendingReturns, "All returns are consumed.");
}
}
[Test]
public void Siege_Size_Equals_Config_Not_Escalation_Curve()
{
var (world, group) = MakeWorld("ThreatSizeConfig", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig());
// A high wave number must NOT influence the event-siege size.
var w = em.CreateEntity(typeof(WaveState));
em.SetComponentData(w, new WaveState { WaveNumber = 30 });
group.Update();
Assert.AreEqual(5, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
"Event-siege size is the config SizeBase, never the WaveSystem escalation curve.");
}
}
[Test]
public void StartCondition_Immediate_Arms_Via_ArmTick()
{
var (world, group) = MakeWorld("ThreatArm", serverTick: 1000);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.PostExpeditionDelayTicks = 120;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, config);
group.Update();
Assert.AreEqual(1120u, em.GetComponentData<ThreatState>(dir).ArmTick,
"Immediate start arms the siege at now + the telegraph delay (1000 + 120).");
}
}
[Test]
public void Empty_Base_Siege_Auto_Resolves_Bounded()
{
var (world, group) = MakeWorld("ThreatTimeout", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.SiegeTimeoutTicks = 60;
// SiegeStartTick 100, now 200 => 100 ticks elapsed > 60 timeout.
var dir = MakeDirector(em, CyclePhase.Siege, new ThreatState { SiegeStartTick = 100 }, config);
var w = em.CreateEntity(typeof(WaveState));
em.SetComponentData(w, new WaveState { RemainingToSpawn = 2, Phase = WavePhase.Spawning });
// Three Husks still on the field with no one to clear them.
for (int i = 0; i < 3; i++)
{
var h = em.CreateEntity(typeof(EnemyTag));
em.AddComponentData(h, new RegionTag { Region = RegionId.Base });
}
group.Update();
using var huskQuery = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(0, huskQuery.CalculateEntityCount(),
"A timed-out (unattended) siege culls the remaining Husks so it can never soft-lock.");
Assert.AreEqual(0, em.GetComponentData<WaveState>(w).RemainingToSpawn,
"The wave stops spawning when the siege collapses.");
Assert.AreEqual(0u, em.GetComponentData<ThreatState>(dir).SiegeStartTick,
"The siege clock resets after collapse.");
}
}
[Test]
public void Schedule_First_Pass_Seeds_NextTick_Without_Firing()
{
var (world, group) = MakeWorld("ThreatScheduleSeed", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.PostExpeditionEnabled = 0;
config.ScheduleEnabled = 1;
config.ScheduleIntervalTicks = 100;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 0 }, config);
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(300u, ts.NextScheduledTick, "The first pass seeds the next scheduled tick one interval out (200 + 100).");
Assert.AreEqual(0, ts.PendingSiegeSize, "The first pass only seeds — it does not arm a siege immediately.");
}
}
[Test]
public void Schedule_Arms_Siege_On_Cadence_Without_An_Expedition()
{
var (world, group) = MakeWorld("ThreatScheduleFire", serverTick: 400);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.PostExpeditionEnabled = 0; // isolate the schedule source
config.ScheduleEnabled = 1;
config.ScheduleIntervalTicks = 100;
config.ScheduleSizePerWave = 0;
config.SizeBase = 5;
config.PostExpeditionDelayTicks = 10;
// NextScheduledTick 300 <= now 400 => the scheduled siege is due.
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 300 }, config);
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(5, ts.PendingSiegeSize, "A due scheduled tick arms a SizeBase siege with NO expedition trip.");
Assert.AreEqual(410u, ts.ArmTick, "The scheduled siege telegraphs at now + delay (400 + 10).");
Assert.AreEqual(500u, ts.NextScheduledTick, "The next scheduled siege is one interval out (400 + 100).");
}
}
}
}