141 lines
4.9 KiB
JavaScript
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,
|
|
};
|
|
}
|