"""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 _DATA = Path(__file__).parent / "data" _POOL = json.loads((_DATA / "wordpool.json").read_text()) # curated static answer pool # Guess dictionaries (same lists the client validates against) — used server-side to # guarantee any admin-added answer is actually guessable. _DICT = {v: set(json.loads((_DATA / f"words-{v}.json").read_text())) for v in ("5", "6")} # 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 _db_pool(conn: sqlite3.Connection, variant: str) -> list[str]: rows = conn.execute("SELECT word FROM word_pool WHERE variant=? ORDER BY word", (variant,)).fetchall() return [r["word"] for r in rows] def answer_pool(conn: sqlite3.Connection, variant: str) -> list[str]: """The day's answer pool = curated static list ∪ admin-added (DB) words.""" return sorted(set(_POOL.get(variant, [])) | set(_db_pool(conn, variant))) # --- Admin: Daily Word pool curation ------------------------------------------- def lookup_word(word: str) -> dict: """Is this word a valid, guessable answer candidate?""" w = (word or "").strip().lower() variant = str(len(w)) return { "word": w, "length": len(w), "alpha": bool(w) and w.isalpha(), "variant": variant if variant in WORD_VARIANTS else None, "in_dictionary": w.isalpha() and w in _DICT.get(variant, set()), } def add_pool_word(conn: sqlite3.Connection, word: str) -> dict: """Add a word to the answer pool. Enforces the invariant (alpha, 5/6 letters, present in the guess dictionary) so the daily answer is always guessable.""" w = (word or "").strip().lower() variant = str(len(w)) if not w.isalpha() or variant not in WORD_VARIANTS: return {"error": "must be a 5- or 6-letter word"} if w not in _DICT[variant]: return {"error": "not in the guess dictionary — it wouldn't be guessable"} if w in set(_POOL.get(variant, [])) or w in set(_db_pool(conn, variant)): return {"error": "already in the pool"} conn.execute("INSERT OR IGNORE INTO word_pool (word, variant) VALUES (?, ?)", (w, variant)) conn.commit() return {"word": w, "variant": variant, "added": True} def remove_pool_word(conn: sqlite3.Connection, word: str) -> dict: """Remove an admin-added word. Curated static words can't be removed here.""" w = (word or "").strip().lower() cur = conn.execute("DELETE FROM word_pool WHERE variant=? AND word=?", (str(len(w)), w)) conn.commit() return {"word": w, "removed": cur.rowcount > 0} def pool_summary(conn: sqlite3.Connection) -> dict: """Pool counts + the removable (admin-added) words, per variant.""" out = {} for v in WORD_VARIANTS: added = _db_pool(conn, v) out[v] = {"curated": len(_POOL.get(v, [])), "added": added, "total": len(set(_POOL.get(v, [])) | set(added))} return out 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 = answer_pool(conn, 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