Files
ducoterra 4febdfe780 phase: 03_simulation_movement_and_interaction
**Final verification pass — phase 03 `simulation_movement_and_interaction` (all 5 task files already in `complete/`)**

- Verified all phase-03 deliverables exist and work: `sim/` kernel (`step`, `state_build`, `rng`, `movement`, `collision`, `interact`, `carry`, `replay`), replay fixtures + 3 committed goldens, `docs/SIMULATION.md`.
- `bash .agents/validate.sh` → **passed**: ruff `All checks passed!`, pyright `0 errors`, `undercooked-levels validate` green, full `pytest` **1033 passed** (coverage **99.71%** ≥ 90% floor), slow sim stage green, repo-hygiene clean.
- Perf smoke (4 players, 2 000 ticks): mean **0.049 ms/tick, 20 423 ticks/s** — ≥ 10 000 ticks/s criterion met (test lives in `test_perf_smoke.py`, which the gate's slow stage runs; the overview names it `test_replay.py`).
- Completion criteria: import `undercooked.sim.step.step` ✅ · `tests/sim` green incl. determinism + goldens ✅ · perf ✅ · constant-change-fails-by-design ✅ (committed-hash equality + mutation test naming first bad tick/field; goldens never rewritten by tests) · `docs/SIMULATION.md` ✅ (constants, 8-step order, purity/determinism, re-bless) · full suite + coverage ✅ · app launches: `/healthz` 200, `/` 200, `/api/levels` serves content ✅ · replay CLI `check walk_solo.json` exit 0 ✅ · no regression in phases 01/02 (their suites green) ✅.
- Documentation: README.md — fixed one stale inconsistency (Status opening listed only 01/02 complete with 03 pending; now lists 01–03 complete, next pending `04_kitchen_pipeline_and_scoring`); all other README claims (CLI flags, config table, shell text, replay CLI exit codes) verified against code. AGENTS.md — verified consistent with the project as it stands, no changes needed.
- No deviations. Next pending phase: **`04_kitchen_pipeline_and_scoring`**.
2026-10-07 02:46:10 -04:00

219 lines
6.7 KiB
Python

"""`sim/rng.py` is golden-vector locked — the replay contract starts here (A21).
The numbers below are not "whatever the code returns today": they are splitmix64
as published (Steele/Lea/Venc 2014, Algorithm 5), written down once and then
never moved. If a future change to the generator breaks them, a committed replay
golden would silently describe a different game, and that is exactly what a
replay is supposed to catch.
Beyond the vectors, the tests pin the properties the kernel relies on: the
counter wraps at 2**64, `next_float` is a half-open [0,1) draw that is actually
spread out, `range_int` hits both of its ends, `fork` is reproducible and never
disturbs its parent, and nothing in the module is shared global state.
"""
from __future__ import annotations
import pytest
from undercooked.sim.rng import GOLDEN_GAMMA, MASK64, Rng, mix64
pytestmark = pytest.mark.unit
# --- published splitmix64 vectors ---------------------------------------------------
def test_seed_zero_u64_vector() -> None:
rng = Rng(0)
assert [rng.next_u64() for _ in range(4)] == [
16294208416658607535,
7960286522194355700,
487617019471545679,
17909611376780542444,
]
def test_round_seed_vector() -> None:
# 0x5EED0401 is the `random_seed` fixture value: what a real round draws first.
rng = Rng(0x5EED_0401)
assert [rng.next_u64() for _ in range(2)] == [
966088850652082199,
13654240339074911480,
]
def test_next_float_vector() -> None:
rng = Rng(0)
assert rng.next_float() == pytest.approx(0.8833108082136426, abs=0.0)
assert rng.next_float() == pytest.approx(0.43152799704850997, abs=0.0)
assert rng.next_float() == pytest.approx(0.026433771592597743, abs=0.0)
def test_range_int_vector() -> None:
rng = Rng(0)
assert [rng.range_int(0, 5) for _ in range(8)] == [1, 0, 1, 4, 1, 0, 5, 2]
def test_choice_vector() -> None:
rng = Rng(1)
assert [rng.choice(["a", "b", "c", "d"]) for _ in range(6)] == [
"b",
"d",
"c",
"d",
"b",
"a",
]
def test_fork_vector() -> None:
assert Rng(0).fork(0).state == 5197578548964807871
assert Rng(0).fork(7).state == 8409816773569330625
assert Rng(0).fork(8).state == 15655543297350242377
assert Rng(0).fork(0).next_u64() == 6235967106033911276
def test_mix64_is_the_published_finalizer() -> None:
assert mix64(0) == 0 # the fixed point `fork` steps around
assert mix64(GOLDEN_GAMMA) == 16294208416658607535 # == Rng(0).next_u64()
# --- the counter --------------------------------------------------------------------
@pytest.mark.parametrize("seed", [0, -1, -2**64, 2**64, 2**65 + 7, MASK64])
def test_any_seed_is_reduced_to_64_bits(seed: int) -> None:
rng = Rng(seed)
assert 0 <= rng.state <= MASK64
def test_seeds_that_agree_mod_2p64_agree() -> None:
assert Rng(-1).state == MASK64
assert Rng(-1).next_u64() == Rng(MASK64).next_u64()
def test_counter_wraps_and_never_leaves_64_bits() -> None:
rng = Rng(MASK64 - 2)
for _ in range(6):
rng.next_u64()
assert 0 <= rng.state <= MASK64
def test_zero_seed_is_not_a_fixed_point() -> None:
rng = Rng(0)
first = rng.next_u64()
assert rng.state != 0
assert first != rng.next_u64()
# --- draw distributions ---------------------------------------------------------------
def test_next_float_is_half_open_over_many_draws() -> None:
rng = Rng(0xC0FFEE)
draws = [rng.next_float() for _ in range(10_000)]
assert all(0.0 <= draw < 1.0 for draw in draws)
def test_next_float_is_spread_out() -> None:
rng = Rng(0xC0FFEE)
buckets = [0] * 10
for _ in range(10_000):
buckets[int(rng.next_float() * 10)] += 1
assert all(850 <= count <= 1150 for count in buckets), buckets
mean_bucket = sum(count * (index + 0.5) for index, count in enumerate(buckets)) / 10_000
assert 4.5 <= mean_bucket <= 5.5
def test_next_float_uses_the_whole_range() -> None:
rng = Rng(424_242)
draws = [rng.next_float() for _ in range(10_000)]
assert min(draws) < 0.01
assert max(draws) > 0.99
def test_range_int_stays_in_bounds_and_reaches_both_ends() -> None:
rng = Rng(2024)
draws = [rng.range_int(3, 9) for _ in range(5_000)]
assert set(draws) == {3, 4, 5, 6, 7, 8, 9}
def test_range_int_of_a_single_value_never_moves() -> None:
rng = Rng(9)
assert [rng.range_int(-2, -2) for _ in range(4)] == [-2, -2, -2, -2]
def test_range_int_rejects_an_empty_range() -> None:
with pytest.raises(ValueError, match=r"empty integer range 5\.\.2"):
Rng(0).range_int(5, 2)
def test_choice_only_returns_members() -> None:
rng = Rng(7)
flavors = ["tomato", "onion", "lettuce"]
assert {rng.choice(flavors) for _ in range(500)} == set(flavors)
def test_choice_rejects_an_empty_sequence() -> None:
with pytest.raises(ValueError, match="empty sequence"):
Rng(0).choice([])
# --- streams are independent, and fork does not disturb the parent ----------------------
def test_two_generators_from_one_seed_agree() -> None:
left, right = Rng(1234), Rng(1234)
assert [left.next_u64() for _ in range(32)] == [right.next_u64() for _ in range(32)]
def test_a_generator_is_not_shared_state() -> None:
first, second = Rng(5), Rng(5)
for _ in range(4):
first.next_u64()
assert second.state == 5
assert second.next_u64() == Rng(5).next_u64()
def test_fork_is_reproducible_and_independent() -> None:
parent = Rng(0xC1)
child_a, child_b = parent.fork(3), parent.fork(3)
assert child_a == child_b
assert [child_a.next_u64() for _ in range(8)] == [child_b.next_u64() for _ in range(8)]
def test_fork_leaves_the_parent_stream_untouched() -> None:
parent = Rng(0xC1)
before = parent.state
for salt in range(6):
parent.fork(salt)
assert parent.state == before
assert parent.next_u64() == Rng(0xC1).next_u64()
@pytest.mark.parametrize(
("salt", "differs_from_zero"),
[(0, False), (1, True), (2, True), (7, True), (-1, True), (MASK64, True)],
)
def test_the_salt_selects_the_stream(salt: int, differs_from_zero: bool) -> None:
parent = Rng(0)
other = parent.fork(salt)
assert (other.state != parent.fork(0).state) is differs_from_zero
def test_fork_zero_is_not_the_parent_stream() -> None:
# mix64(0) == 0, so a naive `state ^ mix64(salt)` would clone the parent here.
assert Rng(0).fork(0).state != 0
assert Rng(0).fork(0).next_u64() != Rng(0).next_u64()
def test_nested_forks_are_reproducible() -> None:
outer = Rng(77)
deep_a = outer.fork(1).fork(2).next_u64()
deep_b = Rng(77).fork(1).fork(2).next_u64()
assert deep_a == deep_b
def test_fork_accepts_any_integer_salt() -> None:
assert Rng(3).fork(-42).state == Rng(3).fork(-42 + (1 << 64)).state