Play hub + Daily Word game (Phase 1 of the games feature)

A calm /play space — "after the brief, a small thing to enjoy." Framework-ready
for more games (Word Search next; zen/coloring later).

* Daily Word (5 letters / 6 guesses) + Long Word (6 / 7) — same Wordle mechanic,
  Upbeat Bytes flavor (no "Wordle" in the UI). Hopeful answers; after solving, a
  one-line "why this word matters."
* LLM proposes, code disposes: answers are picked deterministically by date-seed
  from a hand-curated hopeful pool that's pre-validated ⊆ the guess dictionary
  (always typeable), avoiding recent repeats; the LLM only adds the optional
  "why" (with fallback). daily_puzzles(date, game, variant, payload) stores them
  so everyone gets the same daily; the cycle pre-generates with the "why".
* Bundled guess dictionaries (words-5/6.json, ~12.6k/22.4k) for client-side guess
  validation — never the LLM. Answer lightly obfuscated (base64) in the payload.
* Private, gentle stats (played/solved/streak, guess distribution); spoiler-free
  emoji-grid share. No leaderboard, no timer, no streak-loss drama.
* Play in the bottom nav (replacing Browse, still on the lane rail) + the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-10 16:06:20 -04:00
parent d0fb153e46
commit 215a5c4d64
15 changed files with 668 additions and 6 deletions
+9 -1
View File
@@ -33,7 +33,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import auth, email_send, feeds, oauth_google, queries, share, sources, summarize
from . import auth, email_send, feeds, games, oauth_google, queries, share, sources, summarize
from .localtime import local_today
from .markup import reply_html_to_text, sanitize_reply_html
from .db import connect
from .filters import filter_articles, prefs_from_json
@@ -1422,6 +1423,13 @@ def create_app() -> FastAPI:
items=[Article.from_row(r) for r in rows],
)
@app.get("/api/puzzle/{game}")
def daily_puzzle(game: str, variant: str = Query("5")) -> dict:
if game != "word" or variant not in games.WORD_VARIANTS:
raise HTTPException(status_code=404, detail="no such puzzle")
with get_conn() as conn:
return games.word_puzzle_response(conn, local_today(), variant)
@app.get("/api/since", response_model=FeedResponse)
def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
# A calm welcome-back cue: accepted/non-dup/visible articles discovered
+9
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from .briefs import build_daily_brief, show_brief
from .db import connect, init_db
from .digest import send_due_digests
from .games import generate_daily_puzzles
from .localtime import local_today
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
@@ -512,6 +513,14 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc:
print(f"digest: skipped ({exc})")
# Pre-generate today's daily puzzles (with the LLM 'why'); idempotent.
try:
made = generate_daily_puzzles(conn, local_today(), client=llm_client_from_args(args))
if made:
print(f"puzzles: generated {made}")
except Exception as exc:
print(f"puzzles: skipped ({exc})")
def serve(args: argparse.Namespace) -> None:
try:
+101
View File
@@ -0,0 +1,101 @@
{
"5": [
"ample",
"amply",
"award",
"bliss",
"bloom",
"bonus",
"brave",
"charm",
"cheer",
"clear",
"dream",
"favor",
"fresh",
"gifts",
"glows",
"grace",
"green",
"happy",
"heals",
"heart",
"honor",
"hopes",
"ideal",
"learn",
"light",
"lucky",
"mends",
"mercy",
"merry",
"noble",
"peace",
"prize",
"quiet",
"reach",
"ready",
"relax",
"renew",
"savor",
"share",
"shine",
"smile",
"still",
"sunny",
"teach",
"thank",
"treat",
"trust",
"truth",
"unity",
"value",
"vital"
],
"6": [
"beauty",
"bestow",
"bright",
"credit",
"devote",
"esteem",
"gather",
"gentle",
"giving",
"growth",
"health",
"joyful",
"kindly",
"lively",
"lovely",
"mellow",
"mended",
"nature",
"praise",
"reborn",
"repair",
"rescue",
"revive",
"reward",
"secure",
"serene",
"simple",
"smiled",
"soothe",
"spirit",
"steady",
"strong",
"summit",
"superb",
"tender",
"thanks",
"thrive",
"united",
"uplift",
"valued",
"vision",
"warmth",
"wisdom",
"wonder"
]
}
+10
View File
@@ -260,6 +260,16 @@ CREATE TABLE IF NOT EXISTS feedback_replies (
);
CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id);
CREATE TABLE IF NOT EXISTS daily_puzzles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
puzzle_date TEXT NOT NULL,
game TEXT NOT NULL, -- 'word' | 'wordsearch'
variant TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (puzzle_date, game, variant)
);
CREATE TABLE IF NOT EXISTS user_follows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+129
View File
@@ -0,0 +1,129 @@
"""Daily puzzles for the calm Play hub.
Principle: **the LLM proposes, code disposes.** The LLM only contributes
creative flavor (a one-line "why today's word matters"); the daily answer is
picked deterministically by code from a pre-validated hopeful pool (every word
is guaranteed to be in the guess dictionary, so it's always typeable). Puzzles
are stored per (date, game, variant) so everyone gets the same one and shares
are comparable. Generation never blocks on or trusts the LLM for correctness.
"""
from __future__ import annotations
import base64
import hashlib
import json
import sqlite3
from pathlib import Path
_POOL = json.loads((Path(__file__).parent / "data" / "wordpool.json").read_text())
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
def _seed(*parts: str) -> int:
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
def _recent_answers(conn: sqlite3.Connection, variant: str, limit: int) -> set[str]:
rows = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE game='word' AND variant=? "
"ORDER BY puzzle_date DESC LIMIT ?",
(variant, limit),
).fetchall()
out = set()
for r in rows:
try:
out.add(json.loads(r["payload_json"])["answer"])
except (ValueError, KeyError, TypeError):
pass
return out
def _pick_answer(conn: sqlite3.Connection, date: str, variant: str) -> str:
pool = _POOL.get(variant, [])
recent = _recent_answers(conn, variant, max(1, len(pool) // 2))
start = _seed(date, "word", variant) % len(pool)
for i in range(len(pool)):
cand = pool[(start + i) % len(pool)]
if cand not in recent:
return cand
return pool[start] # pool fully cycled — allow a repeat rather than fail
def _why(client, word: str) -> str | None:
if client is None:
return None
try:
msg = [
{"role": "system", "content": "You write one short, warm, plain sentence (no preamble, no quotes) "
"on why a given word is a hopeful or uplifting one to sit with."},
{"role": "user", "content": f"Word: {word}"},
]
text = (client.chat_text(msg) or "").strip().strip('"').replace("\n", " ")
return text[:200] or None
except Exception: # noqa: BLE001 — flavor only; never block puzzle creation
return None
def generate_word_puzzle(conn: sqlite3.Connection, date: str, variant: str, client=None) -> dict:
"""Ensure a Daily/Long Word puzzle exists for (date, variant). Idempotent.
Code picks the answer; the LLM only adds the optional 'why' (with fallback)."""
if variant not in WORD_VARIANTS:
variant = "5"
existing = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?",
(date, variant),
).fetchone()
if existing:
return json.loads(existing["payload_json"])
answer = _pick_answer(conn, date, variant)
payload = {
"answer": answer,
"why": _why(client, answer),
"length": WORD_VARIANTS[variant]["length"],
"guesses": WORD_VARIANTS[variant]["guesses"],
}
conn.execute(
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'word', ?, ?)",
(date, variant, json.dumps(payload)),
)
conn.commit()
row = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?",
(date, variant),
).fetchone()
return json.loads(row["payload_json"])
def _b64(s: str | None) -> str | None:
return base64.b64encode(s.encode()).decode() if s else None
def word_puzzle_response(conn: sqlite3.Connection, date: str, variant: str) -> dict:
"""API shape: answer/why lightly obfuscated (base64) so the day's word isn't
sitting in plain network view. It's a calm, non-competitive game — not Fort Knox."""
p = generate_word_puzzle(conn, date, variant) # create on demand (no LLM) if missing
return {
"game": "word",
"variant": variant,
"date": date,
"length": p["length"],
"guesses": p["guesses"],
"answer": _b64(p["answer"]),
"why": _b64(p.get("why")),
}
def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) -> int:
"""Cycle hook: pre-generate today's word puzzles (with the LLM 'why')."""
made = 0
for variant in WORD_VARIANTS:
before = conn.execute(
"SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?", (date, variant)
).fetchone()
if not before:
generate_word_puzzle(conn, date, variant, client=client)
made += 1
return made