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>
This commit is contained in:
2026-06-21 22:58:26 -07:00
parent cf45ec82ae
commit 3109b86d71
33 changed files with 1044 additions and 161 deletions
@@ -0,0 +1,109 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
/// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4). A returning player banks flat Ore to the shared ledger
/// IFF this epoch's expedition wave was actually cleared and not yet rewarded — and never twice for the same
/// epoch (the co-op same-tick / gate-re-entry de-dup).
/// </summary>
public class ExpeditionGateRewardTests
{
static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name,
int epoch, byte clearedThisEpoch, int lastRewardedEpoch)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionGateSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
// CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state.
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), typeof(ThreatState));
em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm });
em.SetComponentData(cyc, new CycleRuntime
{
ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch,
});
em.AddBuffer<StorageEntry>(cyc);
// Zone-enemy director singleton (only RewardOre matters to the reward fold).
var dir = em.CreateEntity(typeof(ZoneEnemyDirector));
em.SetComponentData(dir, new ZoneEnemyDirector { RewardOre = 25 });
// A gate Expedition->Base sitting at the expedition origin.
var gate = em.CreateEntity(typeof(ExpeditionGate), typeof(LocalTransform));
em.SetComponentData(gate, new ExpeditionGate
{
FromRegion = RegionId.Expedition, ToRegion = RegionId.Base, Radius = 3f, ArrivalPos = new float3(0, 1, 0),
});
em.SetComponentData(gate, LocalTransform.FromPosition(new float3(1000, 1, 0)));
return (world, group, cyc);
}
static Entity MakeExpeditionPlayerAtGate(EntityManager em)
{
var e = em.CreateEntity();
em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition });
em.AddComponentData(e, LocalTransform.FromPosition(new float3(1000, 1, 0)));
em.AddComponent<PlayerTag>(e);
return e;
}
static int OreInLedger(EntityManager em, Entity cyc)
{
var buf = em.GetBuffer<StorageEntry>(cyc);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == (ushort)ResourceId.Ore) return buf[i].Count;
return 0;
}
[Test]
public void Cleared_Return_Banks_Ore_Once()
{
var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
using (world)
{
var em = world.EntityManager;
var player = MakeExpeditionPlayerAtGate(em);
group.Update(); // player walks the gate back to base -> reward
Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger");
Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(cyc).LastRewardedEpoch, "the epoch is marked rewarded");
// Force a second same-epoch return (the player is back in the expedition at the gate).
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.FromPosition(new float3(1000, 1, 0)));
group.Update(); // returns again, but the epoch was already rewarded
Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)");
}
}
[Test]
public void Uncleared_Return_Banks_Nothing()
{
var (world, group, cyc) = MakeWorld("GateRewardUncleared", epoch: 1, clearedThisEpoch: 0, lastRewardedEpoch: 0);
using (world)
{
var em = world.EntityManager;
MakeExpeditionPlayerAtGate(em);
group.Update();
Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)");
}
}
}
}