fetchLeaderboard conflates load-board with decide-eligibility — solo PLAY AGAIN race + title-L overlay (HIGH, mechanism-traced) #98

Closed
opened 2026-06-22 17:30:01 +02:00 by bosun · 1 comment
Owner

Behavior (Surveyor chamber-playtest 2026-06-22, deployed round-15 @44ef77f, mechanism-traced in source not speculated)

fetchLeaderboard() conflates two concerns:

  • (a) load board for display
  • (b) decide initials-entry eligibility (mutates initialsPhase / pendingInitials as side-effect)

ANY fetch mutates eligibility. This creates two empirically-reproduced manifestations + one mechanism-derived latent harm.

Manifestation 1 — solo PLAY AGAIN race (EMPIRICALLY REPRODUCED)

After solo gameover → submit → PLAY AGAIN, the fresh PLAYING solo game persistently shows initialsPhase='entering' (should be 'idle' — no top-out yet). 6/6 polls over 2s, phase=playing.

Mechanism (traced in 44ef77f source):

  • startSolo() synchronously nulls leaderboard via resetLeaderboard()
  • BUT state.phase is server-driven and still reads 'gameover' until the new game's first WS state arrives
  • Render-loop's gameover-fetch guard at main.ts:1000 (if (phase==='gameover' && leaderboard===null && !leaderboardFetching)) briefly re-fires fetchLeaderboard(state.you.score) with the PREVIOUS game's score
  • fetchLeaderboard sets initialsPhase as a side-effect (qualifies ? 'entering' : 'idle')

Manifestation 2 — title-L overlay flips initialsPhase (EMPIRICALLY REPRODUCED)

Pressing L on title (high-scores overlay) flips initialsPhase from 'idle''entering'.

The inline comment at main.ts:596 is WRONG: // score=0 → won't trigger initials entry. Actually:

  • qualifies = entries.length < 10 || score > lastScore short-circuits TRUE on non-full board
  • fetchLeaderboard(0) sets 'entering' regardless of score
  • Milder than M1 (startSolo resets it if you then play solo) but same conflation

Latent harm (mechanism-derived, NOT yet force-reproduced — needs 10-entry board)

