Pin seed->word across both languages with a shared hash
engine.ts used mulberry32 and engine.py used random.Random(seed). Same seed, different word — so every ?seed= permalink on the site would have shown a different puzzle than the recorded run it claimed to be replaying, and nobody would have noticed until someone checked one by hand. Both now derive the index from FNV-1a 32-bit over the decimal seed. A hash rather than a PRNG because there is no honest one-line JavaScript equivalent of Mersenne Twister, and this way there is nothing to keep in step: both sides compute the same integer from the same string. Math.imul on the JS side is load-bearing — a plain multiply overflows into a double and diverges after the first few bytes. Twelve seeds are pinned as a vector in both test suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -72,6 +72,21 @@ def test_seeded_answer_is_deterministic() -> None:
|
||||
assert answer_for_seed(1) != answer_for_seed(2)
|
||||
|
||||
|
||||
# Pinned so the browser cannot drift. `src/demos/wordle/__tests__/engine.test.ts`
|
||||
# asserts the same twelve words for the same twelve seeds. If a language's
|
||||
# built-in RNG were used instead of the shared hash, these two lists would
|
||||
# differ and every ?seed= permalink would show a different word than the
|
||||
# recorded run it claims to replay.
|
||||
SEED_VECTORS = [
|
||||
"wants", "amber", "spume", "toady", "divot", "filly",
|
||||
"bobby", "clews", "hikes", "lawns", "wreak", "twist",
|
||||
]
|
||||
|
||||
|
||||
def test_seed_vectors_match_the_typescript_port() -> None:
|
||||
assert [answer_for_seed(s) for s in range(12)] == SEED_VECTORS
|
||||
|
||||
|
||||
def test_rejections_do_not_consume_a_row() -> None:
|
||||
game = Game(seed=3, answer="tares")
|
||||
assert game.play("zzzzz")[1] is not None # not a word
|
||||
|
||||
@@ -10,7 +10,6 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
@@ -105,15 +104,27 @@ def hard_mode_violation(guess: str, prev_guess: str, prev_pattern: str) -> str |
|
||||
return None
|
||||
|
||||
|
||||
def answer_for_seed(seed: int) -> str:
|
||||
"""The hidden word for a seed.
|
||||
def fnv1a32(text: str) -> int:
|
||||
"""FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly.
|
||||
|
||||
Seeded from a dedicated Random rather than the module-global one, because
|
||||
TextArena seeds the process-global RNG and anything sharing it becomes
|
||||
order-dependent under concurrency.
|
||||
A language's built-in RNG is not portable: `random.Random(7)` is a
|
||||
Mersenne Twister and there is no honest one-line JavaScript equivalent, so
|
||||
seed 7 would pick one word here and a different one in the browser. Every
|
||||
permalink on the site would then disagree with the recorded run it claims
|
||||
to show. A hash sidesteps the whole problem — both sides compute the same
|
||||
integer from the same string, and there is nothing to keep in step.
|
||||
"""
|
||||
h = 0x811C9DC5
|
||||
for byte in text.encode():
|
||||
h ^= byte
|
||||
h = (h * 0x01000193) & 0xFFFFFFFF
|
||||
return h
|
||||
|
||||
|
||||
def answer_for_seed(seed: int) -> str:
|
||||
"""The hidden word for a seed. Identical in engine.ts — see fnv1a32."""
|
||||
pool = answers()
|
||||
return pool[random.Random(seed).randrange(len(pool))]
|
||||
return pool[fnv1a32(str(seed)) % len(pool)]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
Reference in New Issue
Block a user