// Server-side Ghost dictionary + perfect-play AI. // // Ghost: players alternate appending one letter to a shared fragment. The player // who appends a letter LOSES the round if that letter completes a valid word // (length >= MIN_LEN) or makes the fragment no longer a prefix of any valid word. // // We build a trie of every ENABLE word of length >= MIN_LEN and precompute, once, // a game-theoretic value per node: `node.loss` is true when the player whose turn // it is to move FROM that fragment loses under optimal play. With that table every // AI move is O(26): walk to the fragment's node and read its children's values. const MIN_LEN = 4; let TRIE = null; export function initGhostDictionary(words) { TRIE = { children: Object.create(null), terminal: false, loss: false }; for (const w of words) { if (w.length < MIN_LEN) continue; let node = TRIE; for (const ch of w) { node = node.children[ch] || (node.children[ch] = { children: Object.create(null), terminal: false, loss: false }); } node.terminal = true; } computeLoss(TRIE); } export function dictionaryReady() { return TRIE !== null; } // Post-order: a node is a LOSS for the player to move iff it has no "winning" move. // A winning move is appending a letter L whose child is NOT a word (a safe // continuation) AND hands the opponent a losing position. Letters whose child is // terminal complete a word and so lose for the mover — never winning moves. function computeLoss(node) { let win = false; for (const L in node.children) { const child = node.children[L]; computeLoss(child); if (!child.terminal && child.loss) win = true; } node.loss = !win; } function nodeFor(fragment) { let node = TRIE; for (const ch of fragment) { node = node.children[ch]; if (!node) return null; } return node; } // ── Public: judge a completed fragment (used to grade the human's letter) ────── // Returns { isWord, isPrefix }: // isWord — fragment is a complete valid word (length >= MIN_LEN) -> mover loses // isPrefix — fragment is a prefix of some valid word; if false the mover went // "off-dictionary" and loses. export function judge(fragment) { const F = String(fragment).toUpperCase(); const node = nodeFor(F); if (!node) return { isWord: false, isPrefix: false }; return { isWord: !!node.terminal, isPrefix: true }; } // ── Public: choose the AI's letter ───────────────────────────────────────────── // Skill profiles. `blunder` = chance of a careless pick from ALL legal letters // (which may complete a word and lose). `foresight` = chance, on a careful turn, // of choosing a move that provably hands the opponent a losing position. const SKILL = { 5: { blunder: 0.00, foresight: 1.0 }, 4: { blunder: 0.05, foresight: 1.0 }, 3: { blunder: 0.18, foresight: 0.5 }, 2: { blunder: 0.40, foresight: 0.0 }, 1: { blunder: 0.65, foresight: 0.0 }, }; // Returns { letter, isWord, isPrefix } for the resulting fragment, mirroring judge(). // letter is null only when the AI is at a dead end (should already have ended). export function chooseLetter(fragment, skill) { const F = String(fragment).toUpperCase(); const node = nodeFor(F); if (!node) return { letter: null, isWord: false, isPrefix: false }; const legal = Object.keys(node.children); // every child is a valid prefix if (legal.length === 0) return { letter: null, isWord: false, isPrefix: false }; const profile = SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3]; const safe = legal.filter(L => !node.children[L].terminal); // don't complete a word let letter; if (safe.length === 0) { // Forced: every legal letter completes a word -> the AI self-loses this turn. letter = randomFrom(legal); } else if (Math.random() < profile.blunder) { // Careless: pick any legal letter, which may accidentally complete a word. letter = randomFrom(legal); } else { // Careful: never complete a word; use foresight to hand off a losing position. const winning = safe.filter(L => node.children[L].loss); letter = (winning.length && Math.random() < profile.foresight) ? randomFrom(winning) : randomFrom(safe); } const child = node.children[letter]; return { letter, isWord: !!child.terminal, isPrefix: true }; } // ── Public: example safe words from a faced prefix ───────────────────────────── // Given the fragment a player FACED (before their losing letter), return up to // `max` complete words they could have headed toward via a safe move — i.e. a // letter that does not itself complete a word. Returns [] when no safe move // existed (the player was genuinely cornered). export function suggestWords(prefix, max = 3) { const P = String(prefix).toUpperCase(); const node = nodeFor(P); if (!node) return []; const safe = Object.keys(node.children).filter(L => !node.children[L].terminal); shuffleInPlace(safe); const words = []; for (const L of safe) { const w = shortestWord(node.children[L], P + L); if (w && !words.includes(w)) words.push(w); if (words.length >= max) break; } return words; } // Shortest complete word at or below `node` (BFS), prefixed by `prefix`. function shortestWord(node, prefix) { const queue = [[node, prefix]]; let i = 0; while (i < queue.length) { const [n, p] = queue[i++]; if (n.terminal) return p; for (const L in n.children) queue.push([n.children[L], p + L]); } return null; } function shuffleInPlace(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function randomFrom(arr) { return arr[Math.floor(Math.random() * arr.length)]; }