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:
+135
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user