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:
@@ -1,18 +1,20 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
|
|
||||||
let { variant = '5', onstatus } = $props();
|
let { variant = '5', onstatus } = $props();
|
||||||
|
|
||||||
let length = $state(5);
|
let length = $state(5);
|
||||||
let maxGuesses = $state(6);
|
let maxGuesses = $state(6);
|
||||||
let answer = $state('');
|
let answer = $state(null); // revealed by the server only at win/loss
|
||||||
let why = $state(null);
|
let why = $state(null);
|
||||||
let date = $state('');
|
let date = $state('');
|
||||||
let dict = $state(null); // Set of valid guesses
|
let dict = $state(null); // Set of valid guesses
|
||||||
let guesses = $state([]); // submitted rows (strings)
|
let guesses = $state([]); // submitted rows (strings)
|
||||||
|
let cols = $state([]); // server-returned colours, parallel to guesses
|
||||||
let current = $state(''); // the row being typed
|
let current = $state(''); // the row being typed
|
||||||
let status = $state('playing'); // 'playing' | 'won' | 'lost'
|
let status = $state('playing'); // 'playing' | 'won' | 'lost'
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let submitting = $state(false);
|
||||||
let message = $state('');
|
let message = $state('');
|
||||||
let ready = $state(false); // animate-in once loaded
|
let ready = $state(false); // animate-in once loaded
|
||||||
|
|
||||||
@@ -23,12 +25,10 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true; ready = false;
|
loading = true; ready = false;
|
||||||
guesses = []; current = ''; status = 'playing'; message = '';
|
guesses = []; cols = []; current = ''; status = 'playing'; answer = null; why = null; message = '';
|
||||||
try {
|
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;
|
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) {
|
if (!dict || dict._len !== length) {
|
||||||
const words = await getJSON('/words-' + length + '.json');
|
const words = await getJSON('/words-' + length + '.json');
|
||||||
dict = new Set(words); dict._len = length;
|
dict = new Set(words); dict._len = length;
|
||||||
@@ -46,66 +46,63 @@
|
|||||||
const saved = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
const saved = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||||||
if (saved && Array.isArray(saved.guesses)) {
|
if (saved && Array.isArray(saved.guesses)) {
|
||||||
guesses = saved.guesses;
|
guesses = saved.guesses;
|
||||||
|
cols = saved.cols || [];
|
||||||
status = saved.status || 'playing';
|
status = saved.status || 'playing';
|
||||||
|
answer = saved.answer ?? null;
|
||||||
|
why = saved.why ?? null;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
onstatus?.(summary());
|
onstatus?.(summary());
|
||||||
}
|
}
|
||||||
function persist() {
|
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());
|
onstatus?.(summary());
|
||||||
}
|
}
|
||||||
function summary() {
|
function summary() {
|
||||||
return { variant, date, status, tries: guesses.length, max: maxGuesses };
|
return { variant, date, status, tries: guesses.length, max: maxGuesses };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-pass Wordle colouring: greens first, then yellows limited by letter counts.
|
// Best-known status per key for the on-screen keyboard, from server colours.
|
||||||
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.
|
|
||||||
let keyState = $derived.by(() => {
|
let keyState = $derived.by(() => {
|
||||||
const ks = {};
|
const ks = {};
|
||||||
const rank = { absent: 0, present: 1, correct: 2 };
|
const rank = { absent: 0, present: 1, correct: 2 };
|
||||||
for (const g of guesses) {
|
guesses.forEach((g, gi) => {
|
||||||
const cs = colors(g);
|
const cs = cols[gi] || [];
|
||||||
for (let i = 0; i < g.length; i++) {
|
for (let i = 0; i < g.length; i++) {
|
||||||
const k = g[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;
|
return ks;
|
||||||
});
|
});
|
||||||
|
|
||||||
function flash(m) { message = m; setTimeout(() => (message = ''), 1400); }
|
function flash(m) { message = m; setTimeout(() => (message = ''), 1400); }
|
||||||
|
|
||||||
function key(k) {
|
function key(k) {
|
||||||
if (status !== 'playing' || loading) return;
|
if (status !== 'playing' || loading || submitting) return;
|
||||||
if (k === 'enter') return submit();
|
if (k === 'enter') return submit();
|
||||||
if (k === 'back') { current = current.slice(0, -1); return; }
|
if (k === 'back') { current = current.slice(0, -1); return; }
|
||||||
if (/^[a-z]$/.test(k) && current.length < length) current += k;
|
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 (current.length < length) return flash('Not enough letters');
|
||||||
if (dict && !dict.has(current) && current !== answer) return flash('Not in word list');
|
if (dict && !dict.has(current)) return flash('Not in word list');
|
||||||
guesses = [...guesses, current];
|
submitting = true;
|
||||||
const won = current === answer;
|
try {
|
||||||
current = '';
|
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
|
||||||
if (won) { status = 'won'; recordStat(true); }
|
guesses = [...guesses, current];
|
||||||
else if (guesses.length >= maxGuesses) { status = 'lost'; recordStat(false); }
|
cols = [...cols, res.colors];
|
||||||
persist();
|
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 — couldn’t check that. Try again.');
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordStat(won) {
|
function recordStat(won) {
|
||||||
@@ -136,7 +133,7 @@
|
|||||||
function share() {
|
function share() {
|
||||||
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
||||||
const score = status === 'won' ? guesses.length : 'X';
|
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`;
|
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\nupbeatbytes.com/play`;
|
||||||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
if (navigator.share) navigator.share({ text }).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
@@ -159,7 +156,7 @@
|
|||||||
<div class="board" style="--len:{length}">
|
<div class="board" style="--len:{length}">
|
||||||
{#each Array(maxGuesses) as _, r (r)}
|
{#each Array(maxGuesses) as _, r (r)}
|
||||||
{@const g = guesses[r]}
|
{@const g = guesses[r]}
|
||||||
{@const cs = g ? colors(g) : null}
|
{@const cs = cols[r] || null}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{#each Array(length) as _, c (c)}
|
{#each Array(length) as _, c (c)}
|
||||||
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
|
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
|
||||||
@@ -174,7 +171,7 @@
|
|||||||
{#if status !== 'playing'}
|
{#if status !== 'playing'}
|
||||||
<div class="result rise">
|
<div class="result rise">
|
||||||
<p class="rmark">{status === 'won' ? '✦ nicely done ✦' : 'today’s word:'}
|
<p class="rmark">{status === 'won' ? '✦ nicely done ✦' : 'today’s 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 why}<p class="why"><span class="lbl">Why this word</span>{why}</p>{/if}
|
||||||
{#if stats}
|
{#if stats}
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
|
|||||||
@@ -334,6 +334,12 @@ class SourcePreview(BaseModel):
|
|||||||
examples_rejected: list[RejectedExample]
|
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):
|
class EmailStartRequest(BaseModel):
|
||||||
email: str
|
email: str
|
||||||
|
|
||||||
@@ -1430,6 +1436,16 @@ def create_app() -> FastAPI:
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
return games.word_puzzle_response(conn, local_today(), variant)
|
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)
|
@app.get("/api/since", response_model=FeedResponse)
|
||||||
def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
|
def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
|
||||||
# A calm welcome-back cue: accepted/non-dup/visible articles discovered
|
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -97,13 +96,10 @@ def generate_word_puzzle(conn: sqlite3.Connection, date: str, variant: str, clie
|
|||||||
return json.loads(row["payload_json"])
|
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:
|
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
|
"""Public puzzle shape — deliberately holds NO answer. Guesses are adjudicated
|
||||||
sitting in plain network view. It's a calm, non-competitive game — not Fort Knox."""
|
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
|
p = generate_word_puzzle(conn, date, variant) # create on demand (no LLM) if missing
|
||||||
return {
|
return {
|
||||||
"game": "word",
|
"game": "word",
|
||||||
@@ -111,8 +107,43 @@ def word_puzzle_response(conn: sqlite3.Connection, date: str, variant: str) -> d
|
|||||||
"date": date,
|
"date": date,
|
||||||
"length": p["length"],
|
"length": p["length"],
|
||||||
"guesses": p["guesses"],
|
"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
@@ -284,16 +284,27 @@ def test_since_endpoint(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_puzzle_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)
|
app, api = _make(tmp_path, monkeypatch)
|
||||||
tc = TestClient(app)
|
tc = TestClient(app)
|
||||||
r = tc.get("/api/puzzle/word?variant=5").json()
|
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 r["game"] == "word" and r["variant"] == "5" and r["length"] == 5 and r["guesses"] == 6
|
||||||
assert len(base64.b64decode(r["answer"]).decode()) == 5
|
assert "answer" not in r # the public puzzle response never carries the answer
|
||||||
r6 = tc.get("/api/puzzle/word?variant=6").json()
|
assert tc.get("/api/puzzle/word?variant=6").json()["guesses"] == 7
|
||||||
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 tc.get("/api/puzzle/word?variant=9").status_code == 404
|
assert tc.get("/api/puzzle/word?variant=9").status_code == 404
|
||||||
assert tc.get("/api/puzzle/wordsearch").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
|
||||||
|
|||||||
Reference in New Issue
Block a user