69607fbfe9
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
158 lines
5.7 KiB
Python
158 lines
5.7 KiB
Python
"""The duplicate-letter cases first, because they are the ones that get shipped wrong."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from wordle_five.engine import (
|
|
Game,
|
|
answer_for_seed,
|
|
answers,
|
|
conformance_digest,
|
|
guesses,
|
|
hard_mode_violation,
|
|
is_consistent,
|
|
score_guess,
|
|
)
|
|
|
|
# Every one of these was verified against an independent implementation and
|
|
# against TextArena's own scorer before being written down. They are the
|
|
# regression surface for the two-pass rule.
|
|
VECTORS = [
|
|
("alloy", "llama", "YGYXX"), # greens claim first, yellows take the rest
|
|
("speed", "erase", "YXYYX"), # both E's yellow: exactly two are available
|
|
("array", "radar", "YYYGX"), # double-double with one green
|
|
("sassy", "basis", "YGGXX"), # 3 S guessed, 2 in answer: one Y, one X
|
|
("eerie", "rebel", "YGYXX"), # the same shape on E
|
|
("level", "eagle", "YYXYX"), # two repeated letters, no greens at all
|
|
("geese", "these", "XXGGG"), # greens exhaust the pool, leading E greys
|
|
("abbey", "abbot", "GGGXX"),
|
|
("crane", "plane", "XXGGG"),
|
|
("alloy", "balmy", "YXGXG"),
|
|
("tares", "tares", "GGGGG"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("guess,answer,expected", VECTORS)
|
|
def test_scoring_vectors(guess: str, answer: str, expected: str) -> None:
|
|
assert score_guess(guess, answer) == expected
|
|
|
|
|
|
def test_score_is_case_insensitive() -> None:
|
|
assert score_guess("ALLOY", "LLAMA") == score_guess("alloy", "llama")
|
|
|
|
|
|
def test_a_letter_is_never_marked_more_often_than_it_occurs() -> None:
|
|
"""The invariant the two passes exist to preserve, checked over the list."""
|
|
pool = answers()[:200]
|
|
for answer in pool:
|
|
for guess in pool[:50]:
|
|
pattern = score_guess(guess, answer)
|
|
for letter in set(guess):
|
|
marked = sum(
|
|
1 for i, c in enumerate(guess) if c == letter and pattern[i] != "X"
|
|
)
|
|
assert marked <= answer.count(letter)
|
|
|
|
|
|
def test_consistency_is_symmetric_with_scoring() -> None:
|
|
"""`is_consistent` must agree with `score_guess`, since the reward uses it."""
|
|
for answer in answers()[:100]:
|
|
guess = "tares"
|
|
pattern = score_guess(guess, answer)
|
|
assert is_consistent(answer, guess, pattern)
|
|
|
|
|
|
def test_every_answer_is_guessable() -> None:
|
|
assert set(answers()) <= guesses()
|
|
|
|
|
|
def test_seeded_answer_is_deterministic() -> None:
|
|
assert answer_for_seed(42) == answer_for_seed(42)
|
|
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
|
|
assert game.play("four")[1] is not None # wrong length
|
|
assert len(game.history) == 0
|
|
assert game.rejected == 2
|
|
assert game.play("crane")[0] is not None
|
|
assert len(game.history) == 1
|
|
|
|
|
|
def test_a_repeat_is_rejected() -> None:
|
|
game = Game(seed=3, answer="tares")
|
|
game.play("crane")
|
|
assert "already been guessed" in (game.play("crane")[1] or "")
|
|
|
|
|
|
def test_game_never_raises_on_junk() -> None:
|
|
game = Game(seed=3, answer="tares")
|
|
for junk in ["", " ", "!!!!!", "a", "abcdefghij", "12345"]:
|
|
pattern, reason = game.play(junk)
|
|
assert pattern is None and reason is not None
|
|
|
|
|
|
def test_solving_ends_the_game() -> None:
|
|
game = Game(seed=3, answer="tares")
|
|
game.play("tares")
|
|
assert game.solved and game.over
|
|
|
|
|
|
# ------------------------------------------------------------- hard mode --
|
|
|
|
|
|
def test_hard_mode_locks_greens() -> None:
|
|
assert hard_mode_violation("crown", "crane", "GGXXX") is None
|
|
assert hard_mode_violation("blown", "crane", "GGXXX") is not None
|
|
|
|
|
|
def test_hard_mode_counts_yellows_rather_than_testing_membership() -> None:
|
|
"""Two revealed E's require two E's, not merely one."""
|
|
pattern = score_guess("speed", "erase") # YXYYX — two E's revealed
|
|
assert hard_mode_violation("ester", "speed", pattern) is None # two E's
|
|
assert hard_mode_violation("crest", "speed", pattern) is not None # only one E
|
|
|
|
|
|
def test_hard_mode_does_not_ban_grey_letters() -> None:
|
|
"""The rule most implementations add and the real game does not have."""
|
|
pattern = score_guess("crane", "tares") # C grey
|
|
assert pattern[0] == "X", "C should be grey against TARES"
|
|
# A guess reusing that grey C is legal, so long as it still satisfies every
|
|
# green and yellow that was revealed.
|
|
assert hard_mode_violation("stare", "crane", pattern) is None
|
|
|
|
|
|
# ------------------------------------------------------ cross-language gate --
|
|
|
|
|
|
def test_conformance_digest_matches_the_committed_value() -> None:
|
|
"""The gate that keeps the TypeScript port honest.
|
|
|
|
This hashes every (guess, answer) pair in the answer list. If it changes,
|
|
either the scorer changed or the word list did, and `src/demos/wordle/
|
|
engine.ts` must be re-verified against it — `pnpm conformance` does the
|
|
other half.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
committed = (Path(__file__).parent.parent / "CONFORMANCE.txt").read_text().split()[0]
|
|
assert conformance_digest() == committed
|