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

195 lines
7.3 KiB
JavaScript

// Kiitos engine: dictionary word-set + prefix trie, validation, and AI move choice.
// Pure logic — no Express. Initialized once at server start from the ENABLE list.
//
// Game shape (see public/src/games/kiitos/KiitosLogic.js for the full state model):
// A shared centre sequence is built one letter at a time toward an owner's
// declared target word. On your turn you MUST play the next required letter if
// you hold it; otherwise you may "deviate" — play a letter that repurposes the
// sequence toward a new word you declare. This engine answers the dictionary
// questions (is this a word? does this prefix lead anywhere? what should the AI
// play?) that the pure logic layer deliberately does not embed.
const ABS_MIN = 4; // shortest legal Kiitos word (round 1 minimum)
const MAX_LEN = 12; // cap word length so play stays snappy
let root = null; // trie root: { word: boolean, kids: Map<char, node> }
let wordSet = null; // Set<string> of every legal Kiitos word
const completionCache = new Map(); // `${prefix}|${minLen}` -> boolean
const wordCache = new Map(); // `${prefix}|${minLen}` -> string|null
function makeNode() { return { word: false, kids: new Map() }; }
export function initKiitosDictionary(words) {
root = makeNode();
wordSet = new Set();
completionCache.clear();
wordCache.clear();
for (const raw of words) {
const w = String(raw).toUpperCase();
if (w.length < ABS_MIN || w.length > MAX_LEN || !/^[A-Z]+$/.test(w)) continue;
wordSet.add(w);
let node = root;
for (const ch of w) {
let next = node.kids.get(ch);
if (!next) { next = makeNode(); node.kids.set(ch, next); }
node = next;
}
node.word = true;
}
return { words: wordSet.size };
}
export const MIN_WORD_LEN = ABS_MIN;
// ── Lookups ─────────────────────────────────────────────────────────────────────
function nodeFor(prefix) {
let n = root;
for (const ch of prefix) {
if (!n) return null;
n = n.kids.get(ch);
}
return n ?? null;
}
// Does the subtree rooted at n contain any word marker (n itself counts)?
function subtreeHasWord(n) {
if (!n) return false;
if (n.word) return true;
for (const c of n.kids.values()) if (subtreeHasWord(c)) return true;
return false;
}
// Does the subtree contain a word whose total length is >= prefixLen + depth?
function subtreeHasWordAtDepth(n, depth) {
if (!n) return false;
if (depth <= 0) return subtreeHasWord(n);
for (const c of n.kids.values()) if (subtreeHasWordAtDepth(c, depth - 1)) return true;
return false;
}
export function isValidKiitosWord(word, minLen = ABS_MIN) {
if (!wordSet) return false;
const s = String(word).toUpperCase();
return s.length >= minLen && wordSet.has(s);
}
// Is `prefix` the start of at least one legal word of length >= minLen?
export function hasCompletion(prefix, minLen = ABS_MIN) {
const key = `${prefix}|${minLen}`;
const cached = completionCache.get(key);
if (cached !== undefined) return cached;
const n = nodeFor(prefix);
const ok = n ? subtreeHasWordAtDepth(n, minLen - prefix.length) : false;
completionCache.set(key, ok);
return ok;
}
// Shallowest legal word (length >= minLen) that starts with `prefix`, or null.
export function findWord(prefix, minLen = ABS_MIN) {
const key = `${prefix}|${minLen}`;
const cached = wordCache.get(key);
if (cached !== undefined) return cached;
const start = nodeFor(prefix);
let result = null;
if (start) {
let frontier = [[start, prefix]];
while (frontier.length && !result) {
const next = [];
for (const [n, s] of frontier) {
if (n.word && s.length >= minLen) { result = s; break; }
if (s.length < MAX_LEN) for (const [ch, c] of n.kids) next.push([c, s + ch]);
}
frontier = next;
}
}
wordCache.set(key, result);
return result;
}
// ── AI move selection ─────────────────────────────────────────────────────────
// Returns one of:
// { type: 'play', letter, forced: true } — mandatory next letter
// { type: 'deviate', letter, prefix, word } — repurpose / start a word
// { type: 'pass' } — no legal move
//
// params: { hand:[letters], built, targetWord, minLen, skill, allowPrepend, superKiitos }
function letterCounts(letters) {
const m = Object.create(null);
for (const c of letters) m[c] = (m[c] || 0) + 1;
return m;
}
function weightedPick(items, weights) {
let total = 0;
for (const w of weights) total += w;
if (total <= 0) return items[Math.floor(Math.random() * items.length)];
let roll = Math.random() * total;
for (let i = 0; i < items.length; i++) {
roll -= weights[i];
if (roll <= 0) return items[i];
}
return items[items.length - 1];
}
export function chooseMove({
hand, built = '', targetWord = null, minLen = ABS_MIN,
skill = 3, allowPrepend = false, superKiitos = false,
}) {
const H = hand.map((c) => String(c).toUpperCase());
// 1. Forced play: holding the next required letter is mandatory.
if (targetWord && built.length < targetWord.length) {
const need = targetWord[built.length];
if (H.includes(need)) return { type: 'play', letter: need, forced: true };
}
// 2. Deviate / start: every letter-and-placement that leads to a real word.
const handCounts = letterCounts(H);
const seen = new Set();
const cands = [];
const consider = (prefix, letter) => {
if (seen.has(prefix)) return;
seen.add(prefix);
if (!hasCompletion(prefix, minLen)) return;
const word = findWord(prefix, minLen);
if (word) cands.push({ letter, prefix, word });
};
for (const L of new Set(H)) {
consider(built + L, L);
if (allowPrepend) consider(L + built, L);
if (superKiitos) {
for (let i = 0; i <= built.length; i++) consider(built.slice(0, i) + L + built.slice(i), L);
}
}
if (!cands.length) return { type: 'pass' };
// Score each candidate. The prize move is one that completes a word right now;
// failing that, favour words that are close to done and that the AI can help
// finish from its own hand. Higher skill sharpens these preferences and adds a
// mild taste for longer (higher-scoring) words.
const k = Math.max(1, Math.min(5, skill | 0));
const weights = cands.map((c) => {
const wordCounts = letterCounts(c.word.split(''));
const prefixCounts = letterCounts(c.prefix.split(''));
const handAfter = { ...handCounts };
handAfter[c.letter] = (handAfter[c.letter] || 0) - 1;
let need = 0, have = 0;
for (const ch of Object.keys(wordCounts)) {
const remaining = Math.max(0, wordCounts[ch] - (prefixCounts[ch] || 0));
need += remaining;
have += Math.min(remaining, Math.max(0, handAfter[ch] || 0));
}
if (need === 0) return 40 + c.word.length; // immediate completion — grab it
const selfFrac = have / need;
const closeness = 1 / (1 + need); // fewer letters left is better
const base = closeness * (0.3 + selfFrac);
return Math.pow(base, 0.6 + k * 0.25) * Math.pow(c.word.length, (k - 1) * 0.04);
});
const pick = weightedPick(cands, weights);
return { type: 'deviate', letter: pick.letter, prefix: pick.prefix, word: pick.word };
}