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`**.
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
"""`build_state` + `step` — the kernel's contract: order, clamping, purity, determinism.
|
||||
|
||||
Phase 03 task 01 promises four things, and each has its own test below rather
|
||||
than being inferred from a golden hash (that arrives with task 05):
|
||||
|
||||
* **Clamping** — `dt` outside `[0, MAX_DT]` buys nothing, so a stalled process
|
||||
is never repaid with a burst of substeps;
|
||||
* **Accumulation** — the sim advances in whole `STEP_DT` substeps and carries
|
||||
the remainder, so 17 x 0.05 s is exactly 17 ticks and 0.03 s twice is one tick;
|
||||
* **Rejection, not crash** — input from a player this kitchen does not have
|
||||
becomes an `err` event;
|
||||
* **Purity + determinism** — `step()` never touches the state it was handed,
|
||||
and two states built from one seed stay bit-identical for hundreds of ticks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.level import Level, LevelDef
|
||||
from undercooked.sim.model import (
|
||||
EventKind,
|
||||
Facing,
|
||||
KitchenState,
|
||||
PlayerState,
|
||||
RoundResult,
|
||||
Vec2,
|
||||
)
|
||||
from undercooked.sim.purity import assert_pure_imports
|
||||
from undercooked.sim.recipe import RecipeBook
|
||||
from undercooked.sim.state_build import (
|
||||
SEAT_COLORS,
|
||||
SeatSpec,
|
||||
StateBuildError,
|
||||
build_state,
|
||||
color_for_seat,
|
||||
)
|
||||
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, resolve_inputs, step
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
# --- fixture content ------------------------------------------------------------------
|
||||
|
||||
TILES: dict[str, dict[str, str]] = {
|
||||
".": {"kind": "floor"},
|
||||
"#": {"kind": "wall"},
|
||||
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
|
||||
"C": {"kind": "floor", "station": "chop_board"},
|
||||
"V": {"kind": "floor", "station": "serve"},
|
||||
}
|
||||
|
||||
GRID = [
|
||||
"#######",
|
||||
"#S.C..#",
|
||||
"#.....#",
|
||||
"#....V#",
|
||||
"#######",
|
||||
]
|
||||
|
||||
RECIPES: dict[str, Any] = {
|
||||
"soup": {
|
||||
"id": "soup",
|
||||
"name": "Tomato Soup",
|
||||
"base_score": 40,
|
||||
"ingredients": [{"kind": "tomato", "form": "raw"}],
|
||||
"steps": [{"kind": "serve", "station": "serve"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def make_level(**overrides: Any) -> Level:
|
||||
data: dict[str, Any] = {
|
||||
"id": "01_kernel",
|
||||
"name": "Kernel Kitchen",
|
||||
"order": 1,
|
||||
"time_limit": 12,
|
||||
"target_score": 40,
|
||||
"difficulty": 1,
|
||||
"grid": copy.deepcopy(GRID),
|
||||
"tiles": copy.deepcopy(TILES),
|
||||
"spawns": [[1, 1]],
|
||||
"orders": {"pool": ["soup"], "concurrent": 1, "spawn_interval": [4, 6], "deadline": 30},
|
||||
}
|
||||
data.update(overrides)
|
||||
return Level.build(LevelDef.model_validate(data))
|
||||
|
||||
|
||||
def make_book() -> RecipeBook:
|
||||
return RecipeBook.model_validate({"recipes": copy.deepcopy(RECIPES)})
|
||||
|
||||
|
||||
def seats(count: int, start: int = 0) -> tuple[SeatSpec, ...]:
|
||||
return tuple(
|
||||
SeatSpec(player_id=f"p{index}", seat=index, display_name=f"Cook {index}")
|
||||
for index in range(start, start + count)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def level() -> Level:
|
||||
return make_level()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(level: Level) -> KitchenState:
|
||||
"""A three-cook round on the fixture level, seed 0x5EED0401."""
|
||||
return build_state(level, seats(3), 0x5EED_0401, make_book())
|
||||
|
||||
|
||||
# --- SeatSpec -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_seat_colours_are_a_fixed_palette_of_four() -> None:
|
||||
assert len(SEAT_COLORS) == 4
|
||||
assert len(set(SEAT_COLORS)) == 4
|
||||
assert all(color.startswith("#") and len(color) == 7 for color in SEAT_COLORS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("seat", "expected"),
|
||||
[(0, SEAT_COLORS[0]), (1, SEAT_COLORS[1]), (2, SEAT_COLORS[2]), (3, SEAT_COLORS[3])],
|
||||
)
|
||||
def test_colour_for_seat_follows_the_palette(seat: int, expected: str) -> None:
|
||||
assert color_for_seat(seat) == expected
|
||||
|
||||
|
||||
def test_colour_for_seat_wraps_past_the_fourth_cook() -> None:
|
||||
assert color_for_seat(4) == SEAT_COLORS[0]
|
||||
assert color_for_seat(11) == SEAT_COLORS[3]
|
||||
|
||||
|
||||
def test_seat_spec_defaults_to_the_palette_colour() -> None:
|
||||
seat = SeatSpec(player_id="abc", seat=2, display_name="Reese")
|
||||
assert seat.resolved_color == SEAT_COLORS[2]
|
||||
|
||||
|
||||
def test_seat_spec_keeps_an_explicit_colour() -> None:
|
||||
seat = SeatSpec(player_id="abc", seat=0, display_name="Reese", color="#123456")
|
||||
assert seat.resolved_color == "#123456"
|
||||
|
||||
|
||||
def test_seat_spec_is_hashable_and_a_value() -> None:
|
||||
a = SeatSpec("abc", 1, "Ann", "")
|
||||
assert a == SeatSpec("abc", 1, "Ann", "")
|
||||
assert len({a, SeatSpec("abc", 1, "Ann", "")}) == 1
|
||||
|
||||
|
||||
def test_negative_seat_is_refused() -> None:
|
||||
with pytest.raises(StateBuildError, match="non-negative"):
|
||||
SeatSpec(player_id="abc", seat=-1, display_name="Ann")
|
||||
|
||||
|
||||
# --- build_state ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_state_starts_at_tick_zero_with_the_level_clock(
|
||||
level: Level, state: KitchenState
|
||||
) -> None:
|
||||
assert state.tick == 0
|
||||
assert state.accumulator == 0.0
|
||||
assert state.time_left == level.time_limit == 12
|
||||
assert state.target_score == level.target_score == 40
|
||||
assert state.level_id == "01_kernel"
|
||||
assert state.seed == 0x5EED_0401
|
||||
assert state.rng_state == 0x5EED_0401
|
||||
assert state.score == 0
|
||||
assert state.streak == 0
|
||||
assert state.result is None
|
||||
assert dict(state.counters) == {}
|
||||
|
||||
|
||||
def test_build_state_takes_the_grid_and_stations_from_the_level(
|
||||
level: Level, state: KitchenState
|
||||
) -> None:
|
||||
assert state.grid is level.grid
|
||||
assert state.stations == level.stations
|
||||
assert {station.kind.value for station in state.stations} >= {
|
||||
"spawn_shelf",
|
||||
"chop_board",
|
||||
"serve",
|
||||
}
|
||||
|
||||
|
||||
def test_orders_and_hazards_start_empty_for_later_phases(state: KitchenState) -> None:
|
||||
assert state.orders == ()
|
||||
assert state.hazards == ()
|
||||
|
||||
|
||||
def test_players_are_built_in_seat_order(state: KitchenState) -> None:
|
||||
assert [player.id for player in state.players] == ["p0", "p1", "p2"]
|
||||
assert [player.seat for player in state.players] == [0, 1, 2]
|
||||
|
||||
|
||||
def test_players_start_at_the_spawn_centre_facing_down(level: Level, state: KitchenState) -> None:
|
||||
assert level.spawns == ((1, 1),)
|
||||
first = state.players[0]
|
||||
assert first.pos == Vec2(1.5, 1.5)
|
||||
assert first.facing is Facing.DOWN
|
||||
assert first.state is PlayerState.IDLE
|
||||
assert first.connected is True
|
||||
assert first.carrying is None
|
||||
assert first.vel == Vec2(0.0, 0.0)
|
||||
assert first.stun == 0.0
|
||||
|
||||
|
||||
def test_cooks_sharing_a_spawn_are_offset_by_a_quarter_tile(state: KitchenState) -> None:
|
||||
_, second, third = state.players
|
||||
assert second.pos == Vec2(1.75, 1.5)
|
||||
assert third.pos == Vec2(1.5, 1.75)
|
||||
positions = {player.pos for player in state.players}
|
||||
assert len(positions) == 3
|
||||
|
||||
|
||||
def test_a_full_kitchen_stays_inside_its_spawn_tile(state: KitchenState) -> None:
|
||||
four = build_state(make_level(), seats(4), 7, make_book())
|
||||
assert [player.pos for player in four.players] == [
|
||||
Vec2(1.5, 1.5),
|
||||
Vec2(1.75, 1.5),
|
||||
Vec2(1.5, 1.75),
|
||||
Vec2(1.75, 1.75),
|
||||
]
|
||||
assert all(
|
||||
four.grid.is_walkable(int(player.pos.x), int(player.pos.y)) for player in four.players
|
||||
)
|
||||
|
||||
|
||||
def test_seats_wrap_over_the_available_spawns() -> None:
|
||||
level = make_level(spawns=[[1, 1], [5, 3]])
|
||||
built = build_state(level, seats(4), 1, make_book())
|
||||
assert [(player.pos.x, player.pos.y) for player in built.players] == [
|
||||
(1.5, 1.5), # seat 0 -> spawn 0
|
||||
(5.5, 3.5), # seat 1 -> spawn 1
|
||||
(1.75, 1.5), # seat 2 -> spawn 0 again, offset
|
||||
(5.75, 3.5), # seat 3 -> spawn 1 again, offset
|
||||
]
|
||||
|
||||
|
||||
def test_seat_spec_order_does_not_matter() -> None:
|
||||
scrambled = (
|
||||
SeatSpec("p2", 2, "C"),
|
||||
SeatSpec("p0", 0, "A"),
|
||||
SeatSpec("p1", 1, "B"),
|
||||
)
|
||||
built = build_state(make_level(), scrambled, 5, make_book())
|
||||
assert [player.id for player in built.players] == ["p0", "p1", "p2"]
|
||||
|
||||
|
||||
def test_no_seats_builds_an_empty_kitchen(level: Level) -> None:
|
||||
built = build_state(level, (), 3, make_book())
|
||||
assert built.players == ()
|
||||
assert built.state_hash() # still a perfectly hashable round
|
||||
|
||||
|
||||
def test_a_level_without_spawns_cannot_be_built(level: Level) -> None:
|
||||
barren = replace(level, spawns=())
|
||||
with pytest.raises(StateBuildError, match="defines no spawns"):
|
||||
build_state(barren, seats(1), 1, make_book())
|
||||
|
||||
|
||||
def test_build_state_is_reproducible(level: Level) -> None:
|
||||
first = build_state(level, seats(3), 99, make_book())
|
||||
second = build_state(level, seats(3), 99, make_book())
|
||||
assert first.state_hash() == second.state_hash()
|
||||
|
||||
|
||||
# --- step: clamping and accumulation ------------------------------------------------------
|
||||
|
||||
|
||||
def tick(state: KitchenState, dt: float) -> int:
|
||||
"""Ticks a single `step()` call buys from a fresh accumulator."""
|
||||
advanced, _ = step(state, dt)
|
||||
return advanced.tick
|
||||
|
||||
|
||||
def test_one_substep_is_one_tick(state: KitchenState) -> None:
|
||||
assert tick(state, STEP_DT) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dt", [0.0, -0.0, -1.0, -STEP_DT, float("-inf")])
|
||||
def test_a_non_positive_clock_advance_buys_nothing(state: KitchenState, dt: float) -> None:
|
||||
assert tick(state, dt) == 0
|
||||
|
||||
|
||||
def test_a_nan_clock_advance_is_treated_as_no_time(state: KitchenState) -> None:
|
||||
dt: Any = float("nan")
|
||||
advanced, events = step(state, dt)
|
||||
assert advanced.tick == 0
|
||||
assert events == ()
|
||||
|
||||
|
||||
def test_one_call_never_runs_more_than_max_dt(state: KitchenState) -> None:
|
||||
assert tick(state, MAX_DT) == 2
|
||||
assert tick(state, 60.0) == 2 # a minute of wall time is still two substeps
|
||||
assert tick(state, float("inf")) == 2
|
||||
|
||||
|
||||
def test_the_ceiling_is_exactly_max_dt_over_step_dt() -> None:
|
||||
assert MAX_DT / STEP_DT == 2.0
|
||||
|
||||
|
||||
def test_seventeen_slices_of_step_dt_are_seventeen_ticks(state: KitchenState) -> None:
|
||||
current = state
|
||||
for _ in range(17):
|
||||
current, _events = step(current, STEP_DT)
|
||||
assert current.tick == 17
|
||||
assert current.accumulator == pytest.approx(0.0, abs=1e-12)
|
||||
|
||||
|
||||
def test_time_below_one_substep_is_carried_not_dropped(state: KitchenState) -> None:
|
||||
current = state
|
||||
ticks_seen: list[int] = []
|
||||
for _ in range(10): # 10 x 0.03 s = 0.3 s -> six substeps, remainder 0.0
|
||||
current, _events = step(current, 0.03)
|
||||
ticks_seen.append(current.tick)
|
||||
assert ticks_seen == [0, 1, 1, 2, 3, 3, 4, 4, 5, 6]
|
||||
assert current.accumulator == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_the_accumulator_holds_back_a_partial_substep(state: KitchenState) -> None:
|
||||
current, _events = step(state, 0.04)
|
||||
assert current.tick == 0
|
||||
assert current.accumulator == pytest.approx(0.04)
|
||||
current, _events = step(current, 0.02)
|
||||
assert current.tick == 1
|
||||
assert current.accumulator == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_the_accumulator_never_goes_negative(state: KitchenState) -> None:
|
||||
current = state
|
||||
for dt in (0.05, 0.07, 0.01, 0.001, 0.049):
|
||||
current, _events = step(current, dt)
|
||||
assert current.accumulator >= 0.0
|
||||
assert current.accumulator < STEP_DT
|
||||
|
||||
|
||||
def test_the_clock_counts_down_with_the_ticks(state: KitchenState) -> None:
|
||||
current = state
|
||||
for _ in range(10):
|
||||
current, _events = step(current, STEP_DT)
|
||||
assert current.time_left == pytest.approx(12 - 10 * STEP_DT)
|
||||
|
||||
|
||||
# --- step: inputs ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def inputs(*specs: tuple[str, int, int]) -> tuple[PlayerInput, ...]:
|
||||
"""`PlayerInput` from (player_id, seq, received_at_tick) triples."""
|
||||
return tuple(
|
||||
PlayerInput(player_id=player_id, seq=seq, received_at_tick=received)
|
||||
for player_id, seq, received in specs
|
||||
)
|
||||
|
||||
|
||||
def test_inputs_for_real_players_are_accepted_without_events(state: KitchenState) -> None:
|
||||
advanced, events = step(state, STEP_DT, inputs(("p0", 1, 0)))
|
||||
assert events == ()
|
||||
assert advanced.tick == 1
|
||||
|
||||
|
||||
def test_an_unknown_player_is_an_error_event_not_a_crash(state: KitchenState) -> None:
|
||||
advanced, events = step(state, STEP_DT, inputs(("ghost", 4, 0)))
|
||||
assert advanced.tick == 1 # the round keeps running
|
||||
assert len(events) == 1
|
||||
assert events[0].kind is EventKind.ERROR
|
||||
assert dict(events[0].payload) == {
|
||||
"player_id": "ghost",
|
||||
"reason": "unknown_player",
|
||||
"seq": 4,
|
||||
}
|
||||
# Dropping the ghost must cost the real cooks nothing: compared against the same tick
|
||||
# with no input at all, not against `state`, because movement now separates the two
|
||||
# cooks who share a spawn tile and that is the kernel's doing, not the ghost's.
|
||||
frameless, _events = step(state, STEP_DT)
|
||||
assert advanced.players == frameless.players
|
||||
|
||||
|
||||
def test_a_mixed_batch_reports_only_the_ghost(state: KitchenState) -> None:
|
||||
_advanced, events = step(
|
||||
state, STEP_DT, inputs(("p1", 1, 0), ("nobody", 2, 0), ("p2", 1, 0))
|
||||
)
|
||||
assert [event.kind for event in events] == [EventKind.ERROR]
|
||||
assert events[0].payload["player_id"] == "nobody"
|
||||
|
||||
|
||||
def test_resolve_inputs_keeps_the_earliest_frame(state: KitchenState) -> None:
|
||||
resolved, events = resolve_inputs(
|
||||
state, inputs(("p0", 9, 5), ("p0", 10, 2), ("p0", 11, 7))
|
||||
)
|
||||
assert events == ()
|
||||
assert resolved["p0"].seq == 10
|
||||
|
||||
|
||||
def test_resolve_inputs_breaks_an_arrival_tie_by_seq(state: KitchenState) -> None:
|
||||
resolved, _events = resolve_inputs(state, inputs(("p0", 12, 3), ("p0", 11, 3)))
|
||||
assert resolved["p0"].seq == 11
|
||||
|
||||
|
||||
def test_resolve_inputs_is_stable_on_a_full_tie(state: KitchenState) -> None:
|
||||
resolved, _events = resolve_inputs(state, inputs(("p0", 8, 3), ("p0", 8, 3)))
|
||||
assert resolved["p0"].seq == 8
|
||||
|
||||
|
||||
def test_resolve_inputs_iterates_in_seat_order(state: KitchenState) -> None:
|
||||
resolved, _events = resolve_inputs(state, inputs(("p2", 1, 9), ("p0", 1, 4), ("p1", 1, 6)))
|
||||
assert list(resolved) == ["p0", "p1", "p2"]
|
||||
|
||||
|
||||
def test_resolve_inputs_drops_everything_for_players_that_are_not_here(
|
||||
state: KitchenState,
|
||||
) -> None:
|
||||
resolved, events = resolve_inputs(state, inputs(("ghost", 1, 0), ("p0", 1, 0)))
|
||||
assert list(resolved) == ["p0"]
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
def test_resolve_inputs_of_nothing_is_empty(state: KitchenState) -> None:
|
||||
assert resolve_inputs(state, ()) == ({}, ())
|
||||
|
||||
|
||||
def test_an_empty_kitchen_rejects_every_input() -> None:
|
||||
barren = build_state(make_level(), (), 2, make_book())
|
||||
resolved, events = resolve_inputs(barren, inputs(("p0", 1, 0)))
|
||||
assert resolved == {}
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
def test_input_fields_survive_untouched(state: KitchenState) -> None:
|
||||
frame = PlayerInput(
|
||||
player_id="p0",
|
||||
seq=3,
|
||||
move=Vec2(0.6, -0.8),
|
||||
action=True,
|
||||
face=Facing.LEFT,
|
||||
received_at_tick=11,
|
||||
)
|
||||
resolved, _events = resolve_inputs(state, (frame,))
|
||||
kept = resolved["p0"]
|
||||
assert kept is frame
|
||||
assert (kept.move, kept.action, kept.face, kept.received_at_tick) == (
|
||||
Vec2(0.6, -0.8),
|
||||
True,
|
||||
Facing.LEFT,
|
||||
11,
|
||||
)
|
||||
|
||||
|
||||
def test_player_input_defaults() -> None:
|
||||
frame = PlayerInput(player_id="p0")
|
||||
assert frame.seq == 0
|
||||
assert frame.move == Vec2(0.0, 0.0)
|
||||
assert frame.action is False
|
||||
assert frame.face is None
|
||||
assert frame.received_at_tick == 0
|
||||
|
||||
|
||||
def test_player_input_is_frozen(state: KitchenState) -> None:
|
||||
frame = PlayerInput(player_id="p0")
|
||||
with pytest.raises(AttributeError):
|
||||
frame.seq = 1 # type: ignore[misc]
|
||||
|
||||
|
||||
# --- step: purity ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_step_does_not_mutate_the_state_it_was_given(state: KitchenState) -> None:
|
||||
before_hash = state.state_hash()
|
||||
snapshot = replace(state)
|
||||
advanced, _events = step(state, 0.2, inputs(("p0", 1, 0), ("ghost", 1, 0)))
|
||||
assert state == snapshot
|
||||
assert state.state_hash() == before_hash
|
||||
assert advanced != state
|
||||
assert advanced.tick == 2
|
||||
|
||||
|
||||
def test_the_events_are_a_tuple_in_order(state: KitchenState) -> None:
|
||||
_advanced, events = step(state, STEP_DT, inputs(("ghost-a", 1, 0), ("ghost-b", 1, 0)))
|
||||
assert isinstance(events, tuple)
|
||||
assert [event.payload["player_id"] for event in events] == ["ghost-a", "ghost-b"]
|
||||
|
||||
|
||||
def test_the_kernel_modules_stay_free_of_forbidden_imports() -> None:
|
||||
assert_pure_imports()
|
||||
|
||||
|
||||
# --- step: determinism --------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(
|
||||
state: KitchenState,
|
||||
script: Sequence[tuple[float, tuple[PlayerInput, ...]]],
|
||||
) -> KitchenState:
|
||||
current = state
|
||||
for dt, frames in script:
|
||||
current, _events = step(current, dt, frames)
|
||||
return current
|
||||
|
||||
|
||||
def test_two_rounds_from_one_seed_stay_identical_over_200_steps(level: Level) -> None:
|
||||
left = build_state(level, seats(3), 0x5EED_0401, make_book())
|
||||
right = build_state(level, seats(3), 0x5EED_0401, make_book())
|
||||
script = [
|
||||
(STEP_DT, inputs(("p0", index, index), ("p1", index, index)))
|
||||
for index in range(200)
|
||||
]
|
||||
assert run(left, script).state_hash() == run(right, script).state_hash()
|
||||
|
||||
|
||||
def test_the_seed_reaches_the_state_so_two_seeds_differ(level: Level) -> None:
|
||||
one = build_state(level, seats(2), 1, make_book())
|
||||
other = build_state(level, seats(2), 2, make_book())
|
||||
assert one.state_hash() != other.state_hash()
|
||||
assert one.rng_state != other.rng_state
|
||||
|
||||
|
||||
def test_tick_is_monotonic_across_200_steps(state: KitchenState) -> None:
|
||||
current = state
|
||||
previous = current.tick
|
||||
for _ in range(200):
|
||||
current, _events = step(current, STEP_DT)
|
||||
assert current.tick == previous + 1
|
||||
assert current.time_left <= 12.0
|
||||
previous = current.tick
|
||||
|
||||
|
||||
def test_a_step_that_runs_no_substeps_changes_nothing_but_the_accumulator(
|
||||
state: KitchenState,
|
||||
) -> None:
|
||||
current, events = step(state, 0.01)
|
||||
assert events == ()
|
||||
assert current.tick == 0
|
||||
assert current.time_left == state.time_left
|
||||
assert current.players == state.players
|
||||
|
||||
|
||||
def test_batched_and_sliced_time_agree(state: KitchenState) -> None:
|
||||
batched, _ = step(state, MAX_DT)
|
||||
sliced = step(state, 0.025)[0]
|
||||
sliced = step(sliced, 0.025)[0]
|
||||
sliced = step(sliced, 0.025)[0]
|
||||
sliced, _ = step(sliced, 0.025)
|
||||
assert batched.tick == sliced.tick == 2
|
||||
assert batched.state_hash() == sliced.state_hash()
|
||||
|
||||
|
||||
# --- step: the round timer (stage 7) --------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_round_ends_lost_when_the_clock_beats_the_score(state: KitchenState) -> None:
|
||||
current = state
|
||||
for _ in range(240): # 240 x 0.05 s = the whole 12 s clock
|
||||
current, _events = step(current, STEP_DT)
|
||||
assert current.time_left == 0.0
|
||||
assert current.result is RoundResult.LOST
|
||||
assert current.tick == 240
|
||||
|
||||
|
||||
def test_the_round_is_won_on_the_buzzer_when_the_target_was_reached(
|
||||
state: KitchenState,
|
||||
) -> None:
|
||||
winning = replace(state, score=state.target_score)
|
||||
current = winning
|
||||
for _ in range(240):
|
||||
current, _events = step(current, STEP_DT)
|
||||
assert current.result is RoundResult.WON
|
||||
|
||||
|
||||
def test_the_clock_never_goes_negative_after_the_buzzer(state: KitchenState) -> None:
|
||||
current = replace(state, time_left=STEP_DT)
|
||||
current, _events = step(current, STEP_DT)
|
||||
assert current.time_left == 0.0
|
||||
assert current.result is RoundResult.LOST
|
||||
current, _events = step(current, STEP_DT)
|
||||
assert current.time_left == 0.0
|
||||
assert not math.isclose(current.time_left, -1.0)
|
||||
|
||||
|
||||
def test_an_already_decided_round_keeps_its_result(state: KitchenState) -> None:
|
||||
decided = replace(state, time_left=STEP_DT, result=RoundResult.WON, score=99)
|
||||
current, _events = step(decided, STEP_DT)
|
||||
assert current.result is RoundResult.WON
|
||||
assert current.time_left == 0.0
|
||||
Reference in New Issue
Block a user