60e1e21dd3
Adds CoreIntegrity{[GhostField] Current,Max,OverrunTick} on the GLOBAL
CycleDirector ghost (no new ghost/relevancy). CoreDamageSystem (server,
after EnemyAISystem): a Husk within ~3u of PlotCenter drains + is consumed;
CoreRestoreSystem regenerates only in Calm. The SOFT-loss edge lives inside
CyclePhaseSystem (sole Phase writer): Current<=0 in Siege flips to Calm with
NO goal reward, StorageMath.DrainFraction drains the shared ledger, all Husks
despawn, and OverrunTick is stamped (a transient HUD-flash pulse, not a
latching outcome - the Victory latch is END-2's). EnemyAISystem treats the
Core as a FALLBACK target so an undefended base is overrun instead of idling.
SaveData -> v4 persists CoreCurrent (0 -> born full, the EB-1 HP sentinel);
3 live TuningConfig knobs + a red HUD Core bar. Soft-loss + targeting +
breach-resolution forks operator-locked.
See DR-034.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
9.5 KiB
C#
177 lines
9.5 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
|
|
{
|
|
EntityQuery m_AliveHusks;
|
|
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<NetworkTime>();
|
|
state.RequireForUpdate<CycleState>();
|
|
m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
|
}
|
|
|
|
[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-1 soft-loss edge (checked BEFORE survival): the Engine Core breached to 0 -> the siege ENDS
|
|
// overrun, the shared ledger is drained, the base persists wounded. No rollback, NO goal reward
|
|
// (you lost) — the locked DR-029 soft fork. CyclePhaseSystem stays the sole Phase/WaveState writer.
|
|
bool overrun = SystemAPI.HasComponent<CoreIntegrity>(cycleEntity)
|
|
&& SystemAPI.GetComponent<CoreIntegrity>(cycleEntity).Current <= 0;
|
|
if (overrun)
|
|
{
|
|
cycle.Phase = CyclePhase.Calm;
|
|
cycle.PhaseEndTick = 0;
|
|
|
|
// Penalty: drain a fraction of the shared ledger (the ResourceLedger StorageEntry buffer on
|
|
// THIS director ghost). The drain pct is the live tuning knob with the baked fallback.
|
|
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);
|
|
}
|
|
|
|
// Transient overrun pulse for the HUD flash; Current stays 0 and regenerates in Calm
|
|
// (CoreRestoreSystem) -> the base is wounded, not dead.
|
|
var coreL = SystemAPI.GetComponent<CoreIntegrity>(cycleEntity);
|
|
coreL.OverrunTick = TickUtil.NonZero(now);
|
|
SystemAPI.SetComponent(cycleEntity, coreL);
|
|
|
|
// 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).
|
|
var husks = m_AliveHusks.ToEntityArray(Allocator.Temp);
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
for (int hi = 0; hi < husks.Length; hi++)
|
|
ecb.DestroyEntity(husks[hi]);
|
|
ecb.Playback(state.EntityManager);
|
|
ecb.Dispose();
|
|
husks.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);
|
|
}
|
|
|
|
// Autosave the wounded state (a breach is a meaningful checkpoint).
|
|
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;
|
|
// Long-arc goal: +1 per siege survived (single writer; was +1 per completed timed cycle).
|
|
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
|
{
|
|
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
|
goal.Charge += 1;
|
|
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;
|
|
return wave.WaveNumber > defendStartWave
|
|
&& wave.RemainingToSpawn == 0
|
|
&& m_AliveHusks.CalculateEntityCount() == 0;
|
|
}
|
|
}
|
|
}
|