Word Search mobile: focused viewport, theme placement, unique-per-size words
Per field feedback. * Each day is now THREE distinct puzzles: the three sizes draw DISJOINT word slices from a date-shuffled pool (small/med/large = 6/9/13, sum 28 unique). Curated fallback themes expanded to 30 words each; LLM proposals accepted only if they supply >= 28 unique words, else fall back. No more repeats across sizes. * Word Search is now a focused game screen on mobile (same as Daily Word): body scroll locked + footer hidden (generalized .playing-game), and the grid sizes to the largest square that fits between the theme and the palette (container query) — the whole puzzle is on screen, no page scroll. * Theme placement: full "Today's theme · <name>" on the size-selection screen; just the theme name on the puzzle itself, saving vertical space for Large. * cosy → cozy. 🇺🇸 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,6 @@ button { font-family: inherit; cursor: pointer; }
|
|||||||
the page's scroll). The .playing-word class is toggled by /play and always
|
the page's scroll). The .playing-word class is toggled by /play and always
|
||||||
removed on navigation via effect cleanup. Mobile only — desktop is unaffected. */
|
removed on navigation via effect cleanup. Mobile only — desktop is unaffected. */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
html.playing-word, html.playing-word body { overflow: hidden; }
|
html.playing-game, html.playing-game body { overflow: hidden; }
|
||||||
html.playing-word footer.site { display: none; }
|
html.playing-game footer.site { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,8 +147,9 @@
|
|||||||
{:else if !words.length}
|
{:else if !words.length}
|
||||||
<p class="muted">Could not load today’s word search.</p>
|
<p class="muted">Could not load today’s word search.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="theme"><span class="lbl">Today’s theme</span>{theme}</p>
|
<p class="theme">{theme}</p>
|
||||||
|
|
||||||
|
<div class="gridwrap">
|
||||||
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{n}"
|
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{n}"
|
||||||
role="application" aria-label="Word search grid — drag across letters to find words"
|
role="application" aria-label="Word search grid — drag across letters to find words"
|
||||||
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
|
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
|
||||||
@@ -160,6 +161,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="palette">
|
<div class="palette">
|
||||||
<p class="plabel">Find these · {foundWords.length}/{words.length}</p>
|
<p class="plabel">Find these · {foundWords.length}/{words.length}</p>
|
||||||
@@ -189,9 +191,8 @@
|
|||||||
<style>
|
<style>
|
||||||
.wordsearch { max-width: 520px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
|
.wordsearch { max-width: 520px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
|
||||||
.wordsearch.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
|
.wordsearch.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
|
||||||
.theme { text-align: center; font-family: var(--serif); font-size: 1.3rem; color: var(--accent-deep); margin: 0 0 14px; }
|
.theme { text-align: center; font-family: var(--serif); font-size: 1.4rem; color: var(--accent-deep); margin: 0 0 12px; }
|
||||||
.theme .lbl { display: block; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.64rem;
|
.gridwrap { display: flex; justify-content: center; }
|
||||||
font-family: var(--label); color: var(--muted); margin-bottom: 2px; }
|
|
||||||
.grid {
|
.grid {
|
||||||
/* Cells are ~32px on a wide screen (board grows with the grid) and shrink via
|
/* Cells are ~32px on a wide screen (board grows with the grid) and shrink via
|
||||||
1fr to fit a narrow phone — capped by max-width so it can never overflow. */
|
1fr to fit a narrow phone — capped by max-width so it can never overflow. */
|
||||||
@@ -224,4 +225,21 @@
|
|||||||
padding: 11px 26px; font: inherit; font-weight: 600; cursor: pointer; }
|
padding: 11px 26px; font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
.share:hover { background: var(--accent-deep); }
|
.share:hover { background: var(--accent-deep); }
|
||||||
.muted { color: var(--muted); text-align: center; }
|
.muted { color: var(--muted); text-align: center; }
|
||||||
|
|
||||||
|
/* Mobile: a focused game screen (matches Daily Word). The grid sizes to the
|
||||||
|
largest square that fits the space left between the theme and the palette,
|
||||||
|
so the whole puzzle is on screen with no page scroll. */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.wordsearch { display: flex; flex-direction: column; height: 100%; max-width: 100%; }
|
||||||
|
.theme { flex-shrink: 0; font-size: 1.2rem; margin: 4px 0 8px; }
|
||||||
|
.gridwrap { flex: 1; min-height: 0; container-type: size; align-items: center; padding: 2px 0; }
|
||||||
|
.grid { width: min(100%, 100cqh, calc(var(--n) * 40px)); max-width: none; margin: 0; }
|
||||||
|
.palette { flex-shrink: 0; margin: 10px auto 0; padding: 9px 12px 10px; max-width: 100%;
|
||||||
|
width: 100%; box-sizing: border-box; }
|
||||||
|
.plabel { margin-bottom: 7px; }
|
||||||
|
.words { gap: 6px; }
|
||||||
|
.words li { font-size: 0.78rem; padding: 3px 9px; }
|
||||||
|
.hint { flex-shrink: 0; padding: 9px 0 calc(env(safe-area-inset-bottom) + 6px); }
|
||||||
|
.result { flex-shrink: 0; padding-bottom: calc(env(safe-area-inset-bottom) + 6px); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -101,24 +101,27 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Daily Word on mobile = a focused viewport: lock scroll + hide footer. Cleanup
|
// Any puzzle on mobile = a focused viewport: lock scroll + hide footer. Cleanup
|
||||||
// ALWAYS removes the class (re-run or unmount), so leaving /play can't strand it.
|
// ALWAYS removes the class (re-run or unmount), so leaving /play can't strand it.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
if (view === 'play' && game === 'word') {
|
if (view === 'play') {
|
||||||
document.documentElement.classList.add('playing-word');
|
document.documentElement.classList.add('playing-game');
|
||||||
return () => document.documentElement.classList.remove('playing-word');
|
return () => document.documentElement.classList.remove('playing-game');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const WS_OPTS = [
|
const WS_OPTS = [
|
||||||
['small', 'Small', 'cosy · 8×8'],
|
['small', 'Small', 'cozy · 8×8'],
|
||||||
['med', 'Medium', 'balanced · 11×11'],
|
['med', 'Medium', 'balanced · 11×11'],
|
||||||
['large', 'Large', 'a longer sit · 14×14'],
|
['large', 'Large', 'a longer sit · 14×14'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let wsTheme = $state('');
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
|
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
|
||||||
|
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
});
|
});
|
||||||
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
|
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
|
||||||
@@ -140,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container page" class:gameview={view === 'play' && game === 'word'}>
|
<main class="container page" class:gameview={view === 'play'}>
|
||||||
{#if view === 'hub'}
|
{#if view === 'hub'}
|
||||||
<h1>Play</h1>
|
<h1>Play</h1>
|
||||||
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
|
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
|
||||||
@@ -165,6 +168,9 @@
|
|||||||
|
|
||||||
{:else if view === 'select'}
|
{:else if view === 'select'}
|
||||||
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : 'Word Search'}</h1>
|
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : 'Word Search'}</h1>
|
||||||
|
{#if game === 'wordsearch' && wsTheme}
|
||||||
|
<p class="wstheme"><span>Today’s theme</span>{wsTheme}</p>
|
||||||
|
{/if}
|
||||||
<p class="sub">{game === 'word' ? 'Pick your length.' : 'Pick your size.'}</p>
|
<p class="sub">{game === 'word' ? 'Pick your length.' : 'Pick your size.'}</p>
|
||||||
<div class="opts">
|
<div class="opts">
|
||||||
{#if game === 'word'}
|
{#if game === 'word'}
|
||||||
@@ -206,6 +212,9 @@
|
|||||||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
|
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
|
||||||
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
|
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
|
||||||
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
|
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
|
||||||
|
.wstheme { font-family: var(--serif); font-size: 1.6rem; color: var(--accent-deep); margin: 4px 0 18px; }
|
||||||
|
.wstheme span { display: block; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.66rem;
|
||||||
|
font-family: var(--label); color: var(--muted); margin-bottom: 3px; }
|
||||||
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
||||||
.gamecard {
|
.gamecard {
|
||||||
|
|||||||
+45
-33
@@ -158,42 +158,52 @@ def adjudicate_word_guess(conn: sqlite3.Connection, date: str, variant: str, gue
|
|||||||
|
|
||||||
_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)]
|
||||||
|
|
||||||
# Size tiers: bigger grid → more words → a longer sit. Built per-request from one
|
# Size tiers. The three sizes draw DISJOINT word slices from the day's pool, so
|
||||||
# stored theme+word list, sampling a different subset per size so each is its own
|
# each is its own fresh puzzle (no repeats across sizes). small+med+large counts
|
||||||
# puzzle, while all three share the day's theme.
|
# sum to WS_NEEDED, the minimum unique words a theme must supply.
|
||||||
WS_TIERS = {"small": {"grid": 8, "count": 6}, "med": {"grid": 11, "count": 10}, "large": {"grid": 14, "count": 14}}
|
WS_TIERS = {"small": {"grid": 8, "count": 6}, "med": {"grid": 11, "count": 9}, "large": {"grid": 14, "count": 13}}
|
||||||
WS_TARGET = 28 # words to ask the LLM for, so there's variety to sample from
|
_WS_ORDER = ["small", "med", "large"]
|
||||||
# Only accept an LLM proposal with enough words to fill Large with room to spare;
|
WS_NEEDED = sum(t["count"] for t in WS_TIERS.values()) # 28 unique words across the three
|
||||||
# otherwise fall back to a curated theme (which always has plenty).
|
WS_TARGET = 32 # words to ask the LLM for
|
||||||
WS_MIN_ACCEPT = WS_TIERS["large"]["count"] + 4
|
# Accept an LLM proposal only if it supplies enough UNIQUE words for all three
|
||||||
|
# disjoint puzzles; otherwise fall back to a curated theme (which always has enough).
|
||||||
|
WS_MIN_ACCEPT = WS_NEEDED
|
||||||
|
|
||||||
# Curated fallbacks — calm and neutral everyday scenes, not just upbeat. ~22 words
|
# Curated fallbacks — calm, neutral everyday scenes (4–8 letters, uppercase). Each
|
||||||
# each (4–8 letters, uppercase) so every size has a fresh subset to draw from.
|
# has >= WS_NEEDED words so the three sizes get fully distinct sets.
|
||||||
_WS_FALLBACKS = [
|
_WS_FALLBACKS = [
|
||||||
("Around the house", ["TABLE", "CHAIR", "CLOCK", "SHELF", "COUCH", "PILLOW", "WINDOW", "CARPET",
|
("Around the house", ["TABLE", "CHAIR", "CLOCK", "SHELF", "COUCH", "PILLOW", "WINDOW", "CARPET", "MIRROR",
|
||||||
"MIRROR", "CANDLE", "KETTLE", "DRAWER", "CLOSET", "CURTAIN", "CUSHION",
|
"CANDLE", "KETTLE", "DRAWER", "CLOSET", "CURTAIN", "CUSHION", "BASKET", "BOTTLE",
|
||||||
"BASKET", "BOTTLE", "TOWEL", "BROOM", "LADDER", "STAIRS", "PANTRY", "BLANKET"]),
|
"TOWEL", "BROOM", "LADDER", "STAIRS", "PANTRY", "BLANKET", "VASE", "HALLWAY",
|
||||||
("At the beach", ["WAVES", "SHELL", "SANDY", "TIDE", "SHORE", "TOWEL", "BREEZE", "SUNSET", "PEBBLE",
|
"DOORWAY", "MANTEL", "HAMPER", "GARAGE", "ATTIC"]),
|
||||||
"CORAL", "OCEAN", "SAILS", "SURF", "SEAGULL", "BUCKET", "SPADE", "DUNES", "LAGOON",
|
("At the beach", ["WAVES", "SHELL", "SANDY", "TIDE", "SHORE", "TOWEL", "BREEZE", "SUNSET", "PEBBLE", "CORAL",
|
||||||
"DRIFT", "SALTY", "SUNNY", "HORIZON", "COVE", "PIER"]),
|
"OCEAN", "SAILS", "SURF", "SEAGULL", "BUCKET", "SPADE", "DUNES", "LAGOON", "DRIFT", "SALTY",
|
||||||
("In the kitchen", ["BREAD", "SPOON", "PLATE", "KETTLE", "FLOUR", "APRON", "WHISK", "SUGAR", "BUTTER",
|
"SUNNY", "HORIZON", "COVE", "PIER", "SEAWEED", "FLIPPER", "PADDLE", "MARINA", "BREAKER",
|
||||||
"RECIPE", "SIMMER", "PANTRY", "TEAPOT", "SAUCER", "LADLE", "KNIFE", "BOWL", "GRATER",
|
"SANDAL"]),
|
||||||
"SKILLET", "PEPPER", "GARLIC", "HONEY", "TOAST"]),
|
("In the kitchen", ["BREAD", "SPOON", "PLATE", "KETTLE", "FLOUR", "APRON", "WHISK", "SUGAR", "BUTTER", "RECIPE",
|
||||||
("In the garden", ["BLOOM", "PETAL", "ROOTS", "LEAF", "GARDEN", "FLOWER", "SUNNY", "SEEDS", "MEADOW",
|
"SIMMER", "PANTRY", "TEAPOT", "SAUCER", "LADLE", "KNIFE", "BOWL", "GRATER", "SKILLET",
|
||||||
"SPROUT", "HEDGE", "TROWEL", "VINES", "SOIL", "SHRUB", "BUDS", "STALK", "DAISY",
|
"PEPPER", "GARLIC", "HONEY", "TOAST", "BATTER", "SPATULA", "COLANDER", "MIXER", "GRIDDLE",
|
||||||
"TULIP", "FERNS", "SHOVEL", "BRANCH", "BREEZE"]),
|
"PITCHER", "NAPKIN"]),
|
||||||
|
("In the garden", ["BLOOM", "PETAL", "ROOTS", "LEAF", "GARDEN", "FLOWER", "SUNNY", "SEEDS", "MEADOW", "SPROUT",
|
||||||
|
"HEDGE", "TROWEL", "VINES", "SOIL", "SHRUB", "BUDS", "STALK", "DAISY", "TULIP", "FERNS",
|
||||||
|
"SHOVEL", "BRANCH", "BREEZE", "PEONY", "MOSS", "COMPOST", "BLOSSOM", "TENDRIL", "NECTAR",
|
||||||
|
"WATER"]),
|
||||||
("A walk outdoors", ["TRAIL", "MEADOW", "BROOK", "BIRDS", "BREEZE", "PEBBLE", "FOREST", "MAPLE", "ACORN",
|
("A walk outdoors", ["TRAIL", "MEADOW", "BROOK", "BIRDS", "BREEZE", "PEBBLE", "FOREST", "MAPLE", "ACORN",
|
||||||
"STREAM", "BRANCH", "VALLEY", "PATH", "HILLS", "RIVER", "FIELD", "CLOUDS", "LEAVES",
|
"STREAM", "BRANCH", "VALLEY", "PATH", "HILLS", "RIVER", "FIELD", "CLOUDS", "LEAVES",
|
||||||
"MOSSY", "TWIGS", "FENCE", "BENCH", "RAMBLE"]),
|
"MOSSY", "TWIGS", "FENCE", "BENCH", "RAMBLE", "BOULDER", "THICKET", "CLEARING",
|
||||||
|
"PASTURE", "ORCHARD", "HOLLOW", "SUNSET"]),
|
||||||
("Making music", ["PIANO", "DRUMS", "CHOIR", "MELODY", "GUITAR", "VIOLIN", "SINGER", "BALLAD", "RHYTHM",
|
("Making music", ["PIANO", "DRUMS", "CHOIR", "MELODY", "GUITAR", "VIOLIN", "SINGER", "BALLAD", "RHYTHM",
|
||||||
"ENCORE", "TEMPO", "NOTES", "SONG", "FLUTE", "CELLO", "BRASS", "CHORD", "STRUM",
|
"ENCORE", "TEMPO", "NOTES", "SONG", "FLUTE", "CELLO", "BRASS", "CHORD", "STRUM", "HARMONY",
|
||||||
"HARMONY", "LYRICS", "BANJO", "ANTHEM", "TUNES"]),
|
"LYRICS", "BANJO", "ANTHEM", "TUNES", "TRUMPET", "ORGAN", "OCTAVE", "CONCERT", "SONATA",
|
||||||
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE", "CALM",
|
"TREBLE", "MELODIC"]),
|
||||||
"HUSH", "DRIFT", "EASE", "DREAM", "RELAX", "MELLOW", "STEADY", "SETTLE", "LINGER",
|
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE", "CALM", "HUSH",
|
||||||
"PAUSE", "SLOW", "SOFT", "WARM"]),
|
"DRIFT", "EASE", "DREAM", "RELAX", "MELLOW", "STEADY", "SETTLE", "LINGER", "PAUSE", "SLOW",
|
||||||
|
"SOFT", "WARM", "SILENCE", "REPOSE", "PLACID", "TRANQUIL", "QUIETLY", "UNWIND", "COZY",
|
||||||
|
"DROWSY"]),
|
||||||
("Small joys", ["SMILE", "LAUGH", "CHEER", "HAPPY", "MERRY", "DANCE", "DELIGHT", "GLOW", "PLAY", "GRIN",
|
("Small joys", ["SMILE", "LAUGH", "CHEER", "HAPPY", "MERRY", "DANCE", "DELIGHT", "GLOW", "PLAY", "GRIN",
|
||||||
"BEAM", "GLEE", "WARM", "GIGGLE", "SHARE", "TREAT", "SUNNY", "SWEET", "LUCKY", "CHARM",
|
"BEAM", "GLEE", "WARM", "GIGGLE", "SHARE", "TREAT", "SUNNY", "SWEET", "LUCKY", "CHARM",
|
||||||
"SPARK", "BLISS"]),
|
"SPARK", "BLISS", "WONDER", "FROLIC", "CHUCKLE", "CHEERS", "JOYFUL", "SPARKLE", "TWINKLE",
|
||||||
|
"HUGS"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -285,10 +295,12 @@ def wordsearch_response(conn: sqlite3.Connection, date: str, size: str = "med")
|
|||||||
size = "med"
|
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
|
||||||
tier = WS_TIERS[size]
|
tier = WS_TIERS[size]
|
||||||
usable = [w for w in p["words"] if len(w) <= tier["grid"]]
|
# Shuffle the day's words once (date-seeded → same order for every size) and hand
|
||||||
# Sample a fresh subset per size so each tier is its own puzzle (seeded → stable).
|
# each size a DISJOINT slice, so the three sizes are entirely distinct puzzles.
|
||||||
rng = random.Random(_seed(date, "wordsearch", size, "pick"))
|
words = list(p["words"])
|
||||||
chosen = rng.sample(usable, min(tier["count"], len(usable))) if usable else []
|
random.Random(_seed(date, "wordsearch", "shuffle")).shuffle(words)
|
||||||
|
start = sum(WS_TIERS[s]["count"] for s in _WS_ORDER[:_WS_ORDER.index(size)])
|
||||||
|
chosen = words[start:start + tier["count"]]
|
||||||
grid, placed = _build_grid(chosen, tier["grid"], _seed(date, "wordsearch", size))
|
grid, placed = _build_grid(chosen, tier["grid"], _seed(date, "wordsearch", size))
|
||||||
return {"game": "wordsearch", "date": date, "size": size, "theme": p["theme"],
|
return {"game": "wordsearch", "date": date, "size": size, "theme": p["theme"],
|
||||||
"words": placed, "grid": grid}
|
"words": placed, "grid": grid}
|
||||||
|
|||||||
+8
-3
@@ -325,15 +325,19 @@ def test_wordsearch_endpoint(tmp_path, monkeypatch):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
themes, sizes = set(), {"small": 8, "med": 11, "large": 14}
|
themes, sizes, per_size = set(), {"small": 8, "med": 11, "large": 14}, {}
|
||||||
for tier, dim in sizes.items():
|
for tier, dim in sizes.items():
|
||||||
r = tc.get(f"/api/puzzle/wordsearch?variant={tier}").json()
|
r = tc.get(f"/api/puzzle/wordsearch?variant={tier}").json()
|
||||||
assert r["game"] == "wordsearch" and r["theme"] and r["size"] == tier
|
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 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 "placements" not in r # solution cells never sent
|
||||||
assert all(findable(r["grid"], dim, w) for w in r["words"]) # every word placed → solvable
|
assert all(findable(r["grid"], dim, w) for w in r["words"]) # every word placed → solvable
|
||||||
themes.add(r["theme"])
|
themes.add(r["theme"]); per_size[tier] = set(r["words"])
|
||||||
assert len(themes) == 1 # all sizes share the day's one theme
|
assert len(themes) == 1 # all sizes share the day's one theme
|
||||||
|
# the three sizes are DISJOINT — each day is three distinct puzzles
|
||||||
|
assert per_size["small"] & per_size["med"] == set()
|
||||||
|
assert per_size["small"] & per_size["large"] == set()
|
||||||
|
assert per_size["med"] & per_size["large"] == set()
|
||||||
# an unknown size falls back to med
|
# an unknown size falls back to med
|
||||||
assert tc.get("/api/puzzle/wordsearch?variant=nope").json()["size"] == "med"
|
assert tc.get("/api/puzzle/wordsearch?variant=nope").json()["size"] == "med"
|
||||||
|
|
||||||
@@ -358,6 +362,7 @@ def test_wordsearch_thin_llm_falls_back(tmp_path, monkeypatch):
|
|||||||
assert len(large["words"]) == games.WS_TIERS["large"]["count"] # still full
|
assert len(large["words"]) == games.WS_TIERS["large"]["count"] # still full
|
||||||
|
|
||||||
# A rich proposal (>= WS_MIN_ACCEPT valid words) is accepted.
|
# A rich proposal (>= WS_MIN_ACCEPT valid words) is accepted.
|
||||||
rich = FakeClient("THEME: Rich Theme\nWORDS: " + ", ".join(chr(65 + i) * 5 for i in range(20)))
|
rich_words = [chr(65 + i) * 5 for i in range(26)] + ["ABCDE", "FGHIJ", "KLMNO", "PQRST"] # 30 distinct
|
||||||
|
rich = FakeClient("THEME: Rich Theme\nWORDS: " + ", ".join(rich_words))
|
||||||
p2 = games.generate_wordsearch_puzzle(c, "2026-07-02", client=rich)
|
p2 = games.generate_wordsearch_puzzle(c, "2026-07-02", client=rich)
|
||||||
assert p2["theme"] == "Rich Theme" and len(p2["words"]) >= games.WS_MIN_ACCEPT
|
assert p2["theme"] == "Rich Theme" and len(p2["words"]) >= games.WS_MIN_ACCEPT
|
||||||
|
|||||||
Reference in New Issue
Block a user