Files
upbeatBytes/goodnews/games.py
T
thejayman77 9f7eb11155 Word Search polish: constant cell size, 28-word themes, per-size variety, palette
Playtesting fixes:
* Constant cell size (~32px) — the board GROWS with the grid instead of shrinking
  letters into a fixed box. Fixes Small's oversized spacing; on a narrow phone the
  largest grid gently scales to fit (the standard word-search compromise).
* Themes now gather ~28 words (LLM asked for 28; curated fallbacks ~22 each), and
  each size samples its OWN subset — so every tier is a distinct puzzle. Large is
  now reliably full (14 words on 14×14), fixing the "13 words / 11 listed" mismatch.
* Tiers: small 8×8/6, med 11×11/10, large 14×14/14.
* Word list is now a framed "Find these · n/total" palette panel (pill chips that
  take on each found word's colour) instead of loose text under the grid.
* Size chips use qualitative labels (cosy / balanced / a longer sit) so no count
  can ever contradict the actual puzzle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:57:44 -04:00

310 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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: bigger grid → more words → a longer sit. Built per-request from one
# stored theme+word list, sampling a different subset per size so each is its own
# puzzle, while all three share the day's theme.
WS_TIERS = {"small": {"grid": 8, "count": 6}, "med": {"grid": 11, "count": 10}, "large": {"grid": 14, "count": 14}}
WS_TARGET = 28 # words to gather per theme, so there's variety to sample from
# Curated fallbacks — calm and neutral everyday scenes, not just upbeat. ~22 words
# each (48 letters, uppercase) so every size has a fresh subset to draw from.
_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"]),
("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"]),
("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"]),
("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"]),
("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"]),
("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"]),
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE", "CALM",
"HUSH", "DRIFT", "EASE", "DREAM", "RELAX", "MELLOW", "STEADY", "SETTLE", "LINGER",
"PAUSE", "SLOW", "SOFT", "WARM"]),
("Small joys", ["SMILE", "LAUGH", "CHEER", "HAPPY", "MERRY", "DANCE", "DELIGHT", "GLOW", "PLAY", "GRIN",
"BEAM", "GLEE", "WARM", "GIGGLE", "SHARE", "TREAT", "SUNNY", "SWEET", "LUCKY", "CHARM",
"SPARK", "BLISS"]),
]
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) >= 6:
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]
usable = [w for w in p["words"] if len(w) <= tier["grid"]]
# Sample a fresh subset per size so each tier is its own puzzle (seeded → stable).
rng = random.Random(_seed(date, "wordsearch", size, "pick"))
chosen = rng.sample(usable, min(tier["count"], len(usable))) if usable else []
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