1bc9925e40
Per Codex's v2 hardening. The GET /api/puzzle/word response no longer carries the answer at all — guesses POST to /api/puzzle/word/guess and the server returns the colour pattern, computed against the day's answer. The answer (and the "why") are revealed only once solved or the guesses are spent. This removes the "open DevTools, read the answer" issue without pretending to be a fortress (a deliberate crafted request can still peek; there's no leaderboard or prize, so that's fine). Client keeps local progress/stats; dict validation stays client-side. Trade-off accepted: each guess needs the API (the site already depends on it for today's content). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
6.0 KiB
Python
161 lines
6.0 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 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 word_puzzle_response(conn: sqlite3.Connection, date: str, variant: str) -> dict:
|
|
"""Public puzzle shape — deliberately holds NO answer. Guesses are adjudicated
|
|
server-side (see adjudicate_word_guess), so the day's word never sits in the
|
|
network response for a curious user to read."""
|
|
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"],
|
|
}
|
|
|
|
|
|
def _color(guess: str, answer: str) -> list[str]:
|
|
"""Two-pass Wordle colouring: greens first, then presents limited by counts."""
|
|
res = ["absent"] * len(answer)
|
|
counts: dict[str, int] = {}
|
|
for ch in answer:
|
|
counts[ch] = counts.get(ch, 0) + 1
|
|
for i, ch in enumerate(guess):
|
|
if i < len(answer) and ch == answer[i]:
|
|
res[i] = "correct"; counts[ch] -= 1
|
|
for i, ch in enumerate(guess):
|
|
if res[i] == "correct":
|
|
continue
|
|
if counts.get(ch, 0) > 0:
|
|
res[i] = "present"; counts[ch] -= 1
|
|
return res
|
|
|
|
|
|
def adjudicate_word_guess(conn: sqlite3.Connection, date: str, variant: str, guess: str, n: int) -> dict:
|
|
"""Colour a guess against the day's answer server-side. The answer (and 'why')
|
|
are revealed ONLY once solved or the guesses are spent — never up front."""
|
|
if variant not in WORD_VARIANTS:
|
|
variant = "5"
|
|
p = generate_word_puzzle(conn, date, variant)
|
|
length, maxg, answer = p["length"], p["guesses"], p["answer"]
|
|
guess = (guess or "").strip().lower()
|
|
if len(guess) != length or not guess.isalpha():
|
|
return {"error": "bad guess"}
|
|
solved = guess == answer
|
|
reveal = solved or n >= maxg
|
|
return {
|
|
"colors": _color(guess, answer),
|
|
"solved": solved,
|
|
"answer": answer if reveal else None,
|
|
"why": p.get("why") if reveal else None,
|
|
}
|
|
|
|
|
|
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
|