// 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, }; }