// Memory Match board engine — fully deterministic ("code disposes"): a given seed // always yields the same board, so the daily is shared across devices with no // server round-trip. Faces are stored as KEYS (icon name or color key), never raw // indices, so saved progress survives layout tweaks (Codex). import { ICON_KEYS } from './icons.js'; import { COLORS, deltaE } from './palette.js'; // Tiers — Gentle/Standard match pairs; Expert matches THREE of a kind and uses a // bigger grid so it reads as a real step up (Codex's sizing). // gentle 4×3 = 12 cards = 6 pairs // standard 4×4 = 16 cards = 8 pairs // expert 6×4 = 24 cards = 8 triples export const TIERS = { gentle: { label: 'Gentle', cols: 4, rows: 3, faces: 6, matchN: 2 }, standard: { label: 'Standard', cols: 4, rows: 4, faces: 8, matchN: 2 }, expert: { label: 'Expert', cols: 6, rows: 4, faces: 8, matchN: 3 }, }; export const TIER_KEYS = Object.keys(TIERS); export const FORMAT_KEYS = ['icons', 'colors']; // Minimum perceptual gap (ΔE) we try to keep between a board's colors so two // near-identical shades never appear together; relaxed only if a board can't fill. const MIN_DELTA_E = 26; function hashStr(s) { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function mulberry32(a) { return function () { 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; }; } function shuffle(arr, rng) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // Pick `n` colors, seed-varied but spaced: walk a seeded shuffle keeping only // colors at least `threshold` ΔE from those already chosen; relax if we can't fill. function pickColors(n, rng) { const order = shuffle(COLORS, rng); for (let threshold = MIN_DELTA_E; threshold >= 0; threshold -= 3) { const chosen = []; for (const c of order) { if (chosen.every((p) => deltaE(p.lab, c.lab) >= threshold)) chosen.push(c); if (chosen.length === n) break; } if (chosen.length === n) return chosen.map((c) => c.key); } return order.slice(0, n).map((c) => c.key); // pool too small (shouldn't happen) } function pickFaces(format, n, rng) { if (format === 'colors') return pickColors(n, rng); return shuffle(ICON_KEYS, rng).slice(0, n); } // Build a board. `seed` is the day's date (daily) or a random token (free play). export function buildBoard({ format = 'icons', tier = 'standard', seed = '' } = {}) { const t = TIERS[tier] || TIERS.standard; const fmt = FORMAT_KEYS.includes(format) ? format : 'icons'; const rng = mulberry32(hashStr(`match:v1:${fmt}:${tier}:${seed}`)); const faces = pickFaces(fmt, t.faces, rng); const multiset = []; for (const key of faces) for (let c = 0; c < t.matchN; c++) multiset.push(key); const cards = shuffle(multiset, rng).map((key, id) => ({ id, key })); return { cards, faces, matchN: t.matchN, cols: t.cols, rows: t.rows, format: fmt, tier, label: t.label }; } // A short token for a fresh free-play board (deterministic given the inputs so a // reload resumes the same board); callers persist it. export function freeSeed(n) { return 'f' + (n >>> 0).toString(36); }