Play hub Phase 2: Word Search (LLM theme/words, code places the grid)

A calm second daily game, same philosophy as Daily Word — LLM proposes, code
disposes.

* LLM proposes a hopeful theme + ~8 words; code validates (alpha/length/dedup)
  and PLACES every word in a date-seeded grid, so the puzzle is always solvable.
  Curated fallback themes if the LLM is thin. Only placed words are returned;
  the solution cells (placements) are never sent to the client.
* GET /api/puzzle/wordsearch → {theme, words, grid, size}. No answer to hide:
  the grid and word list are meant to be seen — the play is finding them, which
  the client validates by reading the selected line off the grid.
* WordSearchGame.svelte: pointer-drag selection snapped to the 8 straight
  directions (mouse + touch), found-word highlighting, no-fail, no pressure
  timer — time is recorded quietly and shown at the end with a personal best.
  Spoiler-free share. localStorage progress (restores found cells + timer).
* Hub's Word Search card is now live with today's status; cycle pre-generates
  both games with the LLM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-10 20:15:19 -04:00
parent 1bc9925e40
commit 90cd0291a3
5 changed files with 390 additions and 9 deletions
+26 -4
View File
@@ -2,11 +2,13 @@
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import WordGame from '$lib/components/WordGame.svelte';
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
let view = $state('hub'); // 'hub' | 'word'
let view = $state('hub'); // 'hub' | 'word' | 'wordsearch'
let variant = $state('5');
let date = $state('');
let wordStatus = $state({ 5: null, 6: null }); // null | {status, tries, max}
let wsStatus = $state(null); // null | {status, found, total, ms}
function readStatus(v) {
try {
@@ -17,8 +19,26 @@
} catch { /* ignore */ }
return null;
}
function readWs() {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:wordsearch:${date}`) || 'null');
if (s && Array.isArray(s.found)) return { status: s.status, found: s.found.length, ms: s.ms };
} catch { /* ignore */ }
return null;
}
function refreshStatus() {
wordStatus = { 5: readStatus('5'), 6: readStatus('6') };
wsStatus = readWs();
}
function fmtMs(ms) {
const s = Math.round(ms / 1000);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
function wsLabel() {
if (!wsStatus) return 'Find the days themed words';
if (wsStatus.status === 'done') return `Today: cleared${wsStatus.ms ? ' · ' + fmtMs(wsStatus.ms) : ''}`;
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
return 'Find the days themed words';
}
function wordLabel() {
@@ -68,14 +88,14 @@
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
</div>
</button>
<div class="gamecard soon" aria-disabled="true">
<button class="gamecard" onclick={() => (view = 'wordsearch')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Word Search</h2>
<p class="gc-sub">Find the days themed words</p>
<p class="gc-status">Coming soon</p>
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsLabel()}</p>
</div>
</div>
</button>
</div>
{:else if view === 'word'}
<div class="variant">
@@ -83,6 +103,8 @@
<button class="vchip" class:on={variant === '6'} onclick={() => (variant = '6')}>Long Word<span>6 letters · 7 guesses</span></button>
</div>
<WordGame {variant} onstatus={refreshStatus} />
{:else if view === 'wordsearch'}
<WordSearchGame onstatus={refreshStatus} />
{/if}
</main>