import InfoBox from '../components/InfoBox.astro'
import PullQuote from '../components/PullQuote.astro'
import Figure from '../components/Figure.astro'

A player who finished a Daily Challenge without typing initials right away could lose the entry for good. Switch tabs, or reload, before submitting, and the game swapped in a countdown to tomorrow with no way back to the form. The score stayed in the browser for the player's own reference, but the leaderboard never saw it. Fixing that turned into merging the two screens that handled game-over into one, and closing a trust gap that postponed submission made worse.

The two screens had grown apart because they were built for different moments. `EndGameDialog` appeared the instant a live game ended, with the board still on screen and the submission form right there. `AlreadyPlayedView` appeared on every later visit, and it only knew how to render a countdown, because by the time it existed the leaderboard write was assumed to have already happened. Wiring a submission path onto a dead-end view built to explain why there was nothing to do was the wrong shape for the fix. A structured design map locked three decisions before any implementation started: guarantee a working submission path exists until the next challenge, decide how the two views relate instead of patching around their split, and close the server-side validation gap a postponed submission would otherwise open. Implementation followed as its own issue, the map's decisions treated as settled rather than renegotiated mid-build.

The two views became one `GameRecap` component, switched by an `isRevisit` flag rather than by which route rendered it. Underneath that flag sits a second, smaller state machine: `submitted || skipped` decides whether the player sees the initials form or the results view, so pressing Skip and actually submitting land in the same place afterward.

<Figure caption="The two game-over screens merged into one GameRecap component: EndGameDialog (live game) and AlreadyPlayedView (revisited score) are now gated by isRevisit. Both render the same board, both show the same submission path, both land on the same results view.">

```mermaid
flowchart TD
  accTitle: GameRecap component merging EndGameDialog and AlreadyPlayedView
  accDescr: The two game-over screens merge into a single GameRecap component with an isRevisit flag. EndGameDialog (shown immediately after game ends with live board and submission form) and AlreadyPlayedView (shown on revisit with countdown) become different views of the same component controlled by isRevisit. Both use the same board renderer (live GameState or reconstructed from snapshot) and both lead to the submission form.
  
  subgraph game["Immediate End of Game"]
    direction LR
    endDialog["EndGameDialog<br/>(live game)"]
    endDialog -->|isRevisit = false| gameRecap["GameRecap<br/>component"]
  end
  
  subgraph revisit["Revisited Game"]
    direction LR
    alreadyPlayed["AlreadyPlayedView<br/>(countdown)"]
    alreadyPlayed -->|isRevisit = true| gameRecap2["GameRecap<br/>component"]
  end
  
  gameRecap --> board["Render board<br/>(live or snapshot)"]
  gameRecap2 --> board
  
  board --> state["State: submitted<br/>or skipped?"]
  state -->|Neither| form["Show initials<br/>form"]
  state -->|Submitted/Skipped| results["Show results<br/>view"]
  
  form --> results
  
  class gameRecap,gameRecap2,board,results accent
```

</Figure>

<InfoBox title="One board, two sources">
  A live game hands `GameRecap` its real `GameState`. A revisit has no live state to
  hand it, only the score, best word, and snake/edible positions persisted at
  the [board snapshot](/posts/endgame-and-the-share-card#the-board-outlives-the-game)
  the earlier share-card work already kept. `buildDailyResultShareState` rebuilds
  a `GameState` shape from that summary, so the same board renderer draws both.
</InfoBox>

The other half of the fix had nothing to do with the screens. Once a score could be submitted an unknown amount of time after the game ended, from data sitting in `localStorage` the whole time, the server could no longer take the client's word for what that score was. The leaderboard endpoint now replays the submitted word history through the real engine before writing anything:

```typescript
const replayResult = await replayDailyChallenge(challengeId, language, wordHistory);
if (!replayResult.ok) {
  throw error(400, 'Invalid submission');
}
// replayResult.score is what gets stored; the client's own score is
// logged only on disagreement, never trusted.
```

<Figure caption="Server-side replay validation: each submitted word replays through the engine with real word validation and metadata. The engine-computed score replaces the client's claim, preventing manipulation and closing the trust gap that delayed submission created.">

```mermaid
sequenceDiagram
  accTitle: Server-side Daily replay validation
  accDescr: On submission, the server replays the word history through the engine. Each word is looked up in the database, validated, and fed to the game engine. The resulting score is authoritative; the client's score is logged only on disagreement but never trusted.
  participant Client
  participant Server as Leaderboard<br/>Endpoint
  participant DB as Word<br/>Database
  participant Engine as Game<br/>Engine
  
  Client->>Server: Submit challenge, wordHistory, clientScore
  Note over Client: Hours later, from old localStorage
  
  Server->>Server: Get challenge seed
  loop For each word in history
    Server->>DB: Look up word metadata<br/>(is valid, letter rarity)
    DB-->>Server: Metadata
    Server->>Engine: Apply word move
    Note over Engine: Deterministic replay
    Engine-->>Server: Next board state
  end
  
  Server->>Server: Compute final score
  alt Score matches client
    Server->>DB: Store with score
    Server-->>Client: 200 OK
  else Score differs
    Server->>DB: Log disagreement
    Server->>DB: Store authoritative score
    Server-->>Client: 200 OK
  end
```

</Figure>

`replayDailyChallenge` is modeled directly on [PvP's turn processor](/posts/pvp-duel): word in, database-backed metadata lookup, a pure engine call, next state out, looped once per word instead of once per turn. The daily game already had a deterministic seed and a fixed apple-spawn queue, both needed for the replay to reproduce the original board exactly; server-side validation turned out to be a smaller addition than the screen merge that motivated it.

<PullQuote>The client's score is a claim now, not a fact.</PullQuote>

Two smaller things rode along, both signs of what two separate screens had let slip: a code-review pass caught that dropping the dialog role also dropped its implicit announcement, so the merged component's title and subtitle now sit in an `aria-live="polite"` region, and a stale `/duel` link, pointing the practice action home instead of into an AI duel, turned out to be duplicated in both `EndGameDialog` and `AlreadyPlayedView`, unnoticed because neither got much testing attention on its own. The map's decision to merge outright, rather than keep the two views distinct but consistent, was partly an argument against exactly this kind of drift: one component can't disagree with itself about where the practice link goes.