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
@@ -0,0 +1,61 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// END-2 — server-only marker of which run-phase the macro loop is in. Lives on the GLOBAL CycleDirector
/// entity beside <see cref="CycleState"/>/<see cref="CycleRuntime"/>/<see cref="ThreatState"/>; NOT replicated
/// (the client never needs to distinguish "the final siege is armed" — the larger wave + telegraph already read
/// as escalation; the client shows the terminal banner from the replicated <see cref="RunOutcome"/> instead).
/// SINGLE writer: <c>GoalReachedSystem</c> flips <see cref="RunPhaseId.Normal"/> -&gt;
/// <see cref="RunPhaseId.FinalDefense"/> exactly once when <see cref="GoalProgress.Charge"/> reaches Target.
/// Added at spawn by <c>CycleDirectorSpawnSystem</c> (like CycleRuntime/ThreatState), so it is server-world-only
/// and never on the ghost serializer (no re-hash). A <c>byte</c> (never an enum) so a Bursted reader can't trip
/// the cross-assembly-enum Burst ICE.
/// </summary>
public struct RunPhase : IComponentData
{
public byte Value;
}
/// <summary>Phase constants for <see cref="RunPhase.Value"/> (bytes — never an enum on a Bursted path).</summary>
public static class RunPhaseId
{
/// <summary>Normal play: scheduled / post-expedition sieges arm; the goal meter climbs +1 per survived siege.</summary>
public const byte Normal = 0;
/// <summary>The goal cap was reached: the larger FINAL siege is armed/running. No further sieges arm.</summary>
public const byte FinalDefense = 1;
}
/// <summary>
/// END-2 — the terminal run outcome: the LATCHING win/lose state, REPLICATED so the client HUD shows the
/// victory/loss banner by simply observing it (no fragile client-side reconstruction). Rides the GLOBAL
/// untagged CycleDirector ghost (relevant to every connection in every region — it must NEVER be region-tagged;
/// the shared-global-state rule), one <c>[GhostField] byte</c> alongside <see cref="CoreIntegrity"/>/
/// <see cref="GoalProgress"/>. SINGLE writer: <c>CyclePhaseSystem</c> latches <see cref="RunOutcomeId.Victory"/>
/// (final siege cleared) or <see cref="RunOutcomeId.Loss"/> (Core breached during the final siege). Once it is
/// non-<see cref="RunOutcomeId.InProgress"/> the run HALTS (GoalReachedSystem + ThreatDirectorSystem stop arming;
/// CoreRestoreSystem stops regen). Baked onto the prefab so it is part of the ghost (adding this <c>[GhostField]</c>
/// re-hashes the CycleDirector ghost -&gt; one re-bake); born-correct at spawn (InProgress for a New Game, or the
/// persisted value on Continue — SaveData v5).
/// </summary>
public struct RunOutcome : IComponentData
{
[GhostField] public byte Value;
}
/// <summary>Outcome constants for <see cref="RunOutcome.Value"/> (bytes — never an enum on a Bursted/serialized path).</summary>
public static class RunOutcomeId
{
/// <summary>The run is live (no terminal result yet).</summary>
public const byte InProgress = 0;
/// <summary>The final siege was survived — the Engine holds. Terminal; the run halts.</summary>
public const byte Victory = 1;
/// <summary>The Core was breached during the final siege — overrun. Terminal; the run halts.</summary>
public const byte Loss = 2;
}
}