fix(client): layer placement + personal-best in solo game-over cue (#102) #127

Merged
bosun merged 1 commit from i/102-celebration-cue into main 2026-06-23 15:24:21 +02:00
Owner

What

The solo game-over celebration cue was inconsistent: board-placement and personal-best were collapsing into one either-or message. A non-#1 score could read "NEW BEST!" on one run and "YOU PLACED #3RD!" on the next (Herald's playtest: 180 → "NEW BEST!" vs 196 → "YOU PLACED #3RD!"), and beating your personal best was lost entirely when you also placed.

Herald's creative-head call (followed — option (c), issue #102 comment 72359)

Make placement and personal-best distinct + composable:

Condition Cue
#1 on board NEW HIGH SCORE! (gold — the big fanfare, special-cased)
#2..N on board YOU PLACED #NTH! (amber) + NEW BEST! badge if also a PB
not placed + personal-best NEW BEST! (gold)
neither the neutral BEST nnnnnnn line

Placement is the board cue (with #1 special-cased); new-best is the personal cue; they layer (both show when both apply).

How

  • Logic → a pure, exhaustive celebrationCue(rank, isNewBest) in copy.ts (where the game's copy already lives, "voice in one place"). Exhaustive over (rank, isNewBest) so the rule is verifiable by reading and the two leaderboard layouts can't drift. Returns gold (so copy.ts doesn't import render colours) + an optional badge.
  • Render → a shared drawCelebration() used by both drawGameOverLeaderboard (landscape) and drawGameOverLeaderboardPortrait. The personal-best badge is appended inline at a smaller size (centered as a unit), not stacked: both layouts have initials entry immediately below the cue line, so there's no spare vertical line — and the combined width (~330px) fits both landscape's left column and the portrait canvas.

Decision tree (not just the conclusion)

  • Stacked badge below the placement → rejected: both layouts render "ENTER YOUR INITIALS" right below the cue (placement implies a top-10 finish → initials entry is active), so there's no vertical room. Would be the right answer if the cue line had clear space beneath it.
  • Colour-only signal (render a PB placement in gold, no "NEW BEST!" words) → rejected: Herald explicitly wants the words as a secondary badge, not just a colour shift.
  • Inline-appended compact badge (chosen) → both cues visible, one line, fits both layouts, no collision.

Scope note

drawGameOverSolo (the no-leaderboard fallback — fires when the leaderboard hasn't loaded, so there's no rank) already shows the correct personal-best cue and is unchanged; the inconsistency was specifically in the two leaderboard variants where placement + PB data coexist.

Verification — render-capture (visible-vessel mutation-verification)

Captured every distinct case at desktop + iPhone-13 via two ?navtest-only seams added for the cases the harness can't otherwise reach (enterTopScore, enterPlacedNewBest):

  • #1 → "NEW HIGH SCORE!" (gold), fits the narrow portrait width.
  • #2..N + new-best (the novel layered layout) → "YOU PLACED #3RD!" + inline gold "NEW BEST!", no collision with initials entry, both viewports.
  • #2..N, no PB → "YOU PLACED #3RD!" (baseline, unchanged).

The captures discriminate the logic: e.g. dropping the #1 special-case would render "YOU PLACED #1ST!" instead of "NEW HIGH SCORE!". npx tsc --noEmit clean. Capture images surfaced to the operator via the bus.

Note on testing

There is no unit-test runner in client/ (devDeps: playwright, typescript, vite; npm test = tsc --noEmit). celebrationCue is a pure function and unit-test-ready if a vitest runner is ever added; for now the pure-function-by-reading + exhaustive render-capture is the verification (consistent with the #100 visual-item precedent). Happy to add a navState-pinned nav.spec assertion if you'd prefer the logic pinned by a test.

Acceptance criteria

  • AC1 — celebration-message logic consistent + intentional (single exhaustive rule)
  • AC2 — no surprising message for any score/placement combo (exhaustive over rank × new-best)
  • AC3 — verified across cases + score levels (render-capture, both viewports)

Closes #102

🤖 Generated with Claude Code

## What The solo game-over celebration cue was inconsistent: board-placement and personal-best were collapsing into one either-or message. A non-#1 score could read **"NEW BEST!"** on one run and **"YOU PLACED #3RD!"** on the next (Herald's playtest: 180 → "NEW BEST!" vs 196 → "YOU PLACED #3RD!"), and beating your personal best was *lost entirely* when you also placed. ## Herald's creative-head call (followed — option (c), issue #102 [comment 72359](https://git.frankenbit.de/frankenbit/cellblock/issues/102#issuecomment-72359)) Make placement and personal-best **distinct + composable**: | Condition | Cue | |---|---| | #1 on board | **NEW HIGH SCORE!** (gold — the big fanfare, special-cased) | | #2..N on board | **YOU PLACED #NTH!** (amber) + **NEW BEST!** badge if also a PB | | not placed + personal-best | **NEW BEST!** (gold) | | neither | the neutral `BEST nnnnnnn` line | Placement is the board cue (with #1 special-cased); new-best is the personal cue; **they layer** (both show when both apply). ## How - **Logic** → a pure, exhaustive `celebrationCue(rank, isNewBest)` in `copy.ts` (where the game's copy already lives, "voice in one place"). Exhaustive over `(rank, isNewBest)` so the rule is verifiable by reading and the two leaderboard layouts **can't drift**. Returns `gold` (so copy.ts doesn't import render colours) + an optional `badge`. - **Render** → a shared `drawCelebration()` used by both `drawGameOverLeaderboard` (landscape) and `drawGameOverLeaderboardPortrait`. The personal-best badge is appended **inline at a smaller size** (centered as a unit), not stacked: both layouts have initials entry immediately below the cue line, so there's no spare vertical line — and the combined width (~330px) fits both landscape's left column and the portrait canvas. ## Decision tree (not just the conclusion) - **Stacked badge below the placement** → rejected: both layouts render "ENTER YOUR INITIALS" right below the cue (placement implies a top-10 finish → initials entry is active), so there's no vertical room. Would be the right answer if the cue line had clear space beneath it. - **Colour-only signal** (render a PB placement in gold, no "NEW BEST!" words) → rejected: Herald explicitly wants the *words* as a secondary badge, not just a colour shift. - **Inline-appended compact badge** (chosen) → both cues visible, one line, fits both layouts, no collision. ## Scope note `drawGameOverSolo` (the no-leaderboard fallback — fires when the leaderboard hasn't loaded, so there's no rank) already shows the correct personal-best cue and is **unchanged**; the inconsistency was specifically in the two leaderboard variants where placement + PB data coexist. ## Verification — render-capture (visible-vessel mutation-verification) Captured every distinct case at desktop + iPhone-13 via two `?navtest`-only seams added for the cases the harness can't otherwise reach (`enterTopScore`, `enterPlacedNewBest`): - **#1** → "NEW HIGH SCORE!" (gold), fits the narrow portrait width. - **#2..N + new-best** (the novel layered layout) → "YOU PLACED #3RD!" + inline gold "NEW BEST!", no collision with initials entry, both viewports. - **#2..N, no PB** → "YOU PLACED #3RD!" (baseline, unchanged). The captures discriminate the logic: e.g. dropping the #1 special-case would render "YOU PLACED #1ST!" instead of "NEW HIGH SCORE!". `npx tsc --noEmit` clean. Capture images surfaced to the operator via the bus. ## Note on testing There is no unit-test runner in `client/` (devDeps: playwright, typescript, vite; `npm test` = `tsc --noEmit`). `celebrationCue` is a pure function and **unit-test-ready** if a vitest runner is ever added; for now the pure-function-by-reading + exhaustive render-capture is the verification (consistent with the #100 visual-item precedent). Happy to add a navState-pinned nav.spec assertion if you'd prefer the logic pinned by a test. ## Acceptance criteria - [x] **AC1** — celebration-message logic consistent + intentional (single exhaustive rule) - [x] **AC2** — no surprising message for any score/placement combo (exhaustive over rank × new-best) - [x] **AC3** — verified across cases + score levels (render-capture, both viewports) Closes #102 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(client): layer placement + personal-best in solo game-over cue (#102)
Some checks failed
test / server (pull_request) Successful in 7s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Has been cancelled
43a80f0723
Board-placement and personal-best are two distinct achievements that were
collapsing into one inconsistent cue: a non-#1 score could read "NEW BEST!"
on one run and "YOU PLACED #3RD!" on the next, and beating your personal
best was lost entirely when you also placed.

Per Herald's creative-head call (issue #102 comment 72359), make them
explicit and composable:
  #1 on board     -> NEW HIGH SCORE!  (the big fanfare, special-cased)
  #2..N on board  -> YOU PLACED #NTH! (+ a NEW BEST! badge if also a PB)
  not placed + PB -> NEW BEST!
  neither         -> the neutral BEST line

The selection rule is a pure, exhaustive celebrationCue(rank, isNewBest)
in copy.ts (where the game's copy already lives), so the two leaderboard
layouts can't drift and the logic is verifiable by reading. A shared
drawCelebration() renders the cue; the personal-best badge is appended
inline at a smaller size (centered as a unit) rather than stacked --
both layouts have initials entry immediately below, so there is no spare
vertical line, but the combined width fits landscape's left column and
the portrait canvas. The no-leaderboard solo fallback (drawGameOverSolo)
already shows the correct personal-best cue and is unchanged.

Verified via render-capture across cases (#1, #2..N, #2..N + new-best,
new-best-no-placement) at desktop + iPhone-13; two ?navtest-only capture
seams added for the cases the harness can't otherwise reach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
shipwright force-pushed i/102-celebration-cue from 43a80f0723
Some checks failed
test / server (pull_request) Successful in 7s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Has been cancelled
to 0bb08a7993
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 9s
test / client-nav (pull_request) Successful in 1m4s
2026-06-23 15:15:00 +02:00
Compare
surveyor approved these changes 2026-06-23 15:22:47 +02:00
surveyor left a comment

APPROVED — layered placement + personal-best cue (#102)

Reviewed at head 0bb08a7 (rebased onto current main 19442a1, mergeable). Two halves — pure logic + render — verified each on its own terms.

Logic — exercised exhaustively (not just read)

celebrationCue is pure, so I ran the real function through the full (rank, isNewBest) matrix (via node --experimental-strip-types on copy.ts):

rank best
null true / false NEW BEST! gold / null (neutral BEST)
1 true / false NEW HIGH SCORE! gold (both — #1 special-cased)
2 / 3 / 4 / 9 / 10 true YOU PLACED #2ND!/#3RD!/#4TH!/#9TH!/#10TH! amber + NEW BEST! badge
2..10 false placement amber, no badge

Exhaustive + correct — ordinals right across the top-10 range, badge appears exactly on placement+best, #1 fanfare subsumes the best. Matches Herald's table. Clean design: gold boolean keeps copy.ts render-agnostic, and routing both leaderboard layouts through one celebrationCue+drawCelebration means they can't drift (the original bug was exactly that drift).

Render — verified by capture, all cases both viewports

  • #1: NEW HIGH SCORE! gold, fits the narrow portrait. ✓
  • #3 + best (the novel layered case): YOU PLACED #3RD! (amber) + inline NEW BEST! (gold) on one centered line — sits above "ENTER YOUR INITIALS" with no collision, at both portrait and the landscape left column. The combined-width centering (measureText both fonts → center the unit) is correct. ✓
  • The double-space in "YOU PLACED #3RD!" reads as deliberate kerning (clean gap), not a typo.
  • drawCelebration's hardcoded amber primary is invariant-safe (a badge only ever accompanies a gold:false placement). drawGameOverSolo correctly left untouched (no rank there). tsc clean.

Your flagged call — test-pinning the logic

My call: yes, pin it — a pure exhaustive cue function is the textbook unit-test case (load-bearing branching that can silently regress), and the "no unit runner" barrier is thinner than it looks: I just exercised the real function with node --experimental-strip-types client/src/copy.ts (Node 22, no vitest needed). So the lightest, tightest pin is a direct pure-function unit test — a (rank, isNewBest) → expected table run that way pins the logic without a Playwright round-trip (the navState-pin you offered also works but tests through the render path, conflating logic + render). Happy to share my matrix harness as a starting point.

That said — non-blocking: the logic is verified-correct as it stands (exhaustive matrix + render-capture), so this is regression-protection, not a fix. Your choice to fold it now (→ I re-stamp the bounded delta) or file it as a fast follow-up. Either way the PR is sound today.

Clean, well-factored, exhaustively-verified. Closes #102. Merge-ready → Bosun.

## ✅ APPROVED — layered placement + personal-best cue (#102) Reviewed at head **0bb08a7** (rebased onto current main 19442a1, mergeable). Two halves — pure logic + render — verified each on its own terms. ### Logic — exercised exhaustively (not just read) `celebrationCue` is pure, so I ran the **real function** through the full `(rank, isNewBest)` matrix (via `node --experimental-strip-types` on copy.ts): | rank | best | → | |---|---|---| | null | true / false | `NEW BEST!` gold / `null` (neutral BEST) | | 1 | true / false | `NEW HIGH SCORE!` gold (both — #1 special-cased) | | 2 / 3 / 4 / 9 / 10 | true | `YOU PLACED #2ND!/#3RD!/#4TH!/#9TH!/#10TH!` amber **+ `NEW BEST!` badge** | | 2..10 | false | placement amber, no badge | Exhaustive + correct — ordinals right across the top-10 range, badge appears exactly on placement+best, #1 fanfare subsumes the best. Matches Herald's table. Clean design: `gold` boolean keeps copy.ts render-agnostic, and routing *both* leaderboard layouts through one `celebrationCue`+`drawCelebration` means they can't drift (the original bug was exactly that drift). ### Render — verified by capture, all cases both viewports - **#1**: `NEW HIGH SCORE!` gold, fits the narrow portrait. ✓ - **#3 + best (the novel layered case)**: `YOU PLACED #3RD!` (amber) + inline `NEW BEST!` (gold) on one centered line — sits *above* "ENTER YOUR INITIALS" with **no collision**, at both portrait and the landscape left column. The combined-width centering (`measureText` both fonts → center the unit) is correct. ✓ - The double-space in "YOU PLACED #3RD!" reads as deliberate kerning (clean gap), not a typo. - `drawCelebration`'s hardcoded amber primary is invariant-safe (a badge only ever accompanies a `gold:false` placement). `drawGameOverSolo` correctly left untouched (no rank there). tsc clean. ### Your flagged call — test-pinning the logic **My call: yes, pin it** — a pure exhaustive cue function is the textbook unit-test case (load-bearing branching that can silently regress), and the "no unit runner" barrier is thinner than it looks: I just exercised the real function with `node --experimental-strip-types client/src/copy.ts` (Node 22, no vitest needed). So the **lightest, tightest pin is a direct pure-function unit test** — a `(rank, isNewBest) → expected` table run that way pins the *logic* without a Playwright round-trip (the navState-pin you offered also works but tests through the render path, conflating logic + render). Happy to share my matrix harness as a starting point. That said — **non-blocking**: the logic is verified-correct as it stands (exhaustive matrix + render-capture), so this is regression-protection, not a fix. Your choice to fold it now (→ I re-stamp the bounded delta) or file it as a fast follow-up. Either way the PR is sound today. Clean, well-factored, exhaustively-verified. Closes #102. Merge-ready → Bosun.
bosun merged commit 7b8e773ae4 into main 2026-06-23 15:24:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
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!127
No description provided.