Step 5 - Game flow and persistence

5 min czytania

Game flow and persistence

Wire the engine, the AI and the views into a running application, and remember settings and scores between visits.

Application state (src/state/app-state.ts)

export type GameMode = 'human-vs-human' | 'human-vs-computer';
export type StartingMark = 'X' | 'O' | 'alternate';

export interface Settings {
  readonly mode: GameMode;
  readonly difficulty: Difficulty;
  readonly startingMark: StartingMark;
  /** Which mark the human plays in human-vs-computer mode. */
  readonly humanMark: Player;
}

export interface Scoreboard {
  readonly xWins: number;
  readonly oWins: number;
  readonly draws: number;
}

export interface AppState {
  readonly game: GameState;
  readonly settings: Settings;
  readonly scoreboard: Scoreboard;
  /** True while the computer's move is pending. Blocks all input. */
  readonly isComputerThinking: boolean;
  /** Which mark opens the next round when startingMark is 'alternate'. */
  readonly nextStarter: Player;
}

Defaults: mode human-vs-computer, difficulty medium, startingMark alternate, humanMark X, all scores zero, nextStarter X.

Transitions are pure functions (state, payload) => AppState, each returning a new object: selectCell, startNewRound, changeSettings, resetScores, setComputerThinking. No transition touches the DOM or localStorage.

Round lifecycle

  1. Start. startNewRound computes the opening mark: X, O, or nextStarter when the setting is alternate. It calls createGame(openingMark) and, when alternate is active, flips nextStarter for the round after.
  2. Human move. The cell handler ignores the event when isComputerThinking is true or the game is over. Otherwise it applies the move and re-renders.
  3. Outcome check. If the new status is win or draw, increment the scoreboard exactly once, persist it, and stop. The board stays visible with the winning line highlighted until the player starts a new round; it must never auto-restart.
  4. Computer move. In human-vs-computer mode, if the game is still in progress and it is the computer's turn, set isComputerThinking, schedule chooseMove after the cosmetic delay, apply the result, and re-run from step 3.
  5. Settings change. Changing mode, difficulty, starting mark or human mark immediately abandons the current round and starts a fresh one. Scores are kept. Cancel any pending computer move timer first, so a move from the abandoned round cannot land on the new board.

Guard against double-counting: the scoreboard is incremented only on the transition from in-progress to a terminal status, never during a re-render.

If the human plays O against the computer and the round opens with X, the computer must move first without any user interaction.

Persistence (src/state/storage.ts)

Two keys, both namespaced and versioned:

  • ttt.settings.v1 — the Settings object.
  • ttt.scoreboard.v1 — the Scoreboard object.

Rules:

  • Every stored object carries a schemaVersion: 1 field.
  • Reads are defensive: wrap JSON.parse in try/catch, then validate every field against the allowed values before use. Anything missing, mistyped, out of range, negative or of an unknown schema version causes that key to be discarded and the defaults returned. Never trust storage.
  • Writes are best-effort: localStorage may throw (private mode, quota, disabled storage). Catch, log once with console.error, and continue. A storage failure must never break the game.
  • Detect availability once at startup with a write/read/delete probe; if unavailable, run entirely in memory and skip further attempts.
  • Settings are written on change; the scoreboard is written on each round outcome and on reset.
  • The in-progress board is not persisted. Reloading starts a fresh round with remembered settings and scores — simpler, and it avoids restoring a half-finished game the player has forgotten.

Scoreboard view

Shows three counters labelled X, O and Draws — in human-vs-computer mode label them You, Computer and Draws, mapped through humanMark. A Reset scores button zeroes all three after a confirmation step; use an inline "Are you sure? Yes / No" control rather than window.confirm, which is not stylable and interrupts screen-reader flow.

Composition root (src/main.ts)

The only module that may hold mutable state. It owns one let state: AppState, loads persisted values, builds views, wires callbacks, and defines a single update(next: AppState) that assigns the state and calls every render. All rendering flows through update; no view is ever re-rendered directly from a handler.

Wrap render calls in requestAnimationFrame batching only if a profile shows it is needed; a game this small should re-render synchronously in well under one frame.

Acceptance criteria

  • All state transitions are pure functions returning new AppState objects; src/state/ touches no DOM.
  • src/main.ts is the only module with a mutable module-level variable.
  • Every render goes through the single update function.
  • The scoreboard increments exactly once per finished round, verified by a test that re-renders a terminal state repeatedly.
  • With startingMark alternate, the opening mark flips every round; with X or O it is fixed.
  • Changing any setting starts a fresh round, keeps the scores and cancels any pending computer move.
  • When the human plays O and the round opens with X, the computer moves first unprompted.
  • Settings and scores survive a page reload; the in-progress board does not.
  • Corrupt, truncated, mistyped, negative or unknown-version stored data is discarded silently and replaced with defaults, covered by tests for each case.
  • The game runs correctly with localStorage throwing on every call, covered by a test with a throwing storage stub.
  • Reset scores requires an inline confirmation and does not use window.confirm.
  • Statement coverage of src/state/ is at least 90%.
  • npm run verify passes.

Dyskusja

Komentarze: 0

Brak komentarzy. Rozpocznij dyskusję.