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
@@ -40,8 +40,9 @@ namespace ProjectM.Server
// Snapshot living player targets once this tick (stable query order).
var playerEntities = new NativeList<Entity>(Allocator.Temp);
var playerPositions = new NativeList<float3>(Allocator.Temp);
foreach (var (xform, health, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
var playerRegions = new NativeList<byte>(Allocator.Temp);
foreach (var (xform, health, region, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<PlayerTag>()
.WithEntityAccess())
{
@@ -49,6 +50,7 @@ namespace ProjectM.Server
continue; // don't chase or strike a corpse
playerEntities.Add(entity);
playerPositions.Add(xform.ValueRO.Position);
playerRegions.Add(region.ValueRO.Region);
}
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation
@@ -56,8 +58,9 @@ namespace ProjectM.Server
// the base even with every player dead/away (the locked 'push for structures' fork).
var structureEntities = new NativeList<Entity>(Allocator.Temp);
var structurePositions = new NativeList<float3>(Allocator.Temp);
foreach (var (sx, sh, se) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
var structureRegions = new NativeList<byte>(Allocator.Temp);
foreach (var (sx, sh, sr, se) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<PlacedStructure>()
.WithEntityAccess())
{
@@ -65,6 +68,7 @@ namespace ProjectM.Server
continue; // skip a structure already at 0 (pending destroy this tick)
structureEntities.Add(se);
structurePositions.Add(sx.ValueRO.Position);
structureRegions.Add(sr.ValueRO.Region);
}
// END-1: the Engine Core is a FALLBACK target. When no living player/structure remains, undefended
@@ -77,8 +81,10 @@ namespace ProjectM.Server
{
playerEntities.Dispose();
playerPositions.Dispose();
playerRegions.Dispose();
structureEntities.Dispose();
structurePositions.Dispose();
structureRegions.Dispose();
return;
}
@@ -95,12 +101,14 @@ namespace ProjectM.Server
bool sweep = havePhysics && envMask != 0u;
const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement
foreach (var (xform, stats, cooldown, knockback, windup) in
foreach (var (xform, stats, cooldown, knockback, windup, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>>()
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<LungeState>())
{
float3 pos = xform.ValueRO.Position;
byte huskRegion = region.ValueRO.Region;
bool huskCoreAlive = coreAlive && huskRegion == RegionId.Base;
// Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position.
var kb = knockback.ValueRO;
@@ -122,8 +130,8 @@ namespace ProjectM.Server
// EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/
// turret is the preferred target unless a player is in the way (closer after weighting).
EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool tgtIsStruct, out int tgtIdx);
if (tgtIdx < 0 && !coreAlive)
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, huskRegion, structAggro, out bool tgtIsStruct, out int tgtIdx);
if (tgtIdx < 0 && !huskCoreAlive)
continue; // no player/structure and no Core -> nothing to seek
Entity targetEntity = tgtIdx < 0 ? Entity.Null
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
@@ -201,12 +209,14 @@ namespace ProjectM.Server
uint ChargerWindupTicks = (uint)math.max(1f, tune.ChargerWindupTicks); // readable telegraph lead
uint ChargerWhiffStaggerTicks = (uint)math.max(1f, tune.ChargerWhiffStaggerTicks); // punish window
uint chargerWhiffsThisTick = 0;
foreach (var (xform, stats, cooldown, knockback, windup, lunge) in
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>>()
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
.WithAll<EnemyTag>())
{
float3 pos = xform.ValueRO.Position;
byte cHuskRegion = region.ValueRO.Region;
bool cHuskCoreAlive = coreAlive && cHuskRegion == RegionId.Base;
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
var kb = knockback.ValueRO;
@@ -227,8 +237,8 @@ namespace ProjectM.Server
}
// EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper).
EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool cIsStruct, out int cIdx);
if (cIdx < 0 && !coreAlive)
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, cHuskRegion, structAggro, out bool cIsStruct, out int cIdx);
if (cIdx < 0 && !cHuskCoreAlive)
continue;
Entity cTargetEntity = cIdx < 0 ? Entity.Null
: (cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]);
@@ -340,8 +350,10 @@ namespace ProjectM.Server
ecb.Dispose();
playerEntities.Dispose();
playerPositions.Dispose();
playerRegions.Dispose();
structureEntities.Dispose();
structurePositions.Dispose();
structureRegions.Dispose();
}
// Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against
@@ -23,7 +23,6 @@ namespace ProjectM.Server
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct WaveSystem : ISystem
{
EntityQuery m_AliveHusks;
[BurstCompile]
public void OnCreate(ref SystemState state)
@@ -31,7 +30,6 @@ namespace ProjectM.Server
state.RequireForUpdate<WaveDirector>();
state.RequireForUpdate<WaveState>();
state.RequireForUpdate<NetworkTime>();
m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
}
[BurstCompile]
@@ -100,11 +98,19 @@ namespace ProjectM.Server
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
}
}
else if (m_AliveHusks.CalculateEntityCount() == 0)
else
{
// Wave cleared: calm before the next.
wave.Phase = WavePhase.Lull;
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.LullTicks));
// Wave fully spawned: cleared only when no BASE husk remains. Expedition zone enemies are also
// EnemyTag but RegionTag{Expedition}; they must NOT hold the base siege open (DR-040 BLOCKER 3).
int baseHusks = 0;
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
if (baseHusks == 0)
{
// Wave cleared: calm before the next.
wave.Phase = WavePhase.Lull;
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.LullTicks));
}
}
}
@@ -0,0 +1,134 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only expedition zone-enemy director: while a player is OUT in the expedition region and the base is in
/// <see cref="CyclePhase.Calm"/>, it seeds and drip-spawns one epoch-seeded combat wave around the expedition
/// origin. The wave size + grunt/charger composition is pure <see cref="ZoneEnemyMath"/> of the
/// <see cref="CycleRuntime.ExpeditionEpoch"/> (grunt-heavy -&gt; charger-heavy as the epoch climbs), spawned one
/// every <see cref="ZoneEnemyDirector.SpawnIntervalTicks"/> at a deterministic ring, capped at
/// <see cref="ZoneEnemyDirector.MaxAlive"/> concurrent (the v1 ghost-relevancy budget). Each enemy is the
/// existing Husk ghost prefab + <c>RegionTag{Expedition}</c> + <see cref="ZoneEnemyTag"/>, so it reuses the whole
/// combat/readability/AI stack (the per-region AI filter keeps it seeking the expedition player only). When the
/// wave is fully spawned and every zone enemy is dead, it marks <see cref="CycleRuntime.ClearedThisEpoch"/> once —
/// the gate's once-per-epoch Ore reward reads that on the player's return.
///
/// Ordering: <c>[UpdateAfter(ExpeditionFieldSystem)]</c> ONLY. ExpeditionFieldSystem is itself
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>, so ALSO declaring <c>[UpdateBefore(CyclePhaseSystem)]</c> here (as the
/// v1 plan first sketched) would close a CyclePhase-&gt;Field-&gt;Zone-&gt;CyclePhase sort cycle that throws at Play
/// world creation and is invisible to EditMode. Running after the field manager also reads the freshly-bumped
/// epoch + current phase. Zone enemies are interpolated ghosts, moved server-only — no prediction.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(ExpeditionFieldSystem))]
public partial struct ZoneEnemyDirectorSystem : ISystem
{
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CycleState>();
state.RequireForUpdate<ZoneEnemyDirector>();
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
// Per-player presence: only run while someone is OUT in the expedition (mirrors ExpeditionFieldSystem).
int expeditionPlayers = 0;
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
if (region.ValueRO.Region == RegionId.Expedition)
expeditionPlayers++;
if (expeditionPlayers == 0)
return; // nobody out there: the field manager owns teardown, we do nothing
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length == 0)
return;
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
int epoch = runtime.ExpeditionEpoch;
// (Re)seed this epoch's wave once — its OWN counter, never WaveState's.
if (zs.SeededEpoch != epoch)
{
zs.SeededEpoch = epoch;
zs.SpawnCounter = 0;
zs.RemainingToSpawn = ZoneEnemyMath.WaveSize(epoch, dir.GruntsPerWave, dir.ChargersPerWave);
zs.NextSpawnTick = TickUtil.NonZero(now); // first enemy this tick
}
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
if (zs.RemainingToSpawn > 0)
{
// Spawn only in Calm (a base Siege pauses the expedition wave; it resumes when the base is safe), one
// per cadence, and only while under the concurrent cap.
bool calm = cycle.Phase == CyclePhase.Calm;
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
if (calm && dueNow && aliveZone < math.max(1, dir.MaxAlive))
{
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
int slot = (int)zs.SpawnCounter;
bool charger = ZoneEnemyMath.IsChargerSlot(epoch, slot, dir.GruntsPerWave, dir.ChargersPerWave);
int prefabIdx = charger ? 1 : 0;
if (prefabIdx >= prefabs.Length) prefabIdx = prefabs.Length - 1;
var prefab = prefabs[prefabIdx].Prefab;
float3 pos = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
pos.y = origin.y;
var ecb = new EntityCommandBuffer(Allocator.Temp);
var enemy = ecb.Instantiate(prefab);
// Preserve the prefab's baked Scale ([GhostField]) + rotation — FromPosition would reset Scale->1.
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
ecb.SetComponent(enemy, baked.WithPosition(pos));
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent<ZoneEnemyTag>(enemy);
ecb.Playback(state.EntityManager);
ecb.Dispose();
zs.SpawnCounter += 1;
zs.RemainingToSpawn -= 1;
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
}
}
else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0)
{
// Wave fully spawned AND every zone enemy dead -> a REAL clear (the player killed them; the empty-edge
// teardown can't reach here because we early-return when no one is out). Mark once; the gate pays the
// once-per-epoch Ore reward on the player's return to base.
runtime.ClearedThisEpoch = 1;
SystemAPI.SetComponent(cycleEntity, runtime);
}
SystemAPI.SetComponent(directorEntity, zs);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4dc121e99d0e53640b5e6815640a0bc6