diff --git a/frontend/src/lib/components/WordSearchGame.svelte b/frontend/src/lib/components/WordSearchGame.svelte index cf92398..e70eb11 100644 --- a/frontend/src/lib/components/WordSearchGame.svelte +++ b/frontend/src/lib/components/WordSearchGame.svelte @@ -2,16 +2,18 @@ import { getJSON } from '$lib/api.js'; import { lineFrom, matchWord } from '$lib/wordsearch.js'; - let { onstatus } = $props(); + let { size = 'med', onstatus } = $props(); + + // A calm, distinct palette — each found word gets its own colour. + const PALETTE = ['#a7d3c2', '#f4cf88', '#a9c8ec', '#eab0c0', '#cdb8e6', + '#f3a98a', '#c2d99a', '#e3b0d0', '#9fd0d8', '#d9c79a']; let theme = $state(''); let words = $state([]); - let grid = $state([]); // array of row strings - let size = $state(10); + let grid = $state([]); // array of row strings let date = $state(''); - let found = $state([]); // found words - let foundCells = $state(new Set()); // "r,c" - let sel = $state([]); // current selection cells [[r,c]] + let foundWords = $state([]); // {word, cells:[[r,c]], ci} + let sel = $state([]); // current selection cells let selecting = false; let startTime = 0; let resultMs = $state(0); @@ -19,21 +21,35 @@ let loading = $state(true); let ready = $state(false); let okFlash = $state(false); + let copied = $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 n = $derived(grid.length); + const stateKey = $derived(`goodnews:wordsearch:${size}:${date}`); + const bestKey = $derived(`goodnews:wordsearch:best:${size}`); + const found = $derived(foundWords.map((w) => w.word)); + const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing'); const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c))); + const cellColor = $derived.by(() => { + const m = new Map(); + for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci); + return m; + }); + const wordColor = $derived.by(() => { + const m = new Map(); + for (const w of foundWords) m.set(w.word, w.ci); + return m; + }); async function load() { loading = true; ready = false; + foundWords = []; sel = []; resultMs = 0; startTime = 0; try { - const p = await getJSON('/api/puzzle/wordsearch'); - theme = p.theme; words = p.words; grid = p.grid; size = p.size; date = p.date; + const p = await getJSON('/api/puzzle/wordsearch?variant=' + size); + theme = p.theme; words = p.words; grid = p.grid; date = p.date; restore(); if (!startTime) startTime = Date.now(); - try { best = JSON.parse(localStorage.getItem(BEST_KEY) || '0'); } catch { best = 0; } + try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; } } catch { theme = ''; words = []; } @@ -44,9 +60,8 @@ 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 || []); + if (s && Array.isArray(s.foundWords)) { + foundWords = s.foundWords; startTime = s.startTime || 0; resultMs = s.ms || 0; } @@ -54,19 +69,17 @@ onstatus?.(summary()); } function persist() { - try { - localStorage.setItem(stateKey, JSON.stringify( - { found, cells: [...foundCells], startTime, ms: resultMs, status })); - } catch { /* ignore */ } + try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); } + catch { /* ignore */ } onstatus?.(summary()); } - function summary() { return { game: 'wordsearch', date, status, found: found.length, total: words.length, ms: resultMs }; } + function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.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))); + const cw = rect.width / n; + const c = Math.min(n - 1, Math.max(0, Math.floor((e.clientX - rect.left) / cw))); + const r = Math.min(n - 1, Math.max(0, Math.floor((e.clientY - rect.top) / cw))); return [r, c]; } @@ -80,7 +93,7 @@ } function move(e) { if (!selecting) return; - sel = lineFrom(sel[0], cellAt(e), size); + sel = lineFrom(sel[0], cellAt(e), n); } function up() { if (!selecting) return; @@ -92,12 +105,9 @@ function evaluate(cells) { const hit = matchWord(cells, grid, words, found); if (!hit) return; - found = [...found, hit]; - const fc = new Set(foundCells); - cells.forEach(([r, c]) => fc.add(r + ',' + c)); - foundCells = fc; + foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }]; okFlash = true; setTimeout(() => (okFlash = false), 500); - if (found.length === words.length) finish(); + if (foundWords.length === words.length) finish(); persist(); } @@ -105,7 +115,7 @@ resultMs = startTime ? Date.now() - startTime : 0; if (resultMs && (!best || resultMs < best)) { best = resultMs; - try { localStorage.setItem(BEST_KEY, JSON.stringify(best)); } catch { /* ignore */ } + try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ } } } @@ -114,14 +124,15 @@ 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`; + const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || ''; + const text = `Upbeat Bytes · Word Search (${label}) ${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(); }); + // Load on mount and whenever the size changes. Tracks ONLY `size`. + $effect(() => { size; load(); });
@@ -132,18 +143,21 @@ {:else}

