// Wordle AI — five distinct skill levels modelled on the Chess/Checkers style. // // Four axes control strength: // • openerTier — quality of the first guess (0=random junk → 4=optimal CRANE) // • useFilter — whether the AI eliminates words that contradict revealed info // • blunder — probability of ignoring filtering and guessing randomly (wastes a guess) // • maxCandidates — how many words to score for best information gain (0=random) // • delay — "thinking" time [lo, hi] ms (pacing, not intelligence) // // Empirically calibrated against 900 games on the curated answer pool: // Skill 1: ~35% win rate — random opener, frequently ignores revealed info. Very beatable. // Skill 2: ~76% win rate — common-word opener, blunders often, no scoring. // Skill 3: ~93% win rate — decent opener (AUDIO/RAISE tier), light scoring. // Skill 4: ~97% win rate — strong opener (CRANE/SLATE), scores 80 candidates. // Skill 5: ~97%+ win rate — always CRANE, full entropy scan, responds very fast. // The speed difference (skill 5 answers in <1 s) creates real pressure. import { evaluateGuess } from './WordleLogic.js'; const SKILL_PROFILES = { 1: { openerTier: 0, useFilter: true, blunder: 0.75, maxCandidates: 0, delay: [3800, 6500] }, 2: { openerTier: 1, useFilter: true, blunder: 0.35, maxCandidates: 0, delay: [3000, 5000] }, 3: { openerTier: 2, useFilter: true, blunder: 0.10, maxCandidates: 20, delay: [2000, 3600] }, 4: { openerTier: 3, useFilter: true, blunder: 0.02, maxCandidates: 80, delay: [1100, 2000] }, 5: { openerTier: 4, useFilter: true, blunder: 0.00, maxCandidates: Infinity, delay: [450, 950] }, }; // Opener tiers — ordered by expected information gain. // Tier 0: random from pool (no strategy) // Tier 1: a common English word (sounds natural but isn't optimised) // Tier 2: a decent Wordle opener (good letter coverage) // Tier 3: a top-tier opener (great vowel + consonant spread) // Tier 4: always CRANE (near-optimal by information theory) const OPENERS = { 1: ['ABOUT', 'LIGHT', 'WORLD', 'MIGHT', 'PLACE', 'THINK', 'MONEY', 'HEART'], 2: ['AUDIO', 'ADIEU', 'RAISE', 'STARE', 'TRAIL', 'IRATE', 'ARISE'], 3: ['CRANE', 'SLATE', 'CRATE', 'TRACE', 'LEAST'], 4: ['CRANE'], }; function profileFor(skill) { return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; } // ── Public API ──────────────────────────────────────────────────────────────── export function createAIState(targetWord, wordPool) { return { target: targetWord.toUpperCase(), guesses: [], currentRow: 0, status: 'playing', possibleWords: [...wordPool], // full pool — filtered progressively at skill 3+ }; } /** * Choose the AI's next guess. * Called each turn; aiState.guesses already contains all previous submissions. */ export function chooseGuess(aiState, skill) { const profile = profileFor(skill); const { guesses, possibleWords } = aiState; // ── First guess: opener strategy ───────────────────────────────────────── if (guesses.length === 0) { if (profile.openerTier === 0) return randomFrom(possibleWords); const pool = OPENERS[profile.openerTier]; return pool[Math.floor(Math.random() * pool.length)]; } // ── Subsequent guesses ──────────────────────────────────────────────────── // Blunder: with some probability, ignore all filtering and guess randomly. // This models the AI "forgetting" what it already knows — it may re-use // absent letters or miss present ones, wasting a guess. if (Math.random() < profile.blunder) { return randomFrom(possibleWords); } // Filter the pool to only words consistent with all guesses so far. const remaining = profile.useFilter ? filterWords(possibleWords, guesses) : possibleWords; if (remaining.length === 0) return randomFrom(possibleWords); if (remaining.length === 1) return remaining[0]; // Random pick (skill 1-2, or when pool is tiny enough that scoring adds nothing). if (profile.maxCandidates === 0) return randomFrom(remaining); // Score candidates — pick the word that minimises expected remaining pool size. const cap = Number.isFinite(profile.maxCandidates) ? Math.min(remaining.length, profile.maxCandidates) : remaining.length; // When we can't score everything, sample randomly from the remaining set. const candidates = cap >= remaining.length ? remaining : sampleWithout(remaining, cap); return bestGuess(remaining, candidates); } /** * Milliseconds the AI waits before submitting a guess. * All skill levels speed up slightly on later guesses (narrowing-down effect). * Lower skill is slower overall — they need more "thinking" time despite worse play. */ export function nextThinkDelay(skill, guessNumber) { const [lo, hi] = profileFor(skill).delay; const base = lo + Math.random() * (hi - lo); const speedup = Math.min(guessNumber * 250, hi - lo); return Math.max(lo, base - speedup); } // ── Internal helpers ────────────────────────────────────────────────────────── /** * Filter the pool to words that are consistent with every past guess. * A word W is consistent if: evaluating each prior guess against W produces * the exact same tile colours we actually saw — i.e., W could be the target. */ function filterWords(pool, guesses) { return pool.filter(candidate => { for (const { word, evaluation } of guesses) { const sim = evaluateGuess(word, candidate); for (let i = 0; i < 5; i++) { if (sim[i] !== evaluation[i]) return false; } } return true; }); } /** * Score each candidate by how evenly it partitions the remaining possible words. * Lower score = fewer expected words remaining after this guess = better pick. * Uses sum of squared bucket sizes (equivalent to minimising expected set size). */ function bestGuess(remaining, candidates) { let bestWord = candidates[0]; let bestScore = Infinity; for (const guess of candidates) { const buckets = {}; for (const target of remaining) { const key = evaluateGuess(guess, target).join(''); buckets[key] = (buckets[key] ?? 0) + 1; } const score = Object.values(buckets).reduce((s, n) => s + n * n, 0) / remaining.length; if (score < bestScore) { bestScore = score; bestWord = guess; } } return bestWord; } /** Random sample of `n` items without replacement. */ function sampleWithout(arr, n) { const copy = [...arr]; for (let i = copy.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; } return copy.slice(0, n); } function randomFrom(arr) { return arr[Math.floor(Math.random() * arr.length)]; }