215a5c4d64
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>
130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
"""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
|