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:
@@ -25,14 +25,11 @@ namespace ProjectM.Server
|
||||
[UpdateBefore(typeof(WaveSystem))]
|
||||
public partial struct CyclePhaseSystem : ISystem
|
||||
{
|
||||
EntityQuery m_AliveHusks;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<CycleState>();
|
||||
m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -104,15 +101,17 @@ namespace ProjectM.Server
|
||||
cycle.Phase = CyclePhase.Calm;
|
||||
cycle.PhaseEndTick = 0;
|
||||
|
||||
// The siege ends: despawn every remaining Husk (the locked despawn-on-breach fork) + reset the wave
|
||||
// so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths.
|
||||
var husks = m_AliveHusks.ToEntityArray(Allocator.Temp);
|
||||
// The siege ends: despawn the base siege Husks (the locked despawn-on-breach fork) + reset the
|
||||
// wave so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths.
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int hi = 0; hi < husks.Length; hi++)
|
||||
ecb.DestroyEntity(husks[hi]);
|
||||
// Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must
|
||||
// survive a base Core breach. A region-blind EnemyTag wipe would also spuriously trip the
|
||||
// zone director's aliveZone==0 clear/reward edge. Mirrors ThreatDirectorSystem + DefendCleared.
|
||||
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
|
||||
if (hr.ValueRO.Region == RegionId.Base)
|
||||
ecb.DestroyEntity(he);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
husks.Dispose();
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveLost))
|
||||
{
|
||||
var wl = SystemAPI.GetComponent<WaveState>(waveLost);
|
||||
@@ -189,9 +188,14 @@ namespace ProjectM.Server
|
||||
{
|
||||
if (!SystemAPI.TryGetSingleton<WaveState>(out var wave))
|
||||
return false;
|
||||
// Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition})
|
||||
// must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem).
|
||||
int baseHusks = 0;
|
||||
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
|
||||
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
|
||||
return wave.WaveNumber > defendStartWave
|
||||
&& wave.RemainingToSpawn == 0
|
||||
&& m_AliveHusks.CalculateEntityCount() == 0;
|
||||
&& baseHusks == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,12 +73,33 @@ namespace ProjectM.Server
|
||||
|
||||
// A player returned to base from an expedition -> signal the ThreatDirector (it sizes/arms any
|
||||
// retaliation siege). The gate teleports the returner out of its radius, so this fires once per return.
|
||||
if (returnedToBase && SystemAPI.TryGetSingletonEntity<ThreatState>(out var threatEntity))
|
||||
if (returnedToBase)
|
||||
{
|
||||
var threat = SystemAPI.GetComponent<ThreatState>(threatEntity);
|
||||
threat.PendingReturns += 1;
|
||||
threat.ExpeditionsCompleted += 1;
|
||||
SystemAPI.SetComponent(threatEntity, threat);
|
||||
if (SystemAPI.TryGetSingletonEntity<ThreatState>(out var threatEntity))
|
||||
{
|
||||
var threat = SystemAPI.GetComponent<ThreatState>(threatEntity);
|
||||
threat.PendingReturns += 1;
|
||||
threat.ExpeditionsCompleted += 1;
|
||||
SystemAPI.SetComponent(threatEntity, threat);
|
||||
}
|
||||
|
||||
// Once-per-epoch zone-clear reward: a returner banks flat Ore IFF this epoch's expedition wave was
|
||||
// actually cleared and not yet rewarded. Resolved ONCE here (not per-returner) so two same-tick co-op
|
||||
// returns pay exactly once (DR-040 BLOCKER 4) and gate re-entry before a clear can't farm (MINOR 2).
|
||||
if (SystemAPI.HasSingleton<CycleState>()
|
||||
&& SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
|
||||
&& SystemAPI.HasSingleton<ResourceLedger>())
|
||||
{
|
||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
|
||||
runtime.LastRewardedEpoch = runtime.ExpeditionEpoch;
|
||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ namespace ProjectM.Server
|
||||
[UpdateBefore(typeof(CyclePhaseSystem))]
|
||||
public partial struct ThreatDirectorSystem : ISystem
|
||||
{
|
||||
EntityQuery m_Husks;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
@@ -37,7 +36,6 @@ namespace ProjectM.Server
|
||||
state.RequireForUpdate<CycleState>();
|
||||
state.RequireForUpdate<ThreatState>();
|
||||
state.RequireForUpdate<ThreatConfig>();
|
||||
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -109,17 +107,14 @@ namespace ProjectM.Server
|
||||
var start = new NetworkTick(threat.SiegeStartTick);
|
||||
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
|
||||
{
|
||||
// Collapse the siege: cull every remaining Husk (a cached tag query, never RefRO on a tag).
|
||||
var husks = m_Husks.ToEntityArray(Allocator.Temp);
|
||||
if (husks.Length > 0)
|
||||
{
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int i = 0; i < husks.Length; i++)
|
||||
ecb.DestroyEntity(husks[i]);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
husks.Dispose();
|
||||
// Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also
|
||||
// EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3).
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
|
||||
if (hr.ValueRO.Region == RegionId.Base)
|
||||
ecb.DestroyEntity(he);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user