fertig-classic-games/tools/verifyJumble.js

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.');