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"]