/** * The game, in the browser. * * This is a port of `envs/wordle_five/wordle_five/engine.py`, and "port" is * meant strictly: CI scores every (guess, answer) pair in the answer list * through both implementations and compares a SHA-256 of the result. If these * two files ever disagree by one tile, the build fails. That gate is what lets * the page claim it verified a recorded run rather than merely replayed it. * * Keep this module pure and dependency-free. It runs on the main thread, in a * Web Worker, and under `node --test`. */ export const WORD_LENGTH = 5; export const MAX_GUESSES = 6; /** A tile: correct position, present elsewhere, or absent. */ export type Tile = 'G' | 'Y' | 'X'; /** Five tiles, as a string. `'GYXXY'`. */ export type Pattern = string; export const ALL_GREEN: Pattern = 'G'.repeat(WORD_LENGTH); /** * Green/yellow/grey feedback, as two passes. * * The two passes are not stylistic. A letter may be marked non-grey at most as * many times as it occurs in the answer, and greens have first claim on that * allocation — so every green in the word must be resolved before any yellow * is assigned. A single pass marks the first S of SASSY yellow when BASIS has * already spent both its S's on the greens that come later. * * This is the single most common bug in implementations of this game. It is * also the bug that put a correction video on the most-watched explanation of * it ever made, so it is worth the extra loop. */ export function scoreGuess(guess: string, answer: string): Pattern { const g = guess.toLowerCase(); const a = answer.toLowerCase(); if (g.length !== a.length) { throw new Error(`length mismatch: ${guess} vs ${answer}`); } const n = a.length; const pattern: Tile[] = new Array(n).fill('X'); // Counts of each answer letter still available to yellows, keyed by char // code so this stays allocation-free in the hot loop the solver runs. const remaining = new Map(); // Pass 1 — greens claim their letters out of the pool. for (let i = 0; i < n; i += 1) { if (g[i] === a[i]) { pattern[i] = 'G'; } else { const c = a[i]!; remaining.set(c, (remaining.get(c) ?? 0) + 1); } } // Pass 2 — yellows take only what pass 1 left, left to right. for (let i = 0; i < n; i += 1) { if (pattern[i] === 'G') continue; const c = g[i]!; const left = remaining.get(c) ?? 0; if (left > 0) { pattern[i] = 'Y'; remaining.set(c, left - 1); } } return pattern.join(''); } /** * Would `candidate` have produced `pattern` for `guess`? * * This is the whole of constraint filtering, and it is also how `consistency` * decides whether a guess contradicted what the player had already been told: * a guess is consistent exactly when it was still a possible answer. */ export function isConsistent(candidate: string, guess: string, pattern: Pattern): boolean { return scoreGuess(guess, candidate) === pattern; } /** * Hard-mode legality, or null if the guess is legal. * * Three details are routinely got wrong and are deliberate here: greens are * positional and locked; yellows are COUNTED, not merely present, so a guess * must carry at least as many copies as were revealed; and grey letters are * not banned at all — hard mode places no constraint on known-absent letters. */ export function hardModeViolation( guess: string, prevGuess: string, prevPattern: Pattern, ): string | null { const g = guess.toLowerCase(); const p = prevGuess.toLowerCase(); for (let i = 0; i < prevPattern.length; i += 1) { if (prevPattern[i] === 'G' && g[i] !== p[i]) { return `${p[i]!.toUpperCase()} must stay in position ${i + 1}`; } } const need = new Map(); for (let i = 0; i < prevPattern.length; i += 1) { const tile = prevPattern[i]; if (tile === 'G' || tile === 'Y') { const c = p[i]!; need.set(c, (need.get(c) ?? 0) + 1); } } const have = new Map(); for (const c of g) have.set(c, (have.get(c) ?? 0) + 1); for (const [letter, count] of need) { if ((have.get(letter) ?? 0) < count) { const copies = count === 1 ? '' : ` ${count} copies of`; return `guess must contain${copies} ${letter.toUpperCase()}`; } } return null; } /** * FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly. * * A language's built-in RNG is not portable: Python's `random.Random(7)` is a * Mersenne Twister with no honest one-line equivalent here, so seed 7 would * pick one word in the environment and a different one in this tab. Every * permalink would then disagree with the recorded run it claims to show. A * hash sidesteps it — both sides compute the same integer from the same * string, and there is nothing to keep in step. * * `Math.imul` is what makes the 32-bit multiply exact; a plain `*` overflows * into a double and silently diverges from Python after the first few bytes. */ export function fnv1a32(text: string): number { let h = 0x811c9dc5; for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h >>> 0; } /** The hidden word for a seed. Identical in engine.py — see fnv1a32. */ export function answerForSeed(seed: number, pool: readonly string[]): string { return pool[fnv1a32(String(seed)) % pool.length]!; } /** Why a guess would be refused, or null if it is playable. */ export function rejectionReason( word: string, history: readonly [string, Pattern][], allowed: ReadonlySet, hardMode = false, ): string | null { const w = word.toLowerCase().trim(); if (w.length !== WORD_LENGTH) return `'${w}' is not ${WORD_LENGTH} letters`; if (!allowed.has(w)) return `'${w}' is not in the word list`; if (history.some(([prev]) => prev === w)) return `'${w}' has already been guessed`; if (hardMode && history.length > 0) { const last = history[history.length - 1]!; return hardModeViolation(w, last[0], last[1]); } return null; } /** The board, as the surface renders it. Snapshots, never deltas. */ export interface BoardState { seed: number; answer: string; /** Completed rows. */ rows: { guess: string; pattern: Pattern }[]; /** What is being typed into the next row, if the board is interactive. */ draft: string; /** Replies the game refused. They cost a turn of patience, not a row. */ rejected: number; status: 'playing' | 'won' | 'lost'; /** Set for one render after an illegal guess, to drive the shake. */ invalid: string | null; hardMode: boolean; } export function emptyBoard(seed: number, answer: string, hardMode = false): BoardState { return { seed, answer, rows: [], draft: '', rejected: 0, status: 'playing', invalid: null, hardMode, }; } export function isOver(board: BoardState): boolean { return board.status !== 'playing'; } /** Play a guess, returning the next board. Pure — never mutates its input. */ export function play(board: BoardState, word: string, allowed: ReadonlySet): BoardState { if (isOver(board)) return board; const history = board.rows.map((r) => [r.guess, r.pattern] as [string, Pattern]); const reason = rejectionReason(word, history, allowed, board.hardMode); if (reason) { return { ...board, rejected: board.rejected + 1, invalid: reason, draft: board.draft }; } const guess = word.toLowerCase().trim(); const pattern = scoreGuess(guess, board.answer); const rows = [...board.rows, { guess, pattern }]; const status: BoardState['status'] = pattern === ALL_GREEN ? 'won' : rows.length >= MAX_GUESSES ? 'lost' : 'playing'; return { ...board, rows, draft: '', invalid: null, status }; } /** * What a screen reader hears when a row lands. * * Not optional decoration: `prefers-reduced-motion` clamps the tile flip to * nothing, and colour alone is not a result. This sentence IS the feedback for * anyone who is not looking at the tiles. */ export function announceRow(guess: string, pattern: Pattern, remaining?: number): string { const parts = [...guess].map((letter, i) => { const tile = pattern[i]; const state = tile === 'G' ? 'placed' : tile === 'Y' ? 'present' : 'absent'; return `${letter.toUpperCase()} ${state}`; }); const tail = remaining === undefined ? '' : ` ${remaining} words remain.`; return `${guess.toUpperCase()}: ${parts.join(', ')}.${tail}`; }