Files
upbeatBytes/frontend/src/lib/games/match/board.js
T
thejayman77 89c0fbe1f6 Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.

SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
  (a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
  rep (URL stability) -> quality score, so an accepted page never retires to a
  rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
  falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
  policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).

Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
  picker (bundled data, no CDN) for the blurb editor.

Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.

Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:32:27 -04:00

84 lines
3.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}