52a8bc5326
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>
325 lines
16 KiB
Python
325 lines
16 KiB
Python
"""Daily puzzles for the calm Play hub.
|
||
|
||
Principle: **the LLM proposes, code disposes.** The LLM only contributes
|
||
creative flavor (a one-line "why today's word matters"); the daily answer is
|
||
picked deterministically by code from a pre-validated hopeful pool (every word
|
||
is guaranteed to be in the guess dictionary, so it's always typeable). Puzzles
|
||
are stored per (date, game, variant) so everyone gets the same one and shares
|
||
are comparable. Generation never blocks on or trusts the LLM for correctness.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import random
|
||
import re
|
||
import sqlite3
|
||
from pathlib import Path
|
||
|
||
_POOL = json.loads((Path(__file__).parent / "data" / "wordpool.json").read_text())
|
||
|
||
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
|
||
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
|
||
|
||
|
||
def _seed(*parts: str) -> int:
|
||
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
|
||
|
||
|
||
def _recent_answers(conn: sqlite3.Connection, variant: str, limit: int) -> set[str]:
|
||
rows = conn.execute(
|
||
"SELECT payload_json FROM daily_puzzles WHERE game='word' AND variant=? "
|
||
"ORDER BY puzzle_date DESC LIMIT ?",
|
||
(variant, limit),
|
||
).fetchall()
|
||
out = set()
|
||
for r in rows:
|
||
try:
|
||
out.add(json.loads(r["payload_json"])["answer"])
|
||
except (ValueError, KeyError, TypeError):
|
||
pass
|
||
return out
|
||
|
||
|
||
def _pick_answer(conn: sqlite3.Connection, date: str, variant: str) -> str:
|
||
pool = _POOL.get(variant, [])
|
||
recent = _recent_answers(conn, variant, max(1, len(pool) // 2))
|
||
start = _seed(date, "word", variant) % len(pool)
|
||
for i in range(len(pool)):
|
||
cand = pool[(start + i) % len(pool)]
|
||
if cand not in recent:
|
||
return cand
|
||
return pool[start] # pool fully cycled — allow a repeat rather than fail
|
||
|
||
|
||
def _why(client, word: str) -> str | None:
|
||
if client is None:
|
||
return None
|
||
try:
|
||
msg = [
|
||
{"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", " ")
|
||
return text[:200] or None
|
||
except Exception: # noqa: BLE001 — flavor only; never block puzzle creation
|
||
return None
|
||
|
||
|
||
def generate_word_puzzle(conn: sqlite3.Connection, date: str, variant: str, client=None) -> dict:
|
||
"""Ensure a Daily/Long Word puzzle exists for (date, variant). Idempotent.
|
||
Code picks the answer; the LLM only adds the optional 'why' (with fallback)."""
|
||
if variant not in WORD_VARIANTS:
|
||
variant = "5"
|
||
existing = conn.execute(
|
||
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?",
|
||
(date, variant),
|
||
).fetchone()
|
||
if existing:
|
||
return json.loads(existing["payload_json"])
|
||
answer = _pick_answer(conn, date, variant)
|
||
payload = {
|
||
"answer": answer,
|
||
"why": _why(client, answer),
|
||
"length": WORD_VARIANTS[variant]["length"],
|
||
"guesses": WORD_VARIANTS[variant]["guesses"],
|
||
}
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'word', ?, ?)",
|
||
(date, variant, json.dumps(payload)),
|
||
)
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?",
|
||
(date, variant),
|
||
).fetchone()
|
||
return json.loads(row["payload_json"])
|
||
|
||
|
||
def word_puzzle_response(conn: sqlite3.Connection, date: str, variant: str) -> dict:
|
||
"""Public puzzle shape — deliberately holds NO answer. Guesses are adjudicated
|
||
server-side (see adjudicate_word_guess), so the day's word never sits in the
|
||
network response for a curious user to read."""
|
||
p = generate_word_puzzle(conn, date, variant) # create on demand (no LLM) if missing
|
||
return {
|
||
"game": "word",
|
||
"variant": variant,
|
||
"date": date,
|
||
"length": p["length"],
|
||
"guesses": p["guesses"],
|
||
}
|
||
|
||
|
||
def _color(guess: str, answer: str) -> list[str]:
|
||
"""Two-pass Wordle colouring: greens first, then presents limited by counts."""
|
||
res = ["absent"] * len(answer)
|
||
counts: dict[str, int] = {}
|
||
for ch in answer:
|
||
counts[ch] = counts.get(ch, 0) + 1
|
||
for i, ch in enumerate(guess):
|
||
if i < len(answer) and ch == answer[i]:
|
||
res[i] = "correct"; counts[ch] -= 1
|
||
for i, ch in enumerate(guess):
|
||
if res[i] == "correct":
|
||
continue
|
||
if counts.get(ch, 0) > 0:
|
||
res[i] = "present"; counts[ch] -= 1
|
||
return res
|
||
|
||
|
||
def adjudicate_word_guess(conn: sqlite3.Connection, date: str, variant: str, guess: str, n: int) -> dict:
|
||
"""Colour a guess against the day's answer server-side. The answer (and 'why')
|
||
are revealed ONLY once solved or the guesses are spent — never up front."""
|
||
if variant not in WORD_VARIANTS:
|
||
variant = "5"
|
||
p = generate_word_puzzle(conn, date, variant)
|
||
length, maxg, answer = p["length"], p["guesses"], p["answer"]
|
||
guess = (guess or "").strip().lower()
|
||
if len(guess) != length or not guess.isalpha():
|
||
return {"error": "bad guess"}
|
||
solved = guess == answer
|
||
reveal = solved or n >= maxg
|
||
return {
|
||
"colors": _color(guess, answer),
|
||
"solved": solved,
|
||
"answer": answer if reveal else None,
|
||
"why": p.get("why") if reveal else None,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Word Search — LLM proposes a theme + words, code validates and PLACES them in
|
||
# the grid (so it's always solvable). No answer to hide: the grid and word list
|
||
# are inherently visible; the play is finding them.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_DIRS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
|
||
|
||
# Size tiers. The three sizes draw DISJOINT word slices from the day's pool, so
|
||
# each is its own fresh puzzle (no repeats across sizes). small+med+large counts
|
||
# sum to WS_NEEDED, the minimum unique words a theme must supply.
|
||
WS_TIERS = {"small": {"grid": 8, "count": 6}, "med": {"grid": 11, "count": 9}, "large": {"grid": 14, "count": 13}}
|
||
_WS_ORDER = ["small", "med", "large"]
|
||
WS_NEEDED = sum(t["count"] for t in WS_TIERS.values()) # 28 unique words across the three
|
||
WS_TARGET = 32 # words to ask the LLM for
|
||
# 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, neutral everyday scenes (4–8 letters, uppercase). Each
|
||
# has >= WS_NEEDED words so the three sizes get fully distinct sets.
|
||
_WS_FALLBACKS = [
|
||
("Around the house", ["TABLE", "CHAIR", "CLOCK", "SHELF", "COUCH", "PILLOW", "WINDOW", "CARPET", "MIRROR",
|
||
"CANDLE", "KETTLE", "DRAWER", "CLOSET", "CURTAIN", "CUSHION", "BASKET", "BOTTLE",
|
||
"TOWEL", "BROOM", "LADDER", "STAIRS", "PANTRY", "BLANKET", "VASE", "HALLWAY",
|
||
"DOORWAY", "MANTEL", "HAMPER", "GARAGE", "ATTIC"]),
|
||
("At the beach", ["WAVES", "SHELL", "SANDY", "TIDE", "SHORE", "TOWEL", "BREEZE", "SUNSET", "PEBBLE", "CORAL",
|
||
"OCEAN", "SAILS", "SURF", "SEAGULL", "BUCKET", "SPADE", "DUNES", "LAGOON", "DRIFT", "SALTY",
|
||
"SUNNY", "HORIZON", "COVE", "PIER", "SEAWEED", "FLIPPER", "PADDLE", "MARINA", "BREAKER",
|
||
"SANDAL"]),
|
||
("In the kitchen", ["BREAD", "SPOON", "PLATE", "KETTLE", "FLOUR", "APRON", "WHISK", "SUGAR", "BUTTER", "RECIPE",
|
||
"SIMMER", "PANTRY", "TEAPOT", "SAUCER", "LADLE", "KNIFE", "BOWL", "GRATER", "SKILLET",
|
||
"PEPPER", "GARLIC", "HONEY", "TOAST", "BATTER", "SPATULA", "COLANDER", "MIXER", "GRIDDLE",
|
||
"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",
|
||
"STREAM", "BRANCH", "VALLEY", "PATH", "HILLS", "RIVER", "FIELD", "CLOUDS", "LEAVES",
|
||
"MOSSY", "TWIGS", "FENCE", "BENCH", "RAMBLE", "BOULDER", "THICKET", "CLEARING",
|
||
"PASTURE", "ORCHARD", "HOLLOW", "SUNSET"]),
|
||
("Making music", ["PIANO", "DRUMS", "CHOIR", "MELODY", "GUITAR", "VIOLIN", "SINGER", "BALLAD", "RHYTHM",
|
||
"ENCORE", "TEMPO", "NOTES", "SONG", "FLUTE", "CELLO", "BRASS", "CHORD", "STRUM", "HARMONY",
|
||
"LYRICS", "BANJO", "ANTHEM", "TUNES", "TRUMPET", "ORGAN", "OCTAVE", "CONCERT", "SONATA",
|
||
"TREBLE", "MELODIC"]),
|
||
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE", "CALM", "HUSH",
|
||
"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",
|
||
"BEAM", "GLEE", "WARM", "GIGGLE", "SHARE", "TREAT", "SUNNY", "SWEET", "LUCKY", "CHARM",
|
||
"SPARK", "BLISS", "WONDER", "FROLIC", "CHUCKLE", "CHEERS", "JOYFUL", "SPARKLE", "TWINKLE",
|
||
"HUGS"]),
|
||
]
|
||
|
||
|
||
def _ws_propose(client) -> tuple[str, list[str]] | None:
|
||
"""LLM proposes a theme + words; code disposes (alpha / length / dedup)."""
|
||
if client is None:
|
||
return None
|
||
try:
|
||
msg = [
|
||
{"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, ... W28\n"
|
||
f"Give {WS_TARGET} single real words, 4-8 letters, UPPERCASE, related to the "
|
||
"theme, a good mix of lengths, nothing negative or unpleasant, no phrases."},
|
||
{"role": "user", "content": "Give me one calm theme."},
|
||
]
|
||
text = client.chat_text(msg) or ""
|
||
theme, words = None, []
|
||
for line in text.splitlines():
|
||
s = line.strip()
|
||
if s.upper().startswith("THEME:"):
|
||
theme = s.split(":", 1)[1].strip()[:40]
|
||
elif s.upper().startswith("WORDS:"):
|
||
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) >= WS_MIN_ACCEPT: # enough to fill Large + spare
|
||
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 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()
|
||
if existing:
|
||
return json.loads(existing["payload_json"])
|
||
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 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)),
|
||
)
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,)
|
||
).fetchone()
|
||
return json.loads(row["payload_json"])
|
||
|
||
|
||
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
|
||
tier = WS_TIERS[size]
|
||
# Shuffle the day's words once (date-seeded → same order for every size) and hand
|
||
# each size a DISJOINT slice, so the three sizes are entirely distinct puzzles.
|
||
words = list(p["words"])
|
||
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))
|
||
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:
|
||
"""Cycle hook: pre-generate today's puzzles (word + word search) with the LLM."""
|
||
made = 0
|
||
for variant in WORD_VARIANTS:
|
||
before = conn.execute(
|
||
"SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?", (date, variant)
|
||
).fetchone()
|
||
if not before:
|
||
generate_word_puzzle(conn, date, variant, client=client)
|
||
made += 1
|
||
if not conn.execute(
|
||
"SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,)
|
||
).fetchone():
|
||
generate_wordsearch_puzzle(conn, date, client=client)
|
||
made += 1
|
||
return made
|