218 lines
8.9 KiB
JavaScript
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}`);
|