END-2: final siege + latching win/lose (SL-3)

At GoalProgress.Charge>=Target a new server-only GoalReachedSystem arms a larger final siege (x live FinalSiegeMultiplier) and flips RunPhase=FinalDefense; CyclePhaseSystem latches a REPLICATED RunOutcome (Victory on clear / Loss on Core breach) and halts the director. RunOutcome is a [GhostField] byte on the global CycleDirector ghost (the client banner observes it); RunPhase stays server-only. ThreatDirector/CoreRestore/CoreDamage halt once decided; SiegeTimeout is off during the final siege. SaveData v5 persists the outcome so a won/lost run loads finished. GoalProgress.Target 10->4. Completes Path A's spine. See DR-036.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:38:21 -07:00
parent 33c85c4f9a
commit 4f0b4e8087
16 changed files with 313 additions and 33 deletions
@@ -90,9 +90,13 @@ namespace ProjectM.Server
}
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.
// 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)
@@ -100,23 +104,8 @@ namespace ProjectM.Server
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).
// 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);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int hi = 0; hi < husks.Length; hi++)
@@ -133,7 +122,29 @@ namespace ProjectM.Server
SystemAPI.SetComponent(waveLost, wl);
}
// Autosave the wounded state (a breach is a meaningful checkpoint).
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 });
}
@@ -141,11 +152,21 @@ namespace ProjectM.Server
{
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))
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 += 1;
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))