86 lines
3.2 KiB
JavaScript
86 lines
3.2 KiB
JavaScript
// Jumble — pure puzzle engine (no Phaser). Shared by the Phaser scene, the
|
|
// offline bank generator (tools/genJumble.js) and the verifier.
|
|
//
|
|
// A puzzle has 4 base words, each scrambled into tiles. Some tile letters are
|
|
// "circled" (per word). Solving all 4 words collects their circled letters
|
|
// (in solved word order) into a bonus pool; unscrambling that pool reveals
|
|
// the answer to a riddle shown on the puzzle.
|
|
|
|
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
|
|
export function makeRng(seed) {
|
|
let a = seed >>> 0;
|
|
return () => {
|
|
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
// Fisher-Yates, in place. Returns the (mutated) array.
|
|
export function shuffle(arr, rng) {
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(rng() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
// chars: string | string[] -> Map<char, count>
|
|
export function letterMultiset(chars) {
|
|
const m = new Map();
|
|
for (const ch of chars) m.set(ch, (m.get(ch) ?? 0) + 1);
|
|
return m;
|
|
}
|
|
|
|
// True when every letter in `sub` appears in `sup` at least as many times.
|
|
export function isSupersetMultiset(sup, sub) {
|
|
for (const [ch, count] of sub) {
|
|
if ((sup.get(ch) ?? 0) < count) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── Gameplay (browser-side, but pure — no Phaser deps) ───────────────────────
|
|
|
|
// arrangement: array (one entry per slot) of tile-like {char}|null.
|
|
export function checkWordGuess(arrangement, solution) {
|
|
if (arrangement.length !== solution.length) return false;
|
|
if (arrangement.some((t) => t == null)) return false;
|
|
return arrangement.map((t) => t.char).join('') === solution;
|
|
}
|
|
|
|
// `answer` may contain a space (multi-word answer) — arrangement only covers
|
|
// the letter slots (space slots are static separators, not interactive), so
|
|
// compare against the answer with spaces stripped.
|
|
export function checkBonusGuess(arrangement, answer) {
|
|
const target = answer.replace(/[^A-Z]/g, '');
|
|
if (arrangement.length !== target.length) return false;
|
|
if (arrangement.some((t) => t == null)) return false;
|
|
return arrangement.map((t) => t.char).join('') === target;
|
|
}
|
|
|
|
// Returns the index of the first slot that doesn't yet hold the correct
|
|
// letter, or -1 if `arrangement` already fully matches `solution`.
|
|
export function applyWordHint(arrangement, solution) {
|
|
for (let i = 0; i < solution.length; i++) {
|
|
if (arrangement[i]?.char !== solution[i]) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// Bonus-round hints reveal more of the puzzle at once (a full riddle letter),
|
|
// so they cost double toward the star penalty.
|
|
export const JUMBLE_HINT_WEIGHT = { word: 1, bonus: 2 };
|
|
|
|
export function starsForHints(weightedHintsUsed) {
|
|
if (weightedHintsUsed === 0) return 3;
|
|
if (weightedHintsUsed <= 2) return 2;
|
|
return 1;
|
|
}
|
|
|
|
// wordStates: bool[4] (one per base word), bonusSolved: bool.
|
|
export function isPuzzleComplete(wordStates, bonusSolved) {
|
|
return wordStates.every(Boolean) && bonusSolved;
|
|
}
|