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:
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user