"""Bloom — the daily word wheel (Center Circle / Wild Bloom). DESIGN and ACCEPTANCE are decoupled: • DESIGN (wheel selection, tiers, pangram, the Full-Bloom target) uses the small COMMON list only — deterministic, stored in daily_puzzles, and unaffected by curation. Tiers are scored on COMMON so "Flourishing" is always reachable with everyday vocabulary, and "Full Bloom" = finding the whole *designed* puzzle (the broad bonus words are extra credit beyond it, never required). • ACCEPTANCE is BROAD and DYNAMIC — every valid dictionary word buildable from the wheel, computed at RESPONSE TIME as: broad dict ∪ {allow} − {block}, where allow/block are runtime admin overrides (bloom_word_overrides). So a missed word can be allowed (or a junk word blocked) with NO deploy or regeneration. Accept words never sit in the network response: clients validate against salted hashes and compute their own score/tier/pangram from the 7 letters. """ from __future__ import annotations import hashlib import json import random import sqlite3 from itertools import combinations from pathlib import Path _DATA = Path(__file__).parent / "data" _W = json.loads((_DATA / "bloom_words.json").read_text()) ACCEPT: list[str] = _W["accept"] # broad: all valid dictionary words _COMMON: set[str] = set(_W["common"]) # tight: design / tiers / pangrams only _COMMON_LS: list[tuple[str, frozenset]] = [(w, frozenset(w)) for w in _COMMON] _AVOID: set[str] = set(json.loads((_DATA / "bloom_avoid.json").read_text())) # Broad accept words bucketed by distinct-letter set, so the accepted set for a # 7-letter wheel is gathered by unioning its ≤127 letter-subsets (fast) — no scan # of the whole ~68k list per request. _BY_SET: dict[frozenset, list[str]] = {} for _w in ACCEPT: _BY_SET.setdefault(frozenset(_w), []).append(_w) # Candidate wheels = letter-sets of 7-distinct-letter COMMON words (every wheel # has ≥1 recognizable pangram). Sorted for deterministic order. _PANGRAM_SETS: dict[frozenset, list[str]] = {} for _w in _COMMON: _s = frozenset(_w) if len(_s) == 7: _PANGRAM_SETS.setdefault(_s, []).append(_w) _CANDIDATES: list[frozenset] = sorted(_PANGRAM_SETS, key=lambda s: "".join(sorted(s))) MIN_COMMON_WORDS, MAX_COMMON_WORDS = 14, 45 PANGRAM_BONUS = 7 # 8 / 30 / 70 — Flourishing at 70% keeps Bloom from becoming a completionist # grind. Do NOT raise Flourishing above 0.70 (Codex). TIER_PCTS: tuple[tuple[str, float], ...] = ( ("Sprouting", 0.0), ("Budding", 0.08), ("Blooming", 0.30), ("Flourishing", 0.70), ) TOP_TIER_PCT = 0.70 def score_word(word: str) -> int: """4-letter word = 1 point; longer = its length. Pangram bonus added on top.""" return 1 if len(word) == 4 else len(word) def score_words(payload: dict, words) -> int: """Score found words for a wheel (pangram = uses all 7 letters). Used for the player's running score AND the Full-Bloom check (vs the design's max_score).""" letters = frozenset(payload["center"]) | frozenset(payload["outer"]) total = 0 for w in words: total += score_word(w) if frozenset(w) == letters: total += PANGRAM_BONUS return total # --- DESIGN: common-only, deterministic, stored -------------------------------- def tiers_for(common_max: int) -> list[dict]: return [{"name": n, "score": int(p * common_max)} for n, p in TIER_PCTS] def _design(letters: frozenset, center: str): """Center-mode design from the COMMON list only.""" commons = [w for (w, s) in _COMMON_LS if center in w and s <= letters] pangrams = [w for w in commons if frozenset(w) == letters] common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams) display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p)) return commons, display, common_max def _design_wild(letters: frozenset): """Wild design (no required center) from the COMMON list only.""" commons = [w for (w, s) in _COMMON_LS if s <= letters] pangrams = [w for w in commons if frozenset(w) == letters] common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams) display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p)) vowels = [c for c in sorted(letters) if c in "aeiou"] return commons, display, common_max, (vowels[0] if vowels else sorted(letters)[0]) def _payload(letters: frozenset, center: str, display, common_max: int) -> dict: return { "center": center, "outer": sorted(letters - {center}), "pangram": display[0], "tiers": tiers_for(common_max), # Full Bloom = finding the whole designed (common) puzzle; broad bonus # words push score past this but are never required. "max_score": common_max, } def _generate(seed_str: str, fmt: str) -> dict: """Deterministically pick a wheel design for a seed + format.""" rng = random.Random(int(hashlib.sha256(seed_str.encode()).hexdigest(), 16)) order = _CANDIDATES[:] rng.shuffle(order) for letters in order: if fmt == "wild": commons, display, cmax, center = _design_wild(letters) if len(commons) >= MIN_COMMON_WORDS and display: return _payload(letters, center, display, cmax) else: centers = sorted(letters) rng.shuffle(centers) for center in centers: commons, display, cmax = _design(letters, center) if MIN_COMMON_WORDS <= len(commons) <= MAX_COMMON_WORDS and display: return _payload(letters, center, display, cmax) raise RuntimeError("bloom: no valid wheel found") # impossible with the vendored dict def build_puzzle(date: str) -> dict: """The day's shared Center Circle wheel design (deterministic by date).""" return {"date": date, **_generate(f"bloom:{date}", "center")} def build_free(seed: str, fmt: str = "center") -> dict: """A free-play wheel design (deterministic by seed) — Center Circle or Wild.""" fmt = "wild" if fmt == "wild" else "center" return {"seed": seed, "format": fmt, **_generate(f"free:{fmt}:{seed}", fmt)} # --- ACCEPTANCE: broad + runtime overrides, computed at response time ---------- def overrides(conn: sqlite3.Connection) -> tuple[set, set]: allow, block = set(), set() for r in conn.execute("SELECT word, action FROM bloom_word_overrides"): (allow if r["action"] == "allow" else block).add(r["word"]) return allow, block def _broad_words_for(letters: frozenset) -> list[str]: """Every broad-dictionary word buildable from `letters` (distinct-set ⊆ letters).""" ls = sorted(letters) out = [] for r in range(1, len(ls) + 1): for combo in combinations(ls, r): out.extend(_BY_SET.get(frozenset(combo), ())) return out def accepted_words(conn: sqlite3.Connection, center: str, outer, require_center: bool) -> list[str]: """The wheel's accepted set RIGHT NOW: broad words buildable from the letters (optionally requiring the center), plus allow-overrides, minus block-overrides.""" letters = frozenset(outer) | {center} allow, block = overrides(conn) seen, out = set(), [] for w in _broad_words_for(letters): if w in seen or w in block: continue if require_center and center not in w: continue seen.add(w) out.append(w) for w in allow: # allow words that may not be in the broad dict if w in seen or w in block or len(w) < 4 or "s" in w: continue if not (frozenset(w) <= letters) or (require_center and center not in w): continue seen.add(w) out.append(w) return sorted(out) # --- daily_puzzles storage ----------------------------------------------------- def generate_bloom_puzzle(conn: sqlite3.Connection, date: str) -> dict: """Ensure the day's Bloom DESIGN exists in daily_puzzles. Idempotent, pure code.""" existing = conn.execute( "SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,) ).fetchone() if existing: return json.loads(existing["payload_json"]) payload = build_puzzle(date) conn.execute( "INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'bloom', '', ?)", (date, json.dumps(payload)), ) conn.commit() row = conn.execute( "SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,) ).fetchone() return json.loads(row["payload_json"]) def stored_payload(conn: sqlite3.Connection, date: str) -> dict | None: """The day's design IF it already exists — never generates (used by the state sanitizer, which must not trigger generation).""" row = conn.execute( "SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,) ).fetchone() return json.loads(row["payload_json"]) if row else None def word_hash(salt: str, word: str) -> str: return hashlib.sha256(f"{salt}:{word}".encode()).hexdigest() def _response(salt: str, p: dict, words: list[str], extra: dict) -> dict: return { "game": "bloom", "center": p["center"], "outer": p["outer"], "accepted": [word_hash(salt, w) for w in words], # NO plaintext words leak "max_score": p["max_score"], # Full Bloom = designed puzzle "tiers": p["tiers"], **extra, } def bloom_response(conn: sqlite3.Connection, date: str) -> dict: """Daily Center Circle — accepted set computed live (broad + overrides).""" p = generate_bloom_puzzle(conn, date) words = accepted_words(conn, p["center"], p["outer"], require_center=True) return _response(date, p, words, {"date": date}) def bloom_free_response(conn: sqlite3.Connection, seed: str, fmt: str) -> dict: """Free-play wheel keyed by `seed` (resumable). Accepted set computed live.""" p = build_free(seed, fmt) words = accepted_words(conn, p["center"], p["outer"], require_center=p["format"] != "wild") return _response(seed, p, words, {"mode": "free", "format": p["format"], "seed": p["seed"]}) # --- runtime curation: overrides + player reports ------------------------------ def set_override(conn: sqlite3.Connection, word: str, action: str, reason: str | None = None, by: str | None = None) -> bool: word = (word or "").strip().lower() if not (word.isalpha() and action in ("allow", "block")): return False # An ALLOW that violates Bloom's hard rules (≥4 letters, no 'S') could never # count — reject it rather than store an inert override. BLOCK stays permissive. if action == "allow" and (len(word) < 4 or "s" in word): return False conn.execute( "INSERT INTO bloom_word_overrides (word, action, reason, created_by) VALUES (?,?,?,?) " "ON CONFLICT(word) DO UPDATE SET action=excluded.action, reason=excluded.reason, " "created_by=excluded.created_by, created_at=CURRENT_TIMESTAMP", (word, action, reason, by), ) conn.commit() return True def clear_override(conn: sqlite3.Connection, word: str) -> None: conn.execute("DELETE FROM bloom_word_overrides WHERE word=?", ((word or "").strip().lower(),)) conn.commit() def list_overrides(conn: sqlite3.Connection) -> list[dict]: return [dict(r) for r in conn.execute( "SELECT word, action, reason, created_by, created_at FROM bloom_word_overrides ORDER BY created_at DESC")] def add_report(conn: sqlite3.Connection, word: str, puzzle_date, mode, fmt, letters, reason) -> bool: word = (word or "").strip().lower() if not (word.isalpha() and 4 <= len(word) <= 24): return False # Don't pile up duplicate pending reports for the same word. dup = conn.execute( "SELECT 1 FROM bloom_word_reports WHERE word=? AND status='pending'", (word,)).fetchone() if dup: return True conn.execute( "INSERT INTO bloom_word_reports (word, puzzle_date, mode, format, letters, reason) " "VALUES (?,?,?,?,?,?)", (word, str(puzzle_date or "")[:16], str(mode or "")[:8], str(fmt or "")[:8], str(letters or "")[:16], str(reason or "")[:60]), ) conn.commit() return True def list_reports(conn: sqlite3.Connection, status: str = "pending", limit: int = 100) -> list[dict]: return [dict(r) for r in conn.execute( "SELECT id, word, puzzle_date, mode, format, letters, reason, status, created_at " "FROM bloom_word_reports WHERE status=? ORDER BY created_at DESC LIMIT ?", (status, limit))] def resolve_report(conn: sqlite3.Connection, report_id: int, action: str, by: str | None = None) -> bool: """action: 'approve' (→ allow override) | 'block' (→ block override) | 'dismiss'.""" status = {"approve": "approved", "block": "blocked", "dismiss": "dismissed"}.get(action) row = conn.execute("SELECT word FROM bloom_word_reports WHERE id=?", (report_id,)).fetchone() if not row or not status: return False if action == "approve": if not set_override(conn, row["word"], "allow", reason="report", by=by): return False # can't allow (hard rule) — leave pending; dismiss instead elif action == "block": set_override(conn, row["word"], "block", reason="report", by=by) conn.execute("UPDATE bloom_word_reports SET status=? WHERE id=?", (status, report_id)) conn.commit() return True