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}
{#each words as w (w)}
- - {w}
+ - {w}
{/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"