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
@@ -58,6 +58,9 @@ namespace ProjectM.Server
});
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
ecb.AddComponent(director, new ThreatState());
// END-2: server-only run-phase marker (Normal until the goal cap arms the final siege). Added at
// spawn like CycleRuntime/ThreatState (never on the ghost serializer). RunOutcome is baked on the prefab.
ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal });
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
@@ -66,7 +69,13 @@ namespace ProjectM.Server
var pending = SystemAPI.GetComponent<PendingSave>(pendingEntity);
if (pending.HasData != 0)
{
ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = pending.GoalTarget });
// END-2: clamp the restored Target to the baked run-length so a pre-v5 save carrying the old
// Target=10 still honours the slice's baked Target=4 (the final siege stays reachable).
int bakedTarget = SystemAPI.HasComponent<GoalProgress>(spawner.Prefab)
? SystemAPI.GetComponent<GoalProgress>(spawner.Prefab).Target : pending.GoalTarget;
int restoredTarget = pending.GoalTarget > 0 && pending.GoalTarget < bakedTarget
? pending.GoalTarget : bakedTarget;
ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = restoredTarget });
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
var destLedger = ecb.SetBuffer<StorageEntry>(director);
SaveApply.WriteLedger(srcLedger, destLedger);
@@ -82,6 +91,10 @@ namespace ProjectM.Server
ecb.SetComponent(director, new CoreIntegrity { Current = restoredCore, Max = bakedCore.Max, OverrunTick = 0u });
}
// END-2: born-correct the terminal run outcome (a won/lost run loads finished + halted; a pre-v5
// save / New Game = 0 -> InProgress). Independent of the Core -> NOT nested in the CoreIntegrity guard.
ecb.SetComponent(director, new RunOutcome { Value = pending.RunOutcome });
}
ecb.DestroyEntity(pendingEntity);
}