1"""Generate and validate human-readable portal codes.
  2
  3Format: {number}-{word}-{word} (e.g. "7-crossword-pineapple")
  4
  5The number is 1-99, and each word is drawn from a curated list of
  6common, unambiguous English words. This gives roughly 20 bits of
  7entropy — enough to prevent casual guessing while SPAKE2 protects
  8against brute-force by eavesdroppers.
  9"""
 10
 11from __future__ import annotations
 12
 13import secrets
 14
 15# ~200 words: common, easy to spell, easy to pronounce, no homophones.
 16# Kept short to be memorable over voice/chat.
 17WORDLIST = [
 18    "acorn",
 19    "album",
 20    "anchor",
 21    "apple",
 22    "arrow",
 23    "atlas",
 24    "badge",
 25    "baker",
 26    "banjo",
 27    "barrel",
 28    "basket",
 29    "beacon",
 30    "blanket",
 31    "blizzard",
 32    "bonfire",
 33    "breeze",
 34    "bridge",
 35    "bucket",
 36    "buffalo",
 37    "cabin",
 38    "cactus",
 39    "camel",
 40    "candle",
 41    "canyon",
 42    "carpet",
 43    "castle",
 44    "cedar",
 45    "channel",
 46    "cherry",
 47    "chestnut",
 48    "chimney",
 49    "circuit",
 50    "cobalt",
 51    "comet",
 52    "compass",
 53    "copper",
 54    "coral",
 55    "coyote",
 56    "cradle",
 57    "crystal",
 58    "curtain",
 59    "cypress",
 60    "dagger",
 61    "dolphin",
 62    "donkey",
 63    "dragon",
 64    "drifter",
 65    "eagle",
 66    "ember",
 67    "emerald",
 68    "falcon",
 69    "fern",
 70    "fiddle",
 71    "flannel",
 72    "fossil",
 73    "fountain",
 74    "galaxy",
 75    "garden",
 76    "garlic",
 77    "gazelle",
 78    "geyser",
 79    "glacier",
 80    "goblet",
 81    "granite",
 82    "gravel",
 83    "griffin",
 84    "guitar",
 85    "gypsum",
 86    "hammer",
 87    "harbor",
 88    "harvest",
 89    "hazel",
 90    "helmet",
 91    "horizon",
 92    "hornet",
 93    "hurricane",
 94    "igloo",
 95    "indigo",
 96    "island",
 97    "ivory",
 98    "jacket",
 99    "jasmine",
100    "jigsaw",
101    "jungle",
102    "juniper",
103    "kayak",
104    "kennel",
105    "lantern",
106    "lasso",
107    "lemon",
108    "leopard",
109    "lighthouse",
110    "lizard",
111    "lobster",
112    "magnet",
113    "mammoth",
114    "maple",
115    "marble",
116    "meadow",
117    "meteor",
118    "mitten",
119    "monsoon",
120    "mosaic",
121    "mustard",
122    "narwhal",
123    "nebula",
124    "nectar",
125    "nimbus",
126    "nutmeg",
127    "obsidian",
128    "octopus",
129    "olive",
130    "orchid",
131    "osprey",
132    "otter",
133    "oyster",
134    "paddle",
135    "panther",
136    "parrot",
137    "peacock",
138    "pebble",
139    "pelican",
140    "penguin",
141    "pepper",
142    "phoenix",
143    "pickle",
144    "pillow",
145    "pineapple",
146    "plumber",
147    "porcupine",
148    "pretzel",
149    "prism",
150    "pumpkin",
151    "python",
152    "quartz",
153    "rabbit",
154    "raccoon",
155    "rainbow",
156    "raven",
157    "ribbon",
158    "riddle",
159    "rocket",
160    "saddle",
161    "salmon",
162    "sandal",
163    "sapphire",
164    "scarlet",
165    "seagull",
166    "shelter",
167    "silver",
168    "sparrow",
169    "spider",
170    "squirrel",
171    "steeple",
172    "summit",
173    "sunset",
174    "tambourine",
175    "tandem",
176    "temple",
177    "thicket",
178    "thistle",
179    "thunder",
180    "timber",
181    "toucan",
182    "tractor",
183    "trident",
184    "trophy",
185    "trumpet",
186    "tulip",
187    "turtle",
188    "umbrella",
189    "unicorn",
190    "valley",
191    "velvet",
192    "vessel",
193    "violet",
194    "volcano",
195    "walnut",
196    "warden",
197    "waffle",
198    "whistle",
199    "willow",
200    "wizard",
201    "wombat",
202    "zenith",
203    "zephyr",
204    "zipper",
205]
206
207
208def generate_code() -> str:
209    """Generate a random portal code like '7-crossword-pineapple'."""
210    number = secrets.randbelow(99) + 1  # 1-99
211    word1 = secrets.choice(WORDLIST)
212    word2 = secrets.choice(WORDLIST)
213    # Avoid identical words
214    while word2 == word1:
215        word2 = secrets.choice(WORDLIST)
216    return f"{number}-{word1}-{word2}"
217
218
219def validate_code(code: str) -> bool:
220    """Check that a code looks structurally valid (number-word-word)."""
221    parts = code.split("-")
222    if len(parts) < 3:
223        return False
224    try:
225        n = int(parts[0])
226    except ValueError:
227        return False
228    return 1 <= n <= 99 and len(parts[1]) > 0 and len(parts[2]) > 0