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

141 lines
4.9 KiB
JavaScript

// Spelling Bee engine: NYT-style honeycomb puzzle generation.
// Pure logic — no Express. Initialized once at server start from the ENABLE list.
//
// A puzzle is 7 distinct letters (one "center" required in every word) built
// around a pangram (a word using all 7 letters). Valid words are length >= 4,
// contain the center letter, and use only the 7 puzzle letters (repeats allowed).
// The letter S is excluded (NYT convention) to avoid trivial plurals.
const MIN_LEN = 4;
const A_CODE = 'A'.charCodeAt(0);
const S_BIT = 1 << ('S'.charCodeAt(0) - A_CODE);
// Difficulty target bands for the valid-word count (smaller pool = harder).
const BANDS = {
easy: { min: 40, max: 90 },
normal: { min: 20, max: 45 },
hard: { min: 10, max: 25 },
};
let words = []; // [{ w, mask }] — candidate words (len>=4, no S, <=7 distinct)
let pangramMasks = []; // distinct 7-letter masks that have at least one pangram
// ── Helpers ───────────────────────────────────────────────────────────────────
function wordMask(w) {
let mask = 0;
for (let i = 0; i < w.length; i++) {
mask |= 1 << (w.charCodeAt(i) - A_CODE);
}
return mask;
}
function popcount(n) {
let c = 0;
while (n) { n &= n - 1; c++; }
return c;
}
function maskLetters(mask) {
const letters = [];
for (let i = 0; i < 26; i++) {
if (mask & (1 << i)) letters.push(String.fromCharCode(A_CODE + i));
}
return letters;
}
function shuffle(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;
}
// Scoring (mirrors the client SpellingBeeLogic): 4 letters = 1 pt; longer words
// = 1 pt per letter; pangram (uses all 7 distinct letters) earns a +7 bonus.
function scoreWord(w, isPangram) {
let s = w.length === 4 ? 1 : w.length;
if (isPangram) s += 7;
return s;
}
// ── Initialization ──────────────────────────────────────────────────────────────
export function initSpellingBeeDictionary(rawWords) {
words = [];
const seenPangram = new Set();
pangramMasks = [];
for (const raw of rawWords) {
const w = String(raw).toUpperCase();
if (w.length < MIN_LEN || !/^[A-Z]+$/.test(w)) continue;
const mask = wordMask(w);
if (mask & S_BIT) continue; // S is excluded from puzzles entirely
const distinct = popcount(mask);
if (distinct > 7) continue; // can never fit a 7-letter puzzle
words.push({ w, mask });
if (distinct === 7 && !seenPangram.has(mask)) {
seenPangram.add(mask);
pangramMasks.push(mask);
}
}
return { words: words.length, pangrams: pangramMasks.length };
}
// ── Puzzle generation ───────────────────────────────────────────────────────────
function buildPuzzle(puzzleMask, centerBit) {
const validWords = [];
const pangrams = [];
let maxScore = 0;
const notMask = ~puzzleMask;
for (const { w, mask } of words) {
if ((mask & notMask) !== 0) continue; // uses a letter outside the puzzle
if ((mask & centerBit) === 0) continue; // missing the center letter
const isPangram = mask === puzzleMask; // a <=7-distinct word filling all 7
validWords.push(w);
if (isPangram) pangrams.push(w);
maxScore += scoreWord(w, isPangram);
}
return { validWords, pangrams, maxScore };
}
export function generatePuzzle(difficulty = 'normal') {
const band = BANDS[difficulty] ?? BANDS.normal;
if (!pangramMasks.length) {
return { center: '', outer: [], letters: [], validWords: [], pangrams: [], maxScore: 0 };
}
let best = null;
for (let attempt = 0; attempt < 40; attempt++) {
const puzzleMask = pangramMasks[Math.floor(Math.random() * pangramMasks.length)];
const letters = maskLetters(puzzleMask);
const center = letters[Math.floor(Math.random() * letters.length)];
const centerBit = 1 << (center.charCodeAt(0) - A_CODE);
const { validWords, pangrams, maxScore } = buildPuzzle(puzzleMask, centerBit);
const candidate = { center, letters, validWords, pangrams, maxScore };
// Track the best-so-far by closeness to the band midpoint, as a fallback.
if (!best || Math.abs(validWords.length - (band.min + band.max) / 2)
< Math.abs(best.validWords.length - (band.min + band.max) / 2)) {
best = candidate;
}
if (validWords.length >= band.min && validWords.length <= band.max) {
best = candidate;
break;
}
}
const outer = shuffle(best.letters.filter((l) => l !== best.center));
return {
center: best.center,
outer,
letters: best.letters,
validWords: best.validWords,
pangrams: best.pangrams,
maxScore: best.maxScore,
};
}