171 lines
7.1 KiB
JavaScript
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)];
|
|
}
|