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,611 @@
|
||||
"""`sim/carry.py` + the action gate's carry verbs — the hands, pinned.
|
||||
|
||||
PLAN §5 gives the cook one button and a pair of hands; this file pins what the
|
||||
hands do:
|
||||
|
||||
* **one item per hand** — a second pick while carrying is `HANDS_FULL`, and a press
|
||||
at open floor with a full hand is the ladder's named `NOTHING_HERE` (v1 has no
|
||||
floor drops);
|
||||
* **the set-down** — `RELEASE` / `PLACE_ON_STATION` / `PLATE_DISH` put the item on
|
||||
the fixture, stamp it with a mark, and leave it grabbable the moment it is set
|
||||
down (while an unmarked pipeline input stays the work press's); a pick-and-place
|
||||
round trip preserves the `Item`'s identity and clears the fixture's cell;
|
||||
* **the hand-off** — a pick of a set-down placed by another cook within
|
||||
`HANDOFF_WINDOW_TICKS` (inclusive) is a `HANDED_OFF` with both ids; a self-pick,
|
||||
or a third party's pick outside the window, is a plain `PICKED`;
|
||||
* **the trash** — a long press over the trash can destroys the carried item on the
|
||||
8th substep of the hold and not the 7th; a short press and a walk-away do
|
||||
nothing; a threshold met with an empty hand is `TRASHED_ALREADY_EMPTY`, never a
|
||||
silent no-op; the raw score delta rides the event payload;
|
||||
* **the contested pick** — one winner, one `CONTESTED_LOST` loser, the item count
|
||||
conserved;
|
||||
* **the conservation law** — a 100-tick fuzz of random simultaneous presses from a
|
||||
fixed seed re-checks `hands + stations + trashed == initial` after *every* tick,
|
||||
and runs bit-identical twice.
|
||||
|
||||
The two-player pass-and-contest script with its committed golden hash is
|
||||
`test_replay_carry.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.carry import (
|
||||
HANDOFF_WINDOW_TICKS,
|
||||
TRASH_HOLD_TICKS,
|
||||
TRASH_SCORE_DELTA,
|
||||
first_loose_entry,
|
||||
pick_item,
|
||||
place_item,
|
||||
trash_effect,
|
||||
)
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.interact import (
|
||||
ItemTarget,
|
||||
StationTarget,
|
||||
action_gate,
|
||||
resolve_action,
|
||||
select_target,
|
||||
)
|
||||
from undercooked.sim.model import (
|
||||
Event,
|
||||
EventKind,
|
||||
Facing,
|
||||
Item,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
Player,
|
||||
PlayerState,
|
||||
Station,
|
||||
StationKind,
|
||||
StationState,
|
||||
TileKind,
|
||||
Vec2,
|
||||
)
|
||||
from undercooked.sim.rng import Rng
|
||||
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
WIDTH, HEIGHT = 13, 9
|
||||
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
|
||||
|
||||
|
||||
# --- fixtures ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_state(
|
||||
stations: tuple[Station, ...] = (),
|
||||
players: tuple[Player, ...] = (),
|
||||
tick: int = 0,
|
||||
) -> KitchenState:
|
||||
return KitchenState(
|
||||
level_id="03_carry",
|
||||
seed=7,
|
||||
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
|
||||
tick=tick,
|
||||
stations=stations,
|
||||
players=players,
|
||||
target_score=40,
|
||||
time_left=90.0,
|
||||
)
|
||||
|
||||
|
||||
def cook(
|
||||
player_id: str = "p0",
|
||||
*,
|
||||
pos: Vec2 = DEFAULT_POS,
|
||||
facing: Facing = Facing.UP,
|
||||
carrying: Item | None = None,
|
||||
state: PlayerState = PlayerState.IDLE,
|
||||
seat: int = 0,
|
||||
) -> Player:
|
||||
return Player(
|
||||
id=player_id,
|
||||
pos=pos,
|
||||
facing=facing,
|
||||
carrying=carrying,
|
||||
state=state,
|
||||
seat=seat,
|
||||
)
|
||||
|
||||
|
||||
def fixture(
|
||||
kind: StationKind = StationKind.STOVE,
|
||||
*,
|
||||
x: int = 5,
|
||||
y: int = 4,
|
||||
state: StationState = StationState.IDLE,
|
||||
contents: tuple[Item, ...] = (),
|
||||
dirty: bool = False,
|
||||
) -> Station:
|
||||
return Station(
|
||||
id=f"{kind.value}-{x}-{y}",
|
||||
kind=kind,
|
||||
x=x,
|
||||
y=y,
|
||||
state=state,
|
||||
contents=contents,
|
||||
dirty=dirty,
|
||||
)
|
||||
|
||||
|
||||
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
|
||||
return Item(kind=ItemKind.TOMATO, form=form)
|
||||
|
||||
|
||||
def lettuce() -> Item:
|
||||
return Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW)
|
||||
|
||||
|
||||
def rice() -> Item:
|
||||
return Item(kind=ItemKind.RICE, form=ItemForm.RAW)
|
||||
|
||||
|
||||
def frame(player_id: str, seq: int, action: bool, tick: int) -> PlayerInput:
|
||||
return PlayerInput(player_id=player_id, seq=seq, action=action, received_at_tick=tick)
|
||||
|
||||
|
||||
def kinds(events: tuple[Event, ...]) -> list[EventKind]:
|
||||
return [EventKind(event.kind) for event in events]
|
||||
|
||||
|
||||
def payload(event: Event) -> dict[str, Any]:
|
||||
return dict(event.payload)
|
||||
|
||||
|
||||
def total_items(state: KitchenState) -> int:
|
||||
"""The conservation quantity: hands + station contents + trashed."""
|
||||
in_hands = sum(1 for player in state.players if player.carrying is not None)
|
||||
on_stations = sum(len(station.contents) for station in state.stations)
|
||||
trashed = state.counters.get("trashed", 0)
|
||||
return in_hands + on_stations + trashed
|
||||
|
||||
|
||||
# --- grabbability: a set-down is an object, a pipeline input is work ---------------------
|
||||
|
||||
|
||||
def test_a_set_down_item_is_targeted_by_a_free_hand_on_an_idle_surface() -> None:
|
||||
"""The whole of the hand-off: a placed item on a counter is grabbable at once."""
|
||||
prep = fixture(kind=StationKind.PREP, contents=(lettuce(),))
|
||||
state = make_state(stations=(prep,), players=(cook("p0"),))
|
||||
state = replace(state, item_marks={prep.id: (("placerr", 3),)})
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, ItemTarget)
|
||||
assert target.item == lettuce()
|
||||
|
||||
|
||||
def test_an_unmarked_loaded_surface_holds_its_item_for_the_work_press() -> None:
|
||||
"""An onion on an idle board is *to be chopped*: no mark, not `READY`, not a shelf."""
|
||||
board = fixture(kind=StationKind.CHOP_BOARD, contents=(tomato(),))
|
||||
state = make_state(stations=(board,), players=(cook("p0"),))
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, StationTarget)
|
||||
assert first_loose_entry(state, board) is None
|
||||
|
||||
|
||||
def test_a_ready_unmarked_fixture_hands_its_output_to_a_free_hand() -> None:
|
||||
ready = fixture(
|
||||
kind=StationKind.CHOP_BOARD,
|
||||
state=StationState.READY,
|
||||
contents=(tomato(ItemForm.CHOPPED),),
|
||||
)
|
||||
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, ItemTarget)
|
||||
assert target.item.form is ItemForm.CHOPPED
|
||||
|
||||
|
||||
def test_the_shelf_hands_its_entries_whether_marked_or_stock() -> None:
|
||||
"""Shelf entries are all grabbable: stock (unmarked) first, then set-downs."""
|
||||
shelf = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(), lettuce()))
|
||||
state = make_state(stations=(shelf,), players=(cook("p0"),))
|
||||
entry = first_loose_entry(state, shelf)
|
||||
assert entry is not None and entry[0] == tomato() # the unmarked stock entry is first
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, ItemTarget)
|
||||
assert target.item == tomato()
|
||||
|
||||
|
||||
def test_a_marked_set_down_stays_grabbable_after_the_placer_moves_away() -> None:
|
||||
"""The mark outlives the press: the object is on the counter, not in a hand."""
|
||||
station = fixture(kind=StationKind.PREP, contents=(lettuce(),))
|
||||
state = make_state(stations=(station,), players=(cook("p0"),))
|
||||
state = replace(state, item_marks={station.id: (("other", 5),)})
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, ItemTarget)
|
||||
assert target.item == lettuce()
|
||||
|
||||
|
||||
# --- one item per hand -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_full_hand_refuses_a_second_pick_with_one_named_denial() -> None:
|
||||
ready = fixture(state=StationState.READY, contents=(tomato(),))
|
||||
state = make_state(stations=(ready,), players=(cook("p0", carrying=lettuce()),))
|
||||
frames = {"p0": frame("p0", 0, True, 0)}
|
||||
_new_state, events = action_gate(state, frames, STEP_DT)
|
||||
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||
assert len(denied) == 1 # one refusal per press, never per tick
|
||||
assert payload(denied[0])["reason"] == "hands_full"
|
||||
|
||||
|
||||
def test_a_press_at_open_floor_with_a_full_hand_is_a_named_nothing_here() -> None:
|
||||
"""v1 has no floor drops: a `RELEASE` at a FLOOR cell is `NOTHING_HERE`, not a drop."""
|
||||
state = make_state(players=(cook("p0", carrying=lettuce()),)) # no stations at all
|
||||
new_state, events = resolve_action(state, state.players[0])
|
||||
assert new_state is state
|
||||
assert len(events) == 1
|
||||
assert payload(events[0])["reason"] == "nothing_here"
|
||||
|
||||
|
||||
# --- pick/place round trip --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pick_and_place_preserve_the_item_and_clear_the_station_cell() -> None:
|
||||
"""The identity fields survive the whole round trip; the cell is cleared on the pick."""
|
||||
item = Item(kind=ItemKind.TOMATO, form=ItemForm.CHOPPED, quality=0.42, ttl=3.5)
|
||||
ready = fixture(state=StationState.READY, contents=(item,))
|
||||
shelf = fixture(kind=StationKind.SPAWN_SHELF, x=8)
|
||||
state = make_state(stations=(ready, shelf), players=(cook("p0"),))
|
||||
new_state, events = resolve_action(state, state.players[0])
|
||||
assert len(events) == 1 and events[0].kind is EventKind.PICKED
|
||||
carried = new_state.players[0].carrying
|
||||
assert carried is item # value identity: the very object left the fixture
|
||||
assert new_state.stations[0].contents == () # the cell is cleared
|
||||
# the set-down: move the cook to the shelf, press again
|
||||
cook_at_shelf = replace(new_state.players[0], pos=Vec2(8.5, 5.5))
|
||||
new_state2, events2 = resolve_action(new_state, cook_at_shelf)
|
||||
assert len(events2) == 1 and events2[0].kind is EventKind.DROPPED
|
||||
assert new_state2.players[0].carrying is None
|
||||
assert new_state2.stations[1].contents == (item,) # the same object, back on a fixture
|
||||
assert item.quality == 0.42 and item.ttl == 3.5 # the fields never move
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind",
|
||||
[StationKind.CHOP_BOARD, StationKind.PREP, StationKind.SPAWN_SHELF],
|
||||
ids=["place_on_station", "plate_dish", "release"],
|
||||
)
|
||||
def test_place_verbs_put_the_item_on_the_counter_and_stamp_the_mark(
|
||||
kind: StationKind,
|
||||
) -> None:
|
||||
"""`PLACE_ON_STATION` (board), `PLATE_DISH` (the counter) and `RELEASE` (the shelf)."""
|
||||
held = lettuce()
|
||||
station = fixture(kind=kind)
|
||||
state = make_state(stations=(station,), players=(cook("p0", carrying=held),))
|
||||
new_state, events = resolve_action(state, state.players[0])
|
||||
assert len(events) == 1 and events[0].kind is EventKind.DROPPED
|
||||
assert new_state.players[0].carrying is None
|
||||
assert new_state.stations[0].contents == (held,)
|
||||
# the mark: who put it down, at which tick — the hand-off's whole memory
|
||||
assert new_state.item_marks[station.id] == (("p0", 0),)
|
||||
|
||||
|
||||
# --- the hand-off -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pass_kitchen() -> KitchenState:
|
||||
"""A at the prep, B at its left diagonal, both facing the counter; A holds an item."""
|
||||
prep = fixture(kind=StationKind.PREP, x=9, y=4)
|
||||
a = cook("cook-a", pos=Vec2(9.5, 5.5), carrying=lettuce(), seat=0)
|
||||
b = cook("cook-b", pos=Vec2(8.5, 5.5), seat=1)
|
||||
return make_state(stations=(prep,), players=(a, b))
|
||||
|
||||
|
||||
def test_a_pass_is_a_handed_off_with_both_ids() -> None:
|
||||
state = _pass_kitchen()
|
||||
# t0: A sets the item down (the marked place)
|
||||
state, events = step(state, STEP_DT, (frame("cook-a", 0, True, 0),))
|
||||
assert kinds(events) == [EventKind.DROPPED]
|
||||
assert payload(events[0])["player_id"] == "cook-a"
|
||||
# t1: A releases; B picks it up -> the sanctioned pass
|
||||
state, events = step(
|
||||
state,
|
||||
STEP_DT,
|
||||
(frame("cook-a", 1, False, 1), frame("cook-b", 1, True, 1)),
|
||||
)
|
||||
handed = [event for event in events if event.kind is EventKind.HANDED_OFF]
|
||||
assert len(handed) == 1
|
||||
assert payload(handed[0]) == {
|
||||
"from": "cook-a",
|
||||
"to": "cook-b",
|
||||
"item": "lettuce",
|
||||
"form": "raw",
|
||||
}
|
||||
assert state.players[1].carrying is not None
|
||||
assert state.players[0].carrying is None
|
||||
assert state.stations[0].contents == ()
|
||||
|
||||
|
||||
def test_the_hand_off_window_is_inclusive_at_the_twentieth_tick() -> None:
|
||||
station = fixture(kind=StationKind.PREP)
|
||||
a = cook("cook-a", carrying=lettuce())
|
||||
b = cook("cook-b", seat=1)
|
||||
state = make_state(stations=(station,), players=(a, b))
|
||||
held = a.carrying
|
||||
assert held is not None
|
||||
placed, _events = place_item(state, a, station, held)
|
||||
at_boundary = placed.with_tick(HANDOFF_WINDOW_TICKS)
|
||||
_new_state, events = pick_item(at_boundary, b, station, lettuce())
|
||||
assert kinds(events) == [EventKind.HANDED_OFF]
|
||||
assert payload(events[0]) == {
|
||||
"from": "cook-a",
|
||||
"to": "cook-b",
|
||||
"item": "lettuce",
|
||||
"form": "raw",
|
||||
}
|
||||
just_after = placed.with_tick(HANDOFF_WINDOW_TICKS + 1)
|
||||
_new_state, events = pick_item(just_after, b, station, lettuce())
|
||||
assert kinds(events) == [EventKind.PICKED]
|
||||
|
||||
|
||||
def test_the_placer_picking_their_own_item_is_a_plain_pick() -> None:
|
||||
station = fixture(kind=StationKind.PREP)
|
||||
a = cook("cook-a", carrying=lettuce())
|
||||
state = make_state(stations=(station,), players=(a,))
|
||||
held = a.carrying
|
||||
assert held is not None
|
||||
placed, _events = place_item(state, a, station, held)
|
||||
new_state, events = pick_item(placed.with_tick(1), a, station, lettuce())
|
||||
assert kinds(events) == [EventKind.PICKED]
|
||||
assert new_state.players[0].carrying is not None
|
||||
|
||||
|
||||
def test_a_third_party_pick_outside_the_window_is_a_plain_pick() -> None:
|
||||
"""A's set-down, C's pick one tick past the window: a pick, never a pass."""
|
||||
station = fixture(kind=StationKind.PREP)
|
||||
a = cook("cook-a", carrying=lettuce())
|
||||
c = cook("cook-c", seat=2)
|
||||
state = make_state(stations=(station,), players=(a, c))
|
||||
held = a.carrying
|
||||
assert held is not None
|
||||
placed, _events = place_item(state, a, station, held)
|
||||
late = placed.with_tick(HANDOFF_WINDOW_TICKS + 1)
|
||||
new_state, events = pick_item(late, c, station, lettuce())
|
||||
assert kinds(events) == [EventKind.PICKED]
|
||||
assert payload(events[0])["player_id"] == "cook-c"
|
||||
assert new_state.players[1].carrying is not None
|
||||
assert EventKind.HANDED_OFF not in kinds(events)
|
||||
|
||||
|
||||
def test_a_same_tick_pass_is_inside_the_window() -> None:
|
||||
"""Place and pick in the same substep (seat order): the pass, at zero ticks old."""
|
||||
station = fixture(kind=StationKind.PREP)
|
||||
a = cook("cook-a", carrying=lettuce(), seat=0)
|
||||
b = cook("cook-b", seat=1)
|
||||
state = make_state(stations=(station,), players=(a, b))
|
||||
frames = {"cook-a": frame("cook-a", 0, True, 0), "cook-b": frame("cook-b", 0, True, 0)}
|
||||
new_state, events = action_gate(state, frames, STEP_DT)
|
||||
assert kinds(events) == [EventKind.DROPPED, EventKind.HANDED_OFF]
|
||||
assert payload(events[1]) == {
|
||||
"from": "cook-a",
|
||||
"to": "cook-b",
|
||||
"item": "lettuce",
|
||||
"form": "raw",
|
||||
}
|
||||
assert new_state.players[1].carrying is not None
|
||||
assert new_state.players[0].carrying is None
|
||||
assert new_state.stations[0].contents == ()
|
||||
|
||||
|
||||
# --- the trash ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _trash_kitchen() -> KitchenState:
|
||||
trash = fixture(kind=StationKind.TRASH)
|
||||
return make_state(stations=(trash,), players=(cook("p0", carrying=lettuce()),))
|
||||
|
||||
|
||||
def test_a_trash_long_press_destroys_on_the_eighth_substep_not_the_seventh() -> None:
|
||||
state = _trash_kitchen()
|
||||
for substep in range(TRASH_HOLD_TICKS - 1): # substeps 1..7: the hold builds
|
||||
state, events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),))
|
||||
assert state.players[0].carrying is not None
|
||||
assert EventKind.TRASHED not in kinds(events)
|
||||
# the 8th substep of the hold: the item goes
|
||||
state, events = step(
|
||||
state, STEP_DT, (frame("p0", TRASH_HOLD_TICKS - 1, True, state.tick),)
|
||||
)
|
||||
trashed = [event for event in events if event.kind is EventKind.TRASHED]
|
||||
assert len(trashed) == 1
|
||||
assert payload(trashed[0]) == {
|
||||
"player_id": "p0",
|
||||
"item": "lettuce",
|
||||
"form": "raw",
|
||||
"score_delta": TRASH_SCORE_DELTA,
|
||||
}
|
||||
assert state.players[0].carrying is None
|
||||
assert state.counters["trashed"] == 1
|
||||
assert state.score == 0 # the raw delta is phase 04's to spend (clamped at 0)
|
||||
|
||||
|
||||
def test_a_short_press_does_not_trash() -> None:
|
||||
state = _trash_kitchen()
|
||||
for substep in range(3):
|
||||
state, _events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),))
|
||||
state, _events = step(state, STEP_DT, (frame("p0", 3, False, state.tick),))
|
||||
assert state.players[0].carrying is not None
|
||||
assert "trashed" not in state.counters
|
||||
|
||||
|
||||
def test_releasing_before_the_threshold_starts_a_fresh_hold() -> None:
|
||||
state = _trash_kitchen()
|
||||
plan = (True, True, True, True, True, False) + (True,) * TRASH_HOLD_TICKS
|
||||
for substep, action in enumerate(plan):
|
||||
state, events = step(state, STEP_DT, (frame("p0", substep, action, state.tick),))
|
||||
if substep < len(plan) - 1:
|
||||
assert state.players[0].carrying is not None
|
||||
assert EventKind.TRASHED not in kinds(events)
|
||||
assert state.players[0].carrying is None # the 8th substep of the *second* hold
|
||||
assert state.counters["trashed"] == 1
|
||||
|
||||
|
||||
def test_walking_away_from_the_trash_voids_the_hold() -> None:
|
||||
"""The gate re-derives the hold: off the can, the button is no longer `TRASH`."""
|
||||
state = _trash_kitchen()
|
||||
for substep in range(TRASH_HOLD_TICKS): # the hold's 8 substeps, walking right
|
||||
walking = PlayerInput(
|
||||
player_id="p0",
|
||||
seq=substep,
|
||||
action=True,
|
||||
move=Vec2(1.0, 0.0),
|
||||
received_at_tick=state.tick,
|
||||
)
|
||||
state, events = step(state, STEP_DT, (walking,))
|
||||
assert EventKind.TRASHED not in kinds(events)
|
||||
for substep in range(TRASH_HOLD_TICKS, TRASH_HOLD_TICKS + 6):
|
||||
state, events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),))
|
||||
assert EventKind.TRASHED not in kinds(events)
|
||||
assert state.players[0].carrying is not None # the hold was voided, nothing trashed
|
||||
assert "trashed" not in state.counters
|
||||
|
||||
|
||||
def test_a_trash_threshold_with_an_empty_hand_is_named_not_silent() -> None:
|
||||
"""Defensive path: the threshold met with an empty hand is an event, not a no-op."""
|
||||
trash = fixture(kind=StationKind.TRASH)
|
||||
state = make_state(stations=(trash,), players=(cook("p0"),)) # nothing carried
|
||||
before = state.state_hash()
|
||||
new_state, events = trash_effect(state, state.players[0])
|
||||
assert kinds(events) == [EventKind.TRASHED_ALREADY_EMPTY]
|
||||
assert payload(events[0]) == {"player_id": "p0"}
|
||||
assert new_state is state # nothing moved
|
||||
assert state.state_hash() == before
|
||||
|
||||
|
||||
# --- contested picks (the new paths, conserved) ------------------------------------------
|
||||
|
||||
|
||||
def _contested_kitchen() -> tuple[KitchenState, dict[str, PlayerInput]]:
|
||||
"""Two cooks on one ready item: seat 0 in front, seat 1 on the left diagonal."""
|
||||
ready = fixture(state=StationState.READY, contents=(tomato(),))
|
||||
state = make_state(
|
||||
stations=(ready,),
|
||||
players=(
|
||||
cook("cook-a", seat=0), # (5, 5) facing UP: front cell (5, 4)
|
||||
cook("cook-b", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1),
|
||||
),
|
||||
)
|
||||
frames = {
|
||||
"cook-a": frame("cook-a", 0, True, 0),
|
||||
"cook-b": frame("cook-b", 0, True, 0),
|
||||
}
|
||||
return state, frames
|
||||
|
||||
|
||||
def test_a_contested_pick_has_exactly_one_winner_and_conserves_items() -> None:
|
||||
state, frames = _contested_kitchen()
|
||||
initial = total_items(state)
|
||||
new_state, events = action_gate(state, frames, STEP_DT)
|
||||
picked = [event for event in events if event.kind is EventKind.PICKED]
|
||||
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||
assert len(picked) == 1 and len(denied) == 1
|
||||
assert payload(picked[0])["player_id"] == "cook-a" # the full tie goes to the seat
|
||||
assert payload(denied[0]) == {
|
||||
"player_id": "cook-b",
|
||||
"reason": "contested_lost",
|
||||
"verb": "none",
|
||||
}
|
||||
assert new_state.players[0].carrying is not None
|
||||
assert new_state.players[1].carrying is None
|
||||
assert new_state.stations[0].contents == ()
|
||||
assert total_items(new_state) == initial # the item is in exactly one place
|
||||
|
||||
|
||||
def test_the_earlier_received_frame_wins_regardless_of_seat() -> None:
|
||||
state, frames = _contested_kitchen()
|
||||
frames["cook-a"] = frame("cook-a", 0, True, 1) # seat 0 arrives one tick later
|
||||
new_state, _events = action_gate(state, frames, STEP_DT)
|
||||
assert new_state.players[1].carrying is not None # the earlier frame wins
|
||||
assert new_state.players[0].carrying is None
|
||||
|
||||
|
||||
def test_a_contested_start_work_claims_the_worker_slot() -> None:
|
||||
"""A loaded unmarked board: the single worker slot is the claim, not an item."""
|
||||
board = fixture(kind=StationKind.CHOP_BOARD, contents=(tomato(),))
|
||||
a = cook("cook-a", seat=0)
|
||||
b = cook("cook-b", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1)
|
||||
state = make_state(stations=(board,), players=(a, b))
|
||||
frames = {"cook-a": frame("cook-a", 0, True, 0), "cook-b": frame("cook-b", 0, True, 0)}
|
||||
new_state, events = action_gate(state, frames, STEP_DT)
|
||||
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||
assert len(denied) == 1
|
||||
assert payload(denied[0]) == {
|
||||
"player_id": "cook-b",
|
||||
"reason": "contested_lost",
|
||||
"verb": "none",
|
||||
}
|
||||
assert new_state.stations[0].contents == (tomato(),) # the input stays for the work
|
||||
|
||||
|
||||
# --- the conservation law: 100-tick fuzz -------------------------------------------------
|
||||
|
||||
|
||||
DIRECTIONS: tuple[Vec2, ...] = (
|
||||
Vec2(0.0, 0.0),
|
||||
Vec2(1.0, 0.0),
|
||||
Vec2(-1.0, 0.0),
|
||||
Vec2(0.0, 1.0),
|
||||
Vec2(0.0, -1.0),
|
||||
Vec2(1.0, 1.0),
|
||||
Vec2(1.0, -1.0),
|
||||
Vec2(-1.0, 1.0),
|
||||
Vec2(-1.0, -1.0),
|
||||
)
|
||||
|
||||
|
||||
def _fuzz_kitchen() -> KitchenState:
|
||||
"""Three cooks, one carried item each, at a shelf / prep / trash in a row."""
|
||||
shelf = fixture(kind=StationKind.SPAWN_SHELF, x=5)
|
||||
prep = fixture(kind=StationKind.PREP, x=7)
|
||||
trash = fixture(kind=StationKind.TRASH, x=9)
|
||||
state = make_state(
|
||||
stations=(shelf, prep, trash),
|
||||
players=(
|
||||
cook("p0", pos=Vec2(5.5, 5.5), carrying=tomato(), seat=0),
|
||||
cook("p1", pos=Vec2(7.5, 5.5), carrying=lettuce(), seat=1),
|
||||
cook("p2", pos=Vec2(9.5, 5.5), carrying=rice(), seat=2),
|
||||
),
|
||||
)
|
||||
assert total_items(state) == 3
|
||||
return state
|
||||
|
||||
|
||||
def _fuzz_run(seed: int, ticks: int = 100) -> tuple[KitchenState, list[str]]:
|
||||
"""One fuzz run: random move + random press per cook per tick, from `seed`."""
|
||||
rng = Rng(seed)
|
||||
state = _fuzz_kitchen()
|
||||
hashes: list[str] = []
|
||||
for tick in range(ticks):
|
||||
frames = tuple(
|
||||
PlayerInput(
|
||||
player_id=player.id,
|
||||
seq=tick,
|
||||
move=rng.choice(DIRECTIONS),
|
||||
action=rng.next_float() < 0.35,
|
||||
received_at_tick=state.tick,
|
||||
)
|
||||
for player in state.players
|
||||
)
|
||||
state, _events = step(state, STEP_DT, frames)
|
||||
assert total_items(state) == 3, f"item count lost at tick {state.tick}"
|
||||
hashes.append(state.state_hash())
|
||||
return state, hashes
|
||||
|
||||
|
||||
def test_the_item_count_is_conserved_over_a_hundred_tick_fuzz() -> None:
|
||||
"""The per-tick assert is the point: a leak mid-run would surface immediately."""
|
||||
state, _hashes = _fuzz_run(0xC0FFEE)
|
||||
assert total_items(state) == 3 # and at the end, as the task requires
|
||||
|
||||
|
||||
def test_the_fuzz_is_bit_identical_across_two_runs() -> None:
|
||||
first, first_hashes = _fuzz_run(0xC0FFEE)
|
||||
second, second_hashes = _fuzz_run(0xC0FFEE)
|
||||
assert first_hashes == second_hashes # the whole history, tick for tick
|
||||
assert first.state_hash() == second.state_hash()
|
||||
@@ -0,0 +1,881 @@
|
||||
"""`sim/collision.py` + `sim/movement.py` — the two promises walking makes (PLAN §5).
|
||||
|
||||
Everything here is table-driven against hand-computed geometry, because "it moved" is
|
||||
not a specification. The numbers come from the shapes, not from the code: a cook is a
|
||||
square of half-width `RADIUS` = 0.35 and a tile is 1.0 wide, so a cook pressed against
|
||||
the wall cell at x=6 sits with its centre at `6 - 0.35 - CONTACT_EPS` = 5.6499. If the
|
||||
implementation ever says otherwise, one of those two is wrong.
|
||||
|
||||
The property groups:
|
||||
|
||||
* **terrain** — walk into every `TileKind` (and off the map), and stop flush;
|
||||
* **speed** — the ceiling, the ramp, the harder brake, no diagonal boost, and the
|
||||
server-side clamp that makes a `move` of 50 worth exactly a `move` of 1;
|
||||
* **other cooks** — the symmetric soft push (two cooks and a chain of four), no
|
||||
velocity injection, the transfer of what a wall refuses, and the deadlock revert;
|
||||
* **stun** — pinned, one event, expires;
|
||||
* **facing** — the last non-zero intent, a press that never turns you, an explicit turn.
|
||||
|
||||
The invariant-only version of all this, over thousands of ticks of nonsense input, is
|
||||
`test_movement_stress.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
from itertools import pairwise
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim import movement
|
||||
from undercooked.sim.collision import (
|
||||
AXIS_X,
|
||||
AXIS_Y,
|
||||
CONTACT_EPS,
|
||||
PASS_THROUGH_STATIONS,
|
||||
body_blocker,
|
||||
body_cells,
|
||||
cell_blocked,
|
||||
station_body_cells,
|
||||
sweep_axis,
|
||||
)
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.model import (
|
||||
EventKind,
|
||||
Facing,
|
||||
Hazard,
|
||||
HazardKind,
|
||||
KitchenState,
|
||||
Player,
|
||||
PlayerState,
|
||||
Station,
|
||||
StationKind,
|
||||
TileKind,
|
||||
Vec2,
|
||||
)
|
||||
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, step
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
# --- fixtures ---------------------------------------------------------------------------
|
||||
|
||||
LEGEND: dict[str, TileKind] = {
|
||||
".": TileKind.FLOOR,
|
||||
"#": TileKind.WALL,
|
||||
"=": TileKind.COUNTER,
|
||||
"o": TileKind.VOID,
|
||||
}
|
||||
|
||||
#: 13x5, interior x 1..11 / y 1..3, walls all round.
|
||||
ROOM = (
|
||||
"#############",
|
||||
"#...........#",
|
||||
"#...........#",
|
||||
"#...........#",
|
||||
"#############",
|
||||
)
|
||||
|
||||
#: 21x21: big enough that nothing touches an edge for the first twenty ticks of a
|
||||
#: full-speed run from the centre.
|
||||
FIELD = tuple(["#" * 21] + ["#" + "." * 19 + "#"] * 19 + ["#" * 21])
|
||||
|
||||
#: half-width + the epsilon a body stops short of a blocked cell by.
|
||||
FLUSH = movement.RADIUS + CONTACT_EPS
|
||||
|
||||
TWO_TILES = 2 * movement.RADIUS
|
||||
|
||||
|
||||
def grid(rows: Sequence[str], legend: dict[str, TileKind] | None = None) -> Grid:
|
||||
return Grid.from_rows(rows, legend or LEGEND)
|
||||
|
||||
|
||||
def kitchen(
|
||||
players: Sequence[Player],
|
||||
rows: Sequence[str] = ROOM,
|
||||
stations: Sequence[Station] = (),
|
||||
legend: dict[str, TileKind] | None = None,
|
||||
) -> KitchenState:
|
||||
return KitchenState(
|
||||
level_id="movement",
|
||||
seed=1,
|
||||
grid=grid(rows, legend),
|
||||
stations=tuple(stations),
|
||||
players=tuple(players),
|
||||
time_left=30.0,
|
||||
)
|
||||
|
||||
|
||||
def cook(pid: str, x: float, y: float, seat: int = 0, **overrides: Any) -> Player:
|
||||
return replace(Player(id=pid, pos=Vec2(x, y), seat=seat), **overrides)
|
||||
|
||||
|
||||
def press(
|
||||
pid: str,
|
||||
x: float,
|
||||
y: float,
|
||||
*,
|
||||
seq: int = 1,
|
||||
received: int = 0,
|
||||
action: bool = False,
|
||||
face: Facing | None = None,
|
||||
) -> PlayerInput:
|
||||
return PlayerInput(
|
||||
player_id=pid,
|
||||
seq=seq,
|
||||
move=Vec2(x, y),
|
||||
action=action,
|
||||
face=face,
|
||||
received_at_tick=received,
|
||||
)
|
||||
|
||||
|
||||
def walked(
|
||||
state: KitchenState, frames: dict[str, PlayerInput], ticks: int, dt: float = STEP_DT
|
||||
) -> KitchenState:
|
||||
"""`state` after `ticks` substeps of `frames`."""
|
||||
current = state
|
||||
for _ in range(ticks):
|
||||
current, _events = movement.step_movement(current, dt, frames)
|
||||
return current
|
||||
|
||||
|
||||
def occupied_cells(state: KitchenState, index: int = 0) -> tuple[tuple[int, int], ...]:
|
||||
return body_cells(state.players[index].pos, movement.RADIUS)
|
||||
|
||||
|
||||
def assert_no_body_in_terrain(state: KitchenState) -> None:
|
||||
"""The core guarantee, asserted as geometry instead of as a remembered coordinate."""
|
||||
blocked = body_blocker(state.grid, state.stations)
|
||||
for player in state.players:
|
||||
assert not any(blocked(x, y) for x, y in body_cells(player.pos, movement.RADIUS))
|
||||
|
||||
|
||||
|
||||
# --- collision.cell_blocked -------------------------------------------------------------
|
||||
|
||||
KINDS = ("#####", "#.=o#", "#####")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("x", "y", "expected"),
|
||||
[
|
||||
(1, 1, False), # the only walkable cell
|
||||
(2, 1, True), # counter
|
||||
(3, 1, True), # void: blocked, never a fall (PLAN §5)
|
||||
(1, 0, True), # wall above
|
||||
(1, 2, True), # wall below
|
||||
(0, 1, True), # wall left
|
||||
(4, 1, True), # wall right
|
||||
],
|
||||
)
|
||||
def test_cell_blocked_per_tile_kind(x: int, y: int, expected: bool) -> None:
|
||||
assert cell_blocked(grid(KINDS), x, y) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("x", "y"), [(-1, 1), (5, 1), (1, -1), (1, 5), (99, 99)])
|
||||
def test_out_of_bounds_is_blocked(x: int, y: int) -> None:
|
||||
assert cell_blocked(grid(KINDS), x, y) is True
|
||||
|
||||
|
||||
def test_extra_blocked_uses_row_major_cell_indices() -> None:
|
||||
board = grid(KINDS)
|
||||
assert cell_blocked(board, 1, 1, frozenset()) is False
|
||||
assert cell_blocked(board, 1, 1, frozenset({1 * board.width + 1})) is True
|
||||
# an index for a cell that is blocked anyway changes nothing
|
||||
assert cell_blocked(board, 3, 1, frozenset({3})) is True
|
||||
|
||||
|
||||
# --- collision.station_body_cells / body_blocker ----------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind",
|
||||
[StationKind.SPAWN_SHELF, StationKind.CHOP_BOARD, StationKind.STOVE, StationKind.PREP],
|
||||
)
|
||||
def test_fixture_stations_block_their_tile(kind: StationKind) -> None:
|
||||
assert station_body_cells(Station(id=f"{kind.value}_0", kind=kind, x=4, y=2)) == ((4, 2),)
|
||||
|
||||
|
||||
def ordered(kinds: frozenset[StationKind]) -> list[StationKind]:
|
||||
"""The pass-through set as a stable list — a set has no order a test can rely on."""
|
||||
return sorted(kinds, key=lambda kind: kind.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ordered(PASS_THROUGH_STATIONS))
|
||||
def test_pass_through_stations_block_nothing(kind: StationKind) -> None:
|
||||
assert kind in {StationKind.SERVE, StationKind.SINK, StationKind.TRASH}
|
||||
assert station_body_cells(Station(id=f"{kind.value}_0", kind=kind, x=4, y=2)) == ()
|
||||
|
||||
|
||||
def test_body_blocker_layers_terrain_fixtures_and_exclusions() -> None:
|
||||
board = grid(("#####", "#...#", "#####"))
|
||||
shelf = Station(id="chop_board_0", kind=StationKind.CHOP_BOARD, x=2, y=1)
|
||||
blocked = body_blocker(board, [shelf])
|
||||
assert blocked(2, 1) is True # floor, but a board stands on it
|
||||
assert blocked(1, 1) is False # plain floor
|
||||
assert blocked(0, 1) is True # terrain
|
||||
assert blocked(2, 0) is True # terrain
|
||||
|
||||
carrying = body_blocker(board, [shelf], frozenset({board.width + 2}))
|
||||
assert carrying(2, 1) is False # the cook holding the board walks over it (phase 10)
|
||||
assert carrying(2, 0) is True # `movable_cells` never opens terrain
|
||||
|
||||
|
||||
# --- collision.body_cells ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pos", "expected"),
|
||||
[
|
||||
(Vec2(2.5, 2.5), [(2, 2)]),
|
||||
(Vec2(3.0, 3.0), [(2, 2), (3, 2), (2, 3), (3, 3)]), # a corner touches four cells
|
||||
(Vec2(6 - FLUSH, 2.5), [(5, 2)]), # flush left of x=6 claims only cell x=5
|
||||
(Vec2(2.5, 6 - FLUSH), [(2, 5)]),
|
||||
],
|
||||
)
|
||||
def test_body_cells(pos: Vec2, expected: list[tuple[int, int]]) -> None:
|
||||
assert body_cells(pos, movement.RADIUS) == tuple(expected)
|
||||
|
||||
|
||||
def test_a_small_body_stays_in_one_cell() -> None:
|
||||
assert body_cells(Vec2(3.5, 3.5), 0.1) == ((3, 3),)
|
||||
|
||||
|
||||
# --- collision.sweep_axis ---------------------------------------------------------------
|
||||
|
||||
SWEEP_MAP = (
|
||||
"########",
|
||||
"#......#",
|
||||
"#..#...#",
|
||||
"#......#",
|
||||
"########",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("axis", [AXIS_X, AXIS_Y])
|
||||
def test_a_sweep_of_nothing_moves_nothing(axis: str) -> None:
|
||||
blocked = body_blocker(grid(SWEEP_MAP))
|
||||
assert sweep_axis(Vec2(2.5, 2.5), 0.0, movement.RADIUS, blocked, axis) == 0.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pos", "delta", "axis", "expected"),
|
||||
[
|
||||
# a free corridor: the whole move is legal, both directions
|
||||
(Vec2(1.5, 3.5), 0.25, AXIS_X, 0.25),
|
||||
(Vec2(5.5, 3.5), -0.25, AXIS_X, -0.25),
|
||||
# the pillar at (3,2) stops a rightward move one epsilon short of x=3
|
||||
(Vec2(1.5, 2.5), 3.0, AXIS_X, 3.0 - 1.85 - CONTACT_EPS),
|
||||
# the same pillar met from the right stops one epsilon right of x=4
|
||||
(Vec2(5.5, 2.5), -3.0, AXIS_X, 4.0 + CONTACT_EPS - 5.15),
|
||||
# down into the floor wall at y=4, up into the one at y=0
|
||||
(Vec2(1.5, 1.5), 3.0, AXIS_Y, 4.0 - 1.85 - CONTACT_EPS),
|
||||
(Vec2(1.5, 1.5), -3.0, AXIS_Y, 1.0 + CONTACT_EPS - 1.15),
|
||||
# already flush: no room on that axis, and no bounce back either
|
||||
(Vec2(3 - FLUSH, 2.5), 0.5, AXIS_X, 0.0),
|
||||
(Vec2(4 + FLUSH, 2.5), -0.5, AXIS_X, 0.0),
|
||||
],
|
||||
)
|
||||
def test_sweep_axis_limits(pos: Vec2, delta: float, axis: str, expected: float) -> None:
|
||||
allowed = sweep_axis(pos, delta, movement.RADIUS, body_blocker(grid(SWEEP_MAP)), axis)
|
||||
assert allowed == pytest.approx(expected, abs=1e-12)
|
||||
|
||||
|
||||
def test_a_huge_sweep_never_crosses_a_one_tile_wall() -> None:
|
||||
"""50 tiles in one call is still stopped by the first wall: every cell on the path is tried."""
|
||||
blocked = body_blocker(grid(SWEEP_MAP))
|
||||
assert sweep_axis(Vec2(1.5, 2.5), 50.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx(
|
||||
1.1499, abs=1e-9
|
||||
)
|
||||
assert sweep_axis(Vec2(6.5, 2.5), -50.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx(
|
||||
-2.1499, abs=1e-9
|
||||
)
|
||||
|
||||
|
||||
def test_a_sweep_cannot_push_a_body_out_of_the_map() -> None:
|
||||
"""The edge of the map stops a body the way a wall does: its trail edge at `+EPS`."""
|
||||
blocked = body_blocker(grid(("...", "...", "...")))
|
||||
assert sweep_axis(Vec2(1.5, 1.5), -9.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx(
|
||||
CONTACT_EPS - 1.15, abs=1e-9
|
||||
)
|
||||
|
||||
|
||||
# --- terrain: walking into everything ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "blocked_cell", "expected_cell"),
|
||||
[
|
||||
(TileKind.FLOOR, 5, (4, 1)),
|
||||
(TileKind.WALL, 4, (3, 1)),
|
||||
(TileKind.COUNTER, 4, (3, 1)),
|
||||
(TileKind.VOID, 4, (3, 1)),
|
||||
],
|
||||
)
|
||||
def test_walking_into_each_tile_kind(
|
||||
kind: TileKind, blocked_cell: int, expected_cell: tuple[int, int]
|
||||
) -> None:
|
||||
"""One tile of each kind in the path: the cook stops flush against it, never inside it.
|
||||
|
||||
"Flush" is `blocked_cell - RADIUS` to within `CONTACT_EPS`: a sweep stops a body one
|
||||
epsilon short of the blocked cell, but when the moving edge lands exactly *on* the
|
||||
boundary the body is already flush and no clamp is needed at all.
|
||||
"""
|
||||
state = kitchen(
|
||||
[cook("p0", 1.5, 1.5)],
|
||||
rows=["######", "#...K#", "######"],
|
||||
legend={**LEGEND, "K": kind},
|
||||
)
|
||||
out = walked(state, {"p0": press("p0", 1, 0)}, 60)
|
||||
assert out.players[0].pos.x == pytest.approx(blocked_cell - movement.RADIUS, abs=CONTACT_EPS)
|
||||
assert out.players[0].pos.y == pytest.approx(1.5)
|
||||
assert occupied_cells(out) == (expected_cell,)
|
||||
assert_no_body_in_terrain(out)
|
||||
|
||||
|
||||
def test_a_walk_stops_flush_against_the_room_wall() -> None:
|
||||
state = kitchen([cook("p0", 2.5, 2.5)], rows=ROOM)
|
||||
out = walked(state, {"p0": press("p0", 1, 0)}, 120)
|
||||
assert out.players[0].pos.x == pytest.approx(12 - movement.RADIUS, abs=CONTACT_EPS)
|
||||
assert occupied_cells(out) == ((11, 2),)
|
||||
|
||||
|
||||
def test_a_corner_catch_slides_instead_of_sticking() -> None:
|
||||
"""A fixture column in the path: X is refused, Y keeps going, and the cook goes around.
|
||||
|
||||
The assertion that matters is per-substep: whenever the pillar is level with the
|
||||
cook, the cook is never *inside* it — which is exactly the corner case a
|
||||
centre-only collision test gets wrong.
|
||||
"""
|
||||
state = kitchen(
|
||||
[cook("p0", 1 + FLUSH, 2.5)],
|
||||
rows=("#######", "#.....#", "#.#...#", "#.#...#", "#.....#", "#######"),
|
||||
)
|
||||
current = state
|
||||
for tick in range(30):
|
||||
current, _events = movement.step_movement(
|
||||
current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)}
|
||||
)
|
||||
assert_no_body_in_terrain(current)
|
||||
level_with_pillar = any(y in (2, 3) for _x, y in occupied_cells(current))
|
||||
if level_with_pillar:
|
||||
assert current.players[0].pos.x <= 2 - movement.RADIUS + CONTACT_EPS
|
||||
out = current.players[0]
|
||||
# slid along the wall instead of into it, and stopped flush with the bottom
|
||||
assert out.pos.y == pytest.approx(5 - movement.RADIUS, abs=CONTACT_EPS)
|
||||
assert out.pos.x > 2.0 # and carried on right once the pillar was behind
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"direction",
|
||||
[(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, 1)],
|
||||
ids=["west", "east", "north", "south", "north-west", "south-east"],
|
||||
)
|
||||
def test_out_of_bounds_containment_in_every_direction(direction: tuple[int, int]) -> None:
|
||||
"""A map with no walls at all: the edge is a wall for a cook, in every direction."""
|
||||
state = kitchen([cook("p0", 2.0, 1.5)], rows=("....", "....", "...."))
|
||||
out = walked(state, {"p0": press("p0", *direction)}, 60).players[0]
|
||||
assert out.pos.x - movement.RADIUS >= -CONTACT_EPS
|
||||
assert out.pos.x + movement.RADIUS <= 4.0 + CONTACT_EPS
|
||||
assert out.pos.y - movement.RADIUS >= -CONTACT_EPS
|
||||
assert out.pos.y + movement.RADIUS <= 3.0 + CONTACT_EPS
|
||||
assert_no_body_in_terrain(replace(state, players=(out,)))
|
||||
|
||||
|
||||
def test_double_speed_cannot_cross_a_one_tile_wall(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ceiling doubled and the coarsest legal dt: the sweep still stops the cook flush.
|
||||
|
||||
The per-substep displacement (7 tiles/s x 0.1 s = 0.7) is 70 % of a tile, so a naive
|
||||
"is the destination cell walkable" test would land the cook on the wall's far edge.
|
||||
`sweep_axis` tests every cell between the old and the new edge instead.
|
||||
"""
|
||||
monkeypatch.setattr(movement, "MAX_SPEED", 2 * movement.MAX_SPEED)
|
||||
state = kitchen([cook("p0", 1.5, 1.5)], rows=("######", "#..#.#", "######"))
|
||||
current = state
|
||||
for tick in range(30):
|
||||
current, _events = movement.step_movement(
|
||||
current, MAX_DT, {"p0": press("p0", 1, 0, seq=tick)}
|
||||
)
|
||||
assert_no_body_in_terrain(current)
|
||||
assert current.players[0].pos.x == pytest.approx(3 - movement.RADIUS, abs=2 * CONTACT_EPS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "blocked"), [(StationKind.STOVE, True), (StationKind.SERVE, False)]
|
||||
)
|
||||
def test_a_stove_stops_you_and_a_serve_window_does_not(kind: StationKind, blocked: bool) -> None:
|
||||
fixture = Station(id=f"{kind.value}_0", kind=kind, x=4, y=1)
|
||||
state = kitchen(
|
||||
[cook("p0", 1.5, 1.5)], rows=("#######", "#.....#", "#######"), stations=[fixture]
|
||||
)
|
||||
out = walked(state, {"p0": press("p0", 1, 0)}, 60).players[0]
|
||||
assert out.pos.x == pytest.approx((4 if blocked else 6) - movement.RADIUS, abs=CONTACT_EPS)
|
||||
assert_no_body_in_terrain(replace(state, players=(out,)))
|
||||
|
||||
|
||||
# --- speed ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_intent_is_clamped_to_the_unit_disc() -> None:
|
||||
assert movement.intent_direction(Vec2(0, 0)) == Vec2(0, 0)
|
||||
assert movement.intent_direction(Vec2(0.5, 0)) == Vec2(0.5, 0) # analog throttle kept
|
||||
diagonal = movement.intent_direction(Vec2(3, 4))
|
||||
assert diagonal.x == pytest.approx(0.6)
|
||||
assert diagonal.y == pytest.approx(0.8)
|
||||
assert movement.intent_direction(Vec2(1, 1)).length == pytest.approx(1.0)
|
||||
assert movement.intent_direction(Vec2(50, 0)).x == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_normalisation_is_what_makes_the_diagonal_not_faster(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(movement, "DIAGONAL_NORM", False)
|
||||
assert movement.intent_direction(Vec2(2, 2)) == Vec2(1, 1)
|
||||
assert movement.intent_direction(Vec2(50, -50)) == Vec2(1, -1)
|
||||
assert movement.intent_direction(Vec2(0.4, 0.4)) == Vec2(0.4, 0.4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"intent",
|
||||
[Vec2(1, 0), Vec2(-1, 0), Vec2(0, 1), Vec2(0, -1), Vec2(1, 1), Vec2(-2, -2)],
|
||||
ids=["e", "w", "s", "n", "diag", "diag_overdrive"],
|
||||
)
|
||||
def test_a_diagonal_is_not_a_speed_boost(intent: Vec2) -> None:
|
||||
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
|
||||
travelled = walked(state, {"p0": press("p0", intent.x, intent.y)}, 20).players[0].pos
|
||||
straight = walked(state, {"p0": press("p0", 1, 0)}, 20).players[0].pos
|
||||
start = Vec2(10.5, 10.5)
|
||||
assert (travelled - start).length == pytest.approx((straight - start).length, abs=1e-12)
|
||||
|
||||
|
||||
def test_the_ramp_and_the_harder_brake() -> None:
|
||||
still = cook("p0", 5.5, 2.5)
|
||||
first = movement.desired_velocity(still, Vec2(1, 0), STEP_DT)
|
||||
assert first == pytest.approx(Vec2(movement.ACCEL * STEP_DT, 0))
|
||||
second = movement.desired_velocity(replace(still, vel=first), Vec2(1, 0), STEP_DT)
|
||||
assert second.x == pytest.approx(2 * movement.ACCEL * STEP_DT)
|
||||
third = movement.desired_velocity(replace(still, vel=second), Vec2(1, 0), STEP_DT)
|
||||
assert third.x == pytest.approx(movement.MAX_SPEED) # the ceiling is met, not overshot
|
||||
|
||||
rolling = cook("p0", 5.5, 2.5, vel=Vec2(movement.MAX_SPEED, 0.0))
|
||||
braking = movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT)
|
||||
assert braking.x == pytest.approx(movement.MAX_SPEED - movement.DECEL * STEP_DT)
|
||||
assert movement.DECEL * STEP_DT > movement.ACCEL * STEP_DT # braking is the sharper ramp
|
||||
|
||||
|
||||
def test_speed_never_exceeds_the_ceiling() -> None:
|
||||
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
|
||||
current = state
|
||||
peak = 0.0
|
||||
for tick in range(20):
|
||||
current, _events = movement.step_movement(
|
||||
current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)}
|
||||
)
|
||||
speed = current.players[0].vel.length
|
||||
assert speed <= movement.MAX_SPEED + 1e-12
|
||||
peak = max(peak, speed)
|
||||
assert peak == pytest.approx(movement.MAX_SPEED)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("modifiers", "expected"),
|
||||
[
|
||||
(movement.MoveModifiers(), movement.MAX_SPEED),
|
||||
(movement.MoveModifiers(speed_scale=0.5), movement.MAX_SPEED / 2),
|
||||
(movement.MoveModifiers(speed_scale=0.0), 0.0),
|
||||
],
|
||||
)
|
||||
def test_speed_scale_moves_the_ceiling(modifiers: movement.MoveModifiers, expected: float) -> None:
|
||||
"""`dt = 1.0` so the grip limit cannot bind: this reads the ceiling itself."""
|
||||
walked = movement.desired_velocity(cook("p0", 1, 1), Vec2(1, 0), 1.0, modifiers)
|
||||
assert walked.x == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_accel_scale_reaches_the_ceiling_in_half_the_time() -> None:
|
||||
still = cook("p0", 1, 1)
|
||||
assert movement.desired_velocity(still, Vec2(1, 0), STEP_DT).x == pytest.approx(1.5)
|
||||
grippy = movement.MoveModifiers(accel_scale=2.0)
|
||||
assert movement.desired_velocity(still, Vec2(1, 0), STEP_DT, grippy).x == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_slide_removes_the_brake_but_not_the_throttle() -> None:
|
||||
rolling = cook("p0", 1, 1, vel=Vec2(2.0, 0.0))
|
||||
icy = movement.MoveModifiers(slide=True)
|
||||
assert movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT, icy) == Vec2(2.0, 0.0)
|
||||
assert movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT).x == pytest.approx(0.0)
|
||||
# pushing on a spill still pushes
|
||||
assert movement.desired_velocity(rolling, Vec2(1, 0), STEP_DT, icy).x > 2.0
|
||||
|
||||
|
||||
def test_a_spill_halves_the_cruising_speed() -> None:
|
||||
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
|
||||
frames = {"p0": press("p0", 1, 0)}
|
||||
spill = movement.MoveModifiers(speed_scale=0.5)
|
||||
floor = walked(state, frames, 40).players[0]
|
||||
on_spill = state
|
||||
for _ in range(40):
|
||||
on_spill, _events = movement.step_movement(on_spill, STEP_DT, frames, {"p0": spill})
|
||||
travelled = on_spill.players[0].pos.x - 10.5
|
||||
assert on_spill.players[0].vel.length == pytest.approx(movement.MAX_SPEED / 2, abs=1e-9)
|
||||
assert travelled == pytest.approx(0.5 * (floor.pos.x - 10.5), rel=0.05)
|
||||
|
||||
|
||||
def test_a_client_cannot_send_speed_fifty() -> None:
|
||||
"""The clamp is server-side and it is in the kernel path, not only in the helper."""
|
||||
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
|
||||
polite = state
|
||||
rude = state
|
||||
for tick in range(40):
|
||||
polite, _ = step(polite, STEP_DT, [press("p0", 1, 0, seq=tick)])
|
||||
rude, _ = step(rude, STEP_DT, [press("p0", 50, 0, seq=tick)])
|
||||
assert rude.players[0].pos == pytest.approx(polite.players[0].pos)
|
||||
assert rude.players[0].vel.length <= movement.MAX_SPEED + 1e-12
|
||||
|
||||
|
||||
# --- other cooks ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_one_walked_cook_moves_the_other_and_never_throws_them() -> None:
|
||||
corridor = ("#" * 21, "#" + "." * 19 + "#", "#" * 21)
|
||||
state = kitchen([cook("p0", 2.5, 1.5), cook("p1", 3.6, 1.5, seat=1)], rows=corridor)
|
||||
before = state.players[1]
|
||||
current = state
|
||||
for tick in range(30):
|
||||
current, _events = movement.step_movement(
|
||||
current, STEP_DT, {"p0": press("p0", 1, 0, seq=tick)}
|
||||
)
|
||||
assert_no_body_in_terrain(current)
|
||||
pusher, pushed = current.players
|
||||
assert pushed.pos.x > before.pos.x # it moved
|
||||
assert pushed.vel == before.vel == Vec2(0.0, 0.0) # positional correction, no velocity injected
|
||||
assert pushed.state is PlayerState.IDLE
|
||||
assert pusher.pos.x < pushed.pos.x
|
||||
assert pushed.pos.x - pusher.pos.x >= TWO_TILES - movement.PUSH_SLOP
|
||||
|
||||
|
||||
def test_a_cook_pinned_by_a_wall_takes_the_push_backstage() -> None:
|
||||
"""B is flush against the wall and A walks into B: A absorbs the whole separation."""
|
||||
wall = 6 - movement.RADIUS
|
||||
state = kitchen(
|
||||
[cook("p0", 2.0, 1.5), cook("p1", wall - CONTACT_EPS, 1.5, seat=1)],
|
||||
rows=("#######", "#.....#", "#######"),
|
||||
)
|
||||
current = state
|
||||
for tick in range(40):
|
||||
current, _events = movement.step_movement(
|
||||
current, STEP_DT, {"p0": press("p0", 1, 0, seq=tick)}
|
||||
)
|
||||
assert_no_body_in_terrain(current)
|
||||
pusher, pinned = current.players
|
||||
assert pinned.pos.x == pytest.approx(wall, abs=CONTACT_EPS) # it never moved
|
||||
# A stopped behind B rather than walking through it
|
||||
assert pusher.pos.x == pytest.approx(wall - TWO_TILES, abs=2 * CONTACT_EPS)
|
||||
|
||||
|
||||
def test_a_chain_of_four_cooks_drains_apart() -> None:
|
||||
"""A queue of overlapping cooks separates over a few ticks; nothing reverts forever."""
|
||||
packed = [cook(f"p{i}", 2.0 + 0.6 * i, 1.5, seat=i) for i in range(4)]
|
||||
state = kitchen(packed, rows=("#############", "#...........#", "#############"))
|
||||
current = state
|
||||
for _ in range(40):
|
||||
current, _events = movement.step_movement(current, STEP_DT, {})
|
||||
assert_no_body_in_terrain(current)
|
||||
xs = [player.pos.x for player in current.players]
|
||||
assert xs == sorted(xs)
|
||||
for first, second in pairwise(xs):
|
||||
assert second - first == pytest.approx(TWO_TILES, abs=1e-3)
|
||||
|
||||
|
||||
def test_a_bump_separates_on_the_easy_axis() -> None:
|
||||
"""Two cooks offset more in x than in y separate vertically, not horizontally."""
|
||||
room = tuple(["#" * 11] + ["#" + "." * 9 + "#"] * 11)
|
||||
state = kitchen([cook("p0", 2.5, 2.5), cook("p1", 2.7, 3.0, seat=1)], rows=room)
|
||||
before = state.players[1].pos
|
||||
current = state
|
||||
for _ in range(20):
|
||||
current, _events = movement.step_movement(current, STEP_DT, {})
|
||||
assert_no_body_in_terrain(current)
|
||||
moved = current.players[1].pos
|
||||
assert moved.y > before.y
|
||||
assert moved.x == pytest.approx(before.x)
|
||||
|
||||
|
||||
def test_a_terrain_deadlock_reverts_the_later_input() -> None:
|
||||
"""Two cooks stacked in a 1x1 alcove: terrain wins, so the later frame is the one dropped."""
|
||||
state = kitchen(
|
||||
[cook("p0", 1.5, 1.5, vel=Vec2(0.2, 0.0)), cook("p1", 1.5, 1.5, seat=1)],
|
||||
rows=("###", "#.#", "###"),
|
||||
)
|
||||
players, events = movement.resolve_moves(
|
||||
state,
|
||||
STEP_DT,
|
||||
{
|
||||
"p0": press("p0", 1, 0, seq=9, received=5), # received later: loses
|
||||
"p1": press("p1", 1, 0, seq=1, received=1), # received earlier: keeps its tick
|
||||
},
|
||||
)
|
||||
late, early = players
|
||||
assert late.pos == state.players[0].pos # exactly where the tick started
|
||||
assert late.vel == Vec2(0.2, 0.0) # and exactly the velocity it started with
|
||||
assert early.vel.x == pytest.approx(movement.ACCEL * STEP_DT)
|
||||
# still touching, never through: the overlap never exceeds one body diameter
|
||||
assert abs(early.pos.x - late.pos.x) <= TWO_TILES + movement.PUSH_SLOP
|
||||
assert events == ()
|
||||
|
||||
|
||||
def test_a_pair_that_can_move_is_never_reverted() -> None:
|
||||
"""Two cooks walking abreast into a wall queue up; the revert is only for real deadlocks."""
|
||||
state = kitchen(
|
||||
[cook("p0", 2.5, 1.5), cook("p1", 2.9, 1.5, seat=1)],
|
||||
rows=("#######", "#.....#", "#######"),
|
||||
)
|
||||
current = state
|
||||
for tick in range(20):
|
||||
current, _events = movement.step_movement(
|
||||
current,
|
||||
STEP_DT,
|
||||
{"p0": press("p0", 1, 0, seq=tick), "p1": press("p1", 1, 0, seq=tick)},
|
||||
)
|
||||
assert_no_body_in_terrain(current)
|
||||
first, second = current.players
|
||||
assert second.pos.x - first.pos.x >= TWO_TILES - movement.PUSH_SLOP
|
||||
|
||||
|
||||
def test_the_cook_asking_for_the_move_loses_a_deadlock() -> None:
|
||||
"""A cook with no frame this tick is never the loser, because it wants nothing.
|
||||
|
||||
The revert is not free — it puts a cook back where the substep started — so who gets it
|
||||
matters. Only one cook sent input, and only that cook can be told "not this tick": the
|
||||
idle one is not asking to be anywhere and has no input to discard, so it keeps the
|
||||
position the push left it in and the cook with the joystick is the one refused.
|
||||
"""
|
||||
state = kitchen(
|
||||
[cook("p0", 1.5, 1.5), cook("p1", 1.5, 1.5, seat=1)],
|
||||
rows=("###", "#.#", "###"),
|
||||
)
|
||||
|
||||
players, events = movement.resolve_moves(state, STEP_DT, {"p0": press("p0", 1, 0)})
|
||||
|
||||
framed, frameless = players
|
||||
assert framed.pos == state.players[0].pos # the one that asked is the one that's refused
|
||||
# The idle cook keeps the shove it never asked for — and it was shoved *away* from the
|
||||
# cook that walked into it, which is the only reason the two are not stacked again.
|
||||
assert frameless.pos != state.players[1].pos
|
||||
assert frameless.pos.x < state.players[1].pos.x
|
||||
assert events == ()
|
||||
assert_no_body_in_terrain(state.with_players(players))
|
||||
|
||||
|
||||
def test_a_jam_drains_over_a_few_ticks_instead_of_fusing_forever() -> None:
|
||||
"""Four cooks packed into an open kitchen are a queue, not a deadlock.
|
||||
|
||||
A jam of overlapping bodies unties itself over a handful of ticks as the pushes
|
||||
propagate, so each of those ticks must be legal: nobody inside terrain, nobody's
|
||||
position reverted, because there *is* a way out and reverting would refuse a move that
|
||||
was perfectly possible.
|
||||
"""
|
||||
state = kitchen(
|
||||
[
|
||||
cook("p0", 5.5, 2.5),
|
||||
cook("p1", 5.7, 2.5, seat=1),
|
||||
cook("p2", 5.5, 2.7, seat=2),
|
||||
cook("p3", 5.7, 2.7, seat=3),
|
||||
],
|
||||
rows=FIELD,
|
||||
)
|
||||
current = state
|
||||
for tick in range(6):
|
||||
current, _events = movement.step_movement(
|
||||
current,
|
||||
STEP_DT,
|
||||
{
|
||||
player.id: press(player.id, 1, 1, seq=tick)
|
||||
for player in current.players
|
||||
},
|
||||
)
|
||||
assert_no_body_in_terrain(current)
|
||||
|
||||
positions = [player.pos for player in current.players]
|
||||
assert positions != [player.pos for player in state.players]
|
||||
for index, first in enumerate(current.players):
|
||||
for other in current.players[index + 1 :]:
|
||||
overlapped_x = TWO_TILES - abs(other.pos.x - first.pos.x)
|
||||
overlapped_y = TWO_TILES - abs(other.pos.y - first.pos.y)
|
||||
if overlapped_x > 0.0 and overlapped_y > 0.0:
|
||||
assert min(overlapped_x, overlapped_y) < TWO_TILES
|
||||
|
||||
|
||||
# --- stun -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_stunned_cook_cannot_move_and_reports_the_hit_once() -> None:
|
||||
state = kitchen([cook("p0", 5.5, 2.5, stun=movement.STUN_S)], rows=ROOM)
|
||||
current = state
|
||||
seen: list[str] = []
|
||||
for tick in range(11):
|
||||
current, events = movement.step_movement(
|
||||
current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)}
|
||||
)
|
||||
seen.extend(event.kind for event in events)
|
||||
pinned = current.players[0]
|
||||
assert pinned.pos == Vec2(5.5, 2.5)
|
||||
assert pinned.vel == Vec2(0.0, 0.0)
|
||||
assert pinned.state is PlayerState.STUNNED
|
||||
assert pinned.facing is Facing.DOWN # a hit never spins you either
|
||||
assert pinned.stun == pytest.approx(movement.STUN_S - 11 * STEP_DT)
|
||||
assert seen == [EventKind.STUNNED] # the entry only, not one per substep
|
||||
|
||||
|
||||
def test_a_stun_expires_and_the_cook_walks_again() -> None:
|
||||
state = kitchen([cook("p0", 5.5, 2.5, stun=0.6)], rows=ROOM)
|
||||
frames = {"p0": press("p0", 1, 0)}
|
||||
pinned = walked(state, frames, 11).players[0]
|
||||
assert pinned.state is PlayerState.STUNNED
|
||||
freed = walked(walked(state, frames, 11), frames, 1).players[0]
|
||||
assert freed.stun == 0.0
|
||||
assert freed.state is PlayerState.MOVING
|
||||
assert freed.pos.x > 5.5
|
||||
|
||||
|
||||
def test_apply_stun_pins_velocity_and_clears_a_stale_state() -> None:
|
||||
hurt = cook("p0", 2.5, 2.5, stun=0.3, vel=Vec2(3.0, 0.0), state=PlayerState.MOVING)
|
||||
bystander = cook("p1", 3.5, 2.5, seat=1)
|
||||
players, events = movement.apply_stun([hurt, bystander], STEP_DT)
|
||||
assert players[0].vel == Vec2(0.0, 0.0)
|
||||
assert players[0].state is PlayerState.STUNNED
|
||||
assert players[0].stun == pytest.approx(0.25)
|
||||
assert players[1] is bystander # an unharmed cook is not even copied
|
||||
reported = [(event.kind, event.payload["player_id"]) for event in events]
|
||||
assert reported == [(EventKind.STUNNED, "p0")]
|
||||
|
||||
stuck = cook("p0", 2.5, 2.5, stun=0.0, state=PlayerState.STUNNED)
|
||||
revived, later = movement.apply_stun([stuck], STEP_DT)
|
||||
assert revived[0].state is PlayerState.IDLE # a hazard's half-written state is corrected
|
||||
assert later == ()
|
||||
|
||||
|
||||
# --- facing -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("intent", "expected"),
|
||||
[
|
||||
(Vec2(1, 0), Facing.RIGHT),
|
||||
(Vec2(-1, 0), Facing.LEFT),
|
||||
(Vec2(0, 1), Facing.DOWN),
|
||||
(Vec2(0, -1), Facing.UP),
|
||||
(Vec2(1, 1), Facing.RIGHT), # a 45 degree press always turns the same way
|
||||
(Vec2(-0.4, -0.9), Facing.UP),
|
||||
(Vec2(0.2, -0.1), Facing.RIGHT),
|
||||
],
|
||||
)
|
||||
def test_facing_follows_the_intent(intent: Vec2, expected: Facing) -> None:
|
||||
assert movement.update_facing(cook("p0", 1, 1), intent) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", [False, True])
|
||||
def test_standing_still_keeps_the_facing(action: bool) -> None:
|
||||
facing_left = cook("p0", 1, 1, facing=Facing.LEFT)
|
||||
assert movement.update_facing(facing_left, Vec2(), action) is Facing.LEFT
|
||||
assert movement.update_facing(facing_left, Vec2(0, 0), action, None) is Facing.LEFT
|
||||
|
||||
|
||||
def test_an_explicit_turn_beats_the_vector() -> None:
|
||||
assert movement.update_facing(cook("p0", 1, 1), Vec2(1, 0), face=Facing.UP) is Facing.UP
|
||||
|
||||
|
||||
def test_an_action_press_with_no_intent_does_not_spin_the_cook() -> None:
|
||||
"""Walk right, stop, press: the cook still faces right (US-04, one action button)."""
|
||||
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
|
||||
walking = walked(state, {"p0": press("p0", 1, 0)}, 10)
|
||||
assert walking.players[0].facing is Facing.RIGHT
|
||||
holding = walking
|
||||
for seq in range(5):
|
||||
holding, _events = movement.step_movement(
|
||||
holding,
|
||||
STEP_DT,
|
||||
{"p0": PlayerInput(
|
||||
player_id="p0", seq=seq, move=Vec2(), action=True, received_at_tick=9
|
||||
)},
|
||||
)
|
||||
assert holding.players[0].facing is Facing.RIGHT
|
||||
assert holding.players[0].state is PlayerState.IDLE
|
||||
|
||||
|
||||
def test_the_player_state_tracks_the_velocity() -> None:
|
||||
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
|
||||
walking = walked(state, {"p0": press("p0", 0, -1)}, 3).players[0]
|
||||
assert walking.state is PlayerState.MOVING
|
||||
stopped = walked(replace(state, players=(walking,)), {}, 4).players[0]
|
||||
assert stopped.state is PlayerState.IDLE
|
||||
assert stopped.vel == Vec2(0.0, 0.0)
|
||||
|
||||
|
||||
# --- the kernel seam --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_moves_without_a_frame_brakes_to_a_stop() -> None:
|
||||
state = kitchen([cook("p0", 5.5, 2.5, vel=Vec2(movement.MAX_SPEED, 0.0))], rows=ROOM)
|
||||
players, events = movement.resolve_moves(state, STEP_DT)
|
||||
assert players[0].vel.x == pytest.approx(movement.MAX_SPEED - movement.DECEL * STEP_DT)
|
||||
assert players[0].pos.x > 5.5
|
||||
assert players[0].state is PlayerState.MOVING
|
||||
assert events == ()
|
||||
|
||||
|
||||
def test_step_movement_is_a_pure_stage_of_the_state() -> None:
|
||||
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
|
||||
before = state.state_hash()
|
||||
moved, events = movement.resolve_moves(state, STEP_DT, {"p0": press("p0", 1, 0)})
|
||||
out, stage_events = movement.step_movement(state, STEP_DT, {"p0": press("p0", 1, 0)})
|
||||
assert state.state_hash() == before # the caller's state is untouched
|
||||
assert out.players == moved
|
||||
assert stage_events == events
|
||||
assert out.grid is state.grid
|
||||
assert out.tick == state.tick
|
||||
assert [player.id for player in out.players] == ["p0"]
|
||||
|
||||
|
||||
def patch(patch_id: str, x: float, y: float) -> Hazard:
|
||||
"""A spill at (x, y): inert data until phase 07 gives hazards behaviour.
|
||||
|
||||
It is here for the subsystem order, not for its effect — the determinism check below
|
||||
must cover a tick that runs hazards *and* movement, because that is the tick a replay
|
||||
has to reproduce exactly.
|
||||
"""
|
||||
return Hazard(id=patch_id, kind=HazardKind.SPILL, pos=Vec2(x, y))
|
||||
|
||||
|
||||
def test_the_tick_is_deterministic_through_the_movement_stage() -> None:
|
||||
"""Same seed, same input script, same hash — the replay contract, at the new stage."""
|
||||
script = [
|
||||
press("p0", tick % 4 - 1, tick % 3 - 1, seq=tick)
|
||||
for tick in range(1, 41)
|
||||
]
|
||||
|
||||
def run(seed: int) -> KitchenState:
|
||||
current = replace(
|
||||
kitchen([cook("p0", 5.5, 3.5), cook("p1", 6.5, 3.5, seat=1)], rows=FIELD),
|
||||
seed=seed,
|
||||
hazards=(patch("spill_0", 5.5, 3.5),),
|
||||
)
|
||||
for tick, frame in enumerate(script, start=1):
|
||||
current, _events = step(current, STEP_DT, [frame])
|
||||
assert current.tick == tick
|
||||
return current
|
||||
|
||||
first, second = run(0xC0FFEE), run(0xC0FFEE)
|
||||
assert first.state_hash() == second.state_hash()
|
||||
assert first.players == second.players
|
||||
|
||||
|
||||
def test_the_kernel_runs_the_movement_stage() -> None:
|
||||
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
|
||||
current = state
|
||||
for tick in range(10):
|
||||
current, _events = step(current, STEP_DT, [press("p0", 1, 0, seq=tick)])
|
||||
assert current.tick == 10
|
||||
assert current.players[0].pos.x > 5.5
|
||||
assert_no_body_in_terrain(current)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""The movement stage through the real tick, on the levels the game actually ships with.
|
||||
|
||||
`test_movement.py` proves the geometry and `test_movement_stress.py` proves the model under
|
||||
a random walk on a synthetic maze. This is the seam between them and the content pipeline:
|
||||
`load_content` reads `levels/*.yaml`, `build_state` turns that into a kitchen, and `step()`
|
||||
runs the whole tick — timers, orders, hazards, movement, in that fixed order — with four
|
||||
cooks walking around inside the authored geometry.
|
||||
|
||||
It earns its place by being the only test that would notice any of these:
|
||||
|
||||
* a level whose tiles make a cook contain-able (a spawn the body footprint does not fit
|
||||
in, a station placed where the body cannot clear it),
|
||||
* the movement stage drifting out of the tick's subsystem order, or a whole substep of
|
||||
movement going missing because a hook signature changed under the caller,
|
||||
* authored content making the kernel non-deterministic — the replay contract is per-level,
|
||||
not per-fixture,
|
||||
* the round timer freezing movement: a walk that runs past `time_limit` stops, and the
|
||||
progress assertions below would then be measuring a paused kitchen instead of a stuck
|
||||
cook. TICKS therefore stays well inside the shorter level's clock.
|
||||
|
||||
The kitchen here comes from YAML, so nothing in this file names a tile, a station or a
|
||||
wall: it says what must be true of any level that passes the validator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim import movement
|
||||
from undercooked.sim.collision import body_blocker, body_cells
|
||||
from undercooked.sim.content import load_content
|
||||
from undercooked.sim.level import Level
|
||||
from undercooked.sim.model import KitchenState, TileKind, Vec2
|
||||
from undercooked.sim.rng import Rng
|
||||
from undercooked.sim.state_build import SeatSpec, build_state
|
||||
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SHIPPED_LEVELS = REPO_ROOT / "levels"
|
||||
RECIPES = "recipes.yaml"
|
||||
|
||||
TICKS = 1200
|
||||
SEED = 0x0D1C
|
||||
COOK_COUNT = 4
|
||||
|
||||
#: How much of the level each cook must have stood on by the end of the walk. Measured on
|
||||
#: the shipped campaign at a quarter of TICKS: 16-41 cells per cook. Well clear of the
|
||||
#: 2x2 the pair starts stacked in, and well short of anything that would flap on a redesign.
|
||||
MIN_CELLS_VISITED = 10
|
||||
DIRECTIONS: tuple[Vec2, ...] = (
|
||||
Vec2(1, 0),
|
||||
Vec2(1, 1),
|
||||
Vec2(0, 1),
|
||||
Vec2(-1, 1),
|
||||
Vec2(-1, 0),
|
||||
Vec2(-1, -1),
|
||||
Vec2(0, -1),
|
||||
Vec2(1, -1),
|
||||
Vec2(0, 0),
|
||||
)
|
||||
|
||||
|
||||
#: The shipped campaign, read once at import. Content that will not load should stop
|
||||
#: collection rather than fail one test with a confusing AttributeError.
|
||||
CAMPAIGN = load_content(SHIPPED_LEVELS, SHIPPED_LEVELS / RECIPES)
|
||||
|
||||
|
||||
def shipped_levels() -> list[tuple[str, Level]]:
|
||||
"""Every level in `levels/`, as (id, level) pairs for parametrization."""
|
||||
return [(level.id, level) for level in CAMPAIGN.levels]
|
||||
|
||||
|
||||
def seats(count: int) -> tuple[SeatSpec, ...]:
|
||||
"""Four anonymous seats, in seat order, for a full lobby on any level."""
|
||||
return tuple(
|
||||
SeatSpec(player_id=f"p{index}", seat=index, display_name=f"Cook {index}")
|
||||
for index in range(count)
|
||||
)
|
||||
|
||||
|
||||
def tick_budget(cooks: int) -> float:
|
||||
"""The most one cook may move in a substep: one tick of walking, plus every push."""
|
||||
body = 2 * movement.RADIUS
|
||||
return movement.MAX_SPEED * STEP_DT + movement.PUSH_ITERATIONS * (cooks - 1) * body * 2
|
||||
|
||||
|
||||
def walk_level(
|
||||
level: Level,
|
||||
ticks: int = TICKS,
|
||||
visited: dict[str, set[tuple[int, int]]] | None = None,
|
||||
) -> KitchenState:
|
||||
"""Run `ticks` real substeps of a wandering four-cook kitchen on `level`."""
|
||||
state = build_state(level, seats(COOK_COUNT), SEED, CAMPAIGN.recipe_book)
|
||||
blocked = body_blocker(state.grid, state.stations)
|
||||
assert state.players, "a level with no cooks cannot test movement"
|
||||
budget = tick_budget(len(state.players))
|
||||
rng = Rng(SEED)
|
||||
heading = {player.id: Vec2(0, 0) for player in state.players}
|
||||
|
||||
for tick in range(1, ticks + 1):
|
||||
frames: list[PlayerInput] = []
|
||||
for player in state.players:
|
||||
if rng.next_float() < 0.2:
|
||||
heading[player.id] = rng.choice(DIRECTIONS)
|
||||
frames.append(PlayerInput(
|
||||
player_id=player.id,
|
||||
seq=tick,
|
||||
move=heading[player.id],
|
||||
received_at_tick=tick,
|
||||
))
|
||||
|
||||
before = {player.id: player.pos for player in state.players}
|
||||
state, _events = step(state, STEP_DT, frames)
|
||||
|
||||
assert state.tick == tick, "the movement stage must not own the tick counter"
|
||||
assert state.result is None, "the walk outran the round clock: raise time_limit"
|
||||
for player in state.players:
|
||||
assert math.isfinite(player.pos.x) and math.isfinite(player.pos.y)
|
||||
assert math.isfinite(player.vel.x) and math.isfinite(player.vel.y)
|
||||
assert not any(
|
||||
blocked(x, y) for x, y in body_cells(player.pos, movement.RADIUS)
|
||||
), f"{player.id} is inside {level.id} at tick {tick}: {player.pos}"
|
||||
assert player.vel.length <= movement.MAX_SPEED + 1e-9
|
||||
assert (player.pos - before[player.id]).length <= budget
|
||||
if visited is not None:
|
||||
visited.setdefault(player.id, set()).update(
|
||||
body_cells(player.pos, movement.RADIUS)
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("level_id", "level"), shipped_levels())
|
||||
def test_a_walk_on_a_shipped_level_stays_inside_it(level_id: str, level: Level) -> None:
|
||||
"""Containment, bounded velocity and tick accounting, after every single substep."""
|
||||
del level_id
|
||||
final = walk_level(level)
|
||||
assert final.tick == TICKS
|
||||
assert all(player.vel.length <= movement.MAX_SPEED + 1e-9 for player in final.players)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("level_id", "level"), shipped_levels())
|
||||
def test_a_walk_on_a_shipped_level_goes_somewhere(level_id: str, level: Level) -> None:
|
||||
"""Each cook explores authored geometry instead of wearing out its spawn tile."""
|
||||
del level_id
|
||||
visited: dict[str, set[tuple[int, int]]] = {}
|
||||
walk_level(level, visited=visited)
|
||||
assert len(visited) == 4
|
||||
for player_id, cells in sorted(visited.items()):
|
||||
assert len(cells) >= MIN_CELLS_VISITED, f"{player_id} only ever saw {sorted(cells)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("level_id", "level"), shipped_levels())
|
||||
def test_a_replay_of_a_shipped_level_reproduces_itself(level_id: str, level: Level) -> None:
|
||||
"""Determinism is per level: same seed and script, identical hash, on every map."""
|
||||
del level_id
|
||||
assert walk_level(level).state_hash() == walk_level(level).state_hash()
|
||||
|
||||
|
||||
def test_the_shipped_kitchen_has_something_to_walk_on() -> None:
|
||||
"""Guard for the two tests above: the floor is floor, and it is not one cell."""
|
||||
assert len(CAMPAIGN.levels) >= 2
|
||||
for level in CAMPAIGN.levels:
|
||||
floor = sum(1 for tile in level.grid.tiles if tile is TileKind.FLOOR)
|
||||
assert floor > 4 * MIN_CELLS_VISITED, f"{level.id} is too small to walk"
|
||||
@@ -0,0 +1,230 @@
|
||||
"""`movement` under a scripted random walk: 4 cooks, 2000 ticks, invariants only.
|
||||
|
||||
The table-driven tests in `test_movement.py` prove the geometry for the cases someone
|
||||
thought of. This file is for the cases nobody did: four cooks wandering a pillar maze for
|
||||
2000 substeps, checked after *every* substep — the simulation must never be observed in an
|
||||
impossible state, not even for one tick (PLAN A21).
|
||||
|
||||
The invariants, and why each is the load-bearing kind:
|
||||
|
||||
* **containment** — no body footprint ever overlaps a blocked cell. The maze's walls are
|
||||
one tile thick, so this one check *is* the no-tunnelling proof: a cook that crossed a
|
||||
wall would have to spend a substep inside it. It also covers containment in the void,
|
||||
because out-of-bounds is blocked by definition.
|
||||
* **no pass-through** — no pair ever overlaps by more than `2 * RADIUS`, the brief's
|
||||
bound, which is exactly what a head-on collision at the coarsest legal dt produces.
|
||||
* **no explosions** — a cook's per-tick displacement stays inside one tick of walking
|
||||
plus every push correction that could legally have been applied to it. A sign error in
|
||||
the soft push shows up here, and nowhere in a screenshot.
|
||||
* **progress** — every cook must visit a good many distinct cells. A walk that freezes in
|
||||
a corner passes every safety invariant above while testing nothing.
|
||||
* **determinism** — same seed, same script, same state hash, at `STEP_DT` and at `MAX_DT`.
|
||||
|
||||
The RNG is the sim's own splitmix64: nothing here imports `random`, so a failing run is
|
||||
reproducible to the tick and this file doubles as the movement stage's first golden.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim import movement
|
||||
from undercooked.sim.collision import body_blocker, body_cells
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.model import KitchenState, Player, Station, StationKind, TileKind, Vec2
|
||||
from undercooked.sim.rng import Rng
|
||||
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
LEGEND: dict[str, TileKind] = {
|
||||
".": TileKind.FLOOR,
|
||||
"#": TileKind.WALL,
|
||||
"=": TileKind.COUNTER,
|
||||
"o": TileKind.VOID,
|
||||
}
|
||||
|
||||
#: A pillar maze with three doorways: one-tile corridors in every direction, so the walk
|
||||
#: is mostly corners, doorframes and dead ends. Two tiles of terrain are punched into it
|
||||
#: that a cook must treat as a wall but cannot fall through (`o` void, `=` counter), and
|
||||
#: `maze_state` adds two fixtures — a stove, which must block, and a serve window, which
|
||||
#: must not — because "the invariant held on empty floor" is the weakest version of this
|
||||
#: test. `test_the_maze_is_one_room` keeps it a single connected kitchen.
|
||||
MAZE = (
|
||||
"#################",
|
||||
"#...#..o#...#...#",
|
||||
"#.#...#...#...#.#",
|
||||
"#...#...#...#=..#",
|
||||
"#################",
|
||||
)
|
||||
|
||||
TICKS = 2000
|
||||
SEED = 0x5EED
|
||||
COOK_COUNT = 4
|
||||
|
||||
#: How much of the 32-cell kitchen each cook must have set foot in by the end of the
|
||||
#: scripted walk. The maze connects its pockets through three one-tile doorways, so a
|
||||
#: random heading takes a while to thread one: measured on this seed, every cook covers
|
||||
#: 16-18 cells over TICKS and none covers fewer than half that in under a tenth of it.
|
||||
MIN_CELLS_VISITED = 12
|
||||
|
||||
#: The eight compass directions plus standing still, as a fixed tuple so `Rng.choice`
|
||||
#: never depends on set ordering.
|
||||
DIRECTIONS: tuple[Vec2, ...] = (
|
||||
Vec2(1, 0),
|
||||
Vec2(1, 1),
|
||||
Vec2(0, 1),
|
||||
Vec2(-1, 1),
|
||||
Vec2(-1, 0),
|
||||
Vec2(-1, -1),
|
||||
Vec2(0, -1),
|
||||
Vec2(1, -1),
|
||||
Vec2(0, 0),
|
||||
)
|
||||
|
||||
STARTS: tuple[tuple[float, float], ...] = ((1.5, 1.5), (5.5, 1.5), (9.5, 1.5), (13.5, 1.5))
|
||||
|
||||
|
||||
def maze_state() -> KitchenState:
|
||||
stations = (
|
||||
Station(id="stove_0", kind=StationKind.STOVE, x=6, y=1),
|
||||
Station(id="serve_0", kind=StationKind.SERVE, x=10, y=1),
|
||||
)
|
||||
players = tuple(
|
||||
Player(id=f"p{seat}", seat=seat, pos=Vec2(x, y))
|
||||
for seat, (x, y) in enumerate(STARTS[:COOK_COUNT])
|
||||
)
|
||||
return KitchenState(
|
||||
level_id="stress",
|
||||
seed=SEED,
|
||||
grid=Grid.from_rows(MAZE, LEGEND),
|
||||
stations=stations,
|
||||
players=players,
|
||||
time_left=float(TICKS * STEP_DT),
|
||||
)
|
||||
|
||||
|
||||
def tick_budget(dt: float) -> float:
|
||||
"""The most a cook may legitimately move in one call.
|
||||
|
||||
One tick of walking at the ceiling, plus `PUSH_ITERATIONS` passes over up to
|
||||
`COOK_COUNT - 1` contacts, each of which can move this cook a full body (and the
|
||||
remainder a neighbour could not take). Anything above this is a runaway correction.
|
||||
"""
|
||||
body = 2 * movement.RADIUS
|
||||
return movement.MAX_SPEED * dt + movement.PUSH_ITERATIONS * (COOK_COUNT - 1) * body * 2
|
||||
|
||||
|
||||
def assert_invariants(state: KitchenState, before: Sequence[Vec2], dt: float) -> None:
|
||||
blocked = body_blocker(state.grid, state.stations)
|
||||
budget = tick_budget(dt)
|
||||
for player, was in zip(state.players, before, strict=True):
|
||||
for value in (player.pos.x, player.pos.y, player.vel.x, player.vel.y, player.stun):
|
||||
assert math.isfinite(value), f"{player.id} left the number line at tick {state.tick}"
|
||||
footprint = body_cells(player.pos, movement.RADIUS)
|
||||
assert not any(blocked(x, y) for x, y in footprint), (
|
||||
f"{player.id} is inside terrain at tick {state.tick}: {player.pos} -> {footprint}"
|
||||
)
|
||||
assert player.vel.length <= movement.MAX_SPEED + 1e-9
|
||||
jumped = (player.pos - was).length
|
||||
assert jumped <= budget, f"{player.id} jumped {jumped:.3f} tiles at tick {state.tick}"
|
||||
|
||||
diameter = 2 * movement.RADIUS
|
||||
for i, first in enumerate(state.players):
|
||||
for second in state.players[i + 1 :]:
|
||||
overlap_x = diameter - abs(second.pos.x - first.pos.x)
|
||||
overlap_y = diameter - abs(second.pos.y - first.pos.y)
|
||||
if overlap_x > 0.0 and overlap_y > 0.0: # the two footprints do intersect
|
||||
assert min(overlap_x, overlap_y) <= diameter + 1e-9, (
|
||||
f"{first.id} and {second.id} passed through each other at tick {state.tick}"
|
||||
)
|
||||
|
||||
|
||||
def run_walk(
|
||||
ticks: int = TICKS,
|
||||
dt: float = STEP_DT,
|
||||
visited: dict[str, set[tuple[int, int]]] | None = None,
|
||||
) -> KitchenState:
|
||||
"""The scripted walk: `ticks` substeps of a wandering four-cook kitchen."""
|
||||
current = maze_state()
|
||||
rng = Rng(SEED)
|
||||
heading = [Vec2(0, 0) for _ in current.players]
|
||||
for tick in range(1, ticks + 1):
|
||||
frames: list[PlayerInput] = []
|
||||
for player in current.players:
|
||||
if rng.next_float() < 0.2:
|
||||
heading[player.seat] = rng.choice(DIRECTIONS)
|
||||
frames.append(PlayerInput(
|
||||
player_id=player.id,
|
||||
seq=tick,
|
||||
move=heading[player.seat],
|
||||
received_at_tick=tick,
|
||||
))
|
||||
before = [player.pos for player in current.players]
|
||||
current, _events = movement.step_movement(current, dt, frames)
|
||||
assert_invariants(current, before, dt)
|
||||
if visited is not None:
|
||||
for player in current.players:
|
||||
visited.setdefault(player.id, set()).update(
|
||||
body_cells(player.pos, movement.RADIUS)
|
||||
)
|
||||
return current
|
||||
|
||||
|
||||
def test_the_maze_is_one_room() -> None:
|
||||
"""The progress check below only means something if the kitchen has no sealed pockets."""
|
||||
state = maze_state()
|
||||
blocked = body_blocker(state.grid, state.stations)
|
||||
free = {
|
||||
(x, y)
|
||||
for y in range(state.grid.height)
|
||||
for x in range(state.grid.width)
|
||||
if not blocked(x, y)
|
||||
}
|
||||
seen = {min(free)}
|
||||
stack = [min(free)]
|
||||
while stack:
|
||||
x, y = stack.pop()
|
||||
for step in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
cell = (x + step[0], y + step[1])
|
||||
if cell in free and cell not in seen:
|
||||
seen.add(cell)
|
||||
stack.append(cell)
|
||||
assert seen == free
|
||||
assert len(free) >= MIN_CELLS_VISITED
|
||||
|
||||
|
||||
def test_the_walk_never_breaks_the_kitchen() -> None:
|
||||
"""Containment, no pass-through and no explosions, checked after every substep."""
|
||||
final = run_walk()
|
||||
assert len(final.players) == COOK_COUNT
|
||||
assert [player.id for player in final.players] == [f"p{seat}" for seat in range(COOK_COUNT)]
|
||||
assert all(player.connected for player in final.players)
|
||||
|
||||
|
||||
def test_the_walk_actually_walks() -> None:
|
||||
"""Every cook covers real ground: a frozen cook would satisfy every invariant above."""
|
||||
visited: dict[str, set[tuple[int, int]]] = {}
|
||||
run_walk(TICKS, visited=visited)
|
||||
for player_id, cells in sorted(visited.items()):
|
||||
assert len(cells) >= MIN_CELLS_VISITED, f"{player_id} only ever saw {sorted(cells)}"
|
||||
|
||||
|
||||
def test_the_same_script_always_produces_the_same_kitchen() -> None:
|
||||
"""Same seed, same script, same hash — the replay contract, at the movement stage."""
|
||||
first = run_walk(TICKS // 2)
|
||||
second = run_walk(TICKS // 2)
|
||||
assert first.state_hash() == second.state_hash()
|
||||
assert first.state_hash() != run_walk(TICKS // 2, dt=MAX_DT).state_hash()
|
||||
|
||||
|
||||
def test_the_walk_survives_the_coarsest_legal_tick() -> None:
|
||||
"""`MAX_DT` is 4x the substep: displacement per call quadruples, containment may not."""
|
||||
visited: dict[str, set[tuple[int, int]]] = {}
|
||||
final = run_walk(TICKS // 2, dt=MAX_DT, visited=visited)
|
||||
for player in final.players:
|
||||
assert player.vel.length <= movement.MAX_SPEED + 1e-9
|
||||
assert all(len(cells) >= MIN_CELLS_VISITED for cells in visited.values())
|
||||
@@ -0,0 +1,103 @@
|
||||
"""The headless perf smoke (PLAN §10, marked `slow` so the quick loop skips it).
|
||||
|
||||
Four cooks, 2 000 ticks on the shipped level 1, one direction per cook per tick
|
||||
drawn from the sim's own splitmix64 (a fixed seed — the walk is a property of
|
||||
the script, not of the machine). The budget is the task's: mean step
|
||||
**< 0.2 ms** and total **< 400 ms** — 5 000+ ticks/s, two and a half orders
|
||||
of magnitude more than the 20 Hz the rooms run at — and the measured numbers
|
||||
are printed in the report so a slow machine shows up in the gate, not as a
|
||||
silent failure.
|
||||
|
||||
The loop times `step()` directly (no hash recording), so the number is the
|
||||
kernel's tick cost, exactly what the §10 budget (mean ≤ 2 ms/room) is about;
|
||||
`run_script`'s injected `clock` seam is what a full-harness measurement goes
|
||||
through instead, and `test_replay.py` pins that seam.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.content import load_content
|
||||
from undercooked.sim.model import Vec2
|
||||
from undercooked.sim.rng import Rng
|
||||
from undercooked.sim.state_build import SeatSpec, build_state
|
||||
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||
|
||||
pytestmark = [pytest.mark.slow]
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SHIPPED_LEVELS = REPO_ROOT / "levels"
|
||||
|
||||
#: The campaign, read once at import (the same seam `test_movement_kernel` uses).
|
||||
CAMPAIGN = load_content(SHIPPED_LEVELS, SHIPPED_LEVELS / "recipes.yaml")
|
||||
|
||||
TICKS = 2000
|
||||
SEED = 0x0D1C
|
||||
COOKS = 4
|
||||
|
||||
#: The task's budget: mean step < 0.2 ms, total < 400 ms on the dev machine.
|
||||
BUDGET_MEAN_S = 0.0002
|
||||
BUDGET_TOTAL_S = 0.4
|
||||
|
||||
#: The eight compass directions plus standing still, as a fixed tuple so
|
||||
#: `Rng.choice` (the sim's own generator) is the only source of variation.
|
||||
DIRECTIONS: tuple[Vec2, ...] = (
|
||||
Vec2(1, 0),
|
||||
Vec2(1, 1),
|
||||
Vec2(0, 1),
|
||||
Vec2(-1, 1),
|
||||
Vec2(-1, 0),
|
||||
Vec2(-1, -1),
|
||||
Vec2(0, -1),
|
||||
Vec2(1, -1),
|
||||
Vec2(0, 0),
|
||||
)
|
||||
|
||||
|
||||
def seats(cooks: int) -> tuple[SeatSpec, ...]:
|
||||
"""Four anonymous seats in seat order; spawns wrap past the second (the
|
||||
level has two), with the stack offset `build_state` applies."""
|
||||
return tuple(
|
||||
SeatSpec(player_id=f"p{index}", seat=index, display_name=f"p{index}")
|
||||
for index in range(cooks)
|
||||
)
|
||||
|
||||
|
||||
def test_four_cook_kitchen_stays_within_the_perf_budget() -> None:
|
||||
level = CAMPAIGN.by_id["01_first_kitchen"]
|
||||
state = build_state(level, seats(COOKS), SEED, CAMPAIGN.recipe_book)
|
||||
rng = Rng(SEED)
|
||||
start = time.perf_counter()
|
||||
for tick in range(TICKS):
|
||||
inputs = [
|
||||
PlayerInput(
|
||||
player_id=f"p{seat}",
|
||||
seq=tick,
|
||||
move=rng.choice(DIRECTIONS),
|
||||
received_at_tick=tick,
|
||||
)
|
||||
for seat in range(COOKS)
|
||||
]
|
||||
state, _ = step(state, STEP_DT, inputs)
|
||||
total = time.perf_counter() - start
|
||||
mean = total / TICKS
|
||||
print(
|
||||
f"perf smoke (level 1, {COOKS} cooks, {TICKS} ticks): "
|
||||
f"total {total * 1000:.1f} ms, mean {mean * 1000:.4f} ms/tick, "
|
||||
f"{1 / mean:,.0f} ticks/s"
|
||||
)
|
||||
assert mean < BUDGET_MEAN_S, (
|
||||
f"mean step {mean * 1000:.4f} ms is over the {BUDGET_MEAN_S * 1000:.1f} ms budget"
|
||||
)
|
||||
assert total < BUDGET_TOTAL_S, (
|
||||
f"{TICKS} ticks took {total * 1000:.1f} ms, over the {BUDGET_TOTAL_S * 1000:.0f} ms budget"
|
||||
)
|
||||
# cheap invariants: the walk stayed inside the kitchen, the tick ran to the end
|
||||
assert state.tick == TICKS
|
||||
for player in state.players:
|
||||
assert 0.35 < player.pos.x < level.grid.width - 0.35
|
||||
assert 0.35 < player.pos.y < level.grid.height - 0.35
|
||||
@@ -0,0 +1,585 @@
|
||||
"""The replay harness against its committed scripts and goldens (A21, PLAN §9 layer 3).
|
||||
|
||||
`tests/sim/test_replay_carry.py` pinned the first golden by hand; this file is the
|
||||
formalised contract: the two committed replay scripts (`walk_solo`, the 600-tick
|
||||
movement-only tour of level 1; `pick_drop_pass`, two cooks running the whole
|
||||
pick → set-down → hand-off → long-press-trash chain on the fixture kitchen) each
|
||||
match their committed `tests/goldens/<name>.golden.json`, a deliberately mutated
|
||||
script diverges and `compare()` names the first bad tick and the changed field,
|
||||
the record cadence is stable, a replay is bit-identical within one process, and
|
||||
the goldens are never rewritten by the test run.
|
||||
|
||||
The levels are loaded explicitly (level 1 from the shipped `levels/`, the fixture
|
||||
kitchen from `tests/fixtures/levels/_replay/`), so the session fixture's
|
||||
level-free directory cannot leak in — and the CLI tests point
|
||||
`UNDERCOOKED_LEVELS_DIR` at `levels/` for the same reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.content import load_level_file
|
||||
from undercooked.sim.recipe import load_recipe_book
|
||||
from undercooked.sim.replay import (
|
||||
EXIT_MISMATCH,
|
||||
EXIT_OK,
|
||||
EXIT_UNUSABLE,
|
||||
Frame,
|
||||
InputScript,
|
||||
ReplayError,
|
||||
ScriptItem,
|
||||
_field_diff, # private, exercised directly below
|
||||
compare,
|
||||
main,
|
||||
run_script,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SHIPPED_LEVELS = REPO_ROOT / "levels"
|
||||
SHIPPED_RECIPES = SHIPPED_LEVELS / "recipes.yaml"
|
||||
|
||||
WALK_SOLO_SCRIPT = REPO_ROOT / "tests" / "fixtures" / "replays" / "walk_solo.json"
|
||||
PICK_DROP_PASS_SCRIPT = (
|
||||
REPO_ROOT / "tests" / "fixtures" / "replays" / "pick_drop_pass.json"
|
||||
)
|
||||
PICK_DROP_PASS_LEVEL = (
|
||||
REPO_ROOT / "tests" / "fixtures" / "levels" / "_replay" / "pick_drop_pass.yaml"
|
||||
)
|
||||
WALK_SOLO_GOLDEN = REPO_ROOT / "tests" / "goldens" / "walk_solo.golden.json"
|
||||
PICK_DROP_PASS_GOLDEN = REPO_ROOT / "tests" / "goldens" / "pick_drop_pass.golden.json"
|
||||
|
||||
|
||||
def golden_map(path: Path) -> dict[int, str]:
|
||||
"""A golden file's `hashes` as the `tick -> hash` mapping `compare` takes."""
|
||||
golden = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {int(tick): digest for tick, digest in golden["hashes"].items()}
|
||||
|
||||
|
||||
def golden_final(path: Path) -> str:
|
||||
return json.loads(path.read_text(encoding="utf-8"))["hash"]
|
||||
|
||||
|
||||
def load_walk_solo() -> tuple[Any, Any, InputScript]:
|
||||
"""Level 1, the recipe book and the committed walk_solo script."""
|
||||
level = load_level_file(SHIPPED_LEVELS / "01_first_kitchen.yaml")
|
||||
book = load_recipe_book(SHIPPED_RECIPES)
|
||||
return level, book, InputScript.load(WALK_SOLO_SCRIPT)
|
||||
|
||||
|
||||
def load_pick_drop_pass() -> tuple[Any, Any, InputScript]:
|
||||
"""The fixture kitchen, the recipe book and the committed pick_drop_pass script."""
|
||||
level = load_level_file(PICK_DROP_PASS_LEVEL)
|
||||
book = load_recipe_book(SHIPPED_RECIPES)
|
||||
return level, book, InputScript.load(PICK_DROP_PASS_SCRIPT)
|
||||
|
||||
|
||||
# -- the committed goldens ----------------------------------------------------
|
||||
|
||||
|
||||
def test_walk_solo_matches_its_committed_golden() -> None:
|
||||
"""600 ticks of movement-only walking on level 1, every tick recorded."""
|
||||
level, book, script = load_walk_solo()
|
||||
result = run_script(level, book, script)
|
||||
assert result.state.tick == 600
|
||||
assert len(result.events) == 0 # movement only: no press, no event
|
||||
assert result.state.players[0].vel.length < 1e-6 # the tour ends settled
|
||||
assert [tick for tick, _ in result.hashes] == list(range(601))
|
||||
assert result.state.state_hash() == golden_final(WALK_SOLO_GOLDEN)
|
||||
assert compare(result, golden_map(WALK_SOLO_GOLDEN)) == []
|
||||
|
||||
|
||||
def test_pick_drop_pass_matches_its_committed_golden_and_event_sequence() -> None:
|
||||
"""The whole hand chain, pinned both by hash and by the events it must cause."""
|
||||
level, book, script = load_pick_drop_pass()
|
||||
result = run_script(level, book, script)
|
||||
assert result.state.tick == 139
|
||||
expected: tuple[tuple[str, dict[str, object]], ...] = (
|
||||
(
|
||||
"picked",
|
||||
{"player_id": "p0", "station": "spawn_shelf_0", "item": "tomato", "form": "raw"},
|
||||
),
|
||||
(
|
||||
"dropped",
|
||||
{"player_id": "p0", "station": "prep_0", "item": "tomato", "form": "raw"},
|
||||
),
|
||||
("handed_off", {"from": "p0", "to": "p1", "item": "tomato", "form": "raw"}),
|
||||
("trashed", {"player_id": "p1", "item": "tomato", "score_delta": -2}),
|
||||
)
|
||||
assert len(result.events) == len(expected)
|
||||
for event, (want_kind, want_payload) in zip(result.events, expected, strict=True):
|
||||
assert event.kind == want_kind
|
||||
for key, value in want_payload.items():
|
||||
assert event.payload.get(key) == value, (event.kind, event.payload)
|
||||
assert result.state.counters["trashed"] == 1
|
||||
for player in result.state.players: # the round ends settled, hands empty
|
||||
assert player.carrying is None
|
||||
assert player.vel.length < 1e-6
|
||||
assert result.state.state_hash() == golden_final(PICK_DROP_PASS_GOLDEN)
|
||||
assert compare(result, golden_map(PICK_DROP_PASS_GOLDEN)) == []
|
||||
|
||||
|
||||
def test_a_mutated_coordinate_diverges_and_compare_names_the_bad_tick_and_field() -> None:
|
||||
"""One coordinate edited in the fixture: the first bad tick and the moved field."""
|
||||
level, book, script = load_walk_solo()
|
||||
frames = list(script.frames)
|
||||
frames[0] = replace(frames[0], move=(0.0, -1.0)) # up, not down
|
||||
mutated = replace(script, frames=tuple(frames))
|
||||
result = run_script(level, book, mutated)
|
||||
mismatches = compare(result, golden_map(WALK_SOLO_GOLDEN))
|
||||
assert mismatches, "a mutated script must not match its golden"
|
||||
first = mismatches[0]
|
||||
assert first.kind == "hash"
|
||||
assert first.tick == 1, "the frame at tick 0 is consumed by substep 0 -> tick 1"
|
||||
assert first.actual is not None and first.actual != first.expected
|
||||
assert "players[0].pos.y" in first.changed, (
|
||||
"the diff must name the field the edit moved, " + str(first.changed)
|
||||
)
|
||||
assert "players[0].vel.y" in first.changed
|
||||
assert all(m.tick >= first.tick for m in mismatches)
|
||||
# the tour re-converges on its walls, so the *final* state may agree with the
|
||||
# golden again — what is pinned is the divergence itself:
|
||||
assert len(mismatches) > 1
|
||||
divergent = [m for m in mismatches if m.kind == "hash"]
|
||||
assert divergent and all(m.actual != m.expected for m in divergent)
|
||||
|
||||
|
||||
# -- determinism and cadence --------------------------------------------------
|
||||
|
||||
|
||||
def test_replaying_the_same_script_twice_in_one_process_is_identical() -> None:
|
||||
"""No hidden global state: two runs of each script in one process agree exactly."""
|
||||
for loader in (load_walk_solo, load_pick_drop_pass):
|
||||
level, book, script = loader()
|
||||
first = run_script(level, book, script)
|
||||
second = run_script(level, book, script)
|
||||
assert first.hashes == second.hashes
|
||||
assert first.events == second.events
|
||||
assert first.state.state_hash() == second.state.state_hash()
|
||||
assert first.wall_seconds == second.wall_seconds == 0.0 # no clock injected
|
||||
|
||||
|
||||
def test_the_record_cadence_is_stable_and_the_final_tick_is_always_recorded() -> None:
|
||||
level, book, script = load_walk_solo()
|
||||
sparse = replace(script, record_every=5)
|
||||
first = run_script(level, book, sparse)
|
||||
second = run_script(level, book, sparse)
|
||||
assert first.hashes == second.hashes
|
||||
ticks = [tick for tick, _ in first.hashes]
|
||||
assert ticks[0] == 0
|
||||
assert ticks[-1] == 600 # the final tick is always recorded, cadence or not
|
||||
assert set(ticks) == {tick for tick in range(0, 601) if tick % 5 == 0} | {600}
|
||||
silent = replace(script, record_every=0)
|
||||
unrecorded = run_script(level, book, silent)
|
||||
assert unrecorded.hashes == ()
|
||||
assert dict(unrecorded.states) == {}
|
||||
assert unrecorded.state.tick == 600 # the run still runs; only the recording stops
|
||||
|
||||
|
||||
def test_a_run_with_a_clock_measures_wall_seconds_and_a_pinned_length_clips() -> None:
|
||||
"""`clock` is the seam the perf smoke times through; `max_ticks` pins the run."""
|
||||
level, book, script = load_walk_solo()
|
||||
measured = run_script(level, book, script, clock=time.perf_counter)
|
||||
assert measured.wall_seconds > 0.0
|
||||
clipped = run_script(level, book, script, max_ticks=10)
|
||||
assert clipped.state.tick == 10
|
||||
assert [tick for tick, _ in clipped.hashes] == list(range(11)) # cadence 1 + final
|
||||
|
||||
|
||||
def test_compare_reports_a_cadence_mismatch_as_missing_and_extra() -> None:
|
||||
"""A golden that records ticks the run did not (and vice versa) is visible."""
|
||||
level, book, script = load_walk_solo()
|
||||
sparse = run_script(level, book, replace(script, record_every=5))
|
||||
by_tick = dict(sparse.hashes)
|
||||
golden = {0: by_tick[0], 7: by_tick[5], 10: by_tick[10]}
|
||||
mismatches = compare(sparse, golden)
|
||||
kinds = {mismatch.tick: mismatch.kind for mismatch in mismatches}
|
||||
assert kinds[7] == "missing" # the golden records a tick the run did not
|
||||
assert kinds[5] == "extra" # the run records a tick the golden does not have
|
||||
assert 0 not in kinds and 10 not in kinds # the matching ticks produce nothing
|
||||
assert all(mismatch.changed == () for mismatch in mismatches)
|
||||
|
||||
|
||||
# -- the goldens are read-only for the tests ----------------------------------
|
||||
|
||||
|
||||
def test_the_field_diff_walks_mappings_and_missing_keys() -> None:
|
||||
"""`counters['trashed']` appears mid-run: the diff must cross the mapping.
|
||||
|
||||
The `walk_solo` states all share one empty `counters` object (so the early
|
||||
`a is b` return short-circuits it); the `pick_drop_pass` run is what makes
|
||||
the mapping branch and a missing key on one side actually run.
|
||||
"""
|
||||
level, book, script = load_pick_drop_pass()
|
||||
result = run_script(level, book, script)
|
||||
ticks = sorted(result.states)
|
||||
pair: tuple[Any, Any] | None = None
|
||||
for a, b in pairwise(ticks):
|
||||
if bool(result.states[a].counters) != bool(result.states[b].counters):
|
||||
pair = (result.states[a], result.states[b])
|
||||
break
|
||||
assert pair is not None, "the trashed tick must flip counters from empty to non-empty"
|
||||
changed = _field_diff(*pair)
|
||||
assert "counters['trashed']" in changed, changed
|
||||
|
||||
|
||||
def test_the_golden_files_are_never_rewritten_by_the_tests(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""The whole check flow — harness and CLI — must not touch the committed goldens."""
|
||||
before = (
|
||||
WALK_SOLO_GOLDEN.read_bytes(),
|
||||
PICK_DROP_PASS_GOLDEN.read_bytes(),
|
||||
)
|
||||
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||
level, book, script = load_pick_drop_pass() # the harness flow
|
||||
assert compare(run_script(level, book, script), golden_map(PICK_DROP_PASS_GOLDEN)) == []
|
||||
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_OK # the CLI flow
|
||||
capsys.readouterr()
|
||||
after = (
|
||||
WALK_SOLO_GOLDEN.read_bytes(),
|
||||
PICK_DROP_PASS_GOLDEN.read_bytes(),
|
||||
)
|
||||
assert before == after
|
||||
|
||||
|
||||
# -- the CLI -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_cli_exits_zero_and_reports_the_match(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_OK
|
||||
out = capsys.readouterr().out
|
||||
assert "ok: 601 hashes match walk_solo.golden.json" in out
|
||||
|
||||
|
||||
def test_check_cli_exits_one_and_names_the_bad_tick_when_the_fixture_is_mutated(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""The task's acceptance test: edit one coordinate, `check` fails and diagnoses."""
|
||||
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||
original = WALK_SOLO_SCRIPT.read_bytes()
|
||||
data = json.loads(original)
|
||||
data["frames"][0]["move"] = [0.0, -1.0]
|
||||
WALK_SOLO_SCRIPT.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
try:
|
||||
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_MISMATCH
|
||||
finally:
|
||||
WALK_SOLO_SCRIPT.write_bytes(original)
|
||||
out = capsys.readouterr().out
|
||||
assert "first divergent tick 1" in out
|
||||
assert "players[0].pos.y" in out
|
||||
assert WALK_SOLO_SCRIPT.read_bytes() == original # the fixture is left untouched
|
||||
|
||||
|
||||
def test_check_cli_exits_two_without_a_golden(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||
script = InputScript(
|
||||
name="no_golden_here",
|
||||
seed=1,
|
||||
level_id="01_first_kitchen",
|
||||
tick_hz=20,
|
||||
frames=(Frame(tick=0, player_id="p0", move=(1.0, 0.0)),),
|
||||
)
|
||||
path = tmp_path / "no_golden_here.json"
|
||||
script.dump(path)
|
||||
code = main(["check", str(path)])
|
||||
assert code == EXIT_UNUSABLE
|
||||
|
||||
|
||||
def test_check_cli_exits_two_when_the_level_is_not_in_the_content_dir(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""The pick_drop_pass kitchen is a test fixture, not campaign content."""
|
||||
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||
assert main(["check", str(PICK_DROP_PASS_SCRIPT)]) == EXIT_UNUSABLE
|
||||
err = capsys.readouterr().err
|
||||
assert "99_replay_pick_drop_pass" in err
|
||||
|
||||
|
||||
def test_record_cli_writes_the_golden_and_check_reads_it_back(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""`record` is the manual re-bless; into a temp goldens dir, the real one is untouched."""
|
||||
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||
committed_before = WALK_SOLO_GOLDEN.read_bytes()
|
||||
script = InputScript.load(WALK_SOLO_SCRIPT)
|
||||
path = tmp_path / "record_smoke.json"
|
||||
replace(script, name="record_smoke").dump(path)
|
||||
assert main(["record", str(path)], goldens_dir=tmp_path) == EXIT_OK
|
||||
golden = tmp_path / "record_smoke.golden.json"
|
||||
assert golden.exists()
|
||||
assert main(["check", str(path)], goldens_dir=tmp_path) == EXIT_OK
|
||||
capsys.readouterr()
|
||||
assert main(["record", str(path)], goldens_dir=tmp_path) == EXIT_OK # the re-bless note
|
||||
assert "overwriting" in capsys.readouterr().out
|
||||
# and the committed golden never moves:
|
||||
assert WALK_SOLO_GOLDEN.read_bytes() == committed_before
|
||||
# while a fresh record reproduces the committed hash bit for bit:
|
||||
fresh = json.loads(golden.read_text(encoding="utf-8"))
|
||||
assert fresh["hash"] == json.loads(committed_before.decode("utf-8"))["hash"]
|
||||
|
||||
|
||||
def test_check_cli_exits_two_on_a_broken_script_file(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
bad = tmp_path / "broken_script.json"
|
||||
bad.write_text(json.dumps({"seed": 1, "level_id": "x", "bogus": 1}), encoding="utf-8")
|
||||
assert main(["check", str(bad)]) == EXIT_UNUSABLE
|
||||
assert "unknown script key(s): bogus" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_check_cli_exits_two_when_the_content_would_not_load(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(tmp_path / "no_such_dir"))
|
||||
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_UNUSABLE
|
||||
assert "would not load" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_check_cli_reports_cadence_notes_and_the_missing_and_empty_diff_paths(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""The three diagnostic branches of a failed check, each named in the report."""
|
||||
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||
level, book, script = load_walk_solo()
|
||||
sparse = replace(script, name="cadence_check", record_every=5)
|
||||
path = tmp_path / "cadence_check.json"
|
||||
sparse.dump(path)
|
||||
assert main(["record", str(path)], goldens_dir=tmp_path) == EXIT_OK
|
||||
|
||||
# 1. the cadence note: the golden was recorded at 5, the script now says 1
|
||||
replace(sparse, name="cadence_check", record_every=1).dump(path)
|
||||
assert main(["check", str(path)], goldens_dir=tmp_path) == EXIT_MISMATCH
|
||||
out = capsys.readouterr().out
|
||||
assert "recorded at record_every=5" in out
|
||||
|
||||
# 2. a `missing` first mismatch: the golden records a tick the run did not
|
||||
missing_path = tmp_path / "missing_first.json"
|
||||
replace(sparse, name="missing_first").dump(missing_path)
|
||||
first_zero = run_script(level, book, sparse).hashes[0][1]
|
||||
(tmp_path / "missing_first.golden.json").write_text(
|
||||
json.dumps(
|
||||
{"name": "missing_first", "tick": 600, "record_every": 5,
|
||||
"hashes": {"0": first_zero, "3": "x" * 64}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert main(["check", str(missing_path)], goldens_dir=tmp_path) == EXIT_MISMATCH
|
||||
out = capsys.readouterr().out
|
||||
assert "a golden tick this run did not record" in out
|
||||
|
||||
# 3. a hash mismatch at tick 0 carries no field diff (the baseline is itself)
|
||||
empty_path = tmp_path / "empty_diff.json"
|
||||
replace(sparse, name="empty_diff").dump(empty_path)
|
||||
(tmp_path / "empty_diff.golden.json").write_text(
|
||||
json.dumps(
|
||||
{"name": "empty_diff", "tick": 1, "record_every": 5,
|
||||
"hashes": {"0": "y" * 64}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert main(["check", str(empty_path)], goldens_dir=tmp_path) == EXIT_MISMATCH
|
||||
out = capsys.readouterr().out
|
||||
assert "no field diff" in out
|
||||
|
||||
|
||||
# -- the script format ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_refuses_a_broken_script_naming_every_problem() -> None:
|
||||
script = InputScript(
|
||||
seed=-1, # < 0
|
||||
level_id="", # empty
|
||||
tick_hz=10.0, # not 20
|
||||
record_every=-3, # negative
|
||||
frames=(Frame(tick=-5, player_id="", move=(float("nan"), 1.0), action=0, face="sideways"),), # type: ignore[arg-type] — the guard under test
|
||||
items=(ScriptItem(station="", item="kale", form="soggy"),),
|
||||
)
|
||||
with pytest.raises(ReplayError) as excinfo:
|
||||
script.validate()
|
||||
message = str(excinfo.value)
|
||||
assert "seed must be an integer in [0, 2**64)" in message
|
||||
assert "level_id must be a non-empty string" in message
|
||||
assert "tick_hz must be 20" in message
|
||||
assert "record_every must be >= 0" in message
|
||||
assert "tick must be a non-negative integer" in message
|
||||
assert "move must be two finite numbers" in message
|
||||
assert "action must be a bool" in message
|
||||
assert "face must be one of" in message
|
||||
assert "item must be an ItemKind value" in message
|
||||
assert "form must be an ItemForm value" in message
|
||||
|
||||
|
||||
def test_validate_refuses_a_non_integer_record_every() -> None:
|
||||
script = InputScript(
|
||||
seed=1,
|
||||
level_id="x",
|
||||
tick_hz=20,
|
||||
record_every=1.5, # type: ignore[arg-type] — the guard exists for exactly this
|
||||
)
|
||||
with pytest.raises(ReplayError, match=r"record_every must be an integer"):
|
||||
script.validate()
|
||||
|
||||
|
||||
def test_validate_accepts_the_committed_scripts() -> None:
|
||||
for path in (WALK_SOLO_SCRIPT, PICK_DROP_PASS_SCRIPT):
|
||||
InputScript.load(path).validate()
|
||||
|
||||
|
||||
def test_load_refuses_unknown_keys_at_every_level(tmp_path: Path) -> None:
|
||||
bad = tmp_path / "bad.json"
|
||||
bad.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"seed": 1,
|
||||
"level_id": "x",
|
||||
"tick_hz": 20,
|
||||
"bogus": 1,
|
||||
"frames": [{"tick": 0, "player_id": "p", "move": [1, 0], "extra": 1}],
|
||||
"items": [{"station": "s", "item": "tomato", "form": "raw", "mystery": 2}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ReplayError, match=r"unknown script key\(s\): bogus"):
|
||||
InputScript.load(bad)
|
||||
bad_frame = tmp_path / "bad_frame.json"
|
||||
bad_frame.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"seed": 1,
|
||||
"level_id": "x",
|
||||
"tick_hz": 20,
|
||||
"frames": [{"tick": 0, "player_id": "p", "move": [1, 0], "extra": 1}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ReplayError, match=r"frame 0 has unknown key\(s\): extra"):
|
||||
InputScript.load(bad_frame)
|
||||
bad_item = tmp_path / "bad_item.json"
|
||||
bad_item.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"seed": 1,
|
||||
"level_id": "x",
|
||||
"tick_hz": 20,
|
||||
"items": [
|
||||
{"station": "s", "item": "tomato", "form": "raw", "mystery": 2}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ReplayError, match=r"item 0 has unknown key\(s\): mystery"):
|
||||
InputScript.load(bad_item)
|
||||
|
||||
|
||||
def test_load_refuses_every_malformed_shape(tmp_path: Path) -> None:
|
||||
def writes(name: str, text: str) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
with pytest.raises(ReplayError, match=r"must be a JSON object, got list"):
|
||||
InputScript.load(writes("top_list.json", "[1, 2, 3]"))
|
||||
with pytest.raises(ReplayError, match=r"missing required key 'seed'"):
|
||||
InputScript.load(writes("no_seed.json", json.dumps({"level_id": "x", "tick_hz": 20})))
|
||||
with pytest.raises(ReplayError, match=r"frame 0 must be a JSON object"):
|
||||
InputScript.load(
|
||||
writes("frame_scalar.json", json.dumps(
|
||||
{"seed": 1, "level_id": "x", "tick_hz": 20, "frames": [42]}))
|
||||
)
|
||||
with pytest.raises(ReplayError, match=r"frame 0 is missing required key 'move'"):
|
||||
InputScript.load(
|
||||
writes("frame_no_move.json", json.dumps(
|
||||
{"seed": 1, "level_id": "x", "tick_hz": 20,
|
||||
"frames": [{"tick": 0, "player_id": "p"}]}))
|
||||
)
|
||||
with pytest.raises(ReplayError, match=r"move must be a two-element array"):
|
||||
InputScript.load(
|
||||
writes("move_not_list.json", json.dumps(
|
||||
{"seed": 1, "level_id": "x", "tick_hz": 20,
|
||||
"frames": [{"tick": 0, "player_id": "p", "move": 1}]}))
|
||||
)
|
||||
with pytest.raises(ReplayError, match=r"item 0 must be a JSON object"):
|
||||
InputScript.load(
|
||||
writes("item_scalar.json", json.dumps(
|
||||
{"seed": 1, "level_id": "x", "tick_hz": 20, "items": [42]}))
|
||||
)
|
||||
with pytest.raises(ReplayError, match=r"item 0 is missing required key 'form'"):
|
||||
InputScript.load(
|
||||
writes("item_no_form.json", json.dumps(
|
||||
{"seed": 1, "level_id": "x", "tick_hz": 20,
|
||||
"items": [{"station": "s", "item": "tomato"}]}))
|
||||
)
|
||||
|
||||
|
||||
def test_dump_load_roundtrip_is_lossless(tmp_path: Path) -> None:
|
||||
_, _, script = load_pick_drop_pass()
|
||||
path = tmp_path / "roundtrip.json"
|
||||
script.dump(path)
|
||||
assert InputScript.load(path) == script
|
||||
# an unnamed script dumps without a `name` key; `load` then takes the stem
|
||||
unnamed = replace(script, name="")
|
||||
unnamed_path = tmp_path / "unnamed.json"
|
||||
unnamed.dump(unnamed_path)
|
||||
data = json.loads(unnamed_path.read_text(encoding="utf-8"))
|
||||
assert "name" not in data
|
||||
assert InputScript.load(unnamed_path) == replace(unnamed, name=unnamed_path.stem)
|
||||
|
||||
|
||||
def test_run_script_refuses_a_level_id_mismatch() -> None:
|
||||
level, book, script = load_walk_solo()
|
||||
other = replace(script, level_id="99_replay_pick_drop_pass")
|
||||
with pytest.raises(ReplayError, match="names level"):
|
||||
run_script(level, book, other)
|
||||
|
||||
|
||||
def test_run_script_refuses_an_item_on_an_unknown_station() -> None:
|
||||
level, book, script = load_walk_solo()
|
||||
broken = replace(
|
||||
script,
|
||||
items=(ScriptItem(station="chop_board_9", item="tomato", form="raw"),),
|
||||
)
|
||||
with pytest.raises(ReplayError, match="does not have"):
|
||||
run_script(level, book, broken)
|
||||
|
||||
|
||||
def test_run_script_refuses_a_negative_max_ticks() -> None:
|
||||
level, book, script = load_pick_drop_pass()
|
||||
with pytest.raises(ReplayError, match="max_ticks"):
|
||||
run_script(level, book, script, max_ticks=-1)
|
||||
|
||||
|
||||
def test_a_frameless_script_runs_zero_ticks_and_records_the_built_state() -> None:
|
||||
level, book, _ = load_pick_drop_pass()
|
||||
frameless = InputScript(
|
||||
name="idle", seed=0x5EED, level_id=level.id, tick_hz=20, frames=()
|
||||
)
|
||||
result = run_script(level, book, frameless)
|
||||
assert result.state.tick == 0
|
||||
assert [tick for tick, _ in result.hashes] == [0]
|
||||
assert result.events == ()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""The two-player carry script against its committed golden hash.
|
||||
|
||||
`tests/goldens/carry_two_player.golden.json` pins the state hash of a fixed 12-tick
|
||||
script: one pass (a set-down picked up inside the hand-off window, twice — out and
|
||||
back) and one contested pick (the full tie broken by seat, the loser told
|
||||
`contested_lost`). The event sequence is asserted in code, so a golden drift is
|
||||
diagnosable in one read: if the events match but the hash does not, a constant or
|
||||
a subsystem order moved; if the events differ, a rule changed shape.
|
||||
|
||||
The golden is committed, not computed: a mismatch fails, and this test prints the
|
||||
diff hint. Regeneration is deliberate — an intentional sim-contract change recorded
|
||||
in the phase task, never a side effect of a failing run (task 05's replay harness
|
||||
formalises the re-bless path and the input-script format this script foreshadows).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.model import (
|
||||
Facing,
|
||||
Item,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
Player,
|
||||
Station,
|
||||
StationKind,
|
||||
TileKind,
|
||||
Vec2,
|
||||
)
|
||||
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
GOLDEN_PATH: Path = (
|
||||
Path(__file__).resolve().parent.parent / "goldens" / "carry_two_player.golden.json"
|
||||
)
|
||||
|
||||
WIDTH, HEIGHT = 13, 9
|
||||
|
||||
|
||||
def build_state() -> KitchenState:
|
||||
"""Tick 0: A at the prep holding a lettuce, B at its left diagonal, both facing it."""
|
||||
grid = Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT))
|
||||
prep = Station(id="prep-9-4", kind=StationKind.PREP, x=9, y=4)
|
||||
a = Player(
|
||||
id="cook-a",
|
||||
pos=Vec2(9.5, 5.5),
|
||||
facing=Facing.UP,
|
||||
carrying=Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW),
|
||||
seat=0,
|
||||
)
|
||||
b = Player(id="cook-b", pos=Vec2(8.5, 5.5), facing=Facing.UP, seat=1)
|
||||
return KitchenState(
|
||||
level_id="03_replay_carry",
|
||||
seed=99,
|
||||
grid=grid,
|
||||
stations=(prep,),
|
||||
players=(a, b),
|
||||
target_score=40,
|
||||
time_left=90.0,
|
||||
)
|
||||
|
||||
|
||||
def frame(player_id: str, seq: int, action: bool, tick: int) -> PlayerInput:
|
||||
return PlayerInput(player_id=player_id, seq=seq, action=action, received_at_tick=tick)
|
||||
|
||||
|
||||
# The 12-tick script: (tick, one or two frames). The pass is t0 -> t2 (a sets down,
|
||||
# b picks: HANDED_OFF), the contest is t6 (both press the prep's item: a, seat 0,
|
||||
# wins; b is told contested_lost), and the return pass is t8 -> t10.
|
||||
SCRIPT: tuple[tuple[int, tuple[PlayerInput, ...]], ...] = (
|
||||
(0, (frame("cook-a", 0, True, 0),)),
|
||||
(1, (frame("cook-a", 1, False, 1),)),
|
||||
(2, (frame("cook-b", 2, True, 2),)),
|
||||
(3, (frame("cook-b", 3, False, 3),)),
|
||||
(4, (frame("cook-b", 4, True, 4),)),
|
||||
(5, (frame("cook-b", 5, False, 5),)),
|
||||
(6, (frame("cook-a", 6, True, 6), frame("cook-b", 6, True, 6))),
|
||||
(7, (frame("cook-a", 7, False, 7),)),
|
||||
(8, (frame("cook-a", 8, True, 8),)),
|
||||
(9, (frame("cook-a", 9, False, 9),)),
|
||||
(10, (frame("cook-b", 10, True, 10),)),
|
||||
(11, (frame("cook-b", 11, False, 11),)),
|
||||
)
|
||||
|
||||
# The event the script must produce, tick by tick (kinds and the load-bearing payload).
|
||||
EXPECTED_EVENTS: tuple[tuple[str, dict[str, str]], ...] = (
|
||||
("dropped", {"player_id": "cook-a", "station": "prep-9-4"}),
|
||||
("handed_off", {"from": "cook-a", "to": "cook-b"}),
|
||||
("dropped", {"player_id": "cook-b", "station": "prep-9-4"}),
|
||||
("handed_off", {"from": "cook-b", "to": "cook-a"}),
|
||||
("denied", {"player_id": "cook-b", "reason": "contested_lost"}),
|
||||
("dropped", {"player_id": "cook-a", "station": "prep-9-4"}),
|
||||
("handed_off", {"from": "cook-a", "to": "cook-b"}),
|
||||
)
|
||||
|
||||
|
||||
def run_script() -> tuple[KitchenState, tuple[tuple[str, dict[str, object]], ...]]:
|
||||
"""Run the 12-tick script; return the final state and the events, keyed by kind."""
|
||||
state = build_state()
|
||||
events: list[tuple[str, dict[str, object]]] = []
|
||||
for _tick, frames in SCRIPT:
|
||||
state, produced = step(state, STEP_DT, frames)
|
||||
events.extend((event.kind, dict(event.payload)) for event in produced)
|
||||
return state, tuple(events)
|
||||
|
||||
|
||||
def test_the_script_produces_the_expected_event_sequence() -> None:
|
||||
_state, events = run_script()
|
||||
assert len(events) == len(EXPECTED_EVENTS)
|
||||
for (got_kind, got_payload), (want_kind, want_payload) in zip(
|
||||
events, EXPECTED_EVENTS, strict=True
|
||||
):
|
||||
assert got_kind == want_kind
|
||||
for key, value in want_payload.items():
|
||||
assert got_payload.get(key) == value, (got_kind, got_payload)
|
||||
|
||||
|
||||
def test_the_script_reaches_the_committed_golden_hash() -> None:
|
||||
golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||||
state, _events = run_script()
|
||||
assert state.tick == golden["tick"]
|
||||
if state.state_hash() != golden["hash"]:
|
||||
# The diff hint the phase promises: name both hashes so the commit shows the drift.
|
||||
print(
|
||||
f"golden mismatch for {GOLDEN_PATH.name}:\n"
|
||||
f" expected {golden['hash']}\n"
|
||||
f" actual {state.state_hash()}\n"
|
||||
"Regenerate only on a deliberate sim-contract change recorded in the phase "
|
||||
"task (docs/SIMULATION.md, 'Goldens'); never to silence a failure."
|
||||
)
|
||||
assert state.state_hash() == golden["hash"]
|
||||
|
||||
|
||||
def test_the_golden_file_is_committed_and_well_formed() -> None:
|
||||
golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||||
assert golden["name"] == "carry_two_player"
|
||||
assert golden["tick"] == 12
|
||||
assert len(golden["hash"]) == 64 # a sha256 hex digest, nothing else
|
||||
|
||||
|
||||
def test_rerunning_the_script_is_bit_identical() -> None:
|
||||
"""No hidden global state: the same script twice in one process hashes the same."""
|
||||
first, first_events = run_script()
|
||||
second, second_events = run_script()
|
||||
assert first.state_hash() == second.state_hash()
|
||||
assert first_events == second_events
|
||||
@@ -0,0 +1,218 @@
|
||||
"""`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
|
||||
@@ -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
|
||||
@@ -0,0 +1,495 @@
|
||||
"""`sim/interact.py` targeting — what the reach zone contains (phase 03 task 03).
|
||||
|
||||
The reach zone is discrete first and continuous second: `candidate_cells()` names
|
||||
the tiles the hand walks (front, the two diagonal-front, underfoot) and
|
||||
`REACH + FORGIVE` decides whether the cook is actually standing close enough to
|
||||
the tile it found. This file pins both halves with hand-computed geometry:
|
||||
|
||||
* **candidate cells** — the per-facing order, and the fact that the four
|
||||
facings are 90° rotations of one row rather than four ad-hoc tie-breaks;
|
||||
* **reach** — the forgiveness boundary (0.99 ok, 1.6 itself inclusive, 1.61
|
||||
rejected), including the case the diagonal tier exists for: the same diagonal
|
||||
station is out of reach from the far corner of the cook's tile and in reach
|
||||
from the near corner;
|
||||
* **priority** — item outranks the fixture it sits on, a full hand skips the
|
||||
item, a non-ready fixture holds its contents, the serve window is its own
|
||||
target kind, nothing at all is `None`;
|
||||
* **the wall** — a fixture two tiles ahead (wall in between) is never a
|
||||
candidate: the discrete walk has no "through the wall" mode, and the
|
||||
out-of-reach skip keeps the walk going so a nearer tile still gets its turn.
|
||||
|
||||
The press-edge behaviour — one press, one named refusal, however long the button
|
||||
stays held — is at the bottom, through the full `step()`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.interact import (
|
||||
FORGIVE,
|
||||
REACH,
|
||||
DeniedReason,
|
||||
ItemTarget,
|
||||
ServeTarget,
|
||||
StationTarget,
|
||||
Verb,
|
||||
candidate_cells,
|
||||
faces_target,
|
||||
select_target,
|
||||
select_verb,
|
||||
station_cells,
|
||||
target_center,
|
||||
within_reach,
|
||||
)
|
||||
from undercooked.sim.model import (
|
||||
EventKind,
|
||||
Facing,
|
||||
Item,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
Player,
|
||||
PlayerState,
|
||||
Station,
|
||||
StationKind,
|
||||
StationState,
|
||||
TileKind,
|
||||
Vec2,
|
||||
)
|
||||
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, step
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
WIDTH, HEIGHT = 13, 9
|
||||
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
|
||||
|
||||
|
||||
# --- fixtures ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_state(
|
||||
stations: tuple[Station, ...] = (),
|
||||
players: tuple[Player, ...] = (),
|
||||
tile_overrides: dict[tuple[int, int], TileKind] | None = None,
|
||||
) -> KitchenState:
|
||||
tiles = [TileKind.FLOOR] * (WIDTH * HEIGHT)
|
||||
for (x, y), kind in (tile_overrides or {}).items():
|
||||
tiles[y * WIDTH + x] = kind
|
||||
return KitchenState(
|
||||
level_id="03_targeting",
|
||||
seed=1,
|
||||
grid=Grid(width=WIDTH, height=HEIGHT, tiles=tuple(tiles)),
|
||||
stations=stations,
|
||||
players=players,
|
||||
target_score=40,
|
||||
time_left=90.0,
|
||||
)
|
||||
|
||||
|
||||
def cook(
|
||||
player_id: str = "p0",
|
||||
*,
|
||||
pos: Vec2 = DEFAULT_POS,
|
||||
facing: Facing = Facing.UP,
|
||||
carrying: Item | None = None,
|
||||
stun: float = 0.0,
|
||||
state: PlayerState = PlayerState.IDLE,
|
||||
seat: int = 0,
|
||||
held_ticks: int = 0,
|
||||
pressed_tick: int = 0,
|
||||
) -> Player:
|
||||
return Player(
|
||||
id=player_id,
|
||||
pos=pos,
|
||||
facing=facing,
|
||||
carrying=carrying,
|
||||
state=state,
|
||||
stun=stun,
|
||||
seat=seat,
|
||||
action_held_ticks=held_ticks,
|
||||
action_pressed_tick=pressed_tick,
|
||||
)
|
||||
|
||||
|
||||
def fixture(
|
||||
kind: StationKind = StationKind.STOVE,
|
||||
*,
|
||||
x: int = 5,
|
||||
y: int = 4,
|
||||
state: StationState = StationState.IDLE,
|
||||
contents: tuple[Item, ...] = (),
|
||||
worker_id: str | None = None,
|
||||
dirty: bool = False,
|
||||
cooldown: float = 0.0,
|
||||
) -> Station:
|
||||
return Station(
|
||||
id=f"{kind.value}-{x}-{y}",
|
||||
kind=kind,
|
||||
x=x,
|
||||
y=y,
|
||||
state=state,
|
||||
contents=contents,
|
||||
worker_id=worker_id,
|
||||
dirty=dirty,
|
||||
cooldown=cooldown,
|
||||
)
|
||||
|
||||
|
||||
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
|
||||
return Item(kind=ItemKind.TOMATO, form=form)
|
||||
|
||||
|
||||
# --- candidate cells --------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("facing", "expected"),
|
||||
[
|
||||
(Facing.UP, ((2, 2), (1, 2), (3, 2), (2, 3))),
|
||||
(Facing.RIGHT, ((3, 3), (3, 2), (3, 4), (2, 3))),
|
||||
(Facing.DOWN, ((2, 4), (3, 4), (1, 4), (2, 3))),
|
||||
(Facing.LEFT, ((1, 3), (1, 4), (1, 2), (2, 3))),
|
||||
],
|
||||
)
|
||||
def test_candidate_cells_walk_front_then_both_diagonals_then_underfoot(
|
||||
facing: Facing, expected: tuple[tuple[int, int], ...]
|
||||
) -> None:
|
||||
# The cook stands at the centre of cell (2, 3); every facing reports the front
|
||||
# tile first, its two diagonal neighbours second (left, then right, in the
|
||||
# rotated row), and the tile underfoot last.
|
||||
player = cook("p0", pos=Vec2(2.5, 3.5), facing=facing)
|
||||
assert player.pos == Vec2(2.5, 3.5)
|
||||
assert candidate_cells(player) == expected
|
||||
|
||||
|
||||
def test_candidate_cells_do_not_depend_on_where_in_the_tile_the_cook_stands() -> None:
|
||||
positions = [Vec2(2.51, 3.99), Vec2(2.02, 3.01), Vec2(2.99, 3.51)]
|
||||
for facing in Facing:
|
||||
baseline = candidate_cells(cook("p0", pos=positions[0], facing=facing))
|
||||
for position in positions[1:]:
|
||||
assert candidate_cells(cook("p0", pos=position, facing=facing)) == baseline
|
||||
|
||||
|
||||
def test_candidate_cells_use_the_floor_of_the_position() -> None:
|
||||
# (3.0, 3.0) is in cell (3, 3), not (2, 2): the body centre decides.
|
||||
assert candidate_cells(cook("p0", pos=Vec2(3.0, 3.0), facing=Facing.UP)) == (
|
||||
(3, 2),
|
||||
(2, 2),
|
||||
(4, 2),
|
||||
(3, 3),
|
||||
)
|
||||
|
||||
|
||||
def test_candidate_cells_are_ordered_and_deduplicated() -> None:
|
||||
cells = candidate_cells(cook("p0", pos=Vec2(2.5, 3.5), facing=Facing.RIGHT))
|
||||
assert len(cells) == 4
|
||||
assert len(set(cells)) == 4
|
||||
assert cells[3] == (2, 3) # underfoot is always last, whatever the facing
|
||||
|
||||
|
||||
# --- the forgiveness boundary -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_reach_plus_forgive_is_the_number_the_boundary_tests_use() -> None:
|
||||
assert abs((REACH + FORGIVE) - 1.6) < 1e-12
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("distance", "in_reach"),
|
||||
[
|
||||
(0.99, True), # the task boundary: 0.99 is ok
|
||||
(REACH, True), # the front tile itself
|
||||
(1.6, True), # the boundary is inclusive (with REACH_EPS of slack)
|
||||
(1.61, False), # the task boundary: 1.61 is rejected
|
||||
],
|
||||
)
|
||||
def test_within_reach_forgives_up_to_the_boundary(
|
||||
distance: float, in_reach: bool
|
||||
) -> None:
|
||||
state = make_state(stations=(fixture(),))
|
||||
# The player sits on the vertical line through the station centre (5.5, 4.5),
|
||||
# so the distance is exactly `distance` and no corner math gets in.
|
||||
player = cook("p0", pos=Vec2(5.5, 4.5 - distance), facing=Facing.UP)
|
||||
assert within_reach(state, player, StationTarget(state.stations[0])) is in_reach
|
||||
|
||||
|
||||
def test_target_center_is_the_cell_centre_for_a_fixture() -> None:
|
||||
state = make_state(stations=(fixture(kind=StationKind.SINK, x=3, y=2),))
|
||||
assert target_center(state, StationTarget(state.stations[0])) == Vec2(3.5, 2.5)
|
||||
|
||||
|
||||
def test_target_center_falls_back_to_the_stamped_position_when_the_station_is_gone() -> None:
|
||||
state = make_state()
|
||||
stale = ItemTarget(item=tomato(), on_station_id="gone", at=Vec2(5.5, 4.5))
|
||||
assert target_center(state, stale) == Vec2(5.5, 4.5)
|
||||
|
||||
|
||||
def test_an_item_already_in_the_hand_needs_no_reach() -> None:
|
||||
state = make_state()
|
||||
player = cook("p0")
|
||||
in_hand = ItemTarget(item=tomato()) # no station, no world position
|
||||
assert target_center(state, in_hand) is None
|
||||
assert within_reach(state, player, in_hand) is True
|
||||
assert faces_target(state, player, in_hand) is True
|
||||
|
||||
|
||||
# --- select_target: reach through the cell walk ------------------------------------------
|
||||
|
||||
|
||||
def test_a_station_at_0p99_in_front_is_targeted() -> None:
|
||||
state = make_state(
|
||||
stations=(fixture(),),
|
||||
players=(cook("p0", pos=Vec2(5.5, 4.51), facing=Facing.DOWN),), # 0.99 below the centre
|
||||
)
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, StationTarget)
|
||||
assert target.station.id == "stove-5-4"
|
||||
|
||||
|
||||
def test_a_diagonal_station_at_1p61_is_skipped_not_refused() -> None:
|
||||
# From the far corner of the cook's own tile the diagonal centre is
|
||||
# 1.15 * sqrt(2) = 1.618 away, outside REACH + FORGIVE, so the tile is
|
||||
# skipped rather than becoming the answer.
|
||||
state = make_state(
|
||||
stations=(fixture(kind=StationKind.STOVE, x=6, y=6),),
|
||||
players=(cook("p0", pos=Vec2(5.35, 5.35), facing=Facing.RIGHT),),
|
||||
)
|
||||
assert select_target(state, state.players[0]) is None
|
||||
|
||||
|
||||
def test_the_same_diagonal_station_is_reachable_from_the_near_corner() -> None:
|
||||
state = make_state(
|
||||
stations=(fixture(kind=StationKind.STOVE, x=6, y=6),),
|
||||
players=(cook("p0", pos=Vec2(5.65, 5.65), facing=Facing.RIGHT),),
|
||||
)
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, StationTarget)
|
||||
assert target.station.id == "stove-6-6"
|
||||
|
||||
|
||||
def test_an_out_of_reach_diagonal_is_skipped_so_the_tile_underfoot_still_wins() -> None:
|
||||
state = make_state(
|
||||
stations=(
|
||||
fixture(kind=StationKind.STOVE, x=6, y=6), # candidate 3, out of reach here
|
||||
fixture(kind=StationKind.SINK, x=5, y=5), # candidate 4, underfoot, in reach
|
||||
),
|
||||
players=(cook("p0", pos=Vec2(5.35, 5.35), facing=Facing.RIGHT),),
|
||||
)
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, StationTarget)
|
||||
assert target.station.kind is StationKind.SINK
|
||||
|
||||
|
||||
# --- select_target: priority ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_ready_fixture_with_contents_yields_the_item_to_a_free_hand() -> None:
|
||||
ready = fixture(state=StationState.READY, contents=(tomato(ItemForm.CHOPPED),))
|
||||
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, ItemTarget)
|
||||
assert target.item.form is ItemForm.CHOPPED
|
||||
assert target.on_station_id == ready.id
|
||||
assert target.at == Vec2(5.5, 4.5)
|
||||
|
||||
|
||||
def test_a_full_hand_skips_the_item_and_gets_the_fixture_instead() -> None:
|
||||
ready = fixture(state=StationState.READY, contents=(tomato(ItemForm.CHOPPED),))
|
||||
state = make_state(
|
||||
stations=(ready,),
|
||||
players=(
|
||||
cook(
|
||||
"p0",
|
||||
carrying=Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW),
|
||||
),
|
||||
),
|
||||
)
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, StationTarget)
|
||||
assert target.station is ready
|
||||
|
||||
|
||||
def test_a_spawn_shelf_hands_stock_even_while_idle() -> None:
|
||||
shelf = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(),))
|
||||
state = make_state(stations=(shelf,), players=(cook("p0"),))
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, ItemTarget)
|
||||
assert target.item.kind is ItemKind.TOMATO
|
||||
|
||||
|
||||
def test_a_spawn_shelf_with_no_stock_is_a_plain_fixture() -> None:
|
||||
shelf = fixture(kind=StationKind.SPAWN_SHELF)
|
||||
state = make_state(stations=(shelf,), players=(cook("p0"),))
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, StationTarget)
|
||||
|
||||
|
||||
def test_a_non_ready_fixture_holds_its_contents() -> None:
|
||||
working = fixture(
|
||||
kind=StationKind.CHOP_BOARD,
|
||||
state=StationState.WORKING,
|
||||
contents=(tomato(),),
|
||||
worker_id="p0",
|
||||
)
|
||||
state = make_state(stations=(working,), players=(cook("p0"),))
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, StationTarget) # the onion is *to be chopped*, not grabbable
|
||||
|
||||
|
||||
def test_a_ready_fixture_with_empty_contents_is_a_plain_fixture() -> None:
|
||||
ready = fixture(state=StationState.READY)
|
||||
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, StationTarget)
|
||||
|
||||
|
||||
def test_a_serve_window_is_a_serve_target_not_a_station_target() -> None:
|
||||
window = fixture(kind=StationKind.SERVE)
|
||||
state = make_state(stations=(window,), players=(cook("p0"),))
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, ServeTarget)
|
||||
assert target.station is window
|
||||
|
||||
|
||||
def test_nothing_in_the_zone_is_none() -> None:
|
||||
state = make_state(players=(cook("p0"),))
|
||||
assert select_target(state, state.players[0]) is None
|
||||
|
||||
|
||||
def test_a_station_two_tiles_away_is_never_a_candidate_even_beside_a_wall() -> None:
|
||||
# The wall fills the front tile; the stove is beyond it. The cell walk has
|
||||
# no "through the wall" mode, so the stove is simply not a candidate and
|
||||
# the press cannot land on it.
|
||||
state = make_state(
|
||||
stations=(fixture(kind=StationKind.STOVE, x=5, y=3),),
|
||||
players=(cook("p0"),),
|
||||
tile_overrides={(5, 4): TileKind.WALL},
|
||||
)
|
||||
assert select_target(state, state.players[0]) is None
|
||||
|
||||
|
||||
def test_the_front_tile_outranks_a_diagonal_one() -> None:
|
||||
state = make_state(
|
||||
stations=(
|
||||
fixture(kind=StationKind.STOVE, x=6, y=4), # diagonal (candidate 3 for UP)
|
||||
fixture(kind=StationKind.CHOP_BOARD, x=5, y=4), # front (candidate 1)
|
||||
),
|
||||
players=(cook("p0"),),
|
||||
)
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, StationTarget)
|
||||
assert target.station.kind is StationKind.CHOP_BOARD
|
||||
|
||||
|
||||
def test_station_cells_keys_fixtures_by_their_authored_tile() -> None:
|
||||
state = make_state(
|
||||
stations=(
|
||||
fixture(kind=StationKind.STOVE, x=3, y=2),
|
||||
fixture(kind=StationKind.SINK, x=7, y=1),
|
||||
)
|
||||
)
|
||||
cells = station_cells(state)
|
||||
assert set(cells) == {(3, 2), (7, 1)}
|
||||
assert cells[(3, 2)].kind is StationKind.STOVE
|
||||
|
||||
|
||||
# --- facing: the half-plane, not an angle ---------------------------------------------------
|
||||
|
||||
|
||||
def test_faces_target_refuses_a_target_behind_the_cook() -> None:
|
||||
state = make_state(stations=(fixture(kind=StationKind.SERVE, x=5, y=6),))
|
||||
player = cook("p0", pos=Vec2(5.5, 5.5), facing=Facing.UP)
|
||||
assert faces_target(state, player, StationTarget(state.stations[0])) is False
|
||||
|
||||
|
||||
def test_faces_target_accepts_a_target_level_with_the_cook() -> None:
|
||||
state = make_state(stations=(fixture(kind=StationKind.SERVE, x=4, y=5),))
|
||||
player = cook("p0", pos=Vec2(5.5, 5.5), facing=Facing.UP)
|
||||
# `along` is 0 for a target on the cook's own row, so a serve window the
|
||||
# cook is standing beside is not refused.
|
||||
assert faces_target(state, player, StationTarget(state.stations[0])) is True
|
||||
|
||||
|
||||
# --- the STUNNED denial ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_stunned_cook_is_refused_before_the_target_is_even_read() -> None:
|
||||
state = make_state(
|
||||
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
|
||||
players=(cook("p0", stun=0.3),),
|
||||
)
|
||||
target = select_target(state, state.players[0])
|
||||
verb, denial = select_verb(state, state.players[0], target)
|
||||
assert (verb, denial) == (Verb.NONE, DeniedReason.STUNNED)
|
||||
|
||||
|
||||
def test_the_stunned_state_flag_alone_is_enough() -> None:
|
||||
state = make_state(
|
||||
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
|
||||
players=(cook("p0", state=PlayerState.STUNNED, stun=0.0),),
|
||||
)
|
||||
target = select_target(state, state.players[0])
|
||||
verb, denial = select_verb(state, state.players[0], target)
|
||||
assert (verb, denial) == (Verb.NONE, DeniedReason.STUNNED)
|
||||
|
||||
|
||||
# --- one press, one event (through the full kernel) -------------------------------------------
|
||||
|
||||
|
||||
def test_a_do_nothing_press_emits_exactly_one_denied_not_one_per_tick() -> None:
|
||||
state = make_state(players=(cook("p0"),))
|
||||
frame = PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0)
|
||||
first_before_hash = state.state_hash()
|
||||
|
||||
first, events = step(state, STEP_DT, (frame,))
|
||||
assert len(events) == 1
|
||||
assert events[0].kind is EventKind.DENIED
|
||||
assert dict(events[0].payload) == {
|
||||
"player_id": "p0",
|
||||
"reason": "nothing_here",
|
||||
"verb": "none",
|
||||
}
|
||||
assert (events[0].x, events[0].y) == (5.5, 5.5)
|
||||
assert first.players[0].action_held_ticks == 1
|
||||
assert first.players[0].action_pressed_tick == 0
|
||||
|
||||
# The same frame on the next tick continues the press: no new edge, no new
|
||||
# event, the hold just gets longer.
|
||||
second, held_events = step(first, STEP_DT, (frame,))
|
||||
assert held_events == ()
|
||||
assert second.players[0].action_held_ticks == 2
|
||||
assert second.players[0].action_pressed_tick == 0
|
||||
|
||||
# One call that runs two substeps: still exactly one press.
|
||||
batched, batched_events = step(state, MAX_DT, (frame,))
|
||||
assert sum(event.kind is EventKind.DENIED for event in batched_events) == 1
|
||||
assert batched.players[0].action_held_ticks == 2
|
||||
|
||||
# Releasing the button clears the counters and the next press is a new one.
|
||||
released, released_events = step(
|
||||
second, STEP_DT, (PlayerInput(player_id="p0", seq=1, action=False),)
|
||||
)
|
||||
assert released_events == ()
|
||||
assert released.players[0].action_held_ticks == 0
|
||||
assert released.players[0].action_pressed_tick == 0
|
||||
# Purity: the state the first press was handed is still exactly what it was.
|
||||
assert state.state_hash() == first_before_hash
|
||||
assert state.players[0].action_held_ticks == 0
|
||||
|
||||
|
||||
def test_a_press_at_a_station_out_of_reach_is_one_named_refusal() -> None:
|
||||
state = make_state(
|
||||
stations=(fixture(kind=StationKind.STOVE, x=5, y=3),),
|
||||
players=(cook("p0"),),
|
||||
tile_overrides={(5, 4): TileKind.WALL},
|
||||
)
|
||||
frame = PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0)
|
||||
first, events = step(state, STEP_DT, (frame,))
|
||||
assert len(events) == 1
|
||||
assert events[0].kind is EventKind.DENIED
|
||||
# The discrete zone has no "through the wall" mode, so the press surfaces as
|
||||
# nothing_here: the one effect, named, at the cook.
|
||||
assert dict(events[0].payload)["reason"] == "nothing_here"
|
||||
assert first.players[0].action_held_ticks == 1
|
||||
@@ -0,0 +1,804 @@
|
||||
"""`sim/interact.py` verb ladder — what a press means, or why it means nothing.
|
||||
|
||||
PLAN §5 gives the cook one button and a priority order; this file pins it
|
||||
table-driven as `(carrying, target) -> (verb, denial)`, covering **every**
|
||||
`Verb` and **every** `DeniedReason` at least once (the coverage tests at
|
||||
the bottom assert that the table and the contested tests together cannot quietly
|
||||
lose a row — `CONTESTED_LOST` is produced by the gate, not by `select_verb`).
|
||||
The order as
|
||||
implemented:
|
||||
|
||||
```text
|
||||
stunned -> STUNNED
|
||||
nothing in the zone -> NOTHING_HERE
|
||||
beyond REACH+FORGIVE -> OUT_OF_REACH
|
||||
behind the cook -> NOT_FACING
|
||||
item in reach: full hand -> HANDS_FULL, no holder -> INVALID_STATE,
|
||||
otherwise PICK_ITEM
|
||||
fixture in reach: state first (dirty -> STATION_DIRTY, working ->
|
||||
HOLD_WORK/STATION_BUSY, blocked -> INVALID_STATE,
|
||||
cooling -> STATION_BUSY, ready -> PICK_FROM_STATION),
|
||||
then kind: serve / wash / trash / shelf / work surface
|
||||
```
|
||||
|
||||
After the matrix: what `resolve_action` implements in *this* phase (pick,
|
||||
set-down, named refusals, and the deliberate no-ops phase 04 will spend), and
|
||||
the contested arbitration `action_gate` runs when two cooks press for one thing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.interact import (
|
||||
DeniedReason,
|
||||
ItemTarget,
|
||||
ServeTarget,
|
||||
StationTarget,
|
||||
Target,
|
||||
Verb,
|
||||
action_gate,
|
||||
claim_rank,
|
||||
claims_entity,
|
||||
resolve_action,
|
||||
select_target,
|
||||
select_verb,
|
||||
target_key,
|
||||
)
|
||||
from undercooked.sim.model import (
|
||||
EventKind,
|
||||
Facing,
|
||||
Item,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
Player,
|
||||
PlayerState,
|
||||
Station,
|
||||
StationKind,
|
||||
StationState,
|
||||
TileKind,
|
||||
Vec2,
|
||||
)
|
||||
from undercooked.sim.step import STEP_DT, PlayerInput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
WIDTH, HEIGHT = 13, 9
|
||||
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
|
||||
|
||||
|
||||
# --- fixtures ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_state(
|
||||
stations: tuple[Station, ...] = (),
|
||||
players: tuple[Player, ...] = (),
|
||||
) -> KitchenState:
|
||||
return KitchenState(
|
||||
level_id="03_verbs",
|
||||
seed=2,
|
||||
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
|
||||
stations=stations,
|
||||
players=players,
|
||||
target_score=40,
|
||||
time_left=90.0,
|
||||
)
|
||||
|
||||
|
||||
def cook(
|
||||
player_id: str = "p0",
|
||||
*,
|
||||
pos: Vec2 = DEFAULT_POS,
|
||||
facing: Facing = Facing.UP,
|
||||
carrying: Item | None = None,
|
||||
stun: float = 0.0,
|
||||
state: PlayerState = PlayerState.IDLE,
|
||||
seat: int = 0,
|
||||
held_ticks: int = 0,
|
||||
pressed_tick: int = 0,
|
||||
) -> Player:
|
||||
return Player(
|
||||
id=player_id,
|
||||
pos=pos,
|
||||
facing=facing,
|
||||
carrying=carrying,
|
||||
state=state,
|
||||
stun=stun,
|
||||
seat=seat,
|
||||
action_held_ticks=held_ticks,
|
||||
action_pressed_tick=pressed_tick,
|
||||
)
|
||||
|
||||
|
||||
def fixture(
|
||||
kind: StationKind = StationKind.STOVE,
|
||||
*,
|
||||
x: int = 5,
|
||||
y: int = 4,
|
||||
state: StationState = StationState.IDLE,
|
||||
contents: tuple[Item, ...] = (),
|
||||
worker_id: str | None = None,
|
||||
dirty: bool = False,
|
||||
cooldown: float = 0.0,
|
||||
) -> Station:
|
||||
return Station(
|
||||
id=f"{kind.value}-{x}-{y}",
|
||||
kind=kind,
|
||||
x=x,
|
||||
y=y,
|
||||
state=state,
|
||||
contents=contents,
|
||||
worker_id=worker_id,
|
||||
dirty=dirty,
|
||||
cooldown=cooldown,
|
||||
)
|
||||
|
||||
|
||||
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
|
||||
return Item(kind=ItemKind.TOMATO, form=form)
|
||||
|
||||
|
||||
def lettuce() -> Item:
|
||||
return Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW)
|
||||
|
||||
|
||||
def plated_dish() -> Item:
|
||||
return Item(kind=ItemKind.PLATE, form=ItemForm.PLATED, dish_id=1)
|
||||
|
||||
|
||||
def dirty_plate() -> Item:
|
||||
return Item(kind=ItemKind.PLATE, form=ItemForm.DIRTY)
|
||||
|
||||
|
||||
# --- the verb ladder, table-driven --------------------------------------------------------
|
||||
#
|
||||
# The cook defaults to cell (5, 5) facing UP; the fixture defaults to the front
|
||||
# cell (5, 4). Rows override either. `expect` is the (Verb, DeniedReason) pair.
|
||||
|
||||
MATRIX: list[dict[str, Any]] = [
|
||||
# the cook's own condition outranks everything
|
||||
{
|
||||
"name": "stun_outranks_a_valid_target",
|
||||
"player": {"stun": 0.3},
|
||||
"station": {"kind": StationKind.SPAWN_SHELF},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.STUNNED),
|
||||
},
|
||||
{
|
||||
"name": "the_stunned_state_flag_alone_refuses",
|
||||
"player": {"state": PlayerState.STUNNED},
|
||||
"station": {"kind": StationKind.SPAWN_SHELF},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.STUNNED),
|
||||
},
|
||||
# nothing to act on
|
||||
{
|
||||
"name": "nothing_at_all_is_a_named_refusal",
|
||||
"target": "none",
|
||||
"expect": (Verb.NONE, DeniedReason.NOTHING_HERE),
|
||||
},
|
||||
{
|
||||
"name": "a_target_beyond_reach_plus_forgive_is_out_of_reach",
|
||||
"player": {"pos": Vec2(8.5, 5.5)},
|
||||
"station": {"kind": StationKind.STOVE},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.OUT_OF_REACH),
|
||||
},
|
||||
{
|
||||
"name": "a_target_behind_the_cook_is_not_facing",
|
||||
"station": {"y": 6},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.NOT_FACING),
|
||||
},
|
||||
{
|
||||
"name": "a_lateral_target_passes_the_facing_test_into_the_ladder",
|
||||
"station": {"x": 4, "y": 5},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||
},
|
||||
# items: one hand per cook, and a holder the state can actually empty
|
||||
{
|
||||
"name": "a_free_hand_picks_an_item",
|
||||
"station": {"state": StationState.READY, "contents": (tomato(),)},
|
||||
"target": "item",
|
||||
"expect": (Verb.PICK_ITEM, None),
|
||||
},
|
||||
{
|
||||
"name": "a_full_hand_refuses_the_item",
|
||||
"player": {"carrying": "lettuce"},
|
||||
"target": "item",
|
||||
"expect": (Verb.NONE, DeniedReason.HANDS_FULL),
|
||||
},
|
||||
{
|
||||
"name": "an_item_with_no_holder_is_invalid_state",
|
||||
"target": "item_loose",
|
||||
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||
},
|
||||
{
|
||||
"name": "an_item_whose_fixture_left_the_state_is_invalid_state",
|
||||
"target": "item_ghost",
|
||||
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||
},
|
||||
# station state outranks station kind
|
||||
{
|
||||
"name": "a_dirty_fixture_refuses_anything",
|
||||
"station": {"dirty": True},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
|
||||
},
|
||||
{
|
||||
"name": "the_dirty_state_refuses_anything",
|
||||
"station": {"state": StationState.DIRTY},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
|
||||
},
|
||||
{
|
||||
"name": "a_dirty_blocked_fixture_reports_the_dirt_first",
|
||||
"station": {"dirty": True, "state": StationState.BLOCKED},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
|
||||
},
|
||||
{
|
||||
"name": "another_cooks_working_fixture_is_busy",
|
||||
"station": {"state": StationState.WORKING, "worker_id": "p9"},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
|
||||
},
|
||||
{
|
||||
"name": "your_own_working_fixture_is_held",
|
||||
"station": {"state": StationState.WORKING, "worker_id": "p0"},
|
||||
"target": "station",
|
||||
"expect": (Verb.HOLD_WORK, None),
|
||||
},
|
||||
{
|
||||
"name": "an_unattributed_working_fixture_is_held_not_busy",
|
||||
"station": {"state": StationState.WORKING},
|
||||
"target": "station",
|
||||
"expect": (Verb.HOLD_WORK, None),
|
||||
},
|
||||
{
|
||||
"name": "a_blocked_fixture_admits_no_verb",
|
||||
"station": {"state": StationState.BLOCKED},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||
},
|
||||
{
|
||||
"name": "a_busy_fixture_is_busy",
|
||||
"station": {"state": StationState.BUSY},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
|
||||
},
|
||||
{
|
||||
"name": "a_cooling_fixture_is_busy",
|
||||
"station": {"cooldown": 0.5},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
|
||||
},
|
||||
{
|
||||
"name": "a_ready_fixture_hands_its_output_to_a_free_hand",
|
||||
"station": {"state": StationState.READY},
|
||||
"target": "station",
|
||||
"expect": (Verb.PICK_FROM_STATION, None),
|
||||
},
|
||||
{
|
||||
"name": "a_ready_fixture_refuses_a_full_hand",
|
||||
"station": {"state": StationState.READY},
|
||||
"player": {"carrying": "lettuce"},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.HANDS_FULL),
|
||||
},
|
||||
{
|
||||
"name": "a_ready_fixture_with_stock_yields_the_item",
|
||||
"station": {"state": StationState.READY, "contents": (tomato(ItemForm.CHOPPED),)},
|
||||
"target": "item",
|
||||
"expect": (Verb.PICK_ITEM, None),
|
||||
},
|
||||
# kind: the serve window
|
||||
{
|
||||
"name": "serving_needs_something_in_the_hand",
|
||||
"station": {"kind": StationKind.SERVE},
|
||||
"target": "serve",
|
||||
"expect": (Verb.NONE, DeniedReason.HANDS_EMPTY),
|
||||
},
|
||||
{
|
||||
"name": "serving_refuses_the_wrong_item",
|
||||
"station": {"kind": StationKind.SERVE},
|
||||
"player": {"carrying": "tomato"},
|
||||
"target": "serve",
|
||||
"expect": (Verb.NONE, DeniedReason.WRONG_ITEM),
|
||||
},
|
||||
{
|
||||
"name": "a_plated_dish_is_served",
|
||||
"station": {"kind": StationKind.SERVE},
|
||||
"player": {"carrying": "plated"},
|
||||
"target": "serve",
|
||||
"expect": (Verb.SERVE, None),
|
||||
},
|
||||
{
|
||||
"name": "a_dirty_serve_window_refuses_a_dish",
|
||||
"station": {"kind": StationKind.SERVE, "dirty": True},
|
||||
"player": {"carrying": "plated"},
|
||||
"target": "serve",
|
||||
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
|
||||
},
|
||||
{
|
||||
"name": "a_blocked_serve_window_refuses_a_dish",
|
||||
"station": {"kind": StationKind.SERVE, "state": StationState.BLOCKED},
|
||||
"player": {"carrying": "plated"},
|
||||
"target": "serve",
|
||||
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||
},
|
||||
# kind: the sink
|
||||
{
|
||||
"name": "washing_needs_something_in_the_hand",
|
||||
"station": {"kind": StationKind.SINK},
|
||||
"target": "serve",
|
||||
"expect": (Verb.NONE, DeniedReason.HANDS_EMPTY),
|
||||
},
|
||||
{
|
||||
"name": "washing_refuses_the_clean",
|
||||
"station": {"kind": StationKind.SINK},
|
||||
"player": {"carrying": "tomato"},
|
||||
"target": "serve",
|
||||
"expect": (Verb.NONE, DeniedReason.WRONG_ITEM),
|
||||
},
|
||||
{
|
||||
"name": "a_dirty_plate_is_washed",
|
||||
"station": {"kind": StationKind.SINK},
|
||||
"player": {"carrying": "dirty_plate"},
|
||||
"target": "serve",
|
||||
"expect": (Verb.WASH, None),
|
||||
},
|
||||
# kind: the trash
|
||||
{
|
||||
"name": "trashing_needs_something_in_the_hand",
|
||||
"station": {"kind": StationKind.TRASH},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.HANDS_EMPTY),
|
||||
},
|
||||
{
|
||||
"name": "anything_carries_to_the_trash",
|
||||
"station": {"kind": StationKind.TRASH},
|
||||
"player": {"carrying": "tomato"},
|
||||
"target": "station",
|
||||
"expect": (Verb.TRASH, None),
|
||||
},
|
||||
# kind: the spawn shelf is stock, and a set-down escape hatch
|
||||
{
|
||||
"name": "a_shelf_hands_stock_to_a_free_hand",
|
||||
"station": {"kind": StationKind.SPAWN_SHELF},
|
||||
"target": "station",
|
||||
"expect": (Verb.PICK_FROM_STATION, None),
|
||||
},
|
||||
{
|
||||
"name": "a_shelf_takes_a_set_down_from_a_full_hand",
|
||||
"station": {"kind": StationKind.SPAWN_SHELF},
|
||||
"player": {"carrying": "lettuce"},
|
||||
"target": "station",
|
||||
"expect": (Verb.RELEASE, None),
|
||||
},
|
||||
# kind: the work surfaces act on what is on them
|
||||
{
|
||||
"name": "a_loaded_surface_starts_work",
|
||||
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
|
||||
"target": "station",
|
||||
"expect": (Verb.START_WORK, None),
|
||||
},
|
||||
{
|
||||
"name": "a_loaded_surface_refuses_a_full_hand",
|
||||
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
|
||||
"player": {"carrying": "lettuce"},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
|
||||
},
|
||||
{
|
||||
"name": "an_empty_surface_and_an_empty_hand_is_invalid_state",
|
||||
"station": {"kind": StationKind.CHOP_BOARD},
|
||||
"target": "station",
|
||||
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||
},
|
||||
{
|
||||
"name": "an_empty_surface_takes_a_placed_item",
|
||||
"station": {"kind": StationKind.CHOP_BOARD},
|
||||
"player": {"carrying": "lettuce"},
|
||||
"target": "station",
|
||||
"expect": (Verb.PLACE_ON_STATION, None),
|
||||
},
|
||||
{
|
||||
"name": "an_empty_prep_counter_plates",
|
||||
"station": {"kind": StationKind.PREP},
|
||||
"player": {"carrying": "lettuce"},
|
||||
"target": "station",
|
||||
"expect": (Verb.PLATE_DISH, None),
|
||||
},
|
||||
]
|
||||
|
||||
CARRYING: dict[str, Item] = {
|
||||
"tomato": tomato(),
|
||||
"lettuce": lettuce(),
|
||||
"plated": plated_dish(),
|
||||
"dirty_plate": dirty_plate(),
|
||||
}
|
||||
|
||||
|
||||
def build(row: dict[str, Any]) -> tuple[KitchenState, Player, Target | None]:
|
||||
"""Assemble one matrix row: the kitchen, the cook, and what they point at."""
|
||||
station = fixture(**row["station"]) if row.get("station") else None
|
||||
state = make_state(stations=(station,) if station is not None else ())
|
||||
player_overrides = dict(row.get("player", {}))
|
||||
if "carrying" in player_overrides:
|
||||
player_overrides["carrying"] = CARRYING[player_overrides["carrying"]]
|
||||
player = cook(**player_overrides)
|
||||
kind = row["target"]
|
||||
item = CARRYING.get("tomato", tomato())
|
||||
if kind == "none":
|
||||
target: Target | None = None
|
||||
elif kind == "station":
|
||||
target = StationTarget(station) # type: ignore[arg-type]
|
||||
elif kind == "serve":
|
||||
target = ServeTarget(station) # type: ignore[arg-type]
|
||||
elif kind == "item":
|
||||
target = ItemTarget(item=item, on_station_id=station.id if station else "nope")
|
||||
elif kind == "item_loose":
|
||||
target = ItemTarget(item=item)
|
||||
elif kind == "item_ghost":
|
||||
target = ItemTarget(item=item, on_station_id="ghost", at=Vec2(5.5, 4.5))
|
||||
else:
|
||||
msg = f"unknown target kind {kind!r} in row {row['name']!r}"
|
||||
raise ValueError(msg)
|
||||
return state, player, target
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row", MATRIX, ids=lambda row: row["name"])
|
||||
def test_verb_ladder(row: dict[str, Any]) -> None:
|
||||
state, player, target = build(row)
|
||||
verb, denial = select_verb(state, player, target)
|
||||
assert (verb, denial) == row["expect"]
|
||||
|
||||
|
||||
def test_the_matrix_covers_every_verb() -> None:
|
||||
covered = {row["expect"][0] for row in MATRIX}
|
||||
assert covered == set(Verb)
|
||||
|
||||
|
||||
def test_the_matrix_covers_every_denied_reason() -> None:
|
||||
# `CONTESTED_LOST` is the one reason the ladder cannot produce: it comes
|
||||
# from the action gate's arbitration of two cooks, so the matrix covers the
|
||||
# other ten and the contested tests below pin this one.
|
||||
covered = {row["expect"][1] for row in MATRIX if row["expect"][1] is not None}
|
||||
assert covered == set(DeniedReason) - {DeniedReason.CONTESTED_LOST}
|
||||
|
||||
|
||||
def test_every_denied_reason_appears_in_a_test() -> None:
|
||||
# The set-coverage the phase demands: the matrix's reasons plus the reason a
|
||||
# real contested press produces together are the whole enum.
|
||||
from_matrix = {row["expect"][1] for row in MATRIX if row["expect"][1] is not None}
|
||||
state, frames = _contested_kitchen()
|
||||
_new_state, events = action_gate(state, frames, STEP_DT)
|
||||
from_gate = {
|
||||
DeniedReason(dict(event.payload)["reason"])
|
||||
for event in events
|
||||
if event.kind is EventKind.DENIED
|
||||
}
|
||||
assert from_matrix | from_gate == set(DeniedReason)
|
||||
|
||||
|
||||
def test_no_row_holds_a_verb_and_a_denial_at_once() -> None:
|
||||
for row in MATRIX:
|
||||
verb, denial = row["expect"]
|
||||
if verb is not Verb.NONE:
|
||||
assert denial is None, row["name"]
|
||||
else:
|
||||
assert denial is not None, row["name"]
|
||||
|
||||
|
||||
# --- resolve_action: what this phase implements ---------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_action_without_a_target_is_one_named_refusal() -> None:
|
||||
state = make_state(players=(cook("p0"),))
|
||||
new_state, events = resolve_action(state, state.players[0])
|
||||
assert new_state is state # a refusal moves nothing
|
||||
assert len(events) == 1
|
||||
assert events[0].kind is EventKind.DENIED
|
||||
assert dict(events[0].payload) == {
|
||||
"player_id": "p0",
|
||||
"reason": "nothing_here",
|
||||
"verb": "none",
|
||||
}
|
||||
|
||||
|
||||
def test_resolve_action_for_a_stunned_press_names_the_reason() -> None:
|
||||
state = make_state(
|
||||
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
|
||||
players=(cook("p0", stun=0.3),),
|
||||
)
|
||||
new_state, events = resolve_action(state, state.players[0])
|
||||
assert new_state is state
|
||||
assert len(events) == 1
|
||||
assert dict(events[0].payload)["reason"] == "stunned"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("held_for", [STEP_DT, 0.4])
|
||||
def test_held_for_above_zero_is_the_documented_no_op(held_for: float) -> None:
|
||||
state = make_state(players=(cook("p0"),))
|
||||
new_state, events = resolve_action(state, state.players[0], held_for=held_for)
|
||||
assert new_state is state
|
||||
assert events == ()
|
||||
|
||||
|
||||
def test_pick_takes_the_item_off_the_fixture_and_into_the_hand() -> None:
|
||||
chopped = tomato(ItemForm.CHOPPED)
|
||||
ready = fixture(state=StationState.READY, contents=(chopped,))
|
||||
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||
before = state.state_hash()
|
||||
new_state, events = resolve_action(state, state.players[0])
|
||||
assert new_state.players[0].carrying is chopped
|
||||
assert new_state.stations[0].contents == ()
|
||||
assert len(events) == 1
|
||||
assert events[0].kind is EventKind.PICKED
|
||||
assert dict(events[0].payload) == {
|
||||
"player_id": "p0",
|
||||
"station": ready.id,
|
||||
"item": "tomato",
|
||||
"form": "chopped",
|
||||
}
|
||||
# purity: the input state and the cook object it held are untouched
|
||||
assert state.state_hash() == before
|
||||
assert state.players[0].carrying is None
|
||||
assert state.stations[0].contents == (chopped,)
|
||||
|
||||
|
||||
def test_pick_removes_only_the_first_of_duplicates() -> None:
|
||||
second = Item(kind=ItemKind.TOMATO, form=ItemForm.RAW, quality=0.5)
|
||||
ready = fixture(state=StationState.READY, contents=(tomato(), second))
|
||||
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||
new_state, _events = resolve_action(state, state.players[0])
|
||||
assert len(new_state.stations[0].contents) == 1
|
||||
assert new_state.stations[0].contents[0].quality == 0.5
|
||||
assert new_state.players[0].carrying is ready.contents[0]
|
||||
|
||||
|
||||
def test_release_sets_the_item_down_on_the_fixture() -> None:
|
||||
shelf = fixture(kind=StationKind.SPAWN_SHELF)
|
||||
held = lettuce()
|
||||
state = make_state(stations=(shelf,), players=(cook("p0", carrying=held),))
|
||||
before = state.state_hash()
|
||||
new_state, events = resolve_action(state, state.players[0])
|
||||
assert new_state.players[0].carrying is None
|
||||
assert new_state.stations[0].contents == (held,)
|
||||
assert len(events) == 1
|
||||
assert events[0].kind is EventKind.DROPPED
|
||||
assert dict(events[0].payload) == {
|
||||
"player_id": "p0",
|
||||
"station": shelf.id,
|
||||
"item": "lettuce",
|
||||
"form": "raw",
|
||||
}
|
||||
assert state.state_hash() == before
|
||||
assert state.players[0].carrying is held
|
||||
assert state.stations[0].contents == ()
|
||||
|
||||
|
||||
def test_a_serve_window_is_not_a_set_down_target() -> None:
|
||||
# A full hand at the window: `select_target` skips the item tier and the
|
||||
# SERVE kind is a `ServeTarget`; the serve ladder then demands a plated dish,
|
||||
# so a raw item is refused — the window is the score path, not a shelf to
|
||||
# drop on (`RELEASE` exists only at a fixture, and the shelf is that hatch).
|
||||
window = fixture(kind=StationKind.SERVE)
|
||||
state = make_state(stations=(window,), players=(cook("p0", carrying=lettuce()),))
|
||||
target = select_target(state, state.players[0])
|
||||
assert isinstance(target, ServeTarget)
|
||||
verb, denial = select_verb(state, state.players[0], target)
|
||||
assert (verb, denial) == (Verb.NONE, DeniedReason.WRONG_ITEM)
|
||||
new_state, events = resolve_action(state, state.players[0])
|
||||
assert new_state is state
|
||||
assert len(events) == 1
|
||||
assert dict(events[0].payload)["reason"] == "wrong_item"
|
||||
|
||||
|
||||
# --- the phase 04 verbs: named today, deliberately unspent -----------------------------
|
||||
#
|
||||
# `PLACE_ON_STATION` and `PLATE_DISH` left this table with task 04: a set-down now
|
||||
# actually sets the item down (the hand-off's marked place, `sim/carry.py` — pinned
|
||||
# by `tests/sim/test_carry.py`). What remains unspent is the work itself.
|
||||
|
||||
NO_OP_ROWS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "start_work",
|
||||
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
|
||||
"target": "station",
|
||||
"expect": Verb.START_WORK,
|
||||
},
|
||||
{
|
||||
"name": "hold_work",
|
||||
"station": {
|
||||
"kind": StationKind.CHOP_BOARD,
|
||||
"state": StationState.WORKING,
|
||||
"contents": (tomato(),),
|
||||
"worker_id": "p0",
|
||||
},
|
||||
"target": "station",
|
||||
"expect": Verb.HOLD_WORK,
|
||||
},
|
||||
{
|
||||
"name": "serve",
|
||||
"station": {"kind": StationKind.SERVE},
|
||||
"player": {"carrying": "plated"},
|
||||
"target": "serve",
|
||||
"expect": Verb.SERVE,
|
||||
},
|
||||
{
|
||||
"name": "wash",
|
||||
"station": {"kind": StationKind.SINK},
|
||||
"player": {"carrying": "dirty_plate"},
|
||||
"target": "station",
|
||||
"expect": Verb.WASH,
|
||||
},
|
||||
{
|
||||
"name": "trash",
|
||||
"station": {"kind": StationKind.TRASH},
|
||||
"player": {"carrying": "tomato"},
|
||||
"target": "station",
|
||||
"expect": Verb.TRASH,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row", NO_OP_ROWS, ids=lambda row: row["name"])
|
||||
def test_the_phase_04_verbs_are_named_but_deliberate_no_ops(row: dict[str, Any]) -> None:
|
||||
# `select_verb` names the verb today (the ladder is pinned by the matrix);
|
||||
# `resolve_action` spends nothing for the work verbs until phase 04's pipeline
|
||||
# exists: no state change, and no `DENIED` — a no-op is not a refusal, the
|
||||
# player did nothing wrong, and the client plays nothing rather than a nudge.
|
||||
# `TRASH` is the same shape at the press: the press starts the hold, and the
|
||||
# action gate spends it at the 8-tick threshold (`tests/sim/test_carry.py`).
|
||||
state, player, target = build(row)
|
||||
verb, denial = select_verb(state, player, target)
|
||||
assert (verb, denial) == (row["expect"], None)
|
||||
before = state.state_hash()
|
||||
new_state, events = resolve_action(state, player)
|
||||
assert new_state is state
|
||||
assert events == ()
|
||||
assert state.state_hash() == before
|
||||
|
||||
|
||||
# --- contested arbitration (the action gate) --------------------------------------------
|
||||
|
||||
|
||||
def _contested_kitchen() -> tuple[KitchenState, dict[str, PlayerInput]]:
|
||||
"""Two cooks, one ready item: seat 0 in the front cell, seat 1 on the diagonal.
|
||||
|
||||
Both face the station's cell (5, 4) — seat 0 through its front tier, seat 1
|
||||
through the diagonal tier — and both have a free hand, so both would pick
|
||||
the same item out of the same fixture were there no arbitration.
|
||||
"""
|
||||
ready = fixture(state=StationState.READY, contents=(tomato(),))
|
||||
state = make_state(
|
||||
stations=(ready,),
|
||||
players=(
|
||||
cook("p0", seat=0), # (5, 5) facing UP: front cell (5, 4)
|
||||
cook("p1", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1), # (5, 4) is its diagonal
|
||||
),
|
||||
)
|
||||
frames = {
|
||||
"p0": PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0),
|
||||
"p1": PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=0),
|
||||
}
|
||||
return state, frames
|
||||
|
||||
|
||||
def test_the_earliest_received_frame_wins_a_contested_item() -> None:
|
||||
state, frames = _contested_kitchen()
|
||||
frames["p1"] = PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=1)
|
||||
new_state, events = action_gate(state, frames, STEP_DT)
|
||||
picked = [event for event in events if event.kind is EventKind.PICKED]
|
||||
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||
assert len(picked) == 1 and len(denied) == 1 # one effect per cook, never per tick
|
||||
assert dict(picked[0].payload)["player_id"] == "p0"
|
||||
assert dict(denied[0].payload) == {
|
||||
"player_id": "p1",
|
||||
"reason": "contested_lost",
|
||||
"verb": "none",
|
||||
}
|
||||
assert new_state.players[0].carrying is not None
|
||||
assert new_state.players[1].carrying is None
|
||||
assert new_state.stations[0].contents == ()
|
||||
|
||||
|
||||
def test_a_full_tie_is_broken_by_the_seat_index() -> None:
|
||||
state, frames = _contested_kitchen() # both tick 0, seq 0: the seat decides
|
||||
new_state, events = action_gate(state, frames, STEP_DT)
|
||||
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||
assert len(denied) == 1
|
||||
assert dict(denied[0].payload)["player_id"] == "p1"
|
||||
assert dict(denied[0].payload)["reason"] == "contested_lost"
|
||||
assert new_state.players[0].carrying is not None
|
||||
assert new_state.players[1].carrying is None
|
||||
|
||||
|
||||
def test_the_lower_seq_wins_a_same_tick_contest() -> None:
|
||||
state, frames = _contested_kitchen()
|
||||
frames["p0"] = PlayerInput(player_id="p0", seq=5, action=True, received_at_tick=0)
|
||||
frames["p1"] = PlayerInput(player_id="p1", seq=2, action=True, received_at_tick=0)
|
||||
new_state, _events = action_gate(state, frames, STEP_DT)
|
||||
assert new_state.players[1].carrying is not None # seq 2 < seq 5, same tick
|
||||
assert new_state.players[0].carrying is None
|
||||
|
||||
|
||||
def test_a_stunned_cook_keeps_their_own_reason_and_is_not_a_contest_loser() -> None:
|
||||
state, frames = _contested_kitchen()
|
||||
state = state.with_players(
|
||||
tuple(
|
||||
replace(player, stun=0.3) if player.id == "p1" else player
|
||||
for player in state.players
|
||||
)
|
||||
)
|
||||
_new_state, events = action_gate(state, frames, STEP_DT)
|
||||
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||
# Blaming the other cook for a refusal the stun caused would be a second lie;
|
||||
# the stunned cook gets the stunned reason and never enters the claim scan.
|
||||
assert len(denied) == 1
|
||||
assert dict(denied[0].payload) == {"player_id": "p1", "reason": "stunned", "verb": "none"}
|
||||
|
||||
|
||||
def test_two_cooks_at_a_shelf_with_no_stock_do_not_contend() -> None:
|
||||
# A shelf pick with nothing in `contents` is stock from the level's legend
|
||||
# (phase 04 replenishes it), so two simultaneous taps are both legal and
|
||||
# neither is in the other's way — no `CONTESTED_LOST`, no lie about a fight
|
||||
# there was no fight in.
|
||||
shelf = fixture(kind=StationKind.SPAWN_SHELF)
|
||||
state = make_state(
|
||||
stations=(shelf,),
|
||||
players=(
|
||||
cook("p0", seat=0),
|
||||
cook("p1", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1),
|
||||
),
|
||||
)
|
||||
frames = {
|
||||
"p0": PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0),
|
||||
"p1": PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=0),
|
||||
}
|
||||
new_state, events = action_gate(state, frames, STEP_DT)
|
||||
assert events == () # both are phase 04 no-ops, and neither was refused
|
||||
assert new_state.players[0].action_held_ticks == 1
|
||||
assert new_state.players[1].action_held_ticks == 1
|
||||
|
||||
|
||||
# --- the claim helpers ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_claims_entity_distinguishes_a_stock_pick_from_an_empty_shelf() -> None:
|
||||
shelf = fixture(kind=StationKind.SPAWN_SHELF)
|
||||
stocked = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(),))
|
||||
assert claims_entity(Verb.PICK_FROM_STATION, StationTarget(shelf)) is False
|
||||
assert claims_entity(Verb.PICK_FROM_STATION, StationTarget(stocked)) is True
|
||||
assert claims_entity(Verb.PICK_ITEM, ItemTarget(item=tomato(), on_station_id=shelf.id)) is True
|
||||
assert claims_entity(Verb.START_WORK, StationTarget(stocked)) is True
|
||||
assert claims_entity(Verb.RELEASE, StationTarget(shelf)) is False
|
||||
assert claims_entity(Verb.SERVE, ServeTarget(shelf)) is False
|
||||
|
||||
|
||||
def test_target_key_claims_shelf_stock_as_a_pile_and_floor_items_by_position() -> None:
|
||||
first = ItemTarget(item=tomato(), on_station_id="shelf-1")
|
||||
second = ItemTarget(item=tomato(), on_station_id="shelf-1")
|
||||
assert target_key(first) == target_key(second)
|
||||
assert target_key(first) == ("item", "shelf-1")
|
||||
floor = ItemTarget(item=tomato(), at=Vec2(5.5, 4.5))
|
||||
assert target_key(floor) == ("floor", (5.5 * 64, 4.5 * 64)) # Vec2.KEY, 1/64 tile
|
||||
|
||||
|
||||
def test_claim_rank_orders_by_arrival_then_seq_then_seat() -> None:
|
||||
p0 = cook("p0", seat=0)
|
||||
p1 = cook("p1", seat=1)
|
||||
high_seq = PlayerInput(player_id="p0", seq=9, received_at_tick=0)
|
||||
low_seq = PlayerInput(player_id="p1", seq=1, received_at_tick=0)
|
||||
later_tick = PlayerInput(player_id="p1", seq=0, received_at_tick=1)
|
||||
assert claim_rank(p1, low_seq) < claim_rank(p0, high_seq) # seq before seat
|
||||
assert claim_rank(p0, high_seq) < claim_rank(p1, later_tick) # arrival before seq
|
||||
assert claim_rank(p0, low_seq) < claim_rank(p1, low_seq) # the seat breaks a full tie
|
||||
Reference in New Issue
Block a user