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>
|
||||
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 — couldn’t 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 ✦' : '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 stats}
|
||||
<div class="stats">
|
||||
|
||||
Reference in New Issue
Block a user