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>
This commit is contained in:
jay
2026-06-18 11:32:27 -04:00
parent 2dbe73430c
commit 89c0fbe1f6
66 changed files with 6138 additions and 109 deletions
+135 -1
View File
@@ -17,6 +17,8 @@ import re
import sqlite3
from pathlib import Path
from . import bloom
_DATA = Path(__file__).parent / "data"
_POOL = json.loads((_DATA / "wordpool.json").read_text()) # curated static answer pool
# Guess dictionaries (same lists the client validates against) — used server-side to
@@ -26,6 +28,9 @@ _DICT = {v: set(json.loads((_DATA / f"words-{v}.json").read_text())) for v in ("
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
# Memory Match daily sync variants = "<tier>-<format>" (free play stays local).
MATCH_VARIANTS = {f"{t}-{f}" for t in ("gentle", "standard", "expert") for f in ("icons", "colors")}
def _seed(*parts: str) -> int:
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
@@ -625,12 +630,29 @@ def _merge_word(a: dict, b: dict) -> dict:
return a if _word_rank(a) >= _word_rank(b) else b
def _merge_bloom(a: dict, b: dict) -> dict:
"""Union found words — a find is monotonic (you can't un-find one), so the
union across devices is always correct. Score is recomputed by the sanitizer."""
found, seen = [], set()
for w in list(a.get("found") or []) + list(b.get("found") or []):
if isinstance(w, str) and w not in seen:
seen.add(w)
found.append(w)
return {"found": found}
def merge_game_state(game: str, a: dict | None, b: dict | None) -> dict:
if not a:
return dict(b or {})
if not b:
return dict(a or {})
return _merge_wordsearch(a, b) if game == "wordsearch" else _merge_word(a, b)
if game == "wordsearch":
return _merge_wordsearch(a, b)
if game == "bloom":
return _merge_bloom(a, b)
if game == "match":
return _merge_match(a, b)
return _merge_word(a, b)
def load_game_state(conn: sqlite3.Connection, user_id: int, game: str, variant: str, date: str) -> dict | None:
@@ -729,10 +751,92 @@ def _sanitize_word(variant: str, state: dict) -> dict:
return out
def _sanitize_bloom(conn: sqlite3.Connection, date: str, state: dict) -> dict:
"""Trust only finds real for THIS wheel — a word in the day's DYNAMIC accept
set (broad dict + overrides, computed live; shape-only if the puzzle doesn't
exist yet). Dedupes and recomputes score server-side; Full Bloom = reaching the
designed puzzle's total (max_score). Never trusts a client-sent score/full."""
payload = bloom.stored_payload(conn, date)
valid = (set(bloom.accepted_words(conn, payload["center"], payload["outer"], True))
if payload else None)
clean, seen = [], set()
for w in (state.get("found") or []):
if not isinstance(w, str):
continue
w = w.strip().lower()
if not w or w in seen:
continue
if valid is not None:
if w not in valid:
continue
elif not (len(w) >= 4 and w.isalpha() and "s" not in w): # no puzzle yet → shape only
continue
seen.add(w)
clean.append(w)
clean.sort()
score = bloom.score_words(payload, clean) if payload else 0
out = {"found": clean, "score": score}
if payload and clean and score >= payload.get("max_score", 1):
out["full"] = True # Full Bloom — found the whole designed puzzle
return out
_MATCH_MAX_FACES = 12 # the largest board uses 8 faces; cap generously
_MATCH_FACES = {"gentle": 6, "standard": 8, "expert": 8} # faces per tier = completion target
# Valid face keys — MIRRORS the frontend (icons.js ICON_KEYS + palette.js COLOR_KEYS).
# Matched keys are validated against this so bogus/junk keys can't inflate the
# completion count. Adding a face on the frontend? Add it here too; a missing key only
# under-counts (benign, self-heals once synced), never crashes.
_MATCH_FACE_KEYS = frozenset({
"sun", "moon", "star", "cloud", "raindrop", "wave", "leaf", "flower", "seedling",
"tree", "mountain", "shell", "feather", "acorn", "butterfly", "rainbow", "heart",
"sparkle", "home", "book", "teacup", "candle", "lantern", "compass", "kite", "note",
"boat", "fish", "bird", "mushroom", "bell", "snowflake", "clover",
"color-rose", "color-coral", "color-amber", "color-gold", "color-lime", "color-green",
"color-teal", "color-cyan", "color-sky", "color-blue", "color-indigo", "color-violet",
"color-plum", "color-brown", "color-sand", "color-slate", "color-charcoal", "color-cream",
})
def _match_faces(variant: str) -> int:
return _MATCH_FACES.get((variant or "").split("-", 1)[0], 8)
def _sanitize_match(variant: str, state: dict) -> dict:
"""Light, durability-only sanitize. Memory Match has nothing to cheat — the
board is deterministic and fully visible, with no score/leaderboard — so we
just drop malformed junk: matched FACE KEYS (icon name / color key, never raw
indices, so progress survives layout tweaks), validated against the real face set
(junk can't count), deduped, with a clamped move count. `done` is DERIVED from the
matched count vs the tier's face target — never trusted from the client, so a
stale/bogus flag can't mark a board cleared (matters once the ritual reads it)."""
seen: set[str] = set()
matched: list[str] = []
for k in (state.get("matched") or []):
if isinstance(k, str) and k in _MATCH_FACE_KEYS and k not in seen:
seen.add(k)
matched.append(k)
if len(matched) >= _MATCH_MAX_FACES:
break
return {"matched": matched, "moves": max(0, min(_int(state.get("moves")), 100_000)),
"done": len(matched) >= _match_faces(variant)}
def _merge_match(a: dict, b: dict) -> dict:
"""Union matched faces across devices, keep the larger move count. `done` is not
carried here — the post-merge sanitize re-derives it from the matched count."""
matched = list(dict.fromkeys([*(a.get("matched") or []), *(b.get("matched") or [])]))[:_MATCH_MAX_FACES]
return {"matched": matched, "moves": max(_int(a.get("moves")), _int(b.get("moves")))}
def sanitize_game_state(conn: sqlite3.Connection, game: str, variant: str, date: str, state: dict) -> dict:
"""Never trust client JSON at the storage layer — normalize before merge/store."""
if game == "wordsearch":
return _sanitize_wordsearch(conn, variant, date, state or {})
if game == "bloom":
return _sanitize_bloom(conn, date, state or {})
if game == "match":
return _sanitize_match(variant, state or {})
return _sanitize_word(variant, state or {})
@@ -770,6 +874,31 @@ def game_stats(conn: sqlite3.Connection, user_id: int, game: str, variant: str)
if game == "wordsearch":
times = [s.get("ms") for s in states if s.get("ms")]
return {"completed": sum(1 for s in states if s.get("ms")), "best": min(times) if times else 0}
if game == "bloom":
# Calm, no-pressure record: days played, lifetime words, Full Blooms, and
# the best tier ever reached (computed per day from that wheel's tiers).
tier_names = [t[0] for t in bloom.TIER_PCTS]
played = words = full = 0
best_idx = -1
for r in rows:
try:
s = json.loads(r["state_json"])
except (ValueError, TypeError):
continue
found = s.get("found") or []
if not found:
continue
played += 1
words += len(found)
if s.get("full"):
full += 1
p = bloom.stored_payload(conn, r["puzzle_date"])
if p:
sc = s.get("score") or 0
idx = max((i for i, t in enumerate(p["tiers"]) if sc >= t["score"]), default=0)
best_idx = max(best_idx, idx)
return {"played": played, "words": words, "full_blooms": full,
"best_tier": tier_names[best_idx] if best_idx >= 0 else None}
played = won = 0
dist: dict[int, int] = {}
streak = 0
@@ -823,4 +952,9 @@ def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) ->
).fetchone():
generate_wordsearch_puzzle(conn, date, client=client)
made += 1
if not conn.execute(
"SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
).fetchone():
bloom.generate_bloom_puzzle(conn, date) # pure code, no LLM
made += 1
return made