// 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}`);