// Verifier for Jumble (Node only — no browser). // // Asserts every level in data/jumble.json is internally consistent: base // words are real dictionary words whose scrambles are true rearrangements, // circled letters are genuine subsets of their word, and — the critical // invariant — the circled letters collected across all 4 words exactly // anagram to the bonus answer. // // Usage: node tools/verifyJumble.js import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FILE = path.join(__dirname, '../data/jumble.json'); const WORDS_FILE = path.join(__dirname, '../data/wordlists/common.txt'); let failures = 0; const fail = (msg) => { console.error(` ✗ ${msg}`); failures++; }; const dict = new Set( fs.readFileSync(WORDS_FILE, 'utf8').split('\n').map((w) => w.trim().toUpperCase()).filter(Boolean), ); function sortedChars(str) { return str.split('').sort().join(''); } // ── Bank checks ────────────────────────────────────────────────────────────── const raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); const puzzles = raw.puzzles ?? []; console.log(`[verify] ${FILE}`); console.log(`[verify] ${puzzles.length} levels (seed 0x${(raw.seed >>> 0).toString(16)})`); if (puzzles.length < 40) fail(`expected at least 40 levels, found ${puzzles.length}`); const seenRiddleIds = new Set(); const seenWordSets = new Set(); let prevLevel = 0; for (const pz of puzzles) { const tag = `L${pz.level}`; if (pz.level !== prevLevel + 1) fail(`${tag}: level numbering gap (expected ${prevLevel + 1})`); prevLevel = pz.level; if (seenRiddleIds.has(pz.riddleId)) fail(`${tag}: duplicate riddleId "${pz.riddleId}"`); seenRiddleIds.add(pz.riddleId); if (!pz.words || pz.words.length !== 4) { fail(`${tag}: expected 4 base words, found ${pz.words?.length}`); continue; } const wordSetKey = pz.words.map((w) => w.solution).slice().sort().join('|'); if (seenWordSets.has(wordSetKey)) fail(`${tag}: duplicate 4-word set across levels`); seenWordSets.add(wordSetKey); const perWordCircledChars = []; for (const w of pz.words) { if (!dict.has(w.solution)) fail(`${tag}: "${w.solution}" is not a real dictionary word`); // Scramble must be a rearrangement of the solution (same multiset). const scrambledStr = w.scrambled.map((t) => t.char).join(''); if (sortedChars(scrambledStr) !== sortedChars(w.solution)) { fail(`${tag}: word "${w.solution}" scramble letters don't match solution letters`); } if (scrambledStr === w.solution) { fail(`${tag}: word "${w.solution}" scramble is identity order (not shuffled)`); } // circledSlots marks fixed positions in the SOLVED word (not scrambled // tiles) — read the circled letters directly off the solution string in // left-to-right order, which is exactly the order the bonus round // collects them in. if (!w.circledSlots || w.circledSlots.length !== w.solution.length) { fail(`${tag}: word "${w.solution}" circledSlots length doesn't match solution length`); } const circledChars = w.solution.split('').filter((_, i) => w.circledSlots?.[i]); if (circledChars.length === 0) { fail(`${tag}: word "${w.solution}" has no circled slots`); } perWordCircledChars.push(circledChars); } // Critical invariant: the full circled-letter multiset across all 4 words // (in word order) must exactly equal the bonus answer's letter multiset. const collected = perWordCircledChars.flat(); const bonusAnswerStripped = (pz.bonusAnswer ?? '').replace(/[^A-Z]/g, ''); if (sortedChars(collected.join('')) !== sortedChars(bonusAnswerStripped)) { fail(`${tag}: collected circled letters "${sortedChars(collected.join(''))}" != bonus answer letters "${sortedChars(bonusAnswerStripped)}"`); } // Bonus scramble must be a rearrangement of the bonus answer, and shuffled. if (!pz.bonusScrambled || !pz.bonusScrambled.length) { fail(`${tag}: missing bonusScrambled`); } else { const bonusScrambledStr = pz.bonusScrambled.map((t) => t.char).join(''); if (sortedChars(bonusScrambledStr) !== sortedChars(bonusAnswerStripped)) { fail(`${tag}: bonusScrambled letters don't match bonusAnswer letters`); } if (bonusScrambledStr === bonusAnswerStripped) { fail(`${tag}: bonusScrambled is identity order (not shuffled)`); } } } // ── Per-tier stats ─────────────────────────────────────────────────────────── const TIER_COUNTS = [12, 12, 12, 12, 10]; let idx = 0; TIER_COUNTS.forEach((count, tierIdx) => { const slice = puzzles.slice(idx, idx + count); idx += count; if (!slice.length) return; const avgWordLen = slice.flatMap((p) => p.words.map((w) => w.solution.length)).reduce((a, b) => a + b, 0) / (slice.length * 4); const avgBonusLen = slice.reduce((a, p) => a + (p.bonusAnswer ?? '').replace(/[^A-Z]/g, '').length, 0) / slice.length; console.log(`[verify] tier ${tierIdx + 1}: ${slice.length} levels, avg word len ${avgWordLen.toFixed(1)}, avg bonus len ${avgBonusLen.toFixed(1)}`); }); if (failures > 0) { console.error(`\n[verify] FAILED with ${failures} problem(s).`); process.exit(1); } console.log('\n[verify] All Jumble checks passed.');