Step 2 - Game engine
5 min czytania
Game engine
The engine is the heart of the system and the only part with a formally fixed API. Everything else — AI, UI, persistence — is built on top of it. It contains no DOM code, no randomness and no I/O.
Types (src/engine/types.ts)
/** The two marks. X always moves first in a fresh game unless told otherwise. */
export type Player = 'X' | 'O';
/** A single square: a mark, or null when empty. */
export type Cell = Player | null;
/** Nine cells in row-major order, index 0 = top-left, 8 = bottom-right. */
export type Board = readonly Cell[];
export type GameStatus = 'in-progress' | 'win' | 'draw';
export interface GameState {
readonly board: Board;
/** Whose turn it is. Meaningless when status !== 'in-progress'. */
readonly currentPlayer: Player;
readonly status: GameStatus;
/** The winner, or null on draw or while in progress. */
readonly winner: Player | null;
/** The three indices that won, or null. */
readonly winningLine: readonly [number, number, number] | null;
/** Cell indices in the order they were played. */
readonly moveHistory: readonly number[];
}
export class InvalidMoveError extends Error {
constructor(public readonly index: number, public readonly reason: string) { … }
}
Constants (src/engine/constants.ts)
export const BOARD_SIZE = 3;
export const CELL_COUNT = 9;
/** All eight winning triples: three rows, three columns, two diagonals. */
export const WIN_LINES: readonly (readonly [number, number, number])[] = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
WIN_LINES is written out literally rather than generated. It is the specification of the rules and
must be readable at a glance.
Functions (src/engine/game.ts)
createGame(startingPlayer: Player = 'X'): GameState
Returns a state with nine null cells, currentPlayer set to startingPlayer, status
'in-progress', no winner, no winning line and an empty history.
evaluateBoard(board: Board): { status: GameStatus; winner: Player | null; winningLine: readonly [number, number, number] | null }
Scans WIN_LINES. If any line holds three identical non-null marks, returns a win for that mark with
that line. Otherwise, if no cell is null, returns a draw. Otherwise 'in-progress'.
If two winning lines exist for the same player (possible only in positions the engine cannot itself
produce, but reachable if a board is constructed directly in tests), return the first match in
WIN_LINES order — the result must be deterministic.
applyMove(state: GameState, index: number): GameState
- Throw
InvalidMoveErrorifstate.status !== 'in-progress'(reason'game-over'). - Throw
InvalidMoveErrorifindexis not an integer in0..8(reason'out-of-range'). - Throw
InvalidMoveErrorifstate.board[index] !== null(reason'occupied'). - Otherwise produce a new board with
state.currentPlayerplaced atindex, runevaluateBoard, appendindextomoveHistory, and setcurrentPlayerto the opposite mark. The turn still flips on the winning move, socurrentPlayerafter a win is the loser; consumers must readwinner, never infer it fromcurrentPlayer.
The input state is never mutated. applyMove returns a structurally new object every time.
undoLastMove(state: GameState): GameState
Replays the game from createGame using moveHistory minus its last entry. Returns the state
unchanged when the history is empty. Replaying, rather than patching the board, guarantees status
and winner fields are always consistent.
The starting player used for the replay is derived from history length parity and the current
currentPlayer; simpler still, store nothing extra and recompute by replaying from a createGame
whose starting player equals the mark of the first move. When history is empty there is nothing to
undo, so this is always well defined.
availableMoves(state: GameState): number[]
Ascending indices of empty cells. Returns an empty array when the game is over, so callers cannot accidentally continue a finished game.
otherPlayer(player: Player): Player
Trivial helper, exported because both the AI and the UI need it and neither should re-implement it.
Test requirements (src/engine/game.test.ts)
- A fresh game has nine empty cells, X to move, status in progress.
applyMoveplaces the mark, flips the player and grows the history by one.applyMovedoes not mutate its input: the original state is deep-equal to a snapshot taken before the call.- Each of the eight
WIN_LINESis detected as a win for X and, separately, for O, with the correctwinningLine. - A full board with no line is a draw.
- Playing into an occupied cell, an out-of-range index, a non-integer index and a finished game each
throw
InvalidMoveErrorwith the documentedreason. undoLastMoveafter a winning move returns a state with status'in-progress'and the winner cleared.undoLastMoveon a fresh game is a no-op.availableMovesreturns[]for a won game even when empty cells remain.
Acceptance criteria
-
src/engine/imports nothing fromsrc/ai/,src/ui/orsrc/state/, and references no DOM or browser global. - All exported symbols match the signatures above exactly, including
readonlymodifiers. -
applyMoveandundoLastMovereturn new objects and leave their arguments untouched, proven by a deep-equality snapshot test. - All eight winning lines are detected for both players, with the correct
winningLinetriple. - A drawn board reports status
'draw'andwinner: null. - Every invalid-move path throws
InvalidMoveErrorcarrying the documentedreason. - Statement coverage of
src/engine/is at least 95%. - Every exported function has a TSDoc comment stating its behaviour on invalid input.
-
npm run verifypasses.
Dyskusja
Komentarze: 0Brak komentarzy. Rozpocznij dyskusję.