195 lines
7.3 KiB
JavaScript
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 };
|
|
}
|