nodes/dictionary.pyΒΆ

Part of Claustrowordia.

  1"""Word list + letter pool.
  2
  3Dictionary is bundled at ``assets/words.json``: the public-domain ENABLE
  4word list filtered to 3..7 letter lowercase words (minus profanity). It is
  5not the upstream game's word list; see ``ATTRIBUTION.md``.
  6
  7Letter pool follows the upstream pattern: pick a random word, scramble
  8its letters into a pool, draw from the front, refill when empty. This
  9gives a more "wordlike" letter distribution than uniform sampling.
 10"""
 11
 12from __future__ import annotations
 13
 14import json
 15import random
 16from pathlib import Path
 17
 18_WORDS: set[str] | None = None
 19_WORD_LIST: list[str] | None = None
 20_LETTER_POOL: list[str] = []
 21_RNG: random.Random = random.Random()
 22
 23
 24def seed(value: int) -> None:
 25    """Seed the dictionary RNG (used by the harness for deterministic runs)."""
 26    global _RNG
 27    _RNG = random.Random(value)
 28    _LETTER_POOL.clear()
 29
 30
 31def _load() -> None:
 32    """Load the word list from `assets/words.json`.
 33
 34    On desktop the JSON file lives alongside this module (sys.path injection).
 35    On web (`simvx export web`) the JSON is bundled into the HTML's
 36    `__data__` virtual filesystem which the runtime mounts at "/"; the
 37    relative path resolves there too.
 38    """
 39    global _WORDS, _WORD_LIST
 40    if _WORDS is not None:
 41        return
 42    # Prefer the colocated path (works on desktop + web bundle).
 43    candidates = [
 44        Path(__file__).parent.parent / "assets" / "words.json",
 45        Path("assets/words.json"),
 46    ]
 47    text = None
 48    for p in candidates:
 49        if p.exists():
 50            text = p.read_text(encoding="utf-8", errors="ignore")
 51            break
 52    if text is None:
 53        # No silent fallback: a stand-in word list would turn a packaging error
 54        # into a game that quietly scores almost nothing.
 55        tried = ", ".join(str(p) for p in candidates)
 56        raise FileNotFoundError(f"claustrowordia: words.json not found (tried {tried})")
 57    words = json.loads(text)
 58    _WORD_LIST = [w for w in words if isinstance(w, str) and w.isalpha() and 3 <= len(w) <= 7]
 59    _WORDS = set(_WORD_LIST)
 60
 61
 62def is_word(s: str) -> bool:
 63    """True if `s` is in the dictionary (case-insensitive)."""
 64    _load()
 65    return s.lower() in (_WORDS or set())
 66
 67
 68def random_word() -> str:
 69    """Pick a random dictionary word (used to seed the letter pool)."""
 70    _load()
 71    assert _WORD_LIST is not None
 72    return _RNG.choice(_WORD_LIST)
 73
 74
 75def refill_pool() -> None:
 76    """Refill the letter pool from a randomly chosen word, scrambled."""
 77    word = random_word()
 78    letters = [ch.upper() for ch in word]
 79    _RNG.shuffle(letters)
 80    _LETTER_POOL.extend(letters)
 81
 82
 83def draw_letter() -> str:
 84    """Pop a single letter off the pool, refilling if empty."""
 85    if not _LETTER_POOL:
 86        refill_pool()
 87    return _LETTER_POOL.pop(0)
 88
 89
 90def reset() -> None:
 91    """Clear the letter pool (used on game restart)."""
 92    _LETTER_POOL.clear()
 93
 94
 95def push_front(letters: list[str]) -> None:
 96    """Push letters to the *front* of the pool (used by the harness)."""
 97    for ch in reversed(letters):
 98        _LETTER_POOL.insert(0, ch.upper())
 99
100
101__all__ = ["is_word", "random_word", "draw_letter", "reset", "seed", "push_front"]