Today’s theme{theme}

-
{#each grid as rowStr, r (r)} {#each rowStr.split('') as ch, c (c)} -
{ch}
+ {@const key = r + ',' + c} +
{ch}
{/each} {/each}
@@ -157,7 +171,7 @@
{:else} -

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

+

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

{/if} {/if} @@ -170,22 +184,21 @@ 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; + width: min(100%, 420px); 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; } + .grid.done { opacity: 0.9; } .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; + font-family: var(--label); font-weight: 600; font-size: clamp(0.62rem, 2.7vw, 1rem); + color: var(--ink); border-radius: 5px; 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; + .cell.sel { background: var(--accent) !important; color: #fff !important; } + .words { list-style: none; display: flex; flex-wrap: wrap; gap: 7px 9px; 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; } + .words li { font-family: var(--label); font-size: 0.8rem; letter-spacing: 0.04em; color: var(--ink); + padding: 2px 9px; border-radius: 999px; } .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; } diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte index 72f000e..78b2a16 100644 --- a/frontend/src/routes/play/+page.svelte +++ b/frontend/src/routes/play/+page.svelte @@ -6,9 +6,10 @@ let view = $state('hub'); // 'hub' | 'word' | 'wordsearch' let variant = $state('5'); + let wsSize = $state('med'); let date = $state(''); let wordStatus = $state({ 5: null, 6: null }); // null | {status, tries, max} - let wsStatus = $state(null); // null | {status, found, total, ms} + let wsStatus = $state(null); // null | {status, found, ms} function readStatus(v) { try { @@ -20,11 +21,18 @@ 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; + let inProgress = null; + for (const sz of ['small', 'med', 'large']) { + try { + const s = JSON.parse(localStorage.getItem(`goodnews:wordsearch:${sz}:${date}`) || 'null'); + if (s && Array.isArray(s.foundWords)) { + const st = { status: s.status, found: s.foundWords.length, ms: s.ms }; + if (st.status === 'done') return st; // prefer a finished one + if (st.found > 0 && !inProgress) inProgress = st; + } + } catch { /* ignore */ } + } + return inProgress; } function refreshStatus() { wordStatus = { 5: readStatus('5'), 6: readStatus('6') }; @@ -104,7 +112,12 @@ {:else if view === 'wordsearch'} - +
+ + + +
+ {/if} diff --git a/goodnews/api.py b/goodnews/api.py index 362cb99..254001c 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -1435,7 +1435,7 @@ def create_app() -> FastAPI: 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()) + return games.wordsearch_response(conn, local_today(), variant) raise HTTPException(status_code=404, detail="no such puzzle") @app.post("/api/puzzle/word/guess") diff --git a/goodnews/data/wordpool.json b/goodnews/data/wordpool.json index 5f6d5ca..6f4681a 100644 --- a/goodnews/data/wordpool.json +++ b/goodnews/data/wordpool.json @@ -1,35 +1,70 @@ { "5": [ +"acorn", "adore", "agile", +"album", "alive", "amaze", "ample", "angel", +"apple", "arise", "aroma", +"atlas", "award", "aware", +"balls", +"beach", +"beads", "beams", +"beans", +"bench", +"berry", "bless", "bliss", "bloom", "blush", +"boats", "bonus", +"books", +"boots", "brave", +"bread", "brisk", +"brook", +"brush", +"cabin", +"candy", +"chair", +"chalk", "charm", "cheer", "chime", +"choir", +"claps", "clean", "clear", "climb", +"clock", +"cloud", +"coast", +"coats", +"cocoa", +"coins", +"couch", "craft", +"cream", "daisy", "dance", "dawns", +"desks", +"doors", "dream", +"dress", "drift", +"drive", +"drums", "eager", "earth", "elate", @@ -38,32 +73,53 @@ "fancy", "favor", "feast", +"fence", +"ferns", +"field", +"frame", "fresh", +"frost", +"fruit", +"games", "gifts", "glade", "gleam", "glide", +"globe", "glory", "glows", "grace", "grand", +"grass", "green", "grins", "happy", "heals", "heart", +"hikes", +"homes", +"honey", "honor", "hopes", "humor", "ideal", +"jeans", "jewel", "jolly", +"juice", +"kites", +"lamps", "laugh", "learn", +"lemon", "light", +"linen", "lucky", +"lunch", "magic", +"mango", "maple", +"melon", "mends", "mercy", "merit", @@ -71,114 +127,240 @@ "mirth", "music", "noble", +"novel", "oasis", +"ocean", +"olive", +"paint", +"paper", +"parks", +"party", +"paths", +"patio", "peace", +"peach", +"petal", +"phone", +"photo", +"piano", +"plane", "plant", +"plate", +"plays", "pluck", "poise", +"porch", "pride", "prize", "proud", "quiet", "quilt", +"rainy", "ready", "relax", "renew", +"rings", "river", +"roads", +"rooms", +"roots", +"sails", +"salad", +"sandy", "savor", +"scarf", +"seeds", "share", +"shelf", +"shell", "shine", +"ships", +"shirt", +"shoes", +"shore", "smile", +"snack", +"socks", +"songs", "space", "spark", +"spoon", +"stalk", "stars", "still", +"story", +"study", +"sugar", "sunny", "sweet", "swift", +"swing", +"table", "teach", "thank", +"tides", +"toast", +"towel", +"towns", +"trail", +"train", "treat", "trust", "truth", "tulip", "unity", "value", +"vases", "vigor", +"vines", "vital", "vivid", +"walks", +"watch", "water", +"waves", "winds", +"windy", "wings", "witty", +"words", "worth", "youth", "yummy", "zesty" ], "6": [ +"acorns", "adored", +"almond", +"anchor", +"artist", "ascend", "aspire", +"avenue", +"bakery", +"ballad", +"banana", +"basket", "beauty", "bestow", "better", +"bottle", "bouncy", +"branch", "breeze", +"bridge", "bright", "bubbly", +"butter", +"button", "calmly", +"candle", +"canvas", "caring", +"carpet", +"carrot", +"celery", "cheery", +"cheese", +"cherry", "cherub", "chirpy", +"cinema", "clever", +"closet", +"cookie", +"cotton", +"cradle", +"crayon", "dainty", +"dancer", "dazzle", "devote", +"dinner", "divine", +"drawer", "dreamy", +"drench", "earthy", +"encore", "esteem", +"fabric", "family", "flower", "fondly", "forest", "friend", "garden", +"garlic", "gather", "genial", "gentle", +"ginger", "giving", "glossy", +"gloves", "golden", +"grapes", "growth", +"guitar", +"hanger", +"harbor", "health", "heaven", +"hockey", "honest", +"island", +"jacket", "joyful", "joyous", +"kettle", "kindly", +"lagoon", "laurel", +"leaves", "lively", "lovely", "loving", +"marina", +"market", +"meadow", "mellow", +"melody", "mended", +"mirror", +"mitten", "morale", +"muffin", +"museum", +"napkin", "nature", +"needle", "nestle", "nicely", "nuzzle", +"orange", +"pantry", +"pebble", +"pencil", +"pepper", +"petals", +"pillow", "please", "plenty", +"potato", "praise", "pretty", "prized", "purify", "purity", +"puzzle", "quaint", +"racket", +"raisin", +"ramble", "reborn", +"recipe", "refine", "relish", "renews", @@ -186,37 +368,65 @@ "rescue", "revive", "reward", +"ribbon", +"riddle", +"ripple", +"rocker", +"sandal", +"saucer", "savior", "savory", "secure", "serene", "settle", +"shrubs", +"simmer", "simple", +"singer", +"sketch", "smiled", "smooth", +"soccer", "soothe", "spirit", "spring", "sprout", +"stairs", "steady", +"stream", +"street", +"stroll", "strong", +"subway", "summit", +"sunset", "superb", "supple", +"teapot", "tender", +"tennis", "thanks", +"thread", "thrive", +"tomato", +"travel", +"tunnel", "unfold", "united", "uplift", +"valley", "valued", "velvet", "verity", +"violin", "vision", "voyage", +"walnut", +"wander", "warmly", "warmth", "willow", +"window", "wisdom", "wonder", "zenith", diff --git a/goodnews/games.py b/goodnews/games.py index 6290409..40f473a 100644 --- a/goodnews/games.py +++ b/goodnews/games.py @@ -58,8 +58,9 @@ def _why(client, word: str) -> str | 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": "system", "content": "You write one short, warm, plain sentence (no preamble, no quotes) — " + "a calm or gently interesting little note about the given word: what it " + "evokes, where we meet it in everyday life, or why it's pleasant to sit with."}, {"role": "user", "content": f"Word: {word}"}, ] text = (client.chat_text(msg) or "").strip().strip('"').replace("\n", " ") @@ -155,17 +156,31 @@ def adjudicate_word_guess(conn: sqlite3.Connection, date: str, variant: str, gue # 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. +# Size tiers: bigger grid → more words → a longer sit. Built per-request from one +# stored theme+word list, so all three sizes share the day's theme. +WS_TIERS = {"small": {"grid": 8, "count": 6}, "med": {"grid": 11, "count": 9}, "large": {"grid": 14, "count": 13}} + +# Curated fallbacks — calm and neutral everyday scenes, not just upbeat. ~13 +# words each (4–8 letters, uppercase) so the large tier has enough to place. _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"]), + ("Around the house", ["TABLE", "CHAIR", "CLOCK", "SHELF", "COUCH", "LAMP", "PILLOW", "WINDOW", + "CARPET", "MIRROR", "CANDLE", "KETTLE", "DRAWER"]), + ("At the beach", ["WAVES", "SHELL", "SANDY", "TIDE", "SHORE", "TOWEL", "BREEZE", "SUNSET", + "PEBBLE", "CORAL", "OCEAN", "SAILS", "PIER"]), + ("In the kitchen", ["BREAD", "SPOON", "PLATE", "KETTLE", "FLOUR", "APRON", "WHISK", "SUGAR", + "BUTTER", "RECIPE", "SIMMER", "PANTRY", "TEAPOT"]), + ("In the garden", ["BLOOM", "PETAL", "ROOTS", "LEAF", "GARDEN", "FLOWER", "SUNNY", "SEEDS", + "MEADOW", "SPROUT", "HEDGE", "TROWEL", "VINES"]), + ("A walk outdoors", ["TRAIL", "MEADOW", "BROOK", "BIRDS", "BREEZE", "PEBBLE", "FOREST", "MAPLE", + "ACORN", "STREAM", "BRANCH", "VALLEY", "PATH"]), + ("Making music", ["PIANO", "DRUMS", "CHOIR", "MELODY", "GUITAR", "VIOLIN", "SINGER", "BALLAD", + "RHYTHM", "ENCORE", "TEMPO", "NOTES", "SONG"]), + ("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE", + "CALM", "HUSH", "DRIFT", "EASE", "DREAM"]), + ("Small joys", ["SMILE", "LAUGH", "CHEER", "HAPPY", "MERRY", "DANCE", "DELIGHT", "GLOW", + "PLAY", "GRIN", "BEAM", "GLEE", "WARM"]), ] @@ -175,10 +190,13 @@ def _ws_propose(client) -> tuple[str, list[str]] | 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."}, + {"role": "system", "content": "You set up a calm word search. The theme can be uplifting OR just a " + "pleasant everyday scene (e.g. 'Around the house', 'At the beach', " + "'In the kitchen'). Reply exactly as two lines:\n" + "THEME: <2-4 word theme>\nWORDS: W1, W2, ... W13\n" + "Give 13 single real words, 4-8 letters, UPPERCASE, related to the theme, " + "nothing negative or unpleasant, no phrases."}, + {"role": "user", "content": "Give me one calm theme."}, ] text = client.chat_text(msg) or "" theme, words = None, [] @@ -190,15 +208,15 @@ def _ws_propose(client) -> tuple[str, list[str]] | None: 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] + return theme, words 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.""" + """Ensure today's theme + word list exists (idempotent). The per-size grid is + built at request time, so one LLM call serves all three sizes.""" existing = conn.execute( "SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,) ).fetchone() @@ -207,34 +225,8 @@ def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None) 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 - } + words = [w.upper() for w in dict.fromkeys(words) if w.isalpha() and 4 <= len(w) <= 8] + payload = {"theme": theme, "words": words} conn.execute( "INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'wordsearch', '', ?)", (date, json.dumps(payload)), @@ -246,12 +238,44 @@ def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None) 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).""" +def _build_grid(words: list[str], size: int, seed: int) -> tuple[list[str], list[str]]: + """Place words in a size×size grid (date-seeded, deterministic) and fill the + rest. Returns (rows, placed_words). Every returned word is genuinely placed.""" + rng = random.Random(seed) + grid: list[list[str | None]] = [[None] * size for _ in range(size)] + placed = [] + for word in sorted(words, key=len, reverse=True): + if len(word) > size: + continue + 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) + 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)) + return ["".join(row) for row in grid], placed + + +def wordsearch_response(conn: sqlite3.Connection, date: str, size: str = "med") -> dict: + """Public shape for a size tier: theme + placed words + grid. The grid is meant + to be seen — the play is finding the words — so there's nothing to hide.""" + if size not in WS_TIERS: + size = "med" 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"]} + tier = WS_TIERS[size] + usable = [w for w in p["words"] if len(w) <= tier["grid"]][:tier["count"]] + grid, placed = _build_grid(usable, tier["grid"], _seed(date, "wordsearch", size)) + return {"game": "wordsearch", "date": date, "size": size, "theme": p["theme"], + "words": placed, "grid": grid} def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) -> int: diff --git a/tests/test_admin.py b/tests/test_admin.py index 2ab52c6..a1628f6 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -313,15 +313,9 @@ def test_puzzle_endpoint(tmp_path, monkeypatch): 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): + def findable(grid, n, w): for r0 in range(n): for c0 in range(n): for dr, dc in dirs: @@ -331,4 +325,14 @@ def test_wordsearch_endpoint(tmp_path, monkeypatch): return True return False - assert all(findable(w) for w in r["words"]) + themes, sizes = set(), {"small": 8, "med": 11, "large": 14} + for tier, dim in sizes.items(): + r = tc.get(f"/api/puzzle/wordsearch?variant={tier}").json() + assert r["game"] == "wordsearch" and r["theme"] and r["size"] == tier + assert len(r["grid"]) == dim and all(len(row) == dim for row in r["grid"]) # bigger tier → bigger grid + assert "placements" not in r # solution cells never sent + assert all(findable(r["grid"], dim, w) for w in r["words"]) # every word placed → solvable + themes.add(r["theme"]) + assert len(themes) == 1 # all sizes share the day's one theme + # an unknown size falls back to med + assert tc.get("/api/puzzle/wordsearch?variant=nope").json()["size"] == "med"