fertig-classic-games/public/src/games/wordle/WordleAI.js

171 lines
7.1 KiB
JavaScript

// 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)];
}