From 90cd0291a313cb91451290f6373477e82763f3e1 Mon Sep 17 00:00:00 2001 From: jay Date: Wed, 10 Jun 2026 20:15:19 -0400 Subject: [PATCH] Play hub Phase 2: Word Search (LLM theme/words, code places the grid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/components/WordSearchGame.svelte | 221 ++++++++++++++++++ frontend/src/routes/play/+page.svelte | 30 ++- goodnews/api.py | 8 +- goodnews/games.py | 114 ++++++++- tests/test_admin.py | 26 ++- 5 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 frontend/src/lib/components/WordSearchGame.svelte diff --git a/frontend/src/lib/components/WordSearchGame.svelte b/frontend/src/lib/components/WordSearchGame.svelte new file mode 100644 index 0000000..762f626 --- /dev/null +++ b/frontend/src/lib/components/WordSearchGame.svelte @@ -0,0 +1,221 @@ + + +
+ {#if loading} +

Loading today’s word search…

+ {:else if !words.length} +

Could not load today’s word search.

+ {:else} +

Today’s theme{theme}

+ +
+ {#each grid as rowStr, r (r)} + {#each rowStr.split('') as ch, c (c)} +
{ch}
+ {/each} + {/each} +
+ +
    + {#each words as w (w)} +
  • {w}
  • + {/each} +
+ + {#if status === 'done'} +
+

✦ all found ✦

+
+
{fmt(resultMs)}your time
+ {#if best}
{fmt(best)}best
{/if} +
+ +
+ {:else} +

{found.length} / {words.length} found · drag across the letters

+ {/if} + {/if} +
+ + diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte index d88dbe7..72f000e 100644 --- a/frontend/src/routes/play/+page.svelte +++ b/frontend/src/routes/play/+page.svelte @@ -2,11 +2,13 @@ import { onMount } from 'svelte'; import { getJSON } from '$lib/api.js'; 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 date = $state(''); let wordStatus = $state({ 5: null, 6: null }); // null | {status, tries, max} + let wsStatus = $state(null); // null | {status, found, total, ms} function readStatus(v) { try { @@ -17,8 +19,26 @@ } catch { /* ignore */ } 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() { 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() { @@ -68,14 +88,14 @@

{wordLabel()}

-
+
+ {:else if view === 'word'}
@@ -83,6 +103,8 @@
+ {:else if view === 'wordsearch'} + {/if} diff --git a/goodnews/api.py b/goodnews/api.py index 98d1456..362cb99 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -1431,10 +1431,12 @@ def create_app() -> FastAPI: @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) + 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") def word_guess(body: WordGuessRequest) -> dict: diff --git a/goodnews/games.py b/goodnews/games.py index 27ac846..6290409 100644 --- a/goodnews/games.py +++ b/goodnews/games.py @@ -12,6 +12,8 @@ from __future__ import annotations import hashlib import json +import random +import re import sqlite3 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: - """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 for variant in WORD_VARIANTS: before = conn.execute( @@ -157,4 +264,9 @@ def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) -> if not before: generate_word_puzzle(conn, date, variant, client=client) 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 diff --git a/tests/test_admin.py b/tests/test_admin.py index d09e5f0..2ab52c6 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -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 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 + assert tc.get("/api/puzzle/nonsense").status_code == 404 # server-adjudicated guessing (answer revealed only on solve / exhaustion) 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 # wrong length → 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"])