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:
jay
2026-06-10 20:15:19 -04:00
parent 1bc9925e40
commit 90cd0291a3
5 changed files with 390 additions and 9 deletions
+5 -3
View File
@@ -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:
+113 -1
View File
@@ -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, 48 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