Because M1's spurious fetch re-populates leaderboard, the NEXT game's real gameover-fetch is SKIPPED (leaderboard !== null) → the NEXT game's initials-entry eligibility is decided by the PREVIOUS game's score, NOT the current one.

  • Invisible on non-full board (entries<10 ⇒ everything qualifies — which is why it's been silent)
  • Manifests when board is full AND scores straddle the cutoff (wrong player prompted for initials OR wrongly denied)

Fix-direction (Shipwright lane)

Three options that address the root conflation:

  1. Separate fetch-board from decide-eligibility — pure fetch returns data; eligibility decision happens in a separate function gated on current-game-state
  2. Gate eligibility on current game's gameover+score — not a fetch side-effect; the gameover-transition itself triggers eligibility-check using the just-completed game's score
  3. startSolo resets state.phase OR guards gameover-fetch against just-reset transition — narrowest fix, addresses M1 only

Lean: option 1 or 2 (root-cause fix) > option 3 (narrowest, may leave related issues latent). Option 2 may be cleaner because eligibility IS a property of the game-just-ended, not a fetch-time decision.

Acceptance criteria

  1. After solo gameover → submit → PLAY AGAIN, fresh PLAYING game shows initialsPhase='idle' (no spurious entering state)
  2. Pressing L on title with score=0 does NOT flip initialsPhase to 'entering'
  3. On full board: next game's eligibility decided by CURRENT game's score, not previous
  4. Inline comment at main.ts:596 corrected to match actual behavior (OR the code corrected to match the comment intent)
  5. Harness #81 extended with rows for both manifestations (M1 PLAY-AGAIN race + M2 title-L overlay), mutation-provable
  6. No regression on the leaderboard-display path (board still loads + renders correctly)

Cross-refs

  • main.ts:1000 (gameover-fetch guard)
  • main.ts:596 (wrong inline comment)
  • Surveyor chamber-playtest 7b72 (originating finding, both manifestations)
  • cellblock#81 (harness needs extension)
  • cellblock#80 / cellblock#86 / cellblock#91 (related initialsPhase state-machine work; this is the underlying conflation those fixes worked around)

Anchor

2026-06-22 Surveyor chamber-playtest. Mechanism-tracing surfaced the conflation root that operator-playtest sees as symptoms but can't reach the cause of. Two findings, same root — qualitatively stronger than per-symptom fixes.

## Behavior (Surveyor chamber-playtest 2026-06-22, deployed round-15 @44ef77f, mechanism-traced in source not speculated) **fetchLeaderboard() conflates two concerns**: - (a) load board for display - (b) decide initials-entry eligibility (mutates initialsPhase / pendingInitials as side-effect) ANY fetch mutates eligibility. This creates two empirically-reproduced manifestations + one mechanism-derived latent harm. ## Manifestation 1 — solo PLAY AGAIN race (EMPIRICALLY REPRODUCED) After solo gameover → submit → PLAY AGAIN, the fresh PLAYING solo game persistently shows `initialsPhase='entering'` (should be `'idle'` — no top-out yet). 6/6 polls over 2s, phase=playing. **Mechanism (traced in 44ef77f source)**: - startSolo() synchronously nulls `leaderboard` via resetLeaderboard() - BUT `state.phase` is server-driven and still reads `'gameover'` until the new game's first WS state arrives - Render-loop's gameover-fetch guard at main.ts:1000 (`if (phase==='gameover' && leaderboard===null && !leaderboardFetching)`) briefly re-fires `fetchLeaderboard(state.you.score)` with the PREVIOUS game's score - fetchLeaderboard sets initialsPhase as a side-effect (`qualifies ? 'entering' : 'idle'`) ## Manifestation 2 — title-L overlay flips initialsPhase (EMPIRICALLY REPRODUCED) Pressing L on title (high-scores overlay) flips initialsPhase from `'idle'` → `'entering'`. **The inline comment at main.ts:596 is WRONG**: `// score=0 → won't trigger initials entry`. Actually: - `qualifies = entries.length < 10 || score > lastScore` short-circuits TRUE on non-full board - `fetchLeaderboard(0)` sets `'entering'` regardless of score - Milder than M1 (startSolo resets it if you then play solo) but same conflation ## Latent harm (mechanism-derived, NOT yet force-reproduced — needs 10-entry board) Because M1's spurious fetch re-populates `leaderboard`, the NEXT game's real gameover-fetch is SKIPPED (`leaderboard !== null`) → the NEXT game's initials-entry eligibility is decided by the PREVIOUS game's score, NOT the current one. - Invisible on non-full board (`entries<10` ⇒ everything qualifies — which is why it's been silent) - Manifests when board is full AND scores straddle the cutoff (wrong player prompted for initials OR wrongly denied) ## Fix-direction (Shipwright lane) Three options that address the root conflation: 1. **Separate fetch-board from decide-eligibility** — pure fetch returns data; eligibility decision happens in a separate function gated on current-game-state 2. **Gate eligibility on current game's gameover+score** — not a fetch side-effect; the gameover-transition itself triggers eligibility-check using the just-completed game's score 3. **startSolo resets state.phase OR guards gameover-fetch against just-reset transition** — narrowest fix, addresses M1 only Lean: option 1 or 2 (root-cause fix) > option 3 (narrowest, may leave related issues latent). Option 2 may be cleaner because eligibility IS a property of the game-just-ended, not a fetch-time decision. ## Acceptance criteria 1. After solo gameover → submit → PLAY AGAIN, fresh PLAYING game shows `initialsPhase='idle'` (no spurious entering state) 2. Pressing L on title with score=0 does NOT flip initialsPhase to 'entering' 3. On full board: next game's eligibility decided by CURRENT game's score, not previous 4. Inline comment at main.ts:596 corrected to match actual behavior (OR the code corrected to match the comment intent) 5. Harness #81 extended with rows for both manifestations (M1 PLAY-AGAIN race + M2 title-L overlay), mutation-provable 6. No regression on the leaderboard-display path (board still loads + renders correctly) ## Cross-refs - main.ts:1000 (gameover-fetch guard) - main.ts:596 (wrong inline comment) - Surveyor chamber-playtest 7b72 (originating finding, both manifestations) - cellblock#81 (harness needs extension) - cellblock#80 / cellblock#86 / cellblock#91 (related initialsPhase state-machine work; this is the underlying conflation those fixes worked around) ## Anchor 2026-06-22 Surveyor chamber-playtest. Mechanism-tracing surfaced the conflation root that operator-playtest sees as symptoms but can't reach the cause of. Two findings, same root — qualitatively stronger than per-symptom fixes.
bosun closed this issue 2026-06-22 18:38:56 +02:00
Author
Owner

AC-tick pass — closed by PR #106 (squashed):

  1. After solo gameover → submit → PLAY AGAIN, fresh PLAYING game shows initialsPhase='idle' ✓ — M1 root-fixed via edge-trigger (lastLoopPhase) + decoupled decideInitialsEligibility. Spurious-fetch-with-previous-score eliminated.
  2. Pressing L on title with score=0 does NOT flip initialsPhase to 'entering' ✓ — M2 root-fixed via fetchLeaderboard de-side-effected (now pure load, returns bool).
  3. On full board: next game's eligibility decided by CURRENT game's score, not previous ✓ — latent harm root-fixed via edge-trigger on gameover transition (eligibility decided once per gameover, not on every loop tick or fetch).
  4. Inline comment at main.ts:596 corrected to match actual behavior (OR code corrected to match comment intent) ✓ — fetchLeaderboard(0) no longer triggers initials-entry; code matches expected behavior.
  5. Harness #81 extended with rows for both manifestations (M1 PLAY-AGAIN race + M2 title-L overlay), mutation-provable ✓ — 2 new nav rows added; BOTH discriminating mutations reproduced per Surveyor: level-trigger mutation → M1-only red; side-effect mutation → M2-only red. Per-manifestation independence proven.
  6. No regression on leaderboard-display path ✓ — 32/32 ×2 green; tsc 0; Surveyor independent verification confirmed.

BONUS substrate-discipline applied:

  • Root-cause fix closing N manifestations — 3 manifestations (M1 + M2 + latent full-board) all addressed at the conflation root rather than per-symptom papered-over
  • Edge-trigger NOT level-trigger — lastLoopPhase pattern prevents re-fire when phase persists across loops
  • Pure-load + separate-decision de-conflation — ANY future fetch can no longer mutate eligibility as side-effect; structural prevention not just policy
  • Closed-loop cross-actor cycle — Surveyor playtest mechanism-trace → tracker #98 → Shipwright opt-2 root fix → Surveyor verifies same gap closed. Same shape as #86 cycle's empirical closed-loop discipline.

Honest ceiling: real server-lagged M1 (where Surveyor originally found it on deployed substrate) confirms post-deploy — operator solo→submit→PLAY AGAIN → observe no spurious 'entering' state. Code axis + harness-seam done; live confirm is operator-device-gated.

**AC-tick pass** — closed by PR #106 (squashed): 1. **After solo gameover → submit → PLAY AGAIN, fresh PLAYING game shows initialsPhase='idle'** ✓ — M1 root-fixed via edge-trigger (lastLoopPhase) + decoupled decideInitialsEligibility. Spurious-fetch-with-previous-score eliminated. 2. **Pressing L on title with score=0 does NOT flip initialsPhase to 'entering'** ✓ — M2 root-fixed via fetchLeaderboard de-side-effected (now pure load, returns bool). 3. **On full board: next game's eligibility decided by CURRENT game's score, not previous** ✓ — latent harm root-fixed via edge-trigger on gameover transition (eligibility decided once per gameover, not on every loop tick or fetch). 4. **Inline comment at main.ts:596 corrected to match actual behavior (OR code corrected to match comment intent)** ✓ — fetchLeaderboard(0) no longer triggers initials-entry; code matches expected behavior. 5. **Harness #81 extended with rows for both manifestations (M1 PLAY-AGAIN race + M2 title-L overlay), mutation-provable** ✓ — 2 new nav rows added; BOTH discriminating mutations reproduced per Surveyor: level-trigger mutation → M1-only red; side-effect mutation → M2-only red. Per-manifestation independence proven. 6. **No regression on leaderboard-display path** ✓ — 32/32 ×2 green; tsc 0; Surveyor independent verification confirmed. BONUS substrate-discipline applied: - **Root-cause fix closing N manifestations** — 3 manifestations (M1 + M2 + latent full-board) all addressed at the conflation root rather than per-symptom papered-over - **Edge-trigger NOT level-trigger** — lastLoopPhase pattern prevents re-fire when phase persists across loops - **Pure-load + separate-decision de-conflation** — ANY future fetch can no longer mutate eligibility as side-effect; structural prevention not just policy - **Closed-loop cross-actor cycle** — Surveyor playtest mechanism-trace → tracker #98 → Shipwright opt-2 root fix → Surveyor verifies same gap closed. Same shape as #86 cycle's empirical closed-loop discipline. Honest ceiling: real server-lagged M1 (where Surveyor originally found it on deployed substrate) confirms post-deploy — operator solo→submit→PLAY AGAIN → observe no spurious 'entering' state. Code axis + harness-seam done; live confirm is operator-device-gated.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock#98
No description provided.