160 lines
5.8 KiB
JavaScript
160 lines
5.8 KiB
JavaScript
// 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)];
|
|
}
|