126 lines
5.4 KiB
JavaScript
126 lines
5.4 KiB
JavaScript
// 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.');
|