Files
Project-M/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.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

202 lines
12 KiB
C#

using ProjectM.Simulation;
using Unity.Burst;using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative macro-loop director for the PLAYER-DRIVEN loop. The base sits in <c>Calm</c>
/// (persistent, unhurried — build/prep at your pace, no countdown) until the <see cref="ThreatState"/> arms a
/// siege, then flips to <c>Siege</c> (the base-defense wave) and back to <c>Calm</c> when the wave is cleared.
/// There is no global "Expedition" phase — being out on an expedition is per-player presence (server-only
/// <see cref="RegionTag"/>), read client-side by the HUD, so one global byte never has to represent
/// "player A out / player B home." Maintains the replicated <see cref="CycleState"/> singleton and gates
/// <see cref="WaveSystem"/> (waves spawn only during Siege). Runs in the plain server SimulationSystemGroup
/// before WaveSystem. All timing is wrap-safe NetworkTick math (<see cref="ProjectM.Simulation.TickUtil.NonZero"/>
/// + <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/>), never raw uint compares. Lives on the
/// runtime-spawned CycleDirector ghost. Supersedes the forced timed Expedition→Defend→Build cycle.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(WaveSystem))]
public partial struct CyclePhaseSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CycleState>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
if (cycle.Phase == CyclePhase.Calm)
{
// Default calm: no pending siege => no countdown.
cycle.PhaseEndTick = 0;
if (SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
if (threat.PendingSiegeSize > 0)
{
// Telegraph: mirror the arm tick into the replicated PhaseEndTick so the HUD can show an
// "incursion in Ns" countdown (reuses the existing HUD countdown path) while it arms.
cycle.PhaseEndTick = threat.ArmTick;
bool armed = threat.ArmTick == 0
|| !new NetworkTick(threat.ArmTick).IsNewerThan(serverTick);
if (armed && SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
{
// ---- Calm -> Siege: seed WaveSystem's own Spawning entry atomically. Writing
// Phase=Spawning bypasses its Lull escalation recompute (WaveSystem only recomputes
// RemainingToSpawn while Phase==Lull), so the siege spawns EXACTLY the director-chosen
// size and WaveSystem stays the sole WaveState writer thereafter. ----
var w = SystemAPI.GetComponent<WaveState>(waveEntity);
runtime.DefendStartWave = w.WaveNumber; // capture BEFORE the bump (DefendCleared tests > this)
w.WaveNumber += 1;
w.Phase = WavePhase.Spawning;
w.RemainingToSpawn = math.max(1, threat.PendingSiegeSize);
w.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
SystemAPI.SetComponent(waveEntity, w);
cycle.Phase = CyclePhase.Siege;
cycle.PhaseEndTick = 0; // Siege is wave-driven, not timed.
threat.PendingSiegeSize = 0; // consume once
threat.ArmTick = 0;
SystemAPI.SetComponent(cycleEntity, threat);
}
}
}
}
else if (cycle.Phase == CyclePhase.Siege)
{
// END-2: is this the FINAL siege (the goal cap armed it)? Server-only RunPhase marker; HasComponent-
// guarded so EditMode worlds without RunPhase keep the pre-END-2 (normal) soft-loss + survival paths.
bool isFinal = SystemAPI.HasComponent<RunPhase>(cycleEntity)
&& SystemAPI.GetComponent<RunPhase>(cycleEntity).Value == RunPhaseId.FinalDefense;
// The Engine Core breached to 0 during the siege (checked BEFORE survival). CyclePhaseSystem stays the
// sole Phase/WaveState writer; it is ALSO the sole RunOutcome writer (END-2 single-writer).
bool overrun = SystemAPI.HasComponent<CoreIntegrity>(cycleEntity)
&& SystemAPI.GetComponent<CoreIntegrity>(cycleEntity).Current <= 0;
if (overrun)
{
cycle.Phase = CyclePhase.Calm;
cycle.PhaseEndTick = 0;
// 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);
// 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();
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveLost))
{
var wl = SystemAPI.GetComponent<WaveState>(waveLost);
wl.RemainingToSpawn = 0;
wl.Phase = WavePhase.Lull;
wl.NextActionTick = 0;
SystemAPI.SetComponent(waveLost, wl);
}
if (isFinal)
{
// END-2 TERMINAL LOSS: the final stand fell. Latch Loss + halt (the director stops arming). NO
// ledger drain and NO OverrunTick stamp -> the client shows the dedicated terminal Loss banner
// (from the replicated RunOutcome), not the soft "the Core will recover" flash.
SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Loss });
}
else
{
// END-1 SOFT LOSS (unchanged): drain a fraction of the shared ledger + stamp the transient
// overrun pulse; the base persists wounded and the Core regenerates in Calm (the DR-029 fork).
var tuneL = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfgL) ? tcfgL : TuningConfig.Defaults();
if (SystemAPI.HasBuffer<StorageEntry>(cycleEntity))
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(cycleEntity);
StorageMath.DrainFraction(ledger, tuneL.CoreOverrunDrainPct);
}
var coreL = SystemAPI.GetComponent<CoreIntegrity>(cycleEntity);
coreL.OverrunTick = TickUtil.NonZero(now);
SystemAPI.SetComponent(cycleEntity, coreL);
}
// Autosave the checkpoint (a breach / final loss is a meaningful save point).
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
}
else if (DefendCleared(ref state, runtime.DefendStartWave))
{
cycle.Phase = CyclePhase.Calm;
cycle.PhaseEndTick = 0;
if (isFinal)
{
// END-2 TERMINAL WIN: the final siege was survived -> the Engine holds. Latch Victory + halt;
// do NOT increment the (already-capped) goal.
SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Victory });
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
}
else if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
{
// Long-arc goal: +1 per siege survived, CLAMPED to Target (single writer). Clamping at the
// increment site keeps the persisted Charge bounded regardless of system order; GoalReachedSystem
// only READS this edge to arm the final siege.
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge = math.min(goal.Charge + 1, goal.Target);
SystemAPI.SetComponent(cycleEntity, goal);
// Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag).
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
}
}
}
// Surface the live wave number on the replicated CycleState for the HUD (single writer).
if (SystemAPI.TryGetSingleton<WaveState>(out var waveSync))
cycle.WaveNumber = waveSync.WaveNumber;
SystemAPI.SetComponent(cycleEntity, cycle);
SystemAPI.SetComponent(cycleEntity, runtime);
}
// The Siege wave has run for this phase (WaveNumber advanced past the captured start), is fully spawned,
// and no Husks remain alive.
bool DefendCleared(ref SystemState state, int defendStartWave)
{
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
&& baseHusks == 0;
}
}
}