Play hub Phase 2: Word Search (LLM theme/words, code places the grid)
A calm second daily game, same philosophy as Daily Word — LLM proposes, code
disposes.
* LLM proposes a hopeful theme + ~8 words; code validates (alpha/length/dedup)
and PLACES every word in a date-seeded grid, so the puzzle is always solvable.
Curated fallback themes if the LLM is thin. Only placed words are returned;
the solution cells (placements) are never sent to the client.
* GET /api/puzzle/wordsearch → {theme, words, grid, size}. No answer to hide:
the grid and word list are meant to be seen — the play is finding them, which
the client validates by reading the selected line off the grid.
* WordSearchGame.svelte: pointer-drag selection snapped to the 8 straight
directions (mouse + touch), found-word highlighting, no-fail, no pressure
timer — time is recorded quietly and shown at the end with a personal best.
Spoiler-free share. localStorage progress (restores found cells + timer).
* Hub's Word Search card is now live with today's status; cycle pre-generates
both games with the LLM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
|||||||
|
<script>
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
|
||||||
|
let { onstatus } = $props();
|
||||||
|
|
||||||
|
let theme = $state('');
|
||||||
|
let words = $state([]);
|
||||||
|
let grid = $state([]); // array of row strings
|
||||||
|
let size = $state(10);
|
||||||
|
let date = $state('');
|
||||||
|
let found = $state([]); // found words
|
||||||
|
let foundCells = $state(new Set()); // "r,c"
|
||||||
|
let sel = $state([]); // current selection cells [[r,c]]
|
||||||
|
let selecting = false;
|
||||||
|
let startTime = 0;
|
||||||
|
let resultMs = $state(0);
|
||||||
|
let best = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let ready = $state(false);
|
||||||
|
let okFlash = $state(false);
|
||||||
|
let gridEl;
|
||||||
|
|
||||||
|
const stateKey = $derived(`goodnews:wordsearch:${date}`);
|
||||||
|
const BEST_KEY = 'goodnews:wordsearch:best';
|
||||||
|
const status = $derived(words.length && found.length === words.length ? 'done' : 'playing');
|
||||||
|
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true; ready = false;
|
||||||
|
try {
|
||||||
|
const p = await getJSON('/api/puzzle/wordsearch');
|
||||||
|
theme = p.theme; words = p.words; grid = p.grid; size = p.size; date = p.date;
|
||||||
|
restore();
|
||||||
|
if (!startTime) startTime = Date.now();
|
||||||
|
try { best = JSON.parse(localStorage.getItem(BEST_KEY) || '0'); } catch { best = 0; }
|
||||||
|
} catch {
|
||||||
|
theme = ''; words = [];
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
requestAnimationFrame(() => (ready = true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||||||
|
if (s && Array.isArray(s.found)) {
|
||||||
|
found = s.found;
|
||||||
|
foundCells = new Set(s.cells || []);
|
||||||
|
startTime = s.startTime || 0;
|
||||||
|
resultMs = s.ms || 0;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
onstatus?.(summary());
|
||||||
|
}
|
||||||
|
function persist() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(stateKey, JSON.stringify(
|
||||||
|
{ found, cells: [...foundCells], startTime, ms: resultMs, status }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
onstatus?.(summary());
|
||||||
|
}
|
||||||
|
function summary() { return { game: 'wordsearch', date, status, found: found.length, total: words.length, ms: resultMs }; }
|
||||||
|
|
||||||
|
function cellAt(e) {
|
||||||
|
const rect = gridEl.getBoundingClientRect();
|
||||||
|
const cw = rect.width / size;
|
||||||
|
const c = Math.min(size - 1, Math.max(0, Math.floor((e.clientX - rect.left) / cw)));
|
||||||
|
const r = Math.min(size - 1, Math.max(0, Math.floor((e.clientY - rect.top) / cw)));
|
||||||
|
return [r, c];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap a drag to the nearest of the 8 straight directions.
|
||||||
|
function lineFrom(start, end) {
|
||||||
|
const [r0, c0] = start, [r1, c1] = end;
|
||||||
|
let dr = r1 - r0, dc = c1 - c0;
|
||||||
|
if (dr === 0 && dc === 0) return [start];
|
||||||
|
const adr = Math.abs(dr), adc = Math.abs(dc);
|
||||||
|
if (adr > adc * 2) dc = 0;
|
||||||
|
else if (adc > adr * 2) dr = 0;
|
||||||
|
else { const m = Math.max(adr, adc); dr = Math.sign(dr) * m; dc = Math.sign(dc) * m; }
|
||||||
|
const steps = Math.max(Math.abs(dr), Math.abs(dc));
|
||||||
|
const sr = Math.sign(dr), sc = Math.sign(dc), cells = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const r = r0 + sr * i, c = c0 + sc * i;
|
||||||
|
if (r < 0 || r >= size || c < 0 || c >= size) break;
|
||||||
|
cells.push([r, c]);
|
||||||
|
}
|
||||||
|
return cells.length ? cells : [start];
|
||||||
|
}
|
||||||
|
|
||||||
|
function down(e) {
|
||||||
|
if (status === 'done') return;
|
||||||
|
selecting = true;
|
||||||
|
if (!startTime) startTime = Date.now();
|
||||||
|
sel = [cellAt(e)];
|
||||||
|
gridEl.setPointerCapture?.(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
function move(e) {
|
||||||
|
if (!selecting) return;
|
||||||
|
sel = lineFrom(sel[0], cellAt(e));
|
||||||
|
}
|
||||||
|
function up() {
|
||||||
|
if (!selecting) return;
|
||||||
|
selecting = false;
|
||||||
|
evaluate(sel);
|
||||||
|
sel = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluate(cells) {
|
||||||
|
if (cells.length < 2) return;
|
||||||
|
const word = cells.map(([r, c]) => grid[r][c]).join('');
|
||||||
|
const rev = [...word].reverse().join('');
|
||||||
|
const hit = words.includes(word) && !found.includes(word) ? word
|
||||||
|
: words.includes(rev) && !found.includes(rev) ? rev : null;
|
||||||
|
if (!hit) return;
|
||||||
|
found = [...found, hit];
|
||||||
|
const fc = new Set(foundCells);
|
||||||
|
cells.forEach(([r, c]) => fc.add(r + ',' + c));
|
||||||
|
foundCells = fc;
|
||||||
|
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
||||||
|
if (found.length === words.length) finish();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
resultMs = startTime ? Date.now() - startTime : 0;
|
||||||
|
if (resultMs && (!best || resultMs < best)) {
|
||||||
|
best = resultMs;
|
||||||
|
try { localStorage.setItem(BEST_KEY, JSON.stringify(best)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(ms) {
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
function share() {
|
||||||
|
const text = `Upbeat Bytes · Word Search ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
|
||||||
|
if (navigator.share) navigator.share({ text }).catch(() => {});
|
||||||
|
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wordsearch" class:ready>
|
||||||
|
{#if loading}
|
||||||
|
<p class="muted">Loading today’s word search…</p>
|
||||||
|
{:else if !words.length}
|
||||||
|
<p class="muted">Could not load today’s word search.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="theme"><span class="lbl">Today’s theme</span>{theme}</p>
|
||||||
|
|
||||||
|
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{size}"
|
||||||
|
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
|
||||||
|
{#each grid as rowStr, r (r)}
|
||||||
|
{#each rowStr.split('') as ch, c (c)}
|
||||||
|
<div class="cell" class:found={foundCells.has(r + ',' + c)} class:sel={selSet.has(r + ',' + c)}>{ch}</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="words">
|
||||||
|
{#each words as w (w)}
|
||||||
|
<li class:got={found.includes(w)}>{w}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if status === 'done'}
|
||||||
|
<div class="result rise">
|
||||||
|
<p class="rmark">✦ all found ✦</p>
|
||||||
|
<div class="times">
|
||||||
|
<div><span class="n">{fmt(resultMs)}</span><span class="l">your time</span></div>
|
||||||
|
{#if best}<div><span class="n">{fmt(best)}</span><span class="l">best</span></div>{/if}
|
||||||
|
</div>
|
||||||
|
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="hint">{found.length} / {words.length} found · drag across the letters</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wordsearch { max-width: 460px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
|
||||||
|
.wordsearch.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
|
||||||
|
.theme { text-align: center; font-family: var(--serif); font-size: 1.3rem; color: var(--accent-deep); margin: 0 0 14px; }
|
||||||
|
.theme .lbl { display: block; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.64rem;
|
||||||
|
font-family: var(--label); color: var(--muted); margin-bottom: 2px; }
|
||||||
|
.grid {
|
||||||
|
display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 2px;
|
||||||
|
width: min(100%, 360px); margin: 0 auto 16px; touch-action: none; user-select: none;
|
||||||
|
-webkit-user-select: none; border-radius: 10px; padding: 6px; background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.grid.done { opacity: 0.85; }
|
||||||
|
.cell {
|
||||||
|
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: var(--label); font-weight: 600; font-size: clamp(0.8rem, 3.6vw, 1.05rem);
|
||||||
|
color: var(--ink); border-radius: 6px; background: transparent; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cell.found { background: var(--accent-soft); color: var(--accent-deep); }
|
||||||
|
.cell.sel { background: var(--accent); color: #fff; }
|
||||||
|
.words { list-style: none; display: flex; flex-wrap: wrap; gap: 7px 12px; justify-content: center;
|
||||||
|
padding: 0; margin: 0 0 14px; }
|
||||||
|
.words li { font-family: var(--label); font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink); }
|
||||||
|
.words li.got { color: var(--muted); text-decoration: line-through; }
|
||||||
|
.hint { text-align: center; color: var(--muted); font-size: 0.84rem; margin: 0; }
|
||||||
|
.result { text-align: center; }
|
||||||
|
.rmark { font-family: var(--serif); font-style: italic; color: var(--accent-deep); font-size: 1.2rem; margin: 0 0 12px; }
|
||||||
|
.times { display: flex; justify-content: center; gap: 26px; margin: 0 0 16px; }
|
||||||
|
.times .n { display: block; font-size: 1.5rem; font-weight: 700; font-family: var(--label); }
|
||||||
|
.times .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); }
|
||||||
|
.muted { color: var(--muted); text-align: center; }
|
||||||
|
</style>
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON } from '$lib/api.js';
|
||||||
import WordGame from '$lib/components/WordGame.svelte';
|
import WordGame from '$lib/components/WordGame.svelte';
|
||||||
|
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
|
||||||
|
|
||||||
let view = $state('hub'); // 'hub' | 'word'
|
let view = $state('hub'); // 'hub' | 'word' | 'wordsearch'
|
||||||
let variant = $state('5');
|
let variant = $state('5');
|
||||||
let date = $state('');
|
let date = $state('');
|
||||||
let wordStatus = $state({ 5: null, 6: null }); // null | {status, tries, max}
|
let wordStatus = $state({ 5: null, 6: null }); // null | {status, tries, max}
|
||||||
|
let wsStatus = $state(null); // null | {status, found, total, ms}
|
||||||
|
|
||||||
function readStatus(v) {
|
function readStatus(v) {
|
||||||
try {
|
try {
|
||||||
@@ -17,8 +19,26 @@
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
function readWs() {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(`goodnews:wordsearch:${date}`) || 'null');
|
||||||
|
if (s && Array.isArray(s.found)) return { status: s.status, found: s.found.length, ms: s.ms };
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
function refreshStatus() {
|
function refreshStatus() {
|
||||||
wordStatus = { 5: readStatus('5'), 6: readStatus('6') };
|
wordStatus = { 5: readStatus('5'), 6: readStatus('6') };
|
||||||
|
wsStatus = readWs();
|
||||||
|
}
|
||||||
|
function fmtMs(ms) {
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||||
|
}
|
||||||
|
function wsLabel() {
|
||||||
|
if (!wsStatus) return 'Find the day’s themed words';
|
||||||
|
if (wsStatus.status === 'done') return `Today: cleared${wsStatus.ms ? ' · ' + fmtMs(wsStatus.ms) : ''}`;
|
||||||
|
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
|
||||||
|
return 'Find the day’s themed words';
|
||||||
}
|
}
|
||||||
|
|
||||||
function wordLabel() {
|
function wordLabel() {
|
||||||
@@ -68,14 +88,14 @@
|
|||||||
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
|
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="gamecard soon" aria-disabled="true">
|
<button class="gamecard" onclick={() => (view = 'wordsearch')}>
|
||||||
<div class="gc-icon">▦</div>
|
<div class="gc-icon">▦</div>
|
||||||
<div class="gc-body">
|
<div class="gc-body">
|
||||||
<h2>Word Search</h2>
|
<h2>Word Search</h2>
|
||||||
<p class="gc-sub">Find the day’s themed words</p>
|
<p class="gc-sub">Find the day’s themed words</p>
|
||||||
<p class="gc-status">Coming soon</p>
|
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsLabel()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if view === 'word'}
|
{:else if view === 'word'}
|
||||||
<div class="variant">
|
<div class="variant">
|
||||||
@@ -83,6 +103,8 @@
|
|||||||
<button class="vchip" class:on={variant === '6'} onclick={() => (variant = '6')}>Long Word<span>6 letters · 7 guesses</span></button>
|
<button class="vchip" class:on={variant === '6'} onclick={() => (variant = '6')}>Long Word<span>6 letters · 7 guesses</span></button>
|
||||||
</div>
|
</div>
|
||||||
<WordGame {variant} onstatus={refreshStatus} />
|
<WordGame {variant} onstatus={refreshStatus} />
|
||||||
|
{:else if view === 'wordsearch'}
|
||||||
|
<WordSearchGame onstatus={refreshStatus} />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -1431,10 +1431,12 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/puzzle/{game}")
|
@app.get("/api/puzzle/{game}")
|
||||||
def daily_puzzle(game: str, variant: str = Query("5")) -> dict:
|
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:
|
with get_conn() as conn:
|
||||||
return games.word_puzzle_response(conn, local_today(), variant)
|
if game == "word" and variant in games.WORD_VARIANTS:
|
||||||
|
return games.word_puzzle_response(conn, local_today(), variant)
|
||||||
|
if game == "wordsearch":
|
||||||
|
return games.wordsearch_response(conn, local_today())
|
||||||
|
raise HTTPException(status_code=404, detail="no such puzzle")
|
||||||
|
|
||||||
@app.post("/api/puzzle/word/guess")
|
@app.post("/api/puzzle/word/guess")
|
||||||
def word_guess(body: WordGuessRequest) -> dict:
|
def word_guess(body: WordGuessRequest) -> dict:
|
||||||
|
|||||||
+113
-1
@@ -12,6 +12,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -147,8 +149,113 @@ def adjudicate_word_guess(conn: sqlite3.Connection, date: str, variant: str, gue
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Word Search — LLM proposes a theme + words, code validates and PLACES them in
|
||||||
|
# the grid (so it's always solvable). No answer to hide: the grid and word list
|
||||||
|
# are inherently visible; the play is finding them.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
WORDSEARCH_SIZE = 10
|
||||||
|
WORDSEARCH_COUNT = 8
|
||||||
|
_DIRS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
|
||||||
|
|
||||||
|
# Curated fallbacks (all uppercase alpha, 4–8 letters) used if the LLM is thin.
|
||||||
|
_WS_FALLBACKS = [
|
||||||
|
("Kindness", ["KIND", "CARE", "GIVE", "SHARE", "GENTLE", "WARMTH", "THANKS", "FRIEND"]),
|
||||||
|
("In the garden", ["BLOOM", "PETAL", "ROOTS", "LEAF", "GARDEN", "FLOWER", "SUNNY", "SEEDS"]),
|
||||||
|
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE"]),
|
||||||
|
("Bright skies", ["SUNNY", "CLOUD", "BREEZE", "LIGHT", "SHINE", "RAINBOW", "WARMTH", "DAWN"]),
|
||||||
|
("Small joys", ["SMILE", "LAUGH", "CHEER", "HAPPY", "MERRY", "DANCE", "DELIGHT", "GLOW"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_propose(client) -> tuple[str, list[str]] | None:
|
||||||
|
"""LLM proposes a theme + words; code disposes (alpha / length / dedup)."""
|
||||||
|
if client is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
msg = [
|
||||||
|
{"role": "system", "content": "You set up a calm, hopeful word search. Reply exactly as two lines:\n"
|
||||||
|
"THEME: <2-4 word theme>\nWORDS: W1, W2, W3, W4, W5, W6, W7, W8\n"
|
||||||
|
"Each word a single real word, 4-8 letters, UPPERCASE, related to the theme, no phrases."},
|
||||||
|
{"role": "user", "content": "Give me one gentle, uplifting theme."},
|
||||||
|
]
|
||||||
|
text = client.chat_text(msg) or ""
|
||||||
|
theme, words = None, []
|
||||||
|
for line in text.splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if s.upper().startswith("THEME:"):
|
||||||
|
theme = s.split(":", 1)[1].strip()[:40]
|
||||||
|
elif s.upper().startswith("WORDS:"):
|
||||||
|
words = [w.strip().upper() for w in re.split(r"[,\s]+", s.split(":", 1)[1]) if w.strip()]
|
||||||
|
words = [w for w in dict.fromkeys(words) if w.isalpha() and 4 <= len(w) <= 8]
|
||||||
|
if theme and len(words) >= 6:
|
||||||
|
return theme, words[:WORDSEARCH_COUNT]
|
||||||
|
except Exception: # noqa: BLE001 — fall back to a curated theme
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None) -> dict:
|
||||||
|
"""Ensure today's word search exists. Idempotent. Code places every word, so
|
||||||
|
the puzzle is guaranteed solvable; only placed words are returned."""
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
return json.loads(existing["payload_json"])
|
||||||
|
rng = random.Random(_seed(date, "wordsearch"))
|
||||||
|
proposed = _ws_propose(client)
|
||||||
|
theme, words = proposed if proposed else _WS_FALLBACKS[rng.randrange(len(_WS_FALLBACKS))]
|
||||||
|
words = [w.upper() for w in words]
|
||||||
|
|
||||||
|
size = WORDSEARCH_SIZE
|
||||||
|
grid: list[list[str | None]] = [[None] * size for _ in range(size)]
|
||||||
|
placed = []
|
||||||
|
for word in sorted(words, key=len, reverse=True):
|
||||||
|
for _ in range(400):
|
||||||
|
dr, dc = rng.choice(_DIRS)
|
||||||
|
r0, c0 = rng.randrange(size), rng.randrange(size)
|
||||||
|
cells = [(r0 + dr * i, c0 + dc * i) for i in range(len(word))]
|
||||||
|
if any(not (0 <= r < size and 0 <= c < size) for r, c in cells):
|
||||||
|
continue
|
||||||
|
if all(grid[r][c] in (None, word[i]) for i, (r, c) in enumerate(cells)):
|
||||||
|
for i, (r, c) in enumerate(cells):
|
||||||
|
grid[r][c] = word[i]
|
||||||
|
placed.append({"word": word, "cells": cells})
|
||||||
|
break
|
||||||
|
for r in range(size):
|
||||||
|
for c in range(size):
|
||||||
|
if grid[r][c] is None:
|
||||||
|
grid[r][c] = chr(65 + rng.randrange(26))
|
||||||
|
payload = {
|
||||||
|
"theme": theme,
|
||||||
|
"words": sorted((p["word"] for p in placed), key=len, reverse=True),
|
||||||
|
"grid": ["".join(row) for row in grid],
|
||||||
|
"size": size,
|
||||||
|
"placements": placed, # stored, not sent to the client
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'wordsearch', '', ?)",
|
||||||
|
(date, json.dumps(payload)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,)
|
||||||
|
).fetchone()
|
||||||
|
return json.loads(row["payload_json"])
|
||||||
|
|
||||||
|
|
||||||
|
def wordsearch_response(conn: sqlite3.Connection, date: str) -> dict:
|
||||||
|
"""Public shape: theme + word list + grid. The grid is meant to be seen — the
|
||||||
|
play is finding the words — so there's nothing to hide here (no placements)."""
|
||||||
|
p = generate_wordsearch_puzzle(conn, date) # on-demand (curated fallback) if missing
|
||||||
|
return {"game": "wordsearch", "date": date, "theme": p["theme"],
|
||||||
|
"words": p["words"], "grid": p["grid"], "size": p["size"]}
|
||||||
|
|
||||||
|
|
||||||
def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) -> int:
|
def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) -> int:
|
||||||
"""Cycle hook: pre-generate today's word puzzles (with the LLM 'why')."""
|
"""Cycle hook: pre-generate today's puzzles (word + word search) with the LLM."""
|
||||||
made = 0
|
made = 0
|
||||||
for variant in WORD_VARIANTS:
|
for variant in WORD_VARIANTS:
|
||||||
before = conn.execute(
|
before = conn.execute(
|
||||||
@@ -157,4 +264,9 @@ def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) ->
|
|||||||
if not before:
|
if not before:
|
||||||
generate_word_puzzle(conn, date, variant, client=client)
|
generate_word_puzzle(conn, date, variant, client=client)
|
||||||
made += 1
|
made += 1
|
||||||
|
if not conn.execute(
|
||||||
|
"SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,)
|
||||||
|
).fetchone():
|
||||||
|
generate_wordsearch_puzzle(conn, date, client=client)
|
||||||
|
made += 1
|
||||||
return made
|
return made
|
||||||
|
|||||||
+25
-1
@@ -295,7 +295,7 @@ def test_puzzle_endpoint(tmp_path, monkeypatch):
|
|||||||
assert "answer" not in r # the public puzzle response never carries the answer
|
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=6").json()["guesses"] == 7
|
||||||
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/nonsense").status_code == 404
|
||||||
|
|
||||||
# server-adjudicated guessing (answer revealed only on solve / exhaustion)
|
# server-adjudicated guessing (answer revealed only on solve / exhaustion)
|
||||||
c = sqlite3.connect(os.environ["GOODNEWS_DB"]); c.row_factory = sqlite3.Row
|
c = sqlite3.connect(os.environ["GOODNEWS_DB"]); c.row_factory = sqlite3.Row
|
||||||
@@ -308,3 +308,27 @@ def test_puzzle_endpoint(tmp_path, monkeypatch):
|
|||||||
assert last["answer"] == ans # exhausting guesses reveals it even when wrong
|
assert last["answer"] == ans # exhausting guesses reveals it even when wrong
|
||||||
# wrong length → 400
|
# wrong length → 400
|
||||||
assert tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": "toolong", "n": 1}).status_code == 400
|
assert tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": "toolong", "n": 1}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_wordsearch_endpoint(tmp_path, monkeypatch):
|
||||||
|
app, api = _make(tmp_path, monkeypatch)
|
||||||
|
tc = TestClient(app)
|
||||||
|
r = tc.get("/api/puzzle/wordsearch").json()
|
||||||
|
assert r["game"] == "wordsearch" and r["theme"]
|
||||||
|
assert len(r["grid"]) == r["size"] and all(len(row) == r["size"] for row in r["grid"])
|
||||||
|
assert "placements" not in r and len(r["words"]) >= 6 # solution cells never sent
|
||||||
|
# every returned word is genuinely placed → the puzzle is solvable
|
||||||
|
grid, n = r["grid"], r["size"]
|
||||||
|
dirs = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
|
||||||
|
|
||||||
|
def findable(w):
|
||||||
|
for r0 in range(n):
|
||||||
|
for c0 in range(n):
|
||||||
|
for dr, dc in dirs:
|
||||||
|
cells = [(r0 + dr * i, c0 + dc * i) for i in range(len(w))]
|
||||||
|
if all(0 <= rr < n and 0 <= cc < n for rr, cc in cells) and \
|
||||||
|
"".join(grid[rr][cc] for rr, cc in cells) == w:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
assert all(findable(w) for w in r["words"])
|
||||||
|
|||||||
Reference in New Issue
Block a user