Docs: END-2 session log + DR-036; Backlog/Path_to_Fun/Milestones; CLAUDE.md END-2 line
Path A spine COMPLETE (14/14): Backlog SL-3 blocker cleared + marked done; Path_to_Fun END-2 done + banner; Milestones END-2 row. CLAUDE.md gains the END-2 gotcha line (replicate the outcome, don't client-derive; SiegeTimeout off during the final), net-zero via EB-1/EB-2/END-1/M7/inventory/build-grid condensations (40,445 then 40,510 w/ history note, under the 40,960 limit). DR-036 + session log capture the design, the operator forks (halt+banner, Target=4, SaveData v5), and the pre-coding + post-impl adversarial reviews. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
---
|
||||
date: 2026-06-13
|
||||
type: session
|
||||
tags:
|
||||
- session
|
||||
- endgame
|
||||
- netcode
|
||||
- persistence
|
||||
- slice
|
||||
- end-2
|
||||
permalink: gamevault/07-sessions/2026/2026-06-13-sl3-end2-final-siege-win-lose
|
||||
---
|
||||
|
||||
# SL-3 / END-2 — The charge means something: final siege, win or lose
|
||||
|
||||
> The slice's one **critical-path blocker** ([[End_Of_Month_Game_Jam_Slice]] · [[Backlog#SL-3 — END-2: Final Siege & Win/Lose (PATH-A BLOCKER)]]); the last un-built Path A spine mechanic. Full [[dots-dev]] Feature track, **review-gated** (pre-coding + post-impl adversarial reviews). Locked → [[DR-036_END2_Final_Siege_Win_Lose]]. Continues [[2026-06-12_END1_Losable_Core]] (reuses its ghost + banner pattern). Charge cadence locked in [[DR-035_End_Of_Month_Slice_Adoption]]. Spec: [[Path_to_Fun#END-2 — The charge means something: the cap arms a final siege, win or lose]].
|
||||
|
||||
## The hole END-2 closes
|
||||
END-1 gave a clear *loss* (soft Core overrun → transient flash); the goal meter still incremented past `Target` forever with no clamp and no consequence — **no terminal win, no climax**. END-2 makes the meter mean something: at the cap it arms a visibly-larger **final siege**; surviving it **latches Victory**, a Core breach during it **latches Loss**; both show a HUD banner and the run **halts**. This is the minimum "the game has a point" — it completes Path A.
|
||||
|
||||
## Operator forks (asked up front, present-the-forks ritual)
|
||||
The docs were explicit on most of END-2 (locked cadence, single-writer, clamp, arm-once, banner). Two genuine forks the source docs left open / conflicted on, plus one the review surfaced:
|
||||
- **End behavior → HALT + banner** (chosen over "keep-playing sandbox" and "retry/quit overlay now"). `Path_to_Fun`'s minimum said "keep playing"; the slice's Done Definition wants "a run with an end." Operator picked the clean terminal end; the existing **pause menu** (Esc → Quit-to-Menu/Desktop, autosave) is the escape hatch, the dedicated retry/quit overlay is **SL-5**.
|
||||
- **Run length → `Target = 4`** survived sieges → a 5th, larger final siege (was baked 10; too long for a 5–8 min run).
|
||||
- **Persist the outcome now (SaveData v5)** — the review showed that without it, a *won* run on Continue replays the final siege (the 4th-siege autosave already records `Charge==Target`), erasing the win. Operator approved the +1-byte bump rather than deferring to SL-5.
|
||||
|
||||
## Reviews earned their keep (the design changed twice)
|
||||
The written SL-3 note said *"server-only bytes, derive client-side, no save bump."* Both halves were overturned by review:
|
||||
- **Pre-coding** (3 lenses — netcode/determinism/reuse, all GO-with-changes): the outcome must **replicate** (`[GhostField]`) — the spec literally says "observe `RunOutcome`", and client-side derivation has late-join/late-focus blind spots; split into **two single-writer components** (one combined struct = two systems RMW-ing one component across an ordering boundary); fix the arming handoff + clamp + **SiegeTimeout-during-final** (a timeout cull would fake a Victory, F5).
|
||||
- **Post-impl** (4 lenses over the diff — netcode/determinism GO, persistence/regression GO-with-changes): caught **M-1** — my `RunOutcome` born-correct insert had landed *inside* the `if (HasComponent<CoreIntegrity>(prefab))` guard (my insertion line was the guard's closing brace). Latent (CoreIntegrity is always baked) but it would skip the outcome restore if the Core were ever dropped off the prefab → a finished run re-arms on Continue. Fixed, plus 5 test gaps + 3 nits (all addressed).
|
||||
|
||||
## What shipped — reuse the global ghost, split by writer
|
||||
|
||||
### Two new run-state components (CycleDirector ghost)
|
||||
- `RunPhase { byte Value }` — **server-only** (added at spawn like `CycleRuntime`/`ThreatState`, never on the ghost serializer). `RunPhaseId{Normal,FinalDefense}`. SINGLE writer: `GoalReachedSystem`.
|
||||
- `RunOutcome { [GhostField] byte Value }` — **REPLICATED** (baked on the prefab → part of the ghost → one re-bake, folded with the `Target=4` re-bake). `RunOutcomeId{InProgress,Victory,Loss}`. SINGLE writer: `CyclePhaseSystem`. The HUD shows the banner by observing it directly.
|
||||
|
||||
### `GoalReachedSystem` (new, server, `[UpdateAfter(CyclePhaseSystem)]`)
|
||||
On `Charge>=Target` ∧ `RunPhase==Normal` ∧ `RunOutcome==InProgress` (exactly once): arms `ThreatState.PendingSiegeSize = max(1,(int)((SizeBase+ScheduleSizePerWave*wave) * FinalSiegeMultiplier))` + `ArmTick=TickUtil.NonZero(now+delay)`, flips `RunPhase=FinalDefense`. Never writes `Phase`/`WaveState`/`Charge`.
|
||||
|
||||
### `CyclePhaseSystem` Siege branch split on `RunPhase==FinalDefense` (still sole Phase/WaveState/RunOutcome writer)
|
||||
- final `DefendCleared` → `RunOutcome=Victory`, Calm, **no** goal increment.
|
||||
- final `Core<=0` → `RunOutcome=Loss` (terminal: **no** ledger drain, **no** `OverrunTick` stamp → the dedicated Loss banner, not the soft flash; husks despawned).
|
||||
- Normal-phase paths **byte-unchanged** (END-1 soft loss + survived increment, now `Charge=min(Charge+1,Target)` clamped at the source → `GoalProgress` stays single-writer).
|
||||
|
||||
### Halt + the rest
|
||||
- Halt authority `RunOutcome != InProgress`: `ThreatDirectorSystem` arming sources gated `RunPhase==Normal && RunOutcome==InProgress`; **SiegeTimeout disabled during FinalDefense** (F5); `CoreRestoreSystem` + `CoreDamageSystem` skip once decided (Core freezes at its terminal value).
|
||||
- HUD: a dedicated latched `_runBanner` (NOT the per-player `_downed` overlay) — "THE ENGINE HOLDS / VICTORY" or "OVERRUN / THE FINAL STAND FELL" + an "Esc — menu" hint.
|
||||
- `FinalSiegeMultiplier` live `TuningConfig` knob (default 2.5, floored ≥1) across all 6 touch-points + the `DebugTuningReport` wire.
|
||||
- **SaveData v5** adds `RunOutcome` (additive; `MinLoadableVersion` stays 2; old saves 0-default to InProgress). Persisted at both write sites + staged through `PendingSave` + born-correct at spawn; a won/lost run loads finished + halted (the guard keeps `GoalReachedSystem` inert). Continue also clamps `Target` to the baked run-length so a pre-v5 `Target=10` save still reaches the final siege.
|
||||
|
||||
## Validation
|
||||
- **EditMode: 342/342** (330 prior + 12: 7 END-2 [arms-once+enter, Charge clamp, Victory edge, Loss edge no-soft-effects, **normal-overrun-stays-soft regression**, restored-Victory-no-rearm, SiegeTimeout-not-culling-final] + the review's 5: full-pipeline arm-not-stomped, FinalSiegeMultiplier override + sub-1 floor, SaveData v5 RunOutcome round-trip + pre-v5-defaults-InProgress). Existing CyclePhase/Threat/Tuning/Core/Save suites stay green (no regression).
|
||||
- **Play (live netcode, focused editor):** world creation clean — **no ordering cycle** from `GoalReachedSystem [UpdateAfter(CyclePhaseSystem)]`; the ghost **re-bake** is clean (`RunOutcome [GhostField]` replicates server==client — ClientWorld read `RunOutcome=InProgress`; `RunPhase` server-only — ClientWorld count 0); `Target=4` born-correct; `Goal` already climbing (2/4 via survived scheduled sieges → whole pipeline ticking). Zero console errors.
|
||||
- **Operator fun-gate is OPEN** (the slice's SL-5 work): grind the meter to full, read the final-siege escalation + telegraph, win/lose; tune `FinalSiegeMultiplier`. The retry/quit-on-banner overlay + the rich pre-final telegraph are SL-5.
|
||||
|
||||
## Deliberate cuts / notes
|
||||
- **Retry/quit-on-banner overlay** — NOT built (SL-5). The pause menu (Quit-to-Menu/Desktop, autosave) is the in-session escape hatch; the banner shows "Esc — menu".
|
||||
- **Rich final-siege telegraph** (≥3–5 s hum/sky shift/"FINAL SIEGE INCOMING") — SL-5. SL-3 reuses the existing arm-delay telegraph (PhaseEndTick countdown).
|
||||
- **Client-derive-the-banner / no-rehash** — REJECTED by review (fragile late-join). Replicating one byte + one deliberate re-bake is the correct trade.
|
||||
|
||||
## Files
|
||||
- New (Simulation): `World/RunStateComponents.cs` (RunPhase + RunOutcome + byte-const classes). New (Server): `World/GoalReachedSystem.cs`. New (Tests): `EndgameWinLoseTests.cs`.
|
||||
- Modified (Server): `World/CyclePhaseSystem.cs` (FinalDefense Victory/Loss branches + Charge clamp), `World/ThreatDirectorSystem.cs` (arming gate + SiegeTimeout-off-during-final), `World/CoreRestoreSystem.cs` + `World/CoreDamageSystem.cs` (terminal-halt guards), `World/CycleDirectorSpawnSystem.cs` (RunPhase at spawn + born-correct RunOutcome + Target clamp), `Persistence/SaveWriteSystem.cs` (persist RunOutcome).
|
||||
- Modified (Simulation): `Debug/TuningConfig.cs` (+FinalSiegeMultiplier knob + wire), `Persistence/SaveData.cs` (v5 + RunOutcome), `Persistence/SaveComponents.cs` (PendingSave.RunOutcome).
|
||||
- Modified (Authoring/Client): `Authoring/World/CycleDirectorAuthoring.cs` (Target 10→4 + bake RunOutcome), `Client/UI/WorldLauncher.cs` (stage + quit-save RunOutcome), `Client/Presentation/HudSystem.cs` (terminal banner).
|
||||
- Tests: `SavePersistenceTests.cs` (+2 RunOutcome, v5 comment fixes), `TuningConfigTests.cs` (FinalSiegeMultiplier default pin).
|
||||
- Docs: `DR-036`, this log, `Backlog` (SL-3 done), `Path_to_Fun` (END-2 built), `Milestones`.
|
||||
|
||||
## Next-session intent
|
||||
END-2 completes Path A's spine — the slice is now winnable. The remaining slice work is **tuning + polish, not code**: SL-1 (arena/camera lock), SL-2 (loop tuning, incl. the `Target=4` run-length + `FinalSiegeMultiplier`), SL-4 (visual cohesion), SL-5 (final-siege climax tuning + the retry/quit-on-banner overlay + rich telegraph + autosave-the-outcome continue/retry flow), SL-6 (polish/cut), SL-7 (package + the formal post-slice Decision-Gate note). The one open END-2 item is the **operator fun-gate**: does the climax read as distinct, prompt deliberate prep, and land the win/loss banner?
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: DR-036
|
||||
title: END-2 — the final siege + a latching win/lose (SL-3)
|
||||
status: accepted
|
||||
date: 2026-06-13
|
||||
tags:
|
||||
- decision
|
||||
- design
|
||||
- endgame
|
||||
- netcode
|
||||
- end-2
|
||||
- slice
|
||||
permalink: gamevault/07-sessions/decisions/dr-036-end2-final-siege-win-lose
|
||||
---
|
||||
|
||||
# DR-036 — END-2: The Charge Means Something (final siege, win or lose)
|
||||
|
||||
> The last un-built Path A spine mechanic and the **critical-path blocker** for the [[End_Of_Month_Game_Jam_Slice]] (slice milestone **SL-3**). Answers the slice's "no terminal win" gap. Spec: [[Path_to_Fun#END-2 — The charge means something: the cap arms a final siege, win or lose]]. Continues [[DR-034_END1_Losable_Core]] (reuses its ghost + banner pattern). Charge cadence locked in [[DR-035_End_Of_Month_Slice_Adoption#Locked fork — END-2 charge cadence (2026-06-13)]]. Session: [[2026-06-13_SL3_END2_Final_Siege_Win_Lose]].
|
||||
|
||||
## Context
|
||||
|
||||
Path A's spine was built except for the **win**: `GoalProgress.Charge` incremented past `Target` forever with no clamp and no consequence. END-1 gave a clear *loss* (soft Core overrun → transient flash) but there was no terminal *win* and no climax. END-2 makes the goal meter mean something — at the cap it arms a **bigger final siege**; surviving it **latches Victory**, a Core breach during it **latches Loss**; both show a HUD banner and the run **halts**.
|
||||
|
||||
## Operator decisions (present-the-forks ritual, 2026-06-13)
|
||||
|
||||
- **Charge cadence = siege-survived-only** (re-confirmed from DR-035): goal +1 per survived siege, the existing single writer; no Aether-deposit writer.
|
||||
- **End behavior = HALT + banner** (chosen over "keep-playing sandbox" and "retry/quit overlay now"). On Victory/Loss: latch, stop the siege director, the banner is the end-state. The existing **pause menu** (Esc → Quit-to-Menu/Desktop, with autosave) is the escape hatch; the dedicated retry/quit-on-banner overlay is deferred to **SL-5**. This is a deliberate divergence from `Path_to_Fun`'s literal "keep playing, the base is yours" minimum.
|
||||
- **`GoalProgress.Target` = 4** (was baked 10) → survive 4 sieges, then a larger 5th *final* siege. A live-tunable run-length; baked default re-baked.
|
||||
- **Persist the outcome now (SaveData v5)** rather than deferring save-durability to SL-5 — otherwise a *won* run, on Continue, replays the final siege (the 4th-siege autosave already records `Charge==Target`), erasing the win and undercutting the terminal-end the operator chose.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Two new components on the global CycleDirector ghost, split by writer** (the pre-impl review's BLOCKER — a single combined struct would have two systems read-modify-writing one component across an ordering boundary):
|
||||
- `RunPhase { byte Value }` — **server-only** (added at spawn like `CycleRuntime`/`ThreatState`, never on the ghost serializer). Consts `RunPhaseId{Normal=0, FinalDefense=1}`. SINGLE writer: `GoalReachedSystem`.
|
||||
- `RunOutcome { [GhostField] byte Value }` — **REPLICATED** (baked on the prefab → part of the ghost → one re-bake). Consts `RunOutcomeId{InProgress=0, Victory=1, Loss=2}`. SINGLE writer: `CyclePhaseSystem`. The client HUD shows the banner by **observing** it directly — the spec's "observe `RunOutcome`" — rather than a fragile client-side reconstruction (the rejected "derive from Charge+Phase+Core + a `_sawFinalSiege` latch", which had late-join/late-focus blind spots).
|
||||
|
||||
**2. New `GoalReachedSystem`** (server, `[BurstCompile]`, plain group, `[UpdateAfter(CyclePhaseSystem)]`). On the `Charge>=Target` rising edge — guarded `RunPhase==Normal && RunOutcome==InProgress` ⇒ **exactly once** — it arms a bigger final siege through the existing `ThreatState.PendingSiegeSize` entry point (`= max(1,(int)((SizeBase + ScheduleSizePerWave*wave) * FinalSiegeMultiplier))`, telegraphed via `ArmTick = TickUtil.NonZero(now+delay)`) and flips `RunPhase=FinalDefense`. It **never** writes `CycleState.Phase`/`WaveState` (CyclePhaseSystem stays sole writer) nor `GoalProgress.Charge`.
|
||||
|
||||
**3. `CyclePhaseSystem` Siege branch split on `RunPhase==FinalDefense`** (it remains the sole Phase/WaveState writer and is now the sole `RunOutcome` writer):
|
||||
- final `DefendCleared` → `RunOutcome=Victory`, Phase=Calm, **no** goal increment;
|
||||
- final Core `Current<=0` → `RunOutcome=Loss` (terminal: **no** ledger drain, **no** `OverrunTick` stamp — so the client shows the dedicated terminal Loss banner, not the soft "the Core will recover" flash; husks despawned; Phase=Calm);
|
||||
- **Normal-phase paths are byte-unchanged** (END-1 soft loss + survived-siege increment) — the highest-risk regression, pinned by a dedicated test.
|
||||
- The survived-siege increment now **clamps at the source**: `goal.Charge = math.min(goal.Charge + 1, goal.Target)`, keeping `GoalProgress` single-writer and the persisted value bounded regardless of system order.
|
||||
|
||||
**4. The halt authority is `RunOutcome != InProgress`.** Once decided: `GoalReachedSystem` + `ThreatDirectorSystem`'s two arming sources stop (gated `RunPhase==Normal && RunOutcome==InProgress`); `CoreRestoreSystem` stops regen (Core freezes at its terminal value). **`SiegeTimeout` is disabled during `FinalDefense`** (review F5 — a timeout-cull would trip `DefendCleared` and fake a Victory).
|
||||
|
||||
**5. Client HUD: a dedicated latched `_runBanner`** (NOT reusing the per-player `_downed` overlay) that observes the replicated `RunOutcome` — "THE ENGINE HOLDS / VICTORY" or "OVERRUN / THE FINAL STAND FELL", with an "Esc — menu" hint (the SL-5-deferred retry/quit hatch).
|
||||
|
||||
**6. `FinalSiegeMultiplier`** is a new live `TuningConfig` knob (default **2.5**, floored ≥1 in the `ClampKnob` default bucket + at the use-site) — added across all six touch-points incl. the `DebugTuningReport` IRpcCommand wire (unconditional type → RpcCollection hash matches across peers; safe).
|
||||
|
||||
**7. Persistence: `SaveData` → v5** adds `RunOutcome` (additive; `MinLoadableVersion` stays 2 → old saves 0-default to InProgress). Persisted at both write sites (`SaveWriteSystem` + `WorldLauncher.TrySaveFromServer`), staged through `PendingSave`, and born-correct at spawn (`CycleDirectorSpawnSystem`). A won/lost run loads **finished + halted** (the `RunOutcome` guard keeps `GoalReachedSystem` inert), so Continue never replays the climax.
|
||||
|
||||
## Why the design changed from the SL-3 note (the review earned its keep)
|
||||
|
||||
The written SL-3 note said "server-only bytes, derive client-side, no save bump." The mandatory pre-coding adversarial review (3 lenses) + a post-impl review (4 lenses) overturned both: (a) the outcome must **replicate** (`[GhostField]`) — the spec literally says "observe `RunOutcome`" and client-derivation has late-join blind spots; (b) the terminal halt + replicated outcome means Continue must persist it (**v5**) or a win is erased on reload. Net: one CycleDirector re-bake (folded with the `Target=4` re-bake) and a one-byte save field — a deliberate, logged trade.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The game has a point**: a grind-the-meter → climactic final siege → win/lose run, the minimum shippable Path-A loop. The slice's one blocker (SL-3) is cleared.
|
||||
- **Validation**: 342/342 EditMode green (+12 END-2/save tests — 7 core + the post-impl review's 5: full-pipeline arm-not-stomped, FinalSiegeMultiplier override + sub-1 floor, SaveData v5 RunOutcome round-trip + pre-v5-defaults-InProgress); Play-validated server==client (RunOutcome `[GhostField]` replicates; RunPhase stays server-only; no system-ordering cycle; `Target=4` born-correct; clean console). Two adversarial reviews (pre-coding 3-lens + post-impl 4-lens over the diff) — the post-impl pass caught a real born-correct nesting bug (M-1) since fixed.
|
||||
- **Reversible / tunable**: `FinalSiegeMultiplier` is live; `Target` is a baked run-length knob; halt-vs-keep-playing is the operator's logged choice.
|
||||
- **SL-5 hooks**: the retry/quit-on-banner overlay, a richer pre-final telegraph (≥3–5 s hum/sky shift), and `FinalSiegeMultiplier` tuning all build on this. The banner element + the replicated `RunOutcome` are the surfaces SL-5 dresses.
|
||||
- **Not done here** (deferred, logged): the dedicated retry/quit overlay (SL-5), the rich final-siege telegraph (SL-5), and the fun-gate playtest (operator).
|
||||
Reference in New Issue
Block a user