fertig-classic-games/server/words/ghostEngine.js

117 lines
4.4 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 };
}
function randomFrom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}