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
@@ -52,12 +52,21 @@ namespace ProjectM.Server
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
// END-2: a decided run (Victory/Loss) or one already in the FINAL siege arms NO further sieges. The
// SiegeTimeout cull is also disabled during the final siege (a cull -> false Victory). Guarded with
// HasComponent so EditMode worlds without RunPhase/RunOutcome keep the pre-END-2 behaviour.
byte runPhase = SystemAPI.HasComponent<RunPhase>(cycleEntity)
? SystemAPI.GetComponent<RunPhase>(cycleEntity).Value : RunPhaseId.Normal;
byte runOutcome = SystemAPI.HasComponent<RunOutcome>(cycleEntity)
? SystemAPI.GetComponent<RunOutcome>(cycleEntity).Value : RunOutcomeId.InProgress;
bool canArm = runPhase == RunPhaseId.Normal && runOutcome == RunOutcomeId.InProgress;
// ---- SOURCE: post-expedition retaliation. A returning player arms ONE siege (simultaneous returns
// collapse to a single arming — extending the de-dup the gate's one-increment-per-return starts). ----
if (config.PostExpeditionEnabled != 0 && threat.PendingReturns > 0)
{
if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0)
if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm)
{
int size = config.SizeBase + config.SizePerExpeditionResource * 0; // haul-scaling deferred (field baked)
threat.PendingSiegeSize = math.max(1, size);
@@ -77,7 +86,7 @@ namespace ProjectM.Server
// AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long.
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
}
else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0
else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm
&& !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick))
{
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
@@ -95,7 +104,7 @@ namespace ProjectM.Server
{
threat.SiegeStartTick = TickUtil.NonZero(now);
}
else if (config.SiegeTimeoutTicks > 0)
else if (config.SiegeTimeoutTicks > 0 && runPhase != RunPhaseId.FinalDefense)
{
var start = new NetworkTick(threat.SiegeStartTick);
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)