/** * The entropy solver, in the browser. * * This is what makes the page feel alive rather than pre-baked: it answers on * ANY word the visitor picks, including words no recording covers. It mirrors * `envs/wordle_five/wordle_five/solver.py` in behaviour — same opener, same * greedy rule, same tie-break — but not in implementation: Python precomputes * a 21 MB pattern matrix, which is not a thing to ship to a phone. * * Run this in a Web Worker. The opening scan is ~4,600 x 4,600 pattern * computations and will visibly jank the board on the main thread. */ import { scoreGuess, type Pattern } from './engine'; /** * The opening guess, precomputed. * * It never depends on the game state, and computing it in the browser would * cost the full 21M-pair scan on first paint for an answer that is always the * same. Regenerate with `uv run python -c "from wordle_five.solver import * _best_opener; from wordle_five.engine import answers; print(answers()[_best_opener()])"` * — and if the word list changes, this changes with it. */ export const OPENER = 'tares'; /** Expected bits of information from playing `guess` against a candidate set. */ export function entropyOf(guess: string, candidates: readonly string[]): number { const counts = new Map(); for (const candidate of candidates) { const p = scoreGuess(guess, candidate); counts.set(p, (counts.get(p) ?? 0) + 1); } const total = candidates.length; let bits = 0; for (const n of counts.values()) { const probability = n / total; bits -= probability * Math.log2(probability); } return bits; } /** Every answer still viable given the feedback so far. */ export function filterCandidates( pool: readonly string[], history: readonly { guess: string; pattern: Pattern }[], ): string[] { let alive = pool as string[]; for (const { guess, pattern } of history) { alive = alive.filter((word) => scoreGuess(guess, word) === pattern); } return alive; } export interface Suggestion { guess: string; bits: number; /** True if this guess could itself be the answer. */ viable: boolean; candidatesBefore: number; } /** * The solver's move. * * Ties break toward a guess that could actually win — free expected value, and * it costs nothing in `consistency`. When it does NOT break that way, the * solver is buying information with a word that cannot win, which is exactly * the trade the reward's counterweight prices. The `viable` flag is surfaced * so the UI can show the moment it happens. */ export function suggest( pool: readonly string[], history: readonly { guess: string; pattern: Pattern }[], /** Cap the guesses considered, for responsiveness on a phone. */ budget = 1500, ): Suggestion { const candidates = filterCandidates(pool, history); if (history.length === 0) { return { guess: OPENER, bits: entropyOf(OPENER, pool), viable: pool.includes(OPENER), candidatesBefore: pool.length }; } if (candidates.length <= 2) { const guess = candidates[0] ?? OPENER; return { guess, bits: candidates.length > 1 ? 1 : 0, viable: true, candidatesBefore: candidates.length }; } // Score every remaining candidate, plus a slice of the wider pool — a // non-candidate probe is often the better play, and considering only // candidates would quietly turn this into the candidate-only policy. const considered = new Set(candidates); for (const word of pool) { if (considered.size >= budget) break; considered.add(word); } let best: Suggestion = { guess: candidates[0]!, bits: -1, viable: true, candidatesBefore: candidates.length }; for (const guess of considered) { const bits = entropyOf(guess, candidates); const viable = candidates.includes(guess); if (bits > best.bits + 1e-12 || (Math.abs(bits - best.bits) <= 1e-12 && viable && !best.viable)) { best = { guess, bits, viable, candidatesBefore: candidates.length }; } } return best; } /** Play a whole game against a known answer. Used for the reference depth. */ export function solve(pool: readonly string[], answer: string, maxGuesses = 6): string[] { const history: { guess: string; pattern: Pattern }[] = []; const played: string[] = []; for (let turn = 0; turn < maxGuesses; turn += 1) { const { guess } = suggest(pool, history); played.push(guess); const pattern = scoreGuess(guess, answer); if (pattern === 'GGGGG') return played; history.push({ guess, pattern }); } return played; }