Games batch: neutral words/themes, Word Search sizes + per-word colours
From playtesting findings:
* Pools nearly doubled (115/104 → 228/201) with calm/neutral everyday words
(claps, dance, drench, beach…), not just strictly-upbeat ones — more variety,
~7-month runway. The post-solve "why" prompt reworded to fit neutral words.
* Word Search now stores one theme + word list per day; the grid is built per
request for three SIZE tiers — Small (8×8, 6 words), Medium (11×11, 9),
Large (14×14, 13). Large packs more words = a longer sit ("too fast" fix).
All sizes share the day's theme; every size still code-placed + solvable.
* Word Search themes can now be neutral everyday scenes ("Around the house",
"At the beach", "In the kitchen", "A walk outdoors", "Making music"…), not
only hopeful — same shape as the articles.
* Each found word gets its own colour from a calm palette, in the grid and its
word-list chip. Per-size local progress + best time.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,16 +2,18 @@
|
|||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON } from '$lib/api.js';
|
||||||
import { lineFrom, matchWord } from '$lib/wordsearch.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 theme = $state('');
|
||||||
let words = $state([]);
|
let words = $state([]);
|
||||||
let grid = $state([]); // array of row strings
|
let grid = $state([]); // array of row strings
|
||||||
let size = $state(10);
|
|
||||||
let date = $state('');
|
let date = $state('');
|
||||||
let found = $state([]); // found words
|
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
|
||||||
let foundCells = $state(new Set()); // "r,c"
|
let sel = $state([]); // current selection cells
|
||||||
let sel = $state([]); // current selection cells [[r,c]]
|
|
||||||
let selecting = false;
|
let selecting = false;
|
||||||
let startTime = 0;
|
let startTime = 0;
|
||||||
let resultMs = $state(0);
|
let resultMs = $state(0);
|
||||||
@@ -19,21 +21,35 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let ready = $state(false);
|
let ready = $state(false);
|
||||||
let okFlash = $state(false);
|
let okFlash = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
let gridEl;
|
let gridEl;
|
||||||
|
|
||||||
const stateKey = $derived(`goodnews:wordsearch:${date}`);
|
const n = $derived(grid.length);
|
||||||
const BEST_KEY = 'goodnews:wordsearch:best';
|
const stateKey = $derived(`goodnews:wordsearch:${size}:${date}`);
|
||||||
const status = $derived(words.length && found.length === words.length ? 'done' : 'playing');
|
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 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() {
|
async function load() {
|
||||||
loading = true; ready = false;
|
loading = true; ready = false;
|
||||||
|
foundWords = []; sel = []; resultMs = 0; startTime = 0;
|
||||||
try {
|
try {
|
||||||
const p = await getJSON('/api/puzzle/wordsearch');
|
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
|
||||||
theme = p.theme; words = p.words; grid = p.grid; size = p.size; date = p.date;
|
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
|
||||||
restore();
|
restore();
|
||||||
if (!startTime) startTime = Date.now();
|
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 {
|
} catch {
|
||||||
theme = ''; words = [];
|
theme = ''; words = [];
|
||||||
}
|
}
|
||||||
@@ -44,9 +60,8 @@
|
|||||||
function restore() {
|
function restore() {
|
||||||
try {
|
try {
|
||||||
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||||||
if (s && Array.isArray(s.found)) {
|
if (s && Array.isArray(s.foundWords)) {
|
||||||
found = s.found;
|
foundWords = s.foundWords;
|
||||||
foundCells = new Set(s.cells || []);
|
|
||||||
startTime = s.startTime || 0;
|
startTime = s.startTime || 0;
|
||||||
resultMs = s.ms || 0;
|
resultMs = s.ms || 0;
|
||||||
}
|
}
|
||||||
@@ -54,19 +69,17 @@
|
|||||||
onstatus?.(summary());
|
onstatus?.(summary());
|
||||||
}
|
}
|
||||||
function persist() {
|
function persist() {
|
||||||
try {
|
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); }
|
||||||
localStorage.setItem(stateKey, JSON.stringify(
|
catch { /* ignore */ }
|
||||||
{ found, cells: [...foundCells], startTime, ms: resultMs, status }));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
onstatus?.(summary());
|
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) {
|
function cellAt(e) {
|
||||||
const rect = gridEl.getBoundingClientRect();
|
const rect = gridEl.getBoundingClientRect();
|
||||||
const cw = rect.width / size;
|
const cw = rect.width / n;
|
||||||
const c = Math.min(size - 1, Math.max(0, Math.floor((e.clientX - rect.left) / cw)));
|
const c = Math.min(n - 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 r = Math.min(n - 1, Math.max(0, Math.floor((e.clientY - rect.top) / cw)));
|
||||||
return [r, c];
|
return [r, c];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
function move(e) {
|
function move(e) {
|
||||||
if (!selecting) return;
|
if (!selecting) return;
|
||||||
sel = lineFrom(sel[0], cellAt(e), size);
|
sel = lineFrom(sel[0], cellAt(e), n);
|
||||||
}
|
}
|
||||||
function up() {
|
function up() {
|
||||||
if (!selecting) return;
|
if (!selecting) return;
|
||||||
@@ -92,12 +105,9 @@
|
|||||||
function evaluate(cells) {
|
function evaluate(cells) {
|
||||||
const hit = matchWord(cells, grid, words, found);
|
const hit = matchWord(cells, grid, words, found);
|
||||||
if (!hit) return;
|
if (!hit) return;
|
||||||
found = [...found, hit];
|
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
|
||||||
const fc = new Set(foundCells);
|
|
||||||
cells.forEach(([r, c]) => fc.add(r + ',' + c));
|
|
||||||
foundCells = fc;
|
|
||||||
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
||||||
if (found.length === words.length) finish();
|
if (foundWords.length === words.length) finish();
|
||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +115,7 @@
|
|||||||
resultMs = startTime ? Date.now() - startTime : 0;
|
resultMs = startTime ? Date.now() - startTime : 0;
|
||||||
if (resultMs && (!best || resultMs < best)) {
|
if (resultMs && (!best || resultMs < best)) {
|
||||||
best = resultMs;
|
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');
|
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
let copied = $state(false);
|
|
||||||
function share() {
|
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(() => {});
|
if (navigator.share) navigator.share({ text }).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
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(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wordsearch" class:ready>
|
<div class="wordsearch" class:ready>
|
||||||
@@ -132,18 +143,21 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="theme"><span class="lbl">Today’s theme</span>{theme}</p>
|
<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}"
|
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{n}"
|
||||||
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
|
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
|
||||||
{#each grid as rowStr, r (r)}
|
{#each grid as rowStr, r (r)}
|
||||||
{#each rowStr.split('') as ch, c (c)}
|
{#each rowStr.split('') as ch, c (c)}
|
||||||
<div class="cell" class:found={foundCells.has(r + ',' + c)} class:sel={selSet.has(r + ',' + c)}>{ch}</div>
|
{@const key = r + ',' + c}
|
||||||
|
<div class="cell" class:sel={selSet.has(key)}
|
||||||
|
style={cellColor.has(key) && !selSet.has(key) ? `background:${PALETTE[cellColor.get(key)]};color:#2a2f36` : ''}>{ch}</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="words">
|
<ul class="words">
|
||||||
{#each words as w (w)}
|
{#each words as w (w)}
|
||||||
<li class:got={found.includes(w)}>{w}</li>
|
<li class:got={found.includes(w)}
|
||||||
|
style={wordColor.has(w) ? `background:${PALETTE[wordColor.get(w)]};color:#2a2f36` : ''}>{w}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -157,7 +171,7 @@
|
|||||||
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
|
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="hint">{found.length} / {words.length} found · drag across the letters</p>
|
<p class="hint">{foundWords.length} / {words.length} found · drag across the letters</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,22 +184,21 @@
|
|||||||
font-family: var(--label); color: var(--muted); margin-bottom: 2px; }
|
font-family: var(--label); color: var(--muted); margin-bottom: 2px; }
|
||||||
.grid {
|
.grid {
|
||||||
display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 2px;
|
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);
|
-webkit-user-select: none; border-radius: 10px; padding: 6px; background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.grid.done { opacity: 0.85; }
|
.grid.done { opacity: 0.9; }
|
||||||
.cell {
|
.cell {
|
||||||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
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);
|
font-family: var(--label); font-weight: 600; font-size: clamp(0.62rem, 2.7vw, 1rem);
|
||||||
color: var(--ink); border-radius: 6px; background: transparent; text-transform: uppercase;
|
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) !important; color: #fff !important; }
|
||||||
.cell.sel { background: var(--accent); color: #fff; }
|
.words { list-style: none; display: flex; flex-wrap: wrap; gap: 7px 9px; justify-content: center;
|
||||||
.words { list-style: none; display: flex; flex-wrap: wrap; gap: 7px 12px; justify-content: center;
|
|
||||||
padding: 0; margin: 0 0 14px; }
|
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 { font-family: var(--label); font-size: 0.8rem; letter-spacing: 0.04em; color: var(--ink);
|
||||||
.words li.got { color: var(--muted); text-decoration: line-through; }
|
padding: 2px 9px; border-radius: 999px; }
|
||||||
.hint { text-align: center; color: var(--muted); font-size: 0.84rem; margin: 0; }
|
.hint { text-align: center; color: var(--muted); font-size: 0.84rem; margin: 0; }
|
||||||
.result { text-align: center; }
|
.result { text-align: center; }
|
||||||
.rmark { font-family: var(--serif); font-style: italic; color: var(--accent-deep); font-size: 1.2rem; margin: 0 0 12px; }
|
.rmark { font-family: var(--serif); font-style: italic; color: var(--accent-deep); font-size: 1.2rem; margin: 0 0 12px; }
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
|
|
||||||
let view = $state('hub'); // 'hub' | 'word' | 'wordsearch'
|
let view = $state('hub'); // 'hub' | 'word' | 'wordsearch'
|
||||||
let variant = $state('5');
|
let variant = $state('5');
|
||||||
|
let wsSize = $state('med');
|
||||||
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}
|
let wsStatus = $state(null); // null | {status, found, ms}
|
||||||
|
|
||||||
function readStatus(v) {
|
function readStatus(v) {
|
||||||
try {
|
try {
|
||||||
@@ -20,11 +21,18 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
function readWs() {
|
function readWs() {
|
||||||
try {
|
let inProgress = null;
|
||||||
const s = JSON.parse(localStorage.getItem(`goodnews:wordsearch:${date}`) || 'null');
|
for (const sz of ['small', 'med', 'large']) {
|
||||||
if (s && Array.isArray(s.found)) return { status: s.status, found: s.found.length, ms: s.ms };
|
try {
|
||||||
} catch { /* ignore */ }
|
const s = JSON.parse(localStorage.getItem(`goodnews:wordsearch:${sz}:${date}`) || 'null');
|
||||||
return 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() {
|
function refreshStatus() {
|
||||||
wordStatus = { 5: readStatus('5'), 6: readStatus('6') };
|
wordStatus = { 5: readStatus('5'), 6: readStatus('6') };
|
||||||
@@ -104,7 +112,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<WordGame {variant} onstatus={refreshStatus} />
|
<WordGame {variant} onstatus={refreshStatus} />
|
||||||
{:else if view === 'wordsearch'}
|
{:else if view === 'wordsearch'}
|
||||||
<WordSearchGame onstatus={refreshStatus} />
|
<div class="variant">
|
||||||
|
<button class="vchip" class:on={wsSize === 'small'} onclick={() => (wsSize = 'small')}>Small<span>cosy · 6 words</span></button>
|
||||||
|
<button class="vchip" class:on={wsSize === 'med'} onclick={() => (wsSize = 'med')}>Medium<span>9 words</span></button>
|
||||||
|
<button class="vchip" class:on={wsSize === 'large'} onclick={() => (wsSize = 'large')}>Large<span>a longer sit · 13 words</span></button>
|
||||||
|
</div>
|
||||||
|
<WordSearchGame size={wsSize} onstatus={refreshStatus} />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1435,7 +1435,7 @@ def create_app() -> FastAPI:
|
|||||||
if game == "word" and variant in games.WORD_VARIANTS:
|
if game == "word" and variant in games.WORD_VARIANTS:
|
||||||
return games.word_puzzle_response(conn, local_today(), variant)
|
return games.word_puzzle_response(conn, local_today(), variant)
|
||||||
if game == "wordsearch":
|
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")
|
raise HTTPException(status_code=404, detail="no such puzzle")
|
||||||
|
|
||||||
@app.post("/api/puzzle/word/guess")
|
@app.post("/api/puzzle/word/guess")
|
||||||
|
|||||||
@@ -1,35 +1,70 @@
|
|||||||
{
|
{
|
||||||
"5": [
|
"5": [
|
||||||
|
"acorn",
|
||||||
"adore",
|
"adore",
|
||||||
"agile",
|
"agile",
|
||||||
|
"album",
|
||||||
"alive",
|
"alive",
|
||||||
"amaze",
|
"amaze",
|
||||||
"ample",
|
"ample",
|
||||||
"angel",
|
"angel",
|
||||||
|
"apple",
|
||||||
"arise",
|
"arise",
|
||||||
"aroma",
|
"aroma",
|
||||||
|
"atlas",
|
||||||
"award",
|
"award",
|
||||||
"aware",
|
"aware",
|
||||||
|
"balls",
|
||||||
|
"beach",
|
||||||
|
"beads",
|
||||||
"beams",
|
"beams",
|
||||||
|
"beans",
|
||||||
|
"bench",
|
||||||
|
"berry",
|
||||||
"bless",
|
"bless",
|
||||||
"bliss",
|
"bliss",
|
||||||
"bloom",
|
"bloom",
|
||||||
"blush",
|
"blush",
|
||||||
|
"boats",
|
||||||
"bonus",
|
"bonus",
|
||||||
|
"books",
|
||||||
|
"boots",
|
||||||
"brave",
|
"brave",
|
||||||
|
"bread",
|
||||||
"brisk",
|
"brisk",
|
||||||
|
"brook",
|
||||||
|
"brush",
|
||||||
|
"cabin",
|
||||||
|
"candy",
|
||||||
|
"chair",
|
||||||
|
"chalk",
|
||||||
"charm",
|
"charm",
|
||||||
"cheer",
|
"cheer",
|
||||||
"chime",
|
"chime",
|
||||||
|
"choir",
|
||||||
|
"claps",
|
||||||
"clean",
|
"clean",
|
||||||
"clear",
|
"clear",
|
||||||
"climb",
|
"climb",
|
||||||
|
"clock",
|
||||||
|
"cloud",
|
||||||
|
"coast",
|
||||||
|
"coats",
|
||||||
|
"cocoa",
|
||||||
|
"coins",
|
||||||
|
"couch",
|
||||||
"craft",
|
"craft",
|
||||||
|
"cream",
|
||||||
"daisy",
|
"daisy",
|
||||||
"dance",
|
"dance",
|
||||||
"dawns",
|
"dawns",
|
||||||
|
"desks",
|
||||||
|
"doors",
|
||||||
"dream",
|
"dream",
|
||||||
|
"dress",
|
||||||
"drift",
|
"drift",
|
||||||
|
"drive",
|
||||||
|
"drums",
|
||||||
"eager",
|
"eager",
|
||||||
"earth",
|
"earth",
|
||||||
"elate",
|
"elate",
|
||||||
@@ -38,32 +73,53 @@
|
|||||||
"fancy",
|
"fancy",
|
||||||
"favor",
|
"favor",
|
||||||
"feast",
|
"feast",
|
||||||
|
"fence",
|
||||||
|
"ferns",
|
||||||
|
"field",
|
||||||
|
"frame",
|
||||||
"fresh",
|
"fresh",
|
||||||
|
"frost",
|
||||||
|
"fruit",
|
||||||
|
"games",
|
||||||
"gifts",
|
"gifts",
|
||||||
"glade",
|
"glade",
|
||||||
"gleam",
|
"gleam",
|
||||||
"glide",
|
"glide",
|
||||||
|
"globe",
|
||||||
"glory",
|
"glory",
|
||||||
"glows",
|
"glows",
|
||||||
"grace",
|
"grace",
|
||||||
"grand",
|
"grand",
|
||||||
|
"grass",
|
||||||
"green",
|
"green",
|
||||||
"grins",
|
"grins",
|
||||||
"happy",
|
"happy",
|
||||||
"heals",
|
"heals",
|
||||||
"heart",
|
"heart",
|
||||||
|
"hikes",
|
||||||
|
"homes",
|
||||||
|
"honey",
|
||||||
"honor",
|
"honor",
|
||||||
"hopes",
|
"hopes",
|
||||||
"humor",
|
"humor",
|
||||||
"ideal",
|
"ideal",
|
||||||
|
"jeans",
|
||||||
"jewel",
|
"jewel",
|
||||||
"jolly",
|
"jolly",
|
||||||
|
"juice",
|
||||||
|
"kites",
|
||||||
|
"lamps",
|
||||||
"laugh",
|
"laugh",
|
||||||
"learn",
|
"learn",
|
||||||
|
"lemon",
|
||||||
"light",
|
"light",
|
||||||
|
"linen",
|
||||||
"lucky",
|
"lucky",
|
||||||
|
"lunch",
|
||||||
"magic",
|
"magic",
|
||||||
|
"mango",
|
||||||
"maple",
|
"maple",
|
||||||
|
"melon",
|
||||||
"mends",
|
"mends",
|
||||||
"mercy",
|
"mercy",
|
||||||
"merit",
|
"merit",
|
||||||
@@ -71,114 +127,240 @@
|
|||||||
"mirth",
|
"mirth",
|
||||||
"music",
|
"music",
|
||||||
"noble",
|
"noble",
|
||||||
|
"novel",
|
||||||
"oasis",
|
"oasis",
|
||||||
|
"ocean",
|
||||||
|
"olive",
|
||||||
|
"paint",
|
||||||
|
"paper",
|
||||||
|
"parks",
|
||||||
|
"party",
|
||||||
|
"paths",
|
||||||
|
"patio",
|
||||||
"peace",
|
"peace",
|
||||||
|
"peach",
|
||||||
|
"petal",
|
||||||
|
"phone",
|
||||||
|
"photo",
|
||||||
|
"piano",
|
||||||
|
"plane",
|
||||||
"plant",
|
"plant",
|
||||||
|
"plate",
|
||||||
|
"plays",
|
||||||
"pluck",
|
"pluck",
|
||||||
"poise",
|
"poise",
|
||||||
|
"porch",
|
||||||
"pride",
|
"pride",
|
||||||
"prize",
|
"prize",
|
||||||
"proud",
|
"proud",
|
||||||
"quiet",
|
"quiet",
|
||||||
"quilt",
|
"quilt",
|
||||||
|
"rainy",
|
||||||
"ready",
|
"ready",
|
||||||
"relax",
|
"relax",
|
||||||
"renew",
|
"renew",
|
||||||
|
"rings",
|
||||||
"river",
|
"river",
|
||||||
|
"roads",
|
||||||
|
"rooms",
|
||||||
|
"roots",
|
||||||
|
"sails",
|
||||||
|
"salad",
|
||||||
|
"sandy",
|
||||||
"savor",
|
"savor",
|
||||||
|
"scarf",
|
||||||
|
"seeds",
|
||||||
"share",
|
"share",
|
||||||
|
"shelf",
|
||||||
|
"shell",
|
||||||
"shine",
|
"shine",
|
||||||
|
"ships",
|
||||||
|
"shirt",
|
||||||
|
"shoes",
|
||||||
|
"shore",
|
||||||
"smile",
|
"smile",
|
||||||
|
"snack",
|
||||||
|
"socks",
|
||||||
|
"songs",
|
||||||
"space",
|
"space",
|
||||||
"spark",
|
"spark",
|
||||||
|
"spoon",
|
||||||
|
"stalk",
|
||||||
"stars",
|
"stars",
|
||||||
"still",
|
"still",
|
||||||
|
"story",
|
||||||
|
"study",
|
||||||
|
"sugar",
|
||||||
"sunny",
|
"sunny",
|
||||||
"sweet",
|
"sweet",
|
||||||
"swift",
|
"swift",
|
||||||
|
"swing",
|
||||||
|
"table",
|
||||||
"teach",
|
"teach",
|
||||||
"thank",
|
"thank",
|
||||||
|
"tides",
|
||||||
|
"toast",
|
||||||
|
"towel",
|
||||||
|
"towns",
|
||||||
|
"trail",
|
||||||
|
"train",
|
||||||
"treat",
|
"treat",
|
||||||
"trust",
|
"trust",
|
||||||
"truth",
|
"truth",
|
||||||
"tulip",
|
"tulip",
|
||||||
"unity",
|
"unity",
|
||||||
"value",
|
"value",
|
||||||
|
"vases",
|
||||||
"vigor",
|
"vigor",
|
||||||
|
"vines",
|
||||||
"vital",
|
"vital",
|
||||||
"vivid",
|
"vivid",
|
||||||
|
"walks",
|
||||||
|
"watch",
|
||||||
"water",
|
"water",
|
||||||
|
"waves",
|
||||||
"winds",
|
"winds",
|
||||||
|
"windy",
|
||||||
"wings",
|
"wings",
|
||||||
"witty",
|
"witty",
|
||||||
|
"words",
|
||||||
"worth",
|
"worth",
|
||||||
"youth",
|
"youth",
|
||||||
"yummy",
|
"yummy",
|
||||||
"zesty"
|
"zesty"
|
||||||
],
|
],
|
||||||
"6": [
|
"6": [
|
||||||
|
"acorns",
|
||||||
"adored",
|
"adored",
|
||||||
|
"almond",
|
||||||
|
"anchor",
|
||||||
|
"artist",
|
||||||
"ascend",
|
"ascend",
|
||||||
"aspire",
|
"aspire",
|
||||||
|
"avenue",
|
||||||
|
"bakery",
|
||||||
|
"ballad",
|
||||||
|
"banana",
|
||||||
|
"basket",
|
||||||
"beauty",
|
"beauty",
|
||||||
"bestow",
|
"bestow",
|
||||||
"better",
|
"better",
|
||||||
|
"bottle",
|
||||||
"bouncy",
|
"bouncy",
|
||||||
|
"branch",
|
||||||
"breeze",
|
"breeze",
|
||||||
|
"bridge",
|
||||||
"bright",
|
"bright",
|
||||||
"bubbly",
|
"bubbly",
|
||||||
|
"butter",
|
||||||
|
"button",
|
||||||
"calmly",
|
"calmly",
|
||||||
|
"candle",
|
||||||
|
"canvas",
|
||||||
"caring",
|
"caring",
|
||||||
|
"carpet",
|
||||||
|
"carrot",
|
||||||
|
"celery",
|
||||||
"cheery",
|
"cheery",
|
||||||
|
"cheese",
|
||||||
|
"cherry",
|
||||||
"cherub",
|
"cherub",
|
||||||
"chirpy",
|
"chirpy",
|
||||||
|
"cinema",
|
||||||
"clever",
|
"clever",
|
||||||
|
"closet",
|
||||||
|
"cookie",
|
||||||
|
"cotton",
|
||||||
|
"cradle",
|
||||||
|
"crayon",
|
||||||
"dainty",
|
"dainty",
|
||||||
|
"dancer",
|
||||||
"dazzle",
|
"dazzle",
|
||||||
"devote",
|
"devote",
|
||||||
|
"dinner",
|
||||||
"divine",
|
"divine",
|
||||||
|
"drawer",
|
||||||
"dreamy",
|
"dreamy",
|
||||||
|
"drench",
|
||||||
"earthy",
|
"earthy",
|
||||||
|
"encore",
|
||||||
"esteem",
|
"esteem",
|
||||||
|
"fabric",
|
||||||
"family",
|
"family",
|
||||||
"flower",
|
"flower",
|
||||||
"fondly",
|
"fondly",
|
||||||
"forest",
|
"forest",
|
||||||
"friend",
|
"friend",
|
||||||
"garden",
|
"garden",
|
||||||
|
"garlic",
|
||||||
"gather",
|
"gather",
|
||||||
"genial",
|
"genial",
|
||||||
"gentle",
|
"gentle",
|
||||||
|
"ginger",
|
||||||
"giving",
|
"giving",
|
||||||
"glossy",
|
"glossy",
|
||||||
|
"gloves",
|
||||||
"golden",
|
"golden",
|
||||||
|
"grapes",
|
||||||
"growth",
|
"growth",
|
||||||
|
"guitar",
|
||||||
|
"hanger",
|
||||||
|
"harbor",
|
||||||
"health",
|
"health",
|
||||||
"heaven",
|
"heaven",
|
||||||
|
"hockey",
|
||||||
"honest",
|
"honest",
|
||||||
|
"island",
|
||||||
|
"jacket",
|
||||||
"joyful",
|
"joyful",
|
||||||
"joyous",
|
"joyous",
|
||||||
|
"kettle",
|
||||||
"kindly",
|
"kindly",
|
||||||
|
"lagoon",
|
||||||
"laurel",
|
"laurel",
|
||||||
|
"leaves",
|
||||||
"lively",
|
"lively",
|
||||||
"lovely",
|
"lovely",
|
||||||
"loving",
|
"loving",
|
||||||
|
"marina",
|
||||||
|
"market",
|
||||||
|
"meadow",
|
||||||
"mellow",
|
"mellow",
|
||||||
|
"melody",
|
||||||
"mended",
|
"mended",
|
||||||
|
"mirror",
|
||||||
|
"mitten",
|
||||||
"morale",
|
"morale",
|
||||||
|
"muffin",
|
||||||
|
"museum",
|
||||||
|
"napkin",
|
||||||
"nature",
|
"nature",
|
||||||
|
"needle",
|
||||||
"nestle",
|
"nestle",
|
||||||
"nicely",
|
"nicely",
|
||||||
"nuzzle",
|
"nuzzle",
|
||||||
|
"orange",
|
||||||
|
"pantry",
|
||||||
|
"pebble",
|
||||||
|
"pencil",
|
||||||
|
"pepper",
|
||||||
|
"petals",
|
||||||
|
"pillow",
|
||||||
"please",
|
"please",
|
||||||
"plenty",
|
"plenty",
|
||||||
|
"potato",
|
||||||
"praise",
|
"praise",
|
||||||
"pretty",
|
"pretty",
|
||||||
"prized",
|
"prized",
|
||||||
"purify",
|
"purify",
|
||||||
"purity",
|
"purity",
|
||||||
|
"puzzle",
|
||||||
"quaint",
|
"quaint",
|
||||||
|
"racket",
|
||||||
|
"raisin",
|
||||||
|
"ramble",
|
||||||
"reborn",
|
"reborn",
|
||||||
|
"recipe",
|
||||||
"refine",
|
"refine",
|
||||||
"relish",
|
"relish",
|
||||||
"renews",
|
"renews",
|
||||||
@@ -186,37 +368,65 @@
|
|||||||
"rescue",
|
"rescue",
|
||||||
"revive",
|
"revive",
|
||||||
"reward",
|
"reward",
|
||||||
|
"ribbon",
|
||||||
|
"riddle",
|
||||||
|
"ripple",
|
||||||
|
"rocker",
|
||||||
|
"sandal",
|
||||||
|
"saucer",
|
||||||
"savior",
|
"savior",
|
||||||
"savory",
|
"savory",
|
||||||
"secure",
|
"secure",
|
||||||
"serene",
|
"serene",
|
||||||
"settle",
|
"settle",
|
||||||
|
"shrubs",
|
||||||
|
"simmer",
|
||||||
"simple",
|
"simple",
|
||||||
|
"singer",
|
||||||
|
"sketch",
|
||||||
"smiled",
|
"smiled",
|
||||||
"smooth",
|
"smooth",
|
||||||
|
"soccer",
|
||||||
"soothe",
|
"soothe",
|
||||||
"spirit",
|
"spirit",
|
||||||
"spring",
|
"spring",
|
||||||
"sprout",
|
"sprout",
|
||||||
|
"stairs",
|
||||||
"steady",
|
"steady",
|
||||||
|
"stream",
|
||||||
|
"street",
|
||||||
|
"stroll",
|
||||||
"strong",
|
"strong",
|
||||||
|
"subway",
|
||||||
"summit",
|
"summit",
|
||||||
|
"sunset",
|
||||||
"superb",
|
"superb",
|
||||||
"supple",
|
"supple",
|
||||||
|
"teapot",
|
||||||
"tender",
|
"tender",
|
||||||
|
"tennis",
|
||||||
"thanks",
|
"thanks",
|
||||||
|
"thread",
|
||||||
"thrive",
|
"thrive",
|
||||||
|
"tomato",
|
||||||
|
"travel",
|
||||||
|
"tunnel",
|
||||||
"unfold",
|
"unfold",
|
||||||
"united",
|
"united",
|
||||||
"uplift",
|
"uplift",
|
||||||
|
"valley",
|
||||||
"valued",
|
"valued",
|
||||||
"velvet",
|
"velvet",
|
||||||
"verity",
|
"verity",
|
||||||
|
"violin",
|
||||||
"vision",
|
"vision",
|
||||||
"voyage",
|
"voyage",
|
||||||
|
"walnut",
|
||||||
|
"wander",
|
||||||
"warmly",
|
"warmly",
|
||||||
"warmth",
|
"warmth",
|
||||||
"willow",
|
"willow",
|
||||||
|
"window",
|
||||||
"wisdom",
|
"wisdom",
|
||||||
"wonder",
|
"wonder",
|
||||||
"zenith",
|
"zenith",
|
||||||
|
|||||||
+74
-50
@@ -58,8 +58,9 @@ def _why(client, word: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
msg = [
|
msg = [
|
||||||
{"role": "system", "content": "You write one short, warm, plain sentence (no preamble, no quotes) "
|
{"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."},
|
"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}"},
|
{"role": "user", "content": f"Word: {word}"},
|
||||||
]
|
]
|
||||||
text = (client.chat_text(msg) or "").strip().strip('"').replace("\n", " ")
|
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.
|
# 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)]
|
_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 = [
|
_WS_FALLBACKS = [
|
||||||
("Kindness", ["KIND", "CARE", "GIVE", "SHARE", "GENTLE", "WARMTH", "THANKS", "FRIEND"]),
|
("Around the house", ["TABLE", "CHAIR", "CLOCK", "SHELF", "COUCH", "LAMP", "PILLOW", "WINDOW",
|
||||||
("In the garden", ["BLOOM", "PETAL", "ROOTS", "LEAF", "GARDEN", "FLOWER", "SUNNY", "SEEDS"]),
|
"CARPET", "MIRROR", "CANDLE", "KETTLE", "DRAWER"]),
|
||||||
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE"]),
|
("At the beach", ["WAVES", "SHELL", "SANDY", "TIDE", "SHORE", "TOWEL", "BREEZE", "SUNSET",
|
||||||
("Bright skies", ["SUNNY", "CLOUD", "BREEZE", "LIGHT", "SHINE", "RAINBOW", "WARMTH", "DAWN"]),
|
"PEBBLE", "CORAL", "OCEAN", "SAILS", "PIER"]),
|
||||||
("Small joys", ["SMILE", "LAUGH", "CHEER", "HAPPY", "MERRY", "DANCE", "DELIGHT", "GLOW"]),
|
("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
|
return None
|
||||||
try:
|
try:
|
||||||
msg = [
|
msg = [
|
||||||
{"role": "system", "content": "You set up a calm, hopeful word search. Reply exactly as two lines:\n"
|
{"role": "system", "content": "You set up a calm word search. The theme can be uplifting OR just a "
|
||||||
"THEME: <2-4 word theme>\nWORDS: W1, W2, W3, W4, W5, W6, W7, W8\n"
|
"pleasant everyday scene (e.g. 'Around the house', 'At the beach', "
|
||||||
"Each word a single real word, 4-8 letters, UPPERCASE, related to the theme, no phrases."},
|
"'In the kitchen'). Reply exactly as two lines:\n"
|
||||||
{"role": "user", "content": "Give me one gentle, uplifting theme."},
|
"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 ""
|
text = client.chat_text(msg) or ""
|
||||||
theme, words = None, []
|
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.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]
|
words = [w for w in dict.fromkeys(words) if w.isalpha() and 4 <= len(w) <= 8]
|
||||||
if theme and len(words) >= 6:
|
if theme and len(words) >= 6:
|
||||||
return theme, words[:WORDSEARCH_COUNT]
|
return theme, words
|
||||||
except Exception: # noqa: BLE001 — fall back to a curated theme
|
except Exception: # noqa: BLE001 — fall back to a curated theme
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None) -> dict:
|
def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None) -> dict:
|
||||||
"""Ensure today's word search exists. Idempotent. Code places every word, so
|
"""Ensure today's theme + word list exists (idempotent). The per-size grid is
|
||||||
the puzzle is guaranteed solvable; only placed words are returned."""
|
built at request time, so one LLM call serves all three sizes."""
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,)
|
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -207,34 +225,8 @@ def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None)
|
|||||||
rng = random.Random(_seed(date, "wordsearch"))
|
rng = random.Random(_seed(date, "wordsearch"))
|
||||||
proposed = _ws_propose(client)
|
proposed = _ws_propose(client)
|
||||||
theme, words = proposed if proposed else _WS_FALLBACKS[rng.randrange(len(_WS_FALLBACKS))]
|
theme, words = proposed if proposed else _WS_FALLBACKS[rng.randrange(len(_WS_FALLBACKS))]
|
||||||
words = [w.upper() for w in words]
|
words = [w.upper() for w in dict.fromkeys(words) if w.isalpha() and 4 <= len(w) <= 8]
|
||||||
|
payload = {"theme": theme, "words": 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(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'wordsearch', '', ?)",
|
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'wordsearch', '', ?)",
|
||||||
(date, json.dumps(payload)),
|
(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"])
|
return json.loads(row["payload_json"])
|
||||||
|
|
||||||
|
|
||||||
def wordsearch_response(conn: sqlite3.Connection, date: str) -> dict:
|
def _build_grid(words: list[str], size: int, seed: int) -> tuple[list[str], list[str]]:
|
||||||
"""Public shape: theme + word list + grid. The grid is meant to be seen — the
|
"""Place words in a size×size grid (date-seeded, deterministic) and fill the
|
||||||
play is finding the words — so there's nothing to hide here (no placements)."""
|
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
|
p = generate_wordsearch_puzzle(conn, date) # on-demand (curated fallback) if missing
|
||||||
return {"game": "wordsearch", "date": date, "theme": p["theme"],
|
tier = WS_TIERS[size]
|
||||||
"words": p["words"], "grid": p["grid"], "size": p["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:
|
def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) -> int:
|
||||||
|
|||||||
+12
-8
@@ -313,15 +313,9 @@ def test_puzzle_endpoint(tmp_path, monkeypatch):
|
|||||||
def test_wordsearch_endpoint(tmp_path, monkeypatch):
|
def test_wordsearch_endpoint(tmp_path, monkeypatch):
|
||||||
app, api = _make(tmp_path, monkeypatch)
|
app, api = _make(tmp_path, monkeypatch)
|
||||||
tc = TestClient(app)
|
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)]
|
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 r0 in range(n):
|
||||||
for c0 in range(n):
|
for c0 in range(n):
|
||||||
for dr, dc in dirs:
|
for dr, dc in dirs:
|
||||||
@@ -331,4 +325,14 @@ def test_wordsearch_endpoint(tmp_path, monkeypatch):
|
|||||||
return True
|
return True
|
||||||
return False
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user