// 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 } let wordSet = null; // Set 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 }; }