Daily Word: server-adjudicate guesses (answer no longer in the response)

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>
This commit is contained in:
jay
2026-06-10 18:48:47 -04:00
parent bccf03fb77
commit 1bc9925e40
4 changed files with 110 additions and 55 deletions
+16
View File
@@ -334,6 +334,12 @@ class SourcePreview(BaseModel):
examples_rejected: list[RejectedExample]
class WordGuessRequest(BaseModel):
variant: str = "5"
guess: str
n: int = 1 # this guess's position (1-based); the answer is revealed only at n >= max
class EmailStartRequest(BaseModel):
email: str
@@ -1430,6 +1436,16 @@ def create_app() -> FastAPI:
with get_conn() as conn:
return games.word_puzzle_response(conn, local_today(), variant)
@app.post("/api/puzzle/word/guess")
def word_guess(body: WordGuessRequest) -> dict:
if body.variant not in games.WORD_VARIANTS:
raise HTTPException(status_code=404, detail="no such puzzle")
with get_conn() as conn:
res = games.adjudicate_word_guess(conn, local_today(), body.variant, body.guess, body.n)
if "error" in res:
raise HTTPException(status_code=400, detail=res["error"])
return res
@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
+40 -9
View File
@@ -10,7 +10,6 @@ are comparable. Generation never blocks on or trusts the LLM for correctness.
from __future__ import annotations
import base64
import hashlib
import json
import sqlite3
@@ -97,13 +96,10 @@ def generate_word_puzzle(conn: sqlite3.Connection, date: str, variant: str, clie
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."""
"""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",
@@ -111,8 +107,43 @@ def word_puzzle_response(conn: sqlite3.Connection, date: str, variant: str) -> d
"date": date,
"length": p["length"],
"guesses": p["guesses"],
"answer": _b64(p["answer"]),
"why": _b64(p.get("why")),
}
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,
}