89c0fbe1f6
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>
84 lines
3.4 KiB
JavaScript
84 lines
3.4 KiB
JavaScript
// 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);
|
||
}
|