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
@@ -46,6 +46,11 @@ namespace ProjectM.Server
if (core.Current <= 0)
return; // already breached this beat; the lose-edge (CyclePhaseSystem) owns resolution.
// END-2: once the run is decided (Victory/Loss latched) the Core takes no more damage. Defensive — the
// siege already despawned its Husks on resolution; this mirrors the CoreRestoreSystem terminal-halt guard.
if (SystemAPI.TryGetSingleton<RunOutcome>(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress)
return;
float3 corePos = BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>());
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
int dmgPerHusk = (int)math.max(1f, tune.CoreDamagePerHusk);
@@ -30,6 +30,10 @@ namespace ProjectM.Server
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// END-2: once the run is decided (Victory/Loss latched), the Core freezes at its terminal value (no regen).
if (SystemAPI.TryGetSingleton<RunOutcome>(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress)
return;
if (SystemAPI.GetSingleton<CycleState>().Phase != CyclePhase.Calm)
return; // heal only between sieges
@@ -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);
}
@@ -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))
@@ -0,0 +1,90 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// END-2 — arms the FINAL siege when the long-arc goal meter fills. Server-only, plain
/// <see cref="SimulationSystemGroup"/>, <c>[UpdateAfter(CyclePhaseSystem)]</c> so it reads
/// <see cref="GoalProgress.Charge"/> AFTER the survived-siege increment that may have just reached Target.
/// On the <c>Charge &gt;= Target</c> rising edge — guarded by <see cref="RunPhaseId.Normal"/> +
/// <see cref="RunOutcomeId.InProgress"/> so it fires EXACTLY once — it:
/// <list type="bullet">
/// <item>arms a bigger siege through the existing single entry point <see cref="ThreatState.PendingSiegeSize"/>:
/// the would-be-next normal siege size (<c>SizeBase + ScheduleSizePerWave*wave</c>) times the live
/// <see cref="TuningConfig.FinalSiegeMultiplier"/> (floored at 1 so the final siege is never smaller), telegraphed
/// via <see cref="ThreatState.ArmTick"/> (wrap-safe <see cref="TickUtil.NonZero"/>);</item>
/// <item>flips <see cref="RunPhase"/> to <see cref="RunPhaseId.FinalDefense"/>.</item>
/// </list>
/// It NEVER writes <see cref="CycleState"/>.Phase / <c>WaveState</c> (CyclePhaseSystem stays the sole writer) nor
/// <see cref="GoalProgress"/>.Charge (CyclePhaseSystem clamps it at the increment site) — it only READS the edge.
/// CyclePhaseSystem then consumes <see cref="ThreatState.PendingSiegeSize"/> the next tick exactly like any other
/// armed siege; <c>ThreatDirectorSystem</c> stops arming once <see cref="RunPhase"/> leaves Normal, so no normal
/// siege can stomp the final one. Plain server group =&gt; one run per tick, no rollback/predicted exposure.
/// Bytes, never enums (Burst-safe).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(CyclePhaseSystem))]
public partial struct GoalReachedSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CycleState>();
state.RequireForUpdate<RunPhase>();
}
[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>();
// Exactly-once guards: a decided run, or one already in the final siege, arms nothing.
if (SystemAPI.HasComponent<RunOutcome>(cycleEntity)
&& SystemAPI.GetComponent<RunOutcome>(cycleEntity).Value != RunOutcomeId.InProgress)
return;
var runPhase = SystemAPI.GetComponent<RunPhase>(cycleEntity);
if (runPhase.Value != RunPhaseId.Normal)
return;
// Goal cap reached? (Charge is clamped to Target at the CyclePhaseSystem increment site.)
if (!SystemAPI.HasComponent<GoalProgress>(cycleEntity))
return;
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
if (goal.Target <= 0 || goal.Charge < goal.Target)
return;
if (!SystemAPI.HasComponent<ThreatState>(cycleEntity) || !SystemAPI.HasComponent<ThreatConfig>(cycleEntity))
return;
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
float mult = math.max(1f, SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg)
? tcfg.FinalSiegeMultiplier
: TuningConfig.Defaults().FinalSiegeMultiplier);
int normalSize = config.SizeBase + config.ScheduleSizePerWave * wave;
int finalSize = math.max(1, (int)(normalSize * mult));
// Arm the final siege (overwrites any pending normal siege — the final supersedes; at the goal-reach tick
// PendingSiegeSize is 0 anyway, the just-cleared siege having consumed it). CyclePhaseSystem consumes it.
threat.PendingSiegeSize = finalSize;
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
SystemAPI.SetComponent(cycleEntity, threat);
runPhase.Value = RunPhaseId.FinalDefense;
SystemAPI.SetComponent(cycleEntity, runPhase);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 472c137c49b85e141b0ee00b1d1fa076
@@ -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)