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
+36 -39
View File
@@ -1,18 +1,20 @@
<script>
import { getJSON } from '$lib/api.js';
import { getJSON, postJSON } from '$lib/api.js';
let { variant = '5', onstatus } = $props();
let length = $state(5);
let maxGuesses = $state(6);
let answer = $state('');
let answer = $state(null); // revealed by the server only at win/loss
let why = $state(null);
let date = $state('');
let dict = $state(null); // Set of valid guesses
let guesses = $state([]); // submitted rows (strings)
let cols = $state([]); // server-returned colours, parallel to guesses
let current = $state(''); // the row being typed
let status = $state('playing'); // 'playing' | 'won' | 'lost'
let loading = $state(true);
let submitting = $state(false);
let message = $state('');
let ready = $state(false); // animate-in once loaded
@@ -23,12 +25,10 @@
async function load() {
loading = true; ready = false;
guesses = []; current = ''; status = 'playing'; message = '';
guesses = []; cols = []; current = ''; status = 'playing'; answer = null; why = null; message = '';
try {
const p = await getJSON('/api/puzzle/word?variant=' + variant);
const p = await getJSON('/api/puzzle/word?variant=' + variant); // holds NO answer
length = p.length; maxGuesses = p.guesses; date = p.date;
answer = atob(p.answer);
why = p.why ? atob(p.why) : null;
if (!dict || dict._len !== length) {
const words = await getJSON('/words-' + length + '.json');
dict = new Set(words); dict._len = length;
@@ -46,66 +46,63 @@
const saved = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (saved && Array.isArray(saved.guesses)) {
guesses = saved.guesses;
cols = saved.cols || [];
status = saved.status || 'playing';
answer = saved.answer ?? null;
why = saved.why ?? null;
}
} catch { /* ignore */ }
onstatus?.(summary());
}
function persist() {
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, status })); } catch { /* ignore */ }
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
onstatus?.(summary());
}
function summary() {
return { variant, date, status, tries: guesses.length, max: maxGuesses };
}
// Two-pass Wordle colouring: greens first, then yellows limited by letter counts.
function colors(guess) {
const res = Array(length).fill('absent');
const counts = {};
for (const ch of answer) counts[ch] = (counts[ch] || 0) + 1;
for (let i = 0; i < length; i++) {
if (guess[i] === answer[i]) { res[i] = 'correct'; counts[guess[i]]--; }
}
for (let i = 0; i < length; i++) {
if (res[i] === 'correct') continue;
if (counts[guess[i]] > 0) { res[i] = 'present'; counts[guess[i]]--; }
}
return res;
}
// Best-known status per key for the on-screen keyboard.
// Best-known status per key for the on-screen keyboard, from server colours.
let keyState = $derived.by(() => {
const ks = {};
const rank = { absent: 0, present: 1, correct: 2 };
for (const g of guesses) {
const cs = colors(g);
guesses.forEach((g, gi) => {
const cs = cols[gi] || [];
for (let i = 0; i < g.length; i++) {
const k = g[i];
if (!(k in ks) || rank[cs[i]] > rank[ks[k]]) ks[k] = cs[i];
if (cs[i] && (!(k in ks) || rank[cs[i]] > rank[ks[k]])) ks[k] = cs[i];
}
}
});
return ks;
});
function flash(m) { message = m; setTimeout(() => (message = ''), 1400); }
function key(k) {
if (status !== 'playing' || loading) return;
if (status !== 'playing' || loading || submitting) return;
if (k === 'enter') return submit();
if (k === 'back') { current = current.slice(0, -1); return; }
if (/^[a-z]$/.test(k) && current.length < length) current += k;
}
function submit() {
async function submit() {
if (submitting) return;
if (current.length < length) return flash('Not enough letters');
if (dict && !dict.has(current) && current !== answer) return flash('Not in word list');
guesses = [...guesses, current];
const won = current === answer;
current = '';
if (won) { status = 'won'; recordStat(true); }
else if (guesses.length >= maxGuesses) { status = 'lost'; recordStat(false); }
persist();
if (dict && !dict.has(current)) return flash('Not in word list');
submitting = true;
try {
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
guesses = [...guesses, current];
cols = [...cols, res.colors];
current = '';
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); }
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); }
persist();
} catch {
flash('Hmm — couldnt check that. Try again.');
} finally {
submitting = false;
}
}
function recordStat(won) {
@@ -136,7 +133,7 @@
function share() {
const label = variant === '6' ? 'Long Word' : 'Daily Word';
const score = status === 'won' ? guesses.length : 'X';
const grid = guesses.map((g) => colors(g).map((c) => EMOJI[c]).join('')).join('\n');
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
@@ -159,7 +156,7 @@
<div class="board" style="--len:{length}">
{#each Array(maxGuesses) as _, r (r)}
{@const g = guesses[r]}
{@const cs = g ? colors(g) : null}
{@const cs = cols[r] || null}
<div class="row">
{#each Array(length) as _, c (c)}
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
@@ -174,7 +171,7 @@
{#if status !== 'playing'}
<div class="result rise">
<p class="rmark">{status === 'won' ? '✦ nicely done ✦' : 'todays word:'}
{#if status === 'lost'}<strong>{answer.toUpperCase()}</strong>{/if}</p>
{#if status === 'lost' && answer}<strong>{answer.toUpperCase()}</strong>{/if}</p>
{#if why}<p class="why"><span class="lbl">Why this word</span>{why}</p>{/if}
{#if stats}
<div class="stats">
+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,
}
+18 -7
View File
@@ -284,16 +284,27 @@ def test_since_endpoint(tmp_path, monkeypatch):
def test_puzzle_endpoint(tmp_path, monkeypatch):
import base64
import os
import sqlite3
from goodnews import games
from goodnews.localtime import local_today
app, api = _make(tmp_path, monkeypatch)
tc = TestClient(app)
r = tc.get("/api/puzzle/word?variant=5").json()
assert r["game"] == "word" and r["variant"] == "5" and r["length"] == 5 and r["guesses"] == 6
assert len(base64.b64decode(r["answer"]).decode()) == 5
r6 = tc.get("/api/puzzle/word?variant=6").json()
assert len(base64.b64decode(r6["answer"]).decode()) == 6 and r6["guesses"] == 7
# deterministic for the same day
assert tc.get("/api/puzzle/word?variant=5").json()["answer"] == r["answer"]
# unknown variant / game → 404
assert "answer" not in r # the public puzzle response never carries the answer
assert tc.get("/api/puzzle/word?variant=6").json()["guesses"] == 7
assert tc.get("/api/puzzle/word?variant=9").status_code == 404
assert tc.get("/api/puzzle/wordsearch").status_code == 404
# server-adjudicated guessing (answer revealed only on solve / exhaustion)
c = sqlite3.connect(os.environ["GOODNEWS_DB"]); c.row_factory = sqlite3.Row
ans = games.generate_word_puzzle(c, local_today(), "5")["answer"]
mid = tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": "xxxxx", "n": 1}).json()
assert len(mid["colors"]) == 5 and mid["solved"] is False and mid["answer"] is None
win = tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": ans, "n": 2}).json()
assert win["solved"] is True and win["answer"] == ans and all(x == "correct" for x in win["colors"])
last = tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": "xxxxx", "n": 6}).json()
assert last["answer"] == ans # exhausting guesses reveals it even when wrong
# wrong length → 400
assert tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": "toolong", "n": 1}).status_code == 400