Play hub + Daily Word game (Phase 1 of the games feature)

A calm /play space — "after the brief, a small thing to enjoy." Framework-ready
for more games (Word Search next; zen/coloring later).

* Daily Word (5 letters / 6 guesses) + Long Word (6 / 7) — same Wordle mechanic,
  Upbeat Bytes flavor (no "Wordle" in the UI). Hopeful answers; after solving, a
  one-line "why this word matters."
* LLM proposes, code disposes: answers are picked deterministically by date-seed
  from a hand-curated hopeful pool that's pre-validated ⊆ the guess dictionary
  (always typeable), avoiding recent repeats; the LLM only adds the optional
  "why" (with fallback). daily_puzzles(date, game, variant, payload) stores them
  so everyone gets the same daily; the cycle pre-generates with the "why".
* Bundled guess dictionaries (words-5/6.json, ~12.6k/22.4k) for client-side guess
  validation — never the LLM. Answer lightly obfuscated (base64) in the payload.
* Private, gentle stats (played/solved/streak, guess distribution); spoiler-free
  emoji-grid share. No leaderboard, no timer, no streak-loss drama.
* Play in the bottom nav (replacing Browse, still on the lane rail) + the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-10 16:06:20 -04:00
parent d0fb153e46
commit 215a5c4d64
15 changed files with 668 additions and 6 deletions
+11
View File
@@ -11,6 +11,7 @@
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"an-array-of-english-words": "^2.0.0",
"svelte": "^5.1.0",
"vite": "^6.0.0"
}
@@ -940,6 +941,16 @@
"node": ">=0.4.0"
}
},
"node_modules/an-array-of-english-words": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/an-array-of-english-words/-/an-array-of-english-words-2.0.0.tgz",
"integrity": "sha512-FXnNvZSOI27kkKXeLSquhaTGP7z198UOQ4txaYO9fCfrjCh+D5SV7G7XqzEH0229+pAi4cjBEZ4WIQYgjKtO7Q==",
"dev": true,
"bin": {
"an-array-of-english-words": "cli.js",
"words": "cli.js"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+1
View File
@@ -12,6 +12,7 @@
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"an-array-of-english-words": "^2.0.0",
"svelte": "^5.1.0",
"vite": "^6.0.0"
}
+4 -4
View File
@@ -2,7 +2,7 @@
// Mobile-only primary navigation. Highlights = the brief, Latest = the
// chronological feed, Browse = mood/topic discovery, You = account.
import Avatar from './Avatar.svelte';
let { active = 'today', onToday, onLatest, onBrowse, onYou, user = null } = $props();
let { active = 'today', onToday, onLatest, onPlay, onYou, user = null } = $props();
</script>
<nav class="bottomnav" aria-label="Primary">
@@ -14,9 +14,9 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<span>Latest</span>
</button>
<button class:active={active === 'browse'} onclick={onBrowse}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M15.5 8.5l-2 5-5 2 2-5 5-2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
<span>Browse</span>
<button class:active={active === 'play'} onclick={onPlay}>
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><rect x="13" y="4" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><rect x="4" y="13" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><rect x="13" y="13" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span>Play</span>
</button>
<button class:active={active === 'you'} onclick={onYou}>
{#if user}
@@ -11,6 +11,10 @@
</a>
<nav class="utils" aria-label="Your controls">
<a class="util desk" href="/play" title="Play — daily puzzles">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><rect x="13" y="4" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><rect x="4" y="13" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><rect x="13" y="13" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span>Play</span>
</a>
{#if user}
<button class="util desk" onclick={onSaved} title="Saved articles">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-6-4-6 4z"
+246
View File
@@ -0,0 +1,246 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
let { variant = '5', onstatus } = $props();
let length = $state(5);
let maxGuesses = $state(6);
let answer = $state('');
let why = $state(null);
let date = $state('');
let dict = $state(null); // Set of valid guesses
let guesses = $state([]); // submitted rows (strings)
let current = $state(''); // the row being typed
let status = $state('playing'); // 'playing' | 'won' | 'lost'
let loading = $state(true);
let message = $state('');
let ready = $state(false); // animate-in once loaded
const ROWS = 'qwertyuiop|asdfghjkl|zxcvbnm'.split('|').map((r) => r.split(''));
const stateKey = $derived(`goodnews:word:${variant}:${date}`);
const statsKey = $derived(`goodnews:word:${variant}:stats`);
async function load() {
loading = true; ready = false;
guesses = []; current = ''; status = 'playing'; message = '';
try {
const p = await getJSON('/api/puzzle/word?variant=' + variant);
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;
}
restore();
} catch {
message = 'Could not load todays puzzle.';
}
loading = false;
requestAnimationFrame(() => (ready = true));
}
function restore() {
try {
const saved = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (saved && Array.isArray(saved.guesses)) {
guesses = saved.guesses;
status = saved.status || 'playing';
}
} catch { /* ignore */ }
onstatus?.(summary());
}
function persist() {
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, status })); } 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.
let keyState = $derived.by(() => {
const ks = {};
const rank = { absent: 0, present: 1, correct: 2 };
for (const g of guesses) {
const cs = colors(g);
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];
}
}
return ks;
});
function flash(m) { message = m; setTimeout(() => (message = ''), 1400); }
function key(k) {
if (status !== 'playing' || loading) 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() {
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();
}
function recordStat(won) {
try {
const s = JSON.parse(localStorage.getItem(statsKey) || 'null') || { played: 0, won: 0, streak: 0, dist: {} };
s.played += 1;
if (won) { s.won += 1; s.streak += 1; s.dist[guesses.length] = (s.dist[guesses.length] || 0) + 1; }
else { s.streak = 0; }
localStorage.setItem(statsKey, JSON.stringify(s));
} catch { /* ignore */ }
}
let stats = $derived.by(() => {
if (status === 'playing') return null;
try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; }
});
function onKeydown(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key.toLowerCase();
if (k === 'enter' || k === 'backspace' || /^[a-z]$/.test(k)) {
e.preventDefault();
key(k === 'backspace' ? 'back' : k);
}
}
const EMOJI = { correct: '🟩', present: '🟨', absent: '⬛' };
let copied = $state(false);
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 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); });
}
onMount(load);
$effect(() => { variant; if (!loading) load(); }); // reload when the variant toggles
</script>
<svelte:window onkeydown={onKeydown} />
<div class="wordgame" class:ready>
{#if loading}
<p class="muted">Loading todays puzzle…</p>
{:else}
<div class="board" style="--len:{length}">
{#each Array(maxGuesses) as _, r (r)}
{@const g = guesses[r]}
{@const cs = g ? colors(g) : null}
<div class="row">
{#each Array(length) as _, c (c)}
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}>{(ch || '').toUpperCase()}</div>
{/each}
</div>
{/each}
</div>
{#if message}<p class="flash">{message}</p>{/if}
{#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 why}<p class="why"><span class="lbl">Why this word</span>{why}</p>{/if}
{#if stats}
<div class="stats">
<div><span class="n">{stats.played}</span><span class="l">played</span></div>
<div><span class="n">{stats.played ? Math.round(100 * stats.won / stats.played) : 0}%</span><span class="l">solved</span></div>
<div><span class="n">{stats.streak}</span><span class="l">streak</span></div>
</div>
{/if}
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
</div>
{:else}
<div class="keyboard">
{#each ROWS as row, ri (ri)}
<div class="krow">
{#if ri === 2}<button class="key wide" onclick={() => key('enter')}>Enter</button>{/if}
{#each row as k (k)}
<button class="key {keyState[k] || ''}" onclick={() => key(k)}>{k.toUpperCase()}</button>
{/each}
{#if ri === 2}<button class="key wide" onclick={() => key('back')} aria-label="Backspace"></button>{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.wordgame { max-width: 480px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
.wordgame.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
.board { display: grid; gap: 6px; margin: 0 auto 18px; width: min(100%, 340px); }
.row { display: grid; grid-template-columns: repeat(var(--len), 1fr); gap: 6px; }
.tile {
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
border: 2px solid var(--line); border-radius: 8px; font-family: var(--label);
font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase;
background: var(--surface);
}
.tile.filled { border-color: #b7c0cb; }
.tile.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; }
.tile.present { background: #d8b24a; border-color: #d8b24a; color: #fff; }
.tile.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; }
.flash {
text-align: center; background: var(--ink); color: #fff; border-radius: 8px;
padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem;
}
.keyboard { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.krow { display: flex; gap: 5px; justify-content: center; }
.key {
flex: 1; min-width: 0; max-width: 42px; height: 52px; border: none; border-radius: 7px;
background: #e4e7ea; color: var(--ink); font-family: var(--label); font-weight: 600;
font-size: 0.92rem; cursor: pointer; text-transform: uppercase;
}
.key.wide { max-width: 62px; font-size: 0.7rem; }
.key.correct { background: #4a9d6e; color: #fff; }
.key.present { background: #d8b24a; color: #fff; }
.key.absent { background: #9aa6b2; color: #fff; }
.key:hover { filter: brightness(0.96); }
.muted { color: var(--muted); text-align: center; }
.result { text-align: center; }
.rmark { font-family: var(--serif); font-style: italic; color: var(--accent-deep); font-size: 1.2rem; margin: 0 0 10px; }
.rmark strong { font-style: normal; letter-spacing: 0.06em; }
.result .why { color: #3b4754; font-size: 0.95rem; margin: 0 auto 16px; max-width: 380px;
border-left: 2px solid var(--accent); padding-left: 12px; text-align: left; }
.result .why .lbl { display: block; text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.68rem;
color: var(--accent-deep); font-weight: 600; margin-bottom: 2px; }
.stats { display: flex; justify-content: center; gap: 26px; margin: 0 0 18px; }
.stats .n { display: block; font-size: 1.6rem; font-weight: 700; font-family: var(--label); }
.stats .l { color: var(--muted); font-size: 0.78rem; }
.share { background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 11px 26px; font: inherit; font-weight: 600; cursor: pointer; }
.share:hover { background: var(--accent-deep); }
</style>
+1 -1
View File
@@ -595,7 +595,7 @@
{/if}
</main>
<BottomNav active={activeTab} onToday={() => navigate('today')} onLatest={() => navigate('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
<BottomNav active={activeTab} onToday={() => navigate('today')} onLatest={() => navigate('latest')} onPlay={() => goto('/play')} onYou={openAccount} user={auth.user} />
<style>
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
+125
View File
@@ -0,0 +1,125 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import WordGame from '$lib/components/WordGame.svelte';
let view = $state('hub'); // 'hub' | 'word'
let variant = $state('5');
let date = $state('');
let wordStatus = $state({ 5: null, 6: null }); // null | {status, tries, max}
function readStatus(v) {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
if (s && (s.status === 'won' || s.status === 'lost')) {
return { status: s.status, tries: (s.guesses || []).length, max: v === '6' ? 7 : 6 };
}
} catch { /* ignore */ }
return null;
}
function refreshStatus() {
wordStatus = { 5: readStatus('5'), 6: readStatus('6') };
}
function wordLabel() {
const a = wordStatus['5'], b = wordStatus['6'];
const done = [a, b].filter(Boolean);
if (done.length === 0) return 'Play todays word';
const parts = [];
if (a) parts.push(`5·${a.status === 'won' ? a.tries + '/6' : 'X'}`);
if (b) parts.push(`6·${b.status === 'won' ? b.tries + '/7' : 'X'}`);
return `Today: ${parts.join(' ')}`;
}
onMount(async () => {
try {
const p = await getJSON('/api/puzzle/word?variant=5');
date = p.date;
} catch { /* offline — game view will surface it */ }
refreshStatus();
});
</script>
<svelte:head><title>Play · Upbeat Bytes</title></svelte:head>
<header class="bar">
<div class="container inner">
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
{#if view !== 'hub'}
<button class="back" onclick={() => { view = 'hub'; refreshStatus(); }}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Play hub
</button>
{:else}
<a class="back" href="/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>News</a>
{/if}
</div>
</header>
<main class="container page">
{#if view === 'hub'}
<h1>Play</h1>
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
<div class="cards">
<button class="gamecard" onclick={() => (view = 'word')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Daily Word</h2>
<p class="gc-sub">Guess the hopeful word · 5 or 6 letters</p>
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
</div>
</button>
<div class="gamecard soon" aria-disabled="true">
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Word Search</h2>
<p class="gc-sub">Find the days themed words</p>
<p class="gc-status">Coming soon</p>
</div>
</div>
</div>
{:else if view === 'word'}
<div class="variant">
<button class="vchip" class:on={variant === '5'} onclick={() => (variant = '5')}>Daily Word<span>5 letters · 6 guesses</span></button>
<button class="vchip" class:on={variant === '6'} onclick={() => (variant = '6')}>Long Word<span>6 letters · 7 guesses</span></button>
</div>
<WordGame {variant} onstatus={refreshStatus} />
{/if}
</main>
<style>
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
.logo { height: 40px; display: block; }
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px;
background: none; border: none; font-family: inherit; cursor: pointer; }
.back svg { width: 17px; height: 17px; display: block; }
.page { padding: 22px 20px 70px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
.gamecard {
display: flex; gap: 14px; align-items: center; text-align: left;
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
padding: 18px 20px; cursor: pointer; font: inherit; color: var(--ink);
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
}
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
.gamecard.soon { cursor: default; opacity: 0.6; box-shadow: none; }
.gamecard.soon:hover { border-color: var(--line); transform: none; }
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
.gamecard.soon .gc-status { color: var(--muted); font-weight: 400; font-style: italic; }
.variant { display: flex; gap: 10px; justify-content: center; margin: 0 0 22px; }
.vchip {
display: flex; flex-direction: column; align-items: center; gap: 2px;
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 12px; padding: 8px 18px; font: inherit; font-weight: 600; cursor: pointer;
}
.vchip span { font-weight: 400; font-size: 0.72rem; color: var(--muted); }
.vchip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.vchip.on span { color: rgba(255,255,255,0.8); }
</style>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -33,7 +33,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import auth, email_send, feeds, oauth_google, queries, share, sources, summarize
from . import auth, email_send, feeds, games, oauth_google, queries, share, sources, summarize
from .localtime import local_today
from .markup import reply_html_to_text, sanitize_reply_html
from .db import connect
from .filters import filter_articles, prefs_from_json
@@ -1422,6 +1423,13 @@ def create_app() -> FastAPI:
items=[Article.from_row(r) for r in rows],
)
@app.get("/api/puzzle/{game}")
def daily_puzzle(game: str, variant: str = Query("5")) -> dict:
if game != "word" or variant not in games.WORD_VARIANTS:
raise HTTPException(status_code=404, detail="no such puzzle")
with get_conn() as conn:
return games.word_puzzle_response(conn, local_today(), variant)
@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
+9
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from .briefs import build_daily_brief, show_brief
from .db import connect, init_db
from .digest import send_due_digests
from .games import generate_daily_puzzles
from .localtime import local_today
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
@@ -512,6 +513,14 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc:
print(f"digest: skipped ({exc})")
# Pre-generate today's daily puzzles (with the LLM 'why'); idempotent.
try:
made = generate_daily_puzzles(conn, local_today(), client=llm_client_from_args(args))
if made:
print(f"puzzles: generated {made}")
except Exception as exc:
print(f"puzzles: skipped ({exc})")
def serve(args: argparse.Namespace) -> None:
try:
+101
View File
@@ -0,0 +1,101 @@
{
"5": [
"ample",
"amply",
"award",
"bliss",
"bloom",
"bonus",
"brave",
"charm",
"cheer",
"clear",
"dream",
"favor",
"fresh",
"gifts",
"glows",
"grace",
"green",
"happy",
"heals",
"heart",
"honor",
"hopes",
"ideal",
"learn",
"light",
"lucky",
"mends",
"mercy",
"merry",
"noble",
"peace",
"prize",
"quiet",
"reach",
"ready",
"relax",
"renew",
"savor",
"share",
"shine",
"smile",
"still",
"sunny",
"teach",
"thank",
"treat",
"trust",
"truth",
"unity",
"value",
"vital"
],
"6": [
"beauty",
"bestow",
"bright",
"credit",
"devote",
"esteem",
"gather",
"gentle",
"giving",
"growth",
"health",
"joyful",
"kindly",
"lively",
"lovely",
"mellow",
"mended",
"nature",
"praise",
"reborn",
"repair",
"rescue",
"revive",
"reward",
"secure",
"serene",
"simple",
"smiled",
"soothe",
"spirit",
"steady",
"strong",
"summit",
"superb",
"tender",
"thanks",
"thrive",
"united",
"uplift",
"valued",
"vision",
"warmth",
"wisdom",
"wonder"
]
}
+10
View File
@@ -260,6 +260,16 @@ CREATE TABLE IF NOT EXISTS feedback_replies (
);
CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id);
CREATE TABLE IF NOT EXISTS daily_puzzles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
puzzle_date TEXT NOT NULL,
game TEXT NOT NULL, -- 'word' | 'wordsearch'
variant TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (puzzle_date, game, variant)
);
CREATE TABLE IF NOT EXISTS user_follows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+129
View File
@@ -0,0 +1,129 @@
"""Daily puzzles for the calm Play hub.
Principle: **the LLM proposes, code disposes.** The LLM only contributes
creative flavor (a one-line "why today's word matters"); the daily answer is
picked deterministically by code from a pre-validated hopeful pool (every word
is guaranteed to be in the guess dictionary, so it's always typeable). Puzzles
are stored per (date, game, variant) so everyone gets the same one and shares
are comparable. Generation never blocks on or trusts the LLM for correctness.
"""
from __future__ import annotations
import base64
import hashlib
import json
import sqlite3
from pathlib import Path
_POOL = json.loads((Path(__file__).parent / "data" / "wordpool.json").read_text())
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
def _seed(*parts: str) -> int:
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
def _recent_answers(conn: sqlite3.Connection, variant: str, limit: int) -> set[str]:
rows = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE game='word' AND variant=? "
"ORDER BY puzzle_date DESC LIMIT ?",
(variant, limit),
).fetchall()
out = set()
for r in rows:
try:
out.add(json.loads(r["payload_json"])["answer"])
except (ValueError, KeyError, TypeError):
pass
return out
def _pick_answer(conn: sqlite3.Connection, date: str, variant: str) -> str:
pool = _POOL.get(variant, [])
recent = _recent_answers(conn, variant, max(1, len(pool) // 2))
start = _seed(date, "word", variant) % len(pool)
for i in range(len(pool)):
cand = pool[(start + i) % len(pool)]
if cand not in recent:
return cand
return pool[start] # pool fully cycled — allow a repeat rather than fail
def _why(client, word: str) -> str | None:
if client is None:
return None
try:
msg = [
{"role": "system", "content": "You write one short, warm, plain sentence (no preamble, no quotes) "
"on why a given word is a hopeful or uplifting one to sit with."},
{"role": "user", "content": f"Word: {word}"},
]
text = (client.chat_text(msg) or "").strip().strip('"').replace("\n", " ")
return text[:200] or None
except Exception: # noqa: BLE001 — flavor only; never block puzzle creation
return None
def generate_word_puzzle(conn: sqlite3.Connection, date: str, variant: str, client=None) -> dict:
"""Ensure a Daily/Long Word puzzle exists for (date, variant). Idempotent.
Code picks the answer; the LLM only adds the optional 'why' (with fallback)."""
if variant not in WORD_VARIANTS:
variant = "5"
existing = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?",
(date, variant),
).fetchone()
if existing:
return json.loads(existing["payload_json"])
answer = _pick_answer(conn, date, variant)
payload = {
"answer": answer,
"why": _why(client, answer),
"length": WORD_VARIANTS[variant]["length"],
"guesses": WORD_VARIANTS[variant]["guesses"],
}
conn.execute(
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'word', ?, ?)",
(date, variant, json.dumps(payload)),
)
conn.commit()
row = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?",
(date, variant),
).fetchone()
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."""
p = generate_word_puzzle(conn, date, variant) # create on demand (no LLM) if missing
return {
"game": "word",
"variant": variant,
"date": date,
"length": p["length"],
"guesses": p["guesses"],
"answer": _b64(p["answer"]),
"why": _b64(p.get("why")),
}
def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) -> int:
"""Cycle hook: pre-generate today's word puzzles (with the LLM 'why')."""
made = 0
for variant in WORD_VARIANTS:
before = conn.execute(
"SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?", (date, variant)
).fetchone()
if not before:
generate_word_puzzle(conn, date, variant, client=client)
made += 1
return made
+16
View File
@@ -281,3 +281,19 @@ def test_since_endpoint(tmp_path, monkeypatch):
assert r["count"] == 1 and [i["id"] for i in r["items"]] == [3] # only the post-2027 article
assert tc.get("/api/since?ts=2099-01-01T00:00:00Z").json()["count"] == 0 # nothing newer
assert tc.get("/api/since?ts=not-a-date").json()["count"] == 0 # invalid ts → quiet 0
def test_puzzle_endpoint(tmp_path, monkeypatch):
import base64
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 tc.get("/api/puzzle/word?variant=9").status_code == 404
assert tc.get("/api/puzzle/wordsearch").status_code == 404