fertig-classic-games/tools/genJumble.js

218 lines
8.9 KiB
JavaScript

// Offline generator for Jumble puzzles.
//
// Takes curated riddle/answer pairs (data/jumble-riddles.json), partitions
// each answer's letters across 4 base words, finds a real common word to
// carry each partition (as a superset of its letters), marks which letter
// positions are "circled", and scrambles everything. Writes ordered levels
// to data/jumble.json.
//
// Usage:
// node tools/genJumble.js [seed] [outFile]
//
// Deterministic: same seed -> same bank (riddle bank + common word list held
// fixed).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeRng, shuffle, letterMultiset, isSupersetMultiset } from '../src/games/jumble/JumbleLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RIDDLES_FILE = path.join(__dirname, '../data/jumble-riddles.json');
const WORDS_FILE = path.join(__dirname, '../data/wordlists/common.txt');
const OUT_FILE = process.argv[3]
? path.resolve(process.argv[3])
: path.join(__dirname, '../data/jumble.json');
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x4a4d424c; // 'JMBL'
const MAX_ATTEMPTS = 60;
const MIN_ANSWER_LEN = 6;
const MAX_ANSWER_LEN = 14;
const WORD_LEN_MIN = 4;
const WORD_LEN_MAX = 8;
// Difficulty curve: word length + bonus-answer length both grow with tier.
const TIERS = [
{ count: 12, wordLenRange: [4, 5], bonusLenRange: [6, 8] },
{ count: 12, wordLenRange: [4, 6], bonusLenRange: [7, 9] },
{ count: 12, wordLenRange: [5, 6], bonusLenRange: [8, 10] },
{ count: 12, wordLenRange: [5, 7], bonusLenRange: [9, 11] },
{ count: 10, wordLenRange: [6, 8], bonusLenRange: [10, 14] },
];
function multisetsEqual(a, b) {
if (a.size !== b.size) return false;
for (const [ch, count] of a) if (b.get(ch) !== count) return false;
return true;
}
function candidateLengths(groupSize, tier) {
const lo = Math.max(WORD_LEN_MIN, groupSize);
const hi = WORD_LEN_MAX;
const preferred = [];
for (let l = Math.max(lo, tier.wordLenRange[0]); l <= Math.min(hi, tier.wordLenRange[1]); l++) preferred.push(l);
const rest = [];
for (let l = lo; l <= hi; l++) if (!preferred.includes(l)) rest.push(l);
return [...preferred, ...rest];
}
function findWordForGroup(groupMultiset, groupSize, tier, wordsByLength, wordMultisets, usedWords, answerStripped, rng) {
for (const len of candidateLengths(groupSize, tier)) {
const bucket = wordsByLength.get(len);
if (!bucket || !bucket.length) continue;
const matches = [];
for (const w of bucket) {
if (usedWords.has(w) || w === answerStripped) continue;
if (isSupersetMultiset(wordMultisets.get(w), groupMultiset)) matches.push(w);
}
if (matches.length) return matches[Math.floor(rng() * matches.length)];
}
return null;
}
function pickCircledPositions(word, groupMultiset, rng) {
const indices = new Set();
for (const [ch, count] of groupMultiset) {
const positions = [];
for (let i = 0; i < word.length; i++) if (word[i] === ch) positions.push(i);
shuffle(positions, rng);
for (let i = 0; i < count; i++) indices.add(positions[i]);
}
return indices;
}
// Builds scrambled tile objects for `word`. Rerolls (up to 20 tries) if the
// shuffle lands on `identityTarget` (default: the word itself) so the display
// never shows an already-solved order. Tiles carry only their letter — which
// answer-slot positions are "circled" is tracked separately (see
// circledSlots below), since the circle marks a fixed spot in the SOLVED
// word, not a specific scrambled tile.
function buildScrambledTiles(word, rng, identityTarget = word) {
const tiles = word.split('').map((ch) => ({ char: ch }));
let tries = 0;
do {
shuffle(tiles, rng);
tries++;
} while (tiles.map((t) => t.char).join('') === identityTarget && tries < 20);
return tiles;
}
function buildPuzzle(riddle, tier, wordsByLength, wordMultisets, rng) {
const answerStripped = riddle.answer.replace(/[^A-Za-z]/g, '').toUpperCase();
const n = answerStripped.length;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const shuffled = shuffle(answerStripped.split(''), rng);
const base = Math.floor(n / 4);
const rem = n % 4;
const sizes = [base, base, base, base];
const bumpOrder = shuffle([0, 1, 2, 3], rng);
for (let i = 0; i < rem; i++) sizes[bumpOrder[i]]++;
const groups = [];
let p = 0;
for (let i = 0; i < 4; i++) { groups.push(shuffled.slice(p, p + sizes[i])); p += sizes[i]; }
const usedWords = new Set();
const words = [];
let ok = true;
for (const group of groups) {
const groupMultiset = letterMultiset(group);
const word = findWordForGroup(groupMultiset, group.length, tier, wordsByLength, wordMultisets, usedWords, answerStripped, rng);
if (!word) { ok = false; break; }
usedWords.add(word);
const circledIndices = pickCircledPositions(word, groupMultiset, rng);
const tiles = buildScrambledTiles(word, rng);
const circledSlots = Array.from({ length: word.length }, (_, i) => circledIndices.has(i));
words.push({ solution: word, scrambled: tiles, circledSlots });
}
if (!ok) continue;
// Bonus pool: each word's circled letters, in that word's solved
// (left-to-right) order, concatenated in word order. Read directly off
// the solution string at the circled slot positions — no dependency on
// how the tray tiles ended up scrambled.
const bonusLetters = [];
for (const w of words) {
for (let i = 0; i < w.solution.length; i++) {
if (w.circledSlots[i]) bonusLetters.push(w.solution[i]);
}
}
const bonusMultiset = letterMultiset(bonusLetters);
const answerMultiset = letterMultiset(answerStripped.split(''));
if (!multisetsEqual(bonusMultiset, answerMultiset)) continue; // defensive; should never trigger
const bonusScrambled = buildScrambledTiles(bonusLetters.join(''), rng, answerStripped);
return {
riddleId: riddle.id,
riddle: riddle.setup,
words: words.map((w) => ({ solution: w.solution, scrambled: w.scrambled, circledSlots: w.circledSlots })),
bonusScrambled,
bonusAnswer: riddle.answer.toUpperCase(),
};
}
console.warn(`[jumble] could not build puzzle for riddle "${riddle.id}", skipping`);
return null;
}
// ── Load inputs ──────────────────────────────────────────────────────────────
const riddlesRaw = JSON.parse(fs.readFileSync(RIDDLES_FILE, 'utf8'));
const allRiddles = riddlesRaw.riddles ?? [];
const wordList = fs.readFileSync(WORDS_FILE, 'utf8').split('\n').map((w) => w.trim().toUpperCase()).filter(Boolean);
const wordsByLength = new Map();
const wordMultisets = new Map();
for (const w of wordList) {
if (w.length < WORD_LEN_MIN || w.length > WORD_LEN_MAX) continue;
if (!/^[A-Z]+$/.test(w)) continue;
if (!wordsByLength.has(w.length)) wordsByLength.set(w.length, []);
wordsByLength.get(w.length).push(w);
wordMultisets.set(w, letterMultiset(w.split('')));
}
// ── Assign riddles to tiers ──────────────────────────────────────────────────
// Sort valid-length riddles ascending by answer length, then slice
// consecutively into each tier's slot count — since tiers themselves are
// ordered by increasing difficulty/length, this naturally buckets shorter
// answers into earlier tiers without needing per-tier backfill logic.
const validRiddles = [];
for (const r of allRiddles) {
const len = r.answer.replace(/[^A-Za-z]/g, '').length;
if (len < MIN_ANSWER_LEN || len > MAX_ANSWER_LEN) {
console.warn(`[jumble] skip "${r.id}": answer length ${len} out of ${MIN_ANSWER_LEN}-${MAX_ANSWER_LEN} range`);
continue;
}
validRiddles.push(r);
}
validRiddles.sort((a, b) => a.answer.replace(/[^A-Za-z]/g, '').length - b.answer.replace(/[^A-Za-z]/g, '').length);
const totalSlots = TIERS.reduce((s, t) => s + t.count, 0);
if (validRiddles.length < totalSlots) {
console.warn(`[jumble] only ${validRiddles.length} valid riddles for ${totalSlots} slots — bank will be shorter than planned`);
}
const rng = makeRng(SEED);
console.log(`[jumble] generating with seed 0x${SEED.toString(16)}`);
const puzzles = [];
let riddleIdx = 0;
for (const tier of TIERS) {
for (let i = 0; i < tier.count && riddleIdx < validRiddles.length; i++, riddleIdx++) {
const riddle = validRiddles[riddleIdx];
const puzzle = buildPuzzle(riddle, tier, wordsByLength, wordMultisets, rng);
if (!puzzle) continue;
puzzles.push({ level: puzzles.length + 1, ...puzzle });
}
}
const payload = {
generatedAt: new Date().toISOString(),
seed: SEED,
count: puzzles.length,
puzzles,
};
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
console.log(`[jumble] wrote ${puzzles.length} levels -> ${OUT_FILE}`);