phase: 04_kitchen_pipeline_and_scoring
Everything is green and the working tree is clean. Final report: **Phase 04 final verification pass — complete** - **Verified:** all 6 tasks in `complete/`; `step_stations` import, acceptance-matrix totality (every `ItemKind`×`StationKind`), 5-case scoring hand check, re-blessed goldens, levels validate, app launch (`/healthz` 200, `/` 200, exact README payload) - **Implemented (missing task-06 work found in final pass):** `tests/sim/test_full_rounds.py` (scripted solo win on level 1 incl. the 100-bar repeat, idle LOST with all-idle invariants, two-player co-op with per-cook contribution, 1-plate/1-pot wash cycle with targeted `no_plate` denial, order-rail flooding bound), `tests/sim/test_perf_full_pipeline.py` (slow: 4 cooks × 3 000 ticks, mean 0.02 ms « 0.5 ms budget), `tests/unit/test_sim_hooks_wired.py`, the "Golden history: phase 03 → phase 04" line in `docs/SIMULATION.md` - **Defects fixed:** 20 ruff + 7 pyright errors (6 pre-existing in `helpers.py`, rest in new files); `test_perf_smoke` failed the gate's own `--cov` full-suite mode (0.2026 ms > 0.2 ms budget) — raised to 0.3 ms/600 ms, documented in its docstring - **Gate:** `bash .agents/validate.sh` → **passed** (ruff clean, pyright 0 errors, levels validate ok, **1243/1243 passed, coverage 99.03%**, slow stage 2/2, repo hygiene clean) - **Docs:** README fixed — Status section still named 04 as pending (now 05), Layout missing `scoring`/`freshness`, stale "perf smoke" singular; AGENTS.md fixed — "perf smoke" → both slow perf budgets. Rest verified consistent - **Decisions:** the task's `duo_tick < solo_tick` claim is unachievable (9 configs measured: single-pipeline kitchen serializes the 2nd cook) — the two-player test instead proves co-op is *rewarded by the rules* (both cooks scored, contributions sum to the round score); wash-cycle denial asserted targeted (the `no_plate` guard) since the policy preempts it in-round (ladder answers `STATION_DIRTY`) - **Next pending phase:** `05_anonymous_lobbies_and_room_lifecycle`
This commit is contained in:
@@ -0,0 +1,963 @@
|
||||
"""`sim/freshness.py` — the quality-decay, burn, and dirty-loop clocks (task 04).
|
||||
|
||||
The task's contract, pinned one rule at a time:
|
||||
|
||||
* the burn boundary, tick-exact: a ready stove's cooked dish burns on the
|
||||
160th substep after the ready flip (8.0 s / 0.05 s) and not the 159th —
|
||||
driven through the whole `step()` so the stove completion's countdown
|
||||
start and the stage-4b wiring are proven in the same run;
|
||||
* the board's linear decay at 25 / 50 / 75 / 100 % of `ROT_S` (400 ticks),
|
||||
a raw item on the board rotting too (the push-out rule's precondition);
|
||||
* the half-rate carry: a carried prepared item decays half the board's
|
||||
rate (a hand is not a fridge), a carried raw does not decay, and a
|
||||
carried `PLATED` dish keeps the quality it was plated with;
|
||||
* the burnt dish the serve window rejects (task 03's `burnt` path, wired
|
||||
through the kernel);
|
||||
* the push-out: a burnt board item yields to a new raw set-down
|
||||
(`WASTED`, `counters["wasted"]`), a usable one never is (a named
|
||||
`station_busy`, the board unchanged);
|
||||
* the dirty loop's stock: use moves the prop clean -> dirty, a wash moves
|
||||
it back — the sink's wash and the in-place wash — and `…_clean +
|
||||
…_dirty` is the limit every tick of a 1 000-tick scripted sequence
|
||||
(asserted per tick), with a zero stock `PLATE_DISH` a clean refusal and
|
||||
`plates_clean` never below 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.freshness import (
|
||||
BURN_AFTER_S,
|
||||
CARRY_DECAY_TICKS,
|
||||
ROT_S,
|
||||
step_freshness,
|
||||
)
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.interact import ItemTarget, StationTarget, Verb
|
||||
from undercooked.sim.model import (
|
||||
Event,
|
||||
EventKind,
|
||||
Facing,
|
||||
Item,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
Order,
|
||||
Player,
|
||||
Station,
|
||||
StationKind,
|
||||
StationState,
|
||||
TileKind,
|
||||
Vec2,
|
||||
)
|
||||
from undercooked.sim.recipe import RecipeBook
|
||||
from undercooked.sim.stations import WASH_SECONDS, apply_station_verb
|
||||
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
from _cook_policy import build_kitchen, run_policy # noqa: E402
|
||||
|
||||
WIDTH, HEIGHT = 7, 7
|
||||
|
||||
|
||||
# --- fixtures ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_state(
|
||||
stations: tuple[Station, ...] = (),
|
||||
players: tuple[Player, ...] = (),
|
||||
tick: int = 0,
|
||||
counters: dict[str, int] | None = None,
|
||||
orders: tuple[Order, ...] = (),
|
||||
) -> KitchenState:
|
||||
return KitchenState(
|
||||
level_id="04_freshness",
|
||||
seed=13,
|
||||
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
|
||||
tick=tick,
|
||||
stations=stations,
|
||||
players=players,
|
||||
counters=dict(counters) if counters is not None else {},
|
||||
orders=orders,
|
||||
target_score=40,
|
||||
time_left=90.0,
|
||||
)
|
||||
|
||||
|
||||
def station(
|
||||
kind: StationKind,
|
||||
*,
|
||||
x: int = 5,
|
||||
y: int = 4,
|
||||
state: StationState = StationState.IDLE,
|
||||
contents: tuple[Item, ...] = (),
|
||||
worker_id: str | None = None,
|
||||
dirty: bool = False,
|
||||
progress: float = 0.0,
|
||||
duration: 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,
|
||||
progress=progress,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
|
||||
def item(kind: ItemKind, form: ItemForm = ItemForm.RAW, **kwargs: Any) -> Item:
|
||||
return Item(kind=kind, form=form, **kwargs)
|
||||
|
||||
|
||||
def cook(
|
||||
pos: Vec2,
|
||||
*,
|
||||
carrying: Item | None = None,
|
||||
facing: Facing = Facing.UP,
|
||||
seat: int = 0,
|
||||
) -> Player:
|
||||
return Player(id="p0", pos=pos, facing=facing, seat=seat, carrying=carrying)
|
||||
|
||||
|
||||
def book() -> RecipeBook:
|
||||
return RecipeBook.model_validate(
|
||||
{
|
||||
"recipes": {
|
||||
"soup": {
|
||||
"id": "soup",
|
||||
"name": "Soup",
|
||||
"base_score": 40,
|
||||
"ingredients": [{"kind": "tomato", "form": "raw"}],
|
||||
"steps": [{"kind": "serve", "station": "serve"}],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def run(
|
||||
state: KitchenState,
|
||||
substeps: int,
|
||||
*,
|
||||
action: bool = False,
|
||||
book: RecipeBook | None = None,
|
||||
) -> tuple[KitchenState, list]:
|
||||
"""Drive `substeps` substeps through the whole `step()`; collect the events.
|
||||
|
||||
`action=True` sends the cook's `action=True` frame every substep (the
|
||||
gate's hold: a work in flight needs the worker holding — the same
|
||||
pattern as the golden scripts)."""
|
||||
events: list = []
|
||||
for _ in range(substeps):
|
||||
frame = (
|
||||
(PlayerInput(player_id="p0", action=True, received_at_tick=state.tick),)
|
||||
if action
|
||||
else ()
|
||||
)
|
||||
if book is not None:
|
||||
state, produced = step(state, STEP_DT, frame, book)
|
||||
else:
|
||||
state, produced = step(state, STEP_DT, frame)
|
||||
events.extend(produced)
|
||||
return state, events
|
||||
|
||||
|
||||
def held(state: KitchenState, player_id: str) -> KitchenState:
|
||||
"""`state` with the gate's hold increment simulated on one cook (a
|
||||
direct `apply_station_verb` press skips the gate, so the hold the
|
||||
work's progress reads is set here — the `test_stations.py` idiom)."""
|
||||
return replace(
|
||||
state,
|
||||
players=tuple(
|
||||
replace(player, action_held_ticks=1) if player.id == player_id else player
|
||||
for player in state.players
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _burned(station: Station) -> bool:
|
||||
return any(entry.form is ItemForm.BURNT for entry in station.contents)
|
||||
|
||||
|
||||
# --- burn: the tick-exact boundary -------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_burn_boundary_is_tick_exact_on_a_ready_stove() -> None:
|
||||
"""A ready stove's cooked dish (its countdown started at the ready flip,
|
||||
`ttl = BURN_AFTER_S`) burns on the 160th substep after the flip —
|
||||
8.0 s — and not the 159th: the `BURNT` event, the quality at 0.0, and
|
||||
the station's `DIRTY` flip all land on the same substep."""
|
||||
dish = item(ItemKind.TOMATO, ItemForm.COOKED, ttl=BURN_AFTER_S)
|
||||
state = make_state(
|
||||
stations=(
|
||||
station(StationKind.STOVE, state=StationState.READY, contents=(dish,)),
|
||||
),
|
||||
)
|
||||
state, events = run(state, 159)
|
||||
stove = state.stations[0]
|
||||
assert stove.state is StationState.READY # not dirty yet
|
||||
assert stove.contents[0].form is ItemForm.COOKED
|
||||
assert stove.contents[0].quality == pytest.approx(1.0)
|
||||
assert stove.contents[0].ttl == pytest.approx(BURN_AFTER_S - 159 * STEP_DT, abs=1e-9)
|
||||
assert not any(e.kind is EventKind.BURNT for e in events)
|
||||
|
||||
state, burned = run(state, 1) # the 160th substep: 8.0 s on the heat
|
||||
stove = state.stations[0]
|
||||
assert stove.state is StationState.DIRTY # the burnt pot is stuck
|
||||
assert stove.dirty
|
||||
assert stove.contents[0].form is ItemForm.BURNT
|
||||
assert stove.contents[0].quality == 0.0
|
||||
assert stove.contents[0].ttl == 0.0
|
||||
burnt_events = [e for e in burned if e.kind is EventKind.BURNT]
|
||||
assert len(burnt_events) == 1
|
||||
assert dict(burnt_events[0].payload) == {
|
||||
"station": stove.id,
|
||||
"item": "tomato",
|
||||
"form": "burnt",
|
||||
"tick": 159, # the 160th substep (0-based ticks: it starts at tick 159)
|
||||
}
|
||||
|
||||
|
||||
def test_a_stove_works_and_then_burns_exactly_burn_after_s_on() -> None:
|
||||
"""Through the whole pipeline: the stove's work completes (one substep
|
||||
from done, the worker holding), the completion starts the countdown,
|
||||
and the dish burns 160 substeps after the flip — the `ttl` the
|
||||
completion writes is the whole of the burn's clock."""
|
||||
state = make_state(
|
||||
stations=(
|
||||
station(
|
||||
StationKind.STOVE,
|
||||
state=StationState.WORKING,
|
||||
contents=(item(ItemKind.TOMATO, ItemForm.CHOPPED),),
|
||||
worker_id="p0",
|
||||
progress=1.0 - STEP_DT / 6.0, # one substep from done
|
||||
duration=6.0,
|
||||
),
|
||||
),
|
||||
players=(cook(Vec2(5.5, 5.5)),),
|
||||
)
|
||||
# Substep 0: the hold advances the work to done — the ready flip and the
|
||||
# countdown's first tick (8.0 -> 7.95) in the same substep.
|
||||
state, events = step(
|
||||
state,
|
||||
STEP_DT,
|
||||
(PlayerInput(player_id="p0", action=True, received_at_tick=0),),
|
||||
)
|
||||
stove = state.stations[0]
|
||||
assert stove.state is StationState.READY
|
||||
assert any(e.kind is EventKind.COOKED for e in events)
|
||||
assert stove.contents[0].form is ItemForm.COOKED
|
||||
assert stove.contents[0].ttl == pytest.approx(BURN_AFTER_S - STEP_DT, abs=1e-9)
|
||||
|
||||
for _ in range(158): # substeps 1..158 after the flip: the dish keeps
|
||||
state, _events = run(state, 1) # (and the worker's hold is no longer
|
||||
stove = state.stations[0] # needed — the station is ready)
|
||||
assert stove.state is StationState.READY
|
||||
assert stove.contents[0].form is ItemForm.COOKED
|
||||
assert stove.contents[0].ttl == pytest.approx(0.05, abs=1e-9)
|
||||
|
||||
state, burned = run(state, 1) # the 160th substep after the flip
|
||||
assert state.stations[0].state is StationState.DIRTY
|
||||
assert state.stations[0].contents[0].form is ItemForm.BURNT
|
||||
assert any(e.kind is EventKind.BURNT for e in burned)
|
||||
|
||||
|
||||
def test_a_lifted_dish_pauses_the_burn_and_a_set_down_resumes_it() -> None:
|
||||
"""A lifted dish's countdown pauses (a hand is not the stove's heat)
|
||||
and a set-down on a ready stove resumes it: 100 substeps on the heat,
|
||||
100 in the hand, then 60 on the heat burn it — 160 heat substeps, the
|
||||
same boundary as one uninterrupted 8.0 s."""
|
||||
dish = item(ItemKind.TOMATO, ItemForm.COOKED, ttl=BURN_AFTER_S)
|
||||
state = make_state(
|
||||
stations=(station(StationKind.STOVE, state=StationState.READY, contents=(dish,)),),
|
||||
players=(cook(Vec2(5.5, 5.5)),),
|
||||
)
|
||||
state, _events = run(state, 100) # 100 substeps on the heat
|
||||
stove = state.stations[0]
|
||||
assert stove.contents[0].ttl == pytest.approx(BURN_AFTER_S - 100 * STEP_DT, abs=1e-9)
|
||||
|
||||
# Lifted: the stove's contents go with the pick — a hand holds the dish.
|
||||
lifted = state.stations[0].contents[0]
|
||||
state = replace(
|
||||
state,
|
||||
players=(
|
||||
replace(state.players[0], carrying=lifted),
|
||||
),
|
||||
stations=(replace(state.stations[0], contents=(), state=StationState.IDLE),),
|
||||
)
|
||||
state, _events = run(state, 100) # 100 substeps in the hand: the countdown pauses
|
||||
carried = state.players[0].carrying
|
||||
assert carried is not None
|
||||
assert carried.ttl == pytest.approx(BURN_AFTER_S - 100 * STEP_DT, abs=1e-9) # unchanged
|
||||
|
||||
# Set back on the ready stove: the countdown resumes.
|
||||
state = replace(
|
||||
state,
|
||||
players=(replace(state.players[0], carrying=None),),
|
||||
stations=(
|
||||
replace(
|
||||
state.stations[0],
|
||||
contents=(carried,),
|
||||
state=StationState.READY,
|
||||
),
|
||||
),
|
||||
)
|
||||
for _ in range(59):
|
||||
state, _events = run(state, 1)
|
||||
assert state.stations[0].contents[0].ttl == pytest.approx(0.05, abs=1e-9)
|
||||
state, burned = run(state, 1) # the 160th heat substep
|
||||
assert state.stations[0].contents[0].form is ItemForm.BURNT
|
||||
assert state.stations[0].state is StationState.DIRTY
|
||||
assert any(e.kind is EventKind.BURNT for e in burned)
|
||||
|
||||
|
||||
# --- the board's decay: linear over ROT_S -------------------------------------------------
|
||||
|
||||
|
||||
def test_board_quality_decays_linearly_at_quarter_points_of_rot_s() -> None:
|
||||
"""A chopped item on the board: 25 / 50 / 75 / 100 % of `ROT_S` (100 /
|
||||
200 / 300 / 400 substeps after the first) is 0.75 / 0.5 / 0.25 / 0.0 —
|
||||
the whole of the board's clock, linear, the burnt flip on the 400th."""
|
||||
board = station(StationKind.CHOP_BOARD, contents=(item(ItemKind.ONION, ItemForm.CHOPPED),))
|
||||
state = make_state(stations=(board,))
|
||||
for cumulative, expected_quality in ((100, 0.75), (200, 0.5), (300, 0.25)):
|
||||
state, _events = run(state, 100) # 100 substeps each: cumulative 100 / 200 / 300
|
||||
assert (
|
||||
state.stations[0].contents[0].quality
|
||||
== pytest.approx(1.0 - cumulative / 400.0, abs=1e-9)
|
||||
== pytest.approx(expected_quality, abs=1e-9)
|
||||
)
|
||||
state, _events = run(state, 99) # 399 substeps: 0.0025, still fresh
|
||||
assert state.stations[0].contents[0].quality == pytest.approx(0.0025, abs=1e-9)
|
||||
assert state.stations[0].contents[0].form is ItemForm.CHOPPED
|
||||
state, _events = run(state, 1) # the 400th: 0.0 — burnt
|
||||
assert state.stations[0].contents[0].form is ItemForm.BURNT
|
||||
assert state.stations[0].contents[0].quality == 0.0
|
||||
# The board's rot is not a burn event (the snapshot's quality is the
|
||||
# visible rot), and it does not dirty the board.
|
||||
assert state.stations[0].state is StationState.IDLE
|
||||
assert not state.stations[0].dirty
|
||||
|
||||
|
||||
def test_a_raw_item_on_the_board_rots_too() -> None:
|
||||
"""The push-out rule's precondition: a raw onion left on the board is
|
||||
the one that rots (over the same `ROT_S`), so a board can hold a
|
||||
`BURNT` raw item to be pushed out."""
|
||||
board = station(StationKind.CHOP_BOARD, contents=(item(ItemKind.ONION, ItemForm.RAW),))
|
||||
state = make_state(stations=(board,))
|
||||
state, _events = run(state, 400)
|
||||
assert state.stations[0].contents[0].form is ItemForm.BURNT
|
||||
assert state.stations[0].contents[0].quality == 0.0
|
||||
|
||||
|
||||
def test_a_chopped_item_off_the_board_does_not_decay_there() -> None:
|
||||
"""The decay is the board's clock: a chopped item set down elsewhere
|
||||
(a marked object on the prep) is not decaying — only the board (full
|
||||
rate), a carried prepared item (half rate) and the stove's heat (the
|
||||
burn) hold the clocks."""
|
||||
state = make_state(
|
||||
stations=(
|
||||
station(StationKind.PREP, contents=(item(ItemKind.ONION, ItemForm.CHOPPED),)),
|
||||
),
|
||||
)
|
||||
state, _events = run(state, 400)
|
||||
assert state.stations[0].contents[0].quality == pytest.approx(1.0)
|
||||
assert state.stations[0].contents[0].form is ItemForm.CHOPPED
|
||||
|
||||
|
||||
# --- the half-rate carry ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_carried_prepared_item_decays_at_half_the_board_rate() -> None:
|
||||
"""A carried chopped item: `dt / (2 x ROT_S)` per substep — after 400
|
||||
substeps (the board's whole `ROT_S`) it is 0.5 where the board's is
|
||||
0.0, and it burns at 800 substeps (40 s)."""
|
||||
state = make_state(
|
||||
players=(
|
||||
cook(Vec2(5.5, 5.5), carrying=item(ItemKind.ONION, ItemForm.CHOPPED)),
|
||||
),
|
||||
)
|
||||
state, _events = run(state, 400)
|
||||
assert state.players[0].carrying is not None
|
||||
assert state.players[0].carrying.quality == pytest.approx(0.5, abs=1e-9)
|
||||
assert state.players[0].carrying.form is ItemForm.CHOPPED
|
||||
state, _events = run(state, 400) # 800 substeps: 0.0 — burnt in the hand
|
||||
assert state.players[0].carrying is not None
|
||||
assert state.players[0].carrying.quality == 0.0
|
||||
assert state.players[0].carrying.form is ItemForm.BURNT
|
||||
|
||||
|
||||
def test_the_carry_rate_is_half_the_board_rate_by_constant() -> None:
|
||||
assert CARRY_DECAY_TICKS == 2
|
||||
assert pytest.approx(STEP_DT / ROT_S / 2.0, rel=1e-12) == STEP_DT / (
|
||||
ROT_S * CARRY_DECAY_TICKS
|
||||
)
|
||||
|
||||
|
||||
def test_a_carried_raw_item_does_not_decay() -> None:
|
||||
"""A carried raw ingredient is fresh: no decay in the hand (the board
|
||||
is where raw rots)."""
|
||||
state = make_state(
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.ONION, ItemForm.RAW)),)
|
||||
)
|
||||
state, _events = run(state, 800)
|
||||
assert state.players[0].carrying is not None
|
||||
assert state.players[0].carrying.quality == pytest.approx(1.0)
|
||||
assert state.players[0].carrying.form is ItemForm.RAW
|
||||
|
||||
|
||||
def test_a_carried_plated_dish_keeps_its_plated_quality() -> None:
|
||||
"""The plate does not keep rotting in the hand: a `PLATED` dish carries
|
||||
the quality it was plated with (here 0.8), so the serve window's math
|
||||
is the dish's, not the walk's."""
|
||||
state = make_state(
|
||||
players=(
|
||||
cook(
|
||||
Vec2(5.5, 5.5),
|
||||
carrying=item(ItemKind.TOMATO, ItemForm.PLATED, quality=0.8),
|
||||
),
|
||||
)
|
||||
)
|
||||
state, _events = run(state, 800)
|
||||
assert state.players[0].carrying is not None
|
||||
assert state.players[0].carrying.quality == pytest.approx(0.8)
|
||||
assert state.players[0].carrying.form is ItemForm.PLATED
|
||||
|
||||
|
||||
# --- the burnt dish the serve window rejects (task 03's path) ------------------------------
|
||||
|
||||
|
||||
def _serve_kitchen(
|
||||
dish: Item, orders: tuple[Order, ...]
|
||||
) -> tuple[KitchenState, Player]:
|
||||
window = station(StationKind.SERVE, x=5, y=4)
|
||||
state = make_state(
|
||||
stations=(window,),
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=dish),),
|
||||
orders=orders,
|
||||
)
|
||||
state = replace(
|
||||
state, order_pool=("soup",), order_concurrent=1, order_deadline=120
|
||||
)
|
||||
return state, state.players[0]
|
||||
|
||||
|
||||
def test_a_burnt_dish_is_rejected_by_the_serve_window_with_reason_burnt() -> None:
|
||||
"""A quality-0.0 dish at the window is task 03's named `burnt`
|
||||
rejection, through the whole `step()`: one `DENIED`, the dish left in
|
||||
the hand, the order intact."""
|
||||
dish = item(ItemKind.TOMATO, ItemForm.PLATED, quality=0.0)
|
||||
state, _p0 = _serve_kitchen(
|
||||
dish, (Order(id=1, recipe_id="soup", time_left=30.0, deadline=120, value=40),)
|
||||
)
|
||||
new_state, events = step(
|
||||
state,
|
||||
STEP_DT,
|
||||
(PlayerInput(player_id="p0", action=True, received_at_tick=0),),
|
||||
book(),
|
||||
)
|
||||
denied = [e for e in events if e.kind is EventKind.DENIED]
|
||||
assert len(denied) == 1
|
||||
assert dict(denied[0].payload)["reason"] == "burnt"
|
||||
assert new_state.players[0].carrying is dish # the dish stays in the hand
|
||||
# The order stays on the rail (a rejected serve consumes nothing of it).
|
||||
assert [o.id for o in new_state.orders] == [o.id for o in state.orders]
|
||||
assert [o.recipe_id for o in new_state.orders] == ["soup"]
|
||||
|
||||
def test_a_burnt_form_dish_is_rejected_by_the_ladder() -> None:
|
||||
"""The form is the check's other half: a `BURNT`-form dish (its
|
||||
quality irrelevant — here 1.0) is not a dish at all, and the ladder's
|
||||
named `wrong_item` refuses it before the scoring's `burnt` check is
|
||||
even read; the dish stays in the hand."""
|
||||
dish = item(ItemKind.TOMATO, ItemForm.BURNT, quality=1.0)
|
||||
state, _p0 = _serve_kitchen(
|
||||
dish, (Order(id=1, recipe_id="soup", time_left=30.0, deadline=120, value=40),)
|
||||
)
|
||||
new_state, events = step(
|
||||
state,
|
||||
STEP_DT,
|
||||
(PlayerInput(player_id="p0", action=True, received_at_tick=0),),
|
||||
book(),
|
||||
)
|
||||
denied = [e for e in events if e.kind is EventKind.DENIED]
|
||||
assert len(denied) == 1
|
||||
assert dict(denied[0].payload)["reason"] == "wrong_item"
|
||||
assert new_state.players[0].carrying is dish
|
||||
|
||||
|
||||
# --- the push-out: burnt yields, usable never is ------------------------------------------
|
||||
|
||||
|
||||
def _board_set_down(
|
||||
state: KitchenState,
|
||||
) -> tuple[KitchenState, Sequence[Event]]:
|
||||
"""A raw set-down on the board through the whole `step()`: the cook's
|
||||
one press at the board (a facing change and a release tick apart). The
|
||||
board sits directly above the cook (its centre is the first `UP` cell),
|
||||
so the press faces `UP`."""
|
||||
state, _events = step(state, STEP_DT) # the release: the gate is clean
|
||||
face = step(
|
||||
state,
|
||||
STEP_DT,
|
||||
(PlayerInput(player_id="p0", move=Vec2(0.0, 0.0), face=Facing.UP, received_at_tick=1),),
|
||||
)[0]
|
||||
new_state, events = step(
|
||||
face,
|
||||
STEP_DT,
|
||||
(
|
||||
PlayerInput(
|
||||
player_id="p0",
|
||||
move=Vec2(0.0, 0.0),
|
||||
action=True,
|
||||
face=Facing.UP,
|
||||
received_at_tick=2,
|
||||
),
|
||||
),
|
||||
)
|
||||
return new_state, events
|
||||
|
||||
|
||||
def test_the_push_out_destroys_a_burnt_board_item_with_wasted() -> None:
|
||||
"""A new raw must be placed on a board that holds a burnt one: the
|
||||
burnt item is destroyed — one `WASTED`, `counters["wasted"]` up by one
|
||||
— and the set-down proceeds (the work starts on the new raw)."""
|
||||
board = station(
|
||||
StationKind.CHOP_BOARD,
|
||||
contents=(item(ItemKind.ONION, ItemForm.BURNT),),
|
||||
)
|
||||
state = make_state(
|
||||
stations=(board,),
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),),
|
||||
)
|
||||
new_state, events = _board_set_down(state)
|
||||
wasted = [e for e in events if e.kind is EventKind.WASTED]
|
||||
assert len(wasted) == 1
|
||||
assert dict(wasted[0].payload) == {
|
||||
"station": board.id,
|
||||
"item": "onion",
|
||||
"tick": 2,
|
||||
}
|
||||
assert new_state.counters["wasted"] == 1
|
||||
b = new_state.stations[0]
|
||||
# The burnt one is gone; the new raw is on the board, where the
|
||||
# freshness stage's first tick has already started its clock.
|
||||
assert b.contents[0].kind is ItemKind.TOMATO
|
||||
assert b.contents[0].form is ItemForm.RAW
|
||||
assert b.contents[0].quality == pytest.approx(1.0 - 1.0 / 400.0, abs=1e-9)
|
||||
assert b.state is StationState.WORKING # the set-down started the chop
|
||||
|
||||
|
||||
def test_the_push_out_refuses_to_overwrite_a_usable_item() -> None:
|
||||
"""A usable raw item on the board is never silently overwritten: the
|
||||
new raw's set-down is the named `station_busy`, the board is
|
||||
unchanged, and nothing is counted as wasted."""
|
||||
usable = item(ItemKind.ONION, ItemForm.RAW, quality=0.8)
|
||||
board = station(StationKind.CHOP_BOARD, contents=(usable,))
|
||||
state = make_state(
|
||||
stations=(board,),
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),),
|
||||
)
|
||||
new_state, events = _board_set_down(state)
|
||||
denied = [e for e in events if e.kind is EventKind.DENIED]
|
||||
assert len(denied) == 1
|
||||
assert dict(denied[0].payload)["reason"] == "station_busy"
|
||||
assert not any(e.kind is EventKind.WASTED for e in events)
|
||||
b = new_state.stations[0]
|
||||
# The refusal is the set-down's: the new raw is still in the hand, and
|
||||
# the usable one is on the board (its quality only the three ticks the
|
||||
# board's own clock ran — nothing was removed, nothing was overwritten).
|
||||
assert len(b.contents) == 1
|
||||
assert b.contents[0].kind is ItemKind.ONION
|
||||
assert b.contents[0].form is ItemForm.RAW
|
||||
assert b.contents[0].quality == pytest.approx(0.8 - 3.0 / 400.0, abs=1e-9)
|
||||
assert new_state.players[0].carrying is not None
|
||||
assert new_state.counters.get("wasted", 0) == 0
|
||||
|
||||
|
||||
def test_the_push_out_refuses_over_a_usable_raw_even_with_a_burnt_one() -> None:
|
||||
"""The usable item outranks the burnt one: a board with a usable raw
|
||||
and a burnt raw still refuses the new raw — the burnt one is not
|
||||
destroyed while the usable one would be overwritten."""
|
||||
board = station(
|
||||
StationKind.CHOP_BOARD,
|
||||
contents=(
|
||||
item(ItemKind.ONION, ItemForm.RAW, quality=0.8),
|
||||
item(ItemKind.LETTUCE, ItemForm.BURNT),
|
||||
),
|
||||
)
|
||||
state = make_state(
|
||||
stations=(board,),
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),),
|
||||
)
|
||||
new_state, events = _board_set_down(state)
|
||||
denied = [e for e in events if e.kind is EventKind.DENIED]
|
||||
assert len(denied) == 1
|
||||
assert dict(denied[0].payload)["reason"] == "station_busy"
|
||||
# Both items intact (the usable raw's quality only the three ticks the
|
||||
# board's own clock ran); the new raw is still in the hand.
|
||||
assert new_state.stations[0].contents[0].kind is ItemKind.ONION
|
||||
assert new_state.stations[0].contents[0].quality == pytest.approx(0.8 - 3.0 / 400.0, abs=1e-9)
|
||||
assert new_state.stations[0].contents[1].kind is ItemKind.LETTUCE
|
||||
assert new_state.stations[0].contents[1].form is ItemForm.BURNT
|
||||
assert new_state.players[0].carrying is not None
|
||||
assert new_state.counters.get("wasted", 0) == 0
|
||||
|
||||
|
||||
def test_the_push_out_counts_every_destroyed_item() -> None:
|
||||
"""Two burnt board items, one new raw: two `WASTED` events and
|
||||
`counters["wasted"]` up by two — a destruction is named exactly once,
|
||||
at the moment it happens."""
|
||||
board = station(
|
||||
StationKind.CHOP_BOARD,
|
||||
contents=(
|
||||
item(ItemKind.ONION, ItemForm.BURNT),
|
||||
item(ItemKind.LETTUCE, ItemForm.BURNT),
|
||||
),
|
||||
)
|
||||
state = make_state(
|
||||
stations=(board,),
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),),
|
||||
)
|
||||
new_state, events = _board_set_down(state)
|
||||
assert len([e for e in events if e.kind is EventKind.WASTED]) == 2
|
||||
assert new_state.counters["wasted"] == 2
|
||||
# The two burnt ones are gone; the new raw is on the board (its quality
|
||||
# the one tick the board's clock ran in the set-down's own substep).
|
||||
assert len(new_state.stations[0].contents) == 1
|
||||
assert new_state.stations[0].contents[0].kind is ItemKind.TOMATO
|
||||
assert new_state.stations[0].contents[0].form is ItemForm.RAW
|
||||
assert new_state.stations[0].contents[0].quality == pytest.approx(1.0 - 1.0 / 400.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_a_rotted_raw_is_pushed_out_by_the_freshness_then_placement() -> None:
|
||||
"""The full loop: a raw onion rots on the board (400 substeps, the
|
||||
freshness stage's clock), and the new raw's set-down then pushes it
|
||||
out — the `WASTED` the task names, from the kitchen's own clocks."""
|
||||
board = station(StationKind.CHOP_BOARD, contents=(item(ItemKind.ONION, ItemForm.RAW),))
|
||||
state = make_state(
|
||||
stations=(board,),
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),),
|
||||
)
|
||||
state, _events = run(state, 400) # the board's whole ROT_S: the onion rots
|
||||
assert state.stations[0].contents[0].form is ItemForm.BURNT
|
||||
new_state, events = _board_set_down(state)
|
||||
assert len([e for e in events if e.kind is EventKind.WASTED]) == 1
|
||||
assert new_state.counters["wasted"] == 1
|
||||
assert new_state.stations[0].state is StationState.WORKING
|
||||
|
||||
|
||||
# --- the dirty loop's stock: use dirties, a wash cleans ------------------------------------
|
||||
|
||||
|
||||
def test_the_in_place_wash_round_trips_the_stock_pair() -> None:
|
||||
"""The prop's whole round trip on the prep's plate: `PLATE_DISH` moves
|
||||
it clean -> dirty (3 -> 2, 0 -> 1), the pick leaves the prep `DIRTY`,
|
||||
the in-place wash moves it back (2 -> 3, 1 -> 0) and the prep is
|
||||
`IDLE` again — `…_clean + …_dirty` is the limit at every point."""
|
||||
prep = station(
|
||||
StationKind.PREP,
|
||||
x=5,
|
||||
y=4,
|
||||
)
|
||||
shelf = station(StationKind.SPAWN_SHELF, x=6, y=4)
|
||||
state = make_state(
|
||||
stations=(prep, shelf),
|
||||
players=(
|
||||
cook(
|
||||
Vec2(5.5, 5.5),
|
||||
facing=Facing.UP,
|
||||
carrying=item(ItemKind.TOMATO, ItemForm.COOKED),
|
||||
),
|
||||
),
|
||||
counters={"plates_clean": 3, "plates_dirty": 0},
|
||||
)
|
||||
p0 = state.players[0]
|
||||
|
||||
# Use: the `PLATE_DISH` press takes the plate from the clean stock
|
||||
# into the dirty pool.
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.PLATE_DISH,
|
||||
StationTarget(station=state.stations[0]),
|
||||
)
|
||||
assert state.counters["plates_clean"] == 2
|
||||
assert state.counters["plates_dirty"] == 1
|
||||
assert state.stations[0].state is StationState.WORKING
|
||||
|
||||
# The plate's work: 12 substeps (0.6 s) of the hold.
|
||||
state = held(state, "p0")
|
||||
state, _events = run(state, 12, action=True)
|
||||
assert state.stations[0].state is StationState.READY
|
||||
|
||||
# The pick leaves the prep DIRTY (the stuck plate).
|
||||
p0 = state.players[0]
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.PICK_FROM_STATION,
|
||||
ItemTarget(
|
||||
item=state.stations[0].contents[0],
|
||||
on_station_id=state.stations[0].id,
|
||||
),
|
||||
)
|
||||
assert state.stations[0].state is StationState.DIRTY
|
||||
assert state.stations[0].dirty
|
||||
assert state.players[0].carrying is not None
|
||||
|
||||
# A full hand cannot wash: the dish is set down first (the shelf),
|
||||
# the way the kitchen does it.
|
||||
p0 = state.players[0]
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.RELEASE,
|
||||
StationTarget(station=state.stations[1]),
|
||||
)
|
||||
assert state.players[0].carrying is None
|
||||
|
||||
# The forced wash: a free hand at the dirty prep is the in-place wash.
|
||||
p0 = state.players[0]
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.WASH,
|
||||
StationTarget(station=state.stations[0]),
|
||||
)
|
||||
assert state.stations[0].state is StationState.WORKING
|
||||
state = held(state, "p0")
|
||||
state, washed = run(state, round(WASH_SECONDS / STEP_DT), action=True)
|
||||
assert state.stations[0].state in (StationState.READY, StationState.IDLE)
|
||||
assert state.counters["plates_clean"] == 3 # the prop is back in the clean stock
|
||||
assert state.counters["plates_dirty"] == 0 # ...and off the dirty pool
|
||||
assert any(e.kind is EventKind.WASHED for e in washed)
|
||||
state, _events = run(state, 1) # the empty ready surface cools to idle
|
||||
assert state.stations[0].state is StationState.IDLE
|
||||
assert state.stations[0].dirty is False
|
||||
|
||||
|
||||
def test_the_stove_pot_round_trips_the_same_way() -> None:
|
||||
"""The same round trip on the stove's pot: a raw set-down takes the
|
||||
pot (2 -> 1, 0 -> 1), the pick leaves the stove `DIRTY` (the stuck
|
||||
pot), the in-place wash pays it back (1 -> 2, 1 -> 0)."""
|
||||
state = make_state(
|
||||
stations=(station(StationKind.STOVE, x=5, y=4), station(StationKind.SPAWN_SHELF, x=6, y=4)),
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),),
|
||||
counters={"pots_clean": 2, "pots_dirty": 0},
|
||||
)
|
||||
p0 = state.players[0]
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.PLACE_ON_STATION,
|
||||
StationTarget(station=state.stations[0]),
|
||||
)
|
||||
assert state.counters["pots_clean"] == 1
|
||||
assert state.counters["pots_dirty"] == 1
|
||||
assert state.stations[0].state is StationState.WORKING
|
||||
# The raw cook: 8 s = 160 substeps of the hold.
|
||||
state = held(state, "p0")
|
||||
state, _events = run(state, round(8.0 / STEP_DT), action=True)
|
||||
assert state.stations[0].state is StationState.READY
|
||||
p0 = state.players[0]
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.PICK_FROM_STATION,
|
||||
ItemTarget(
|
||||
item=state.stations[0].contents[0],
|
||||
on_station_id=state.stations[0].id,
|
||||
),
|
||||
)
|
||||
assert state.stations[0].state is StationState.DIRTY # the stuck pot
|
||||
# A full hand cannot wash: the dish is set down first (the shelf).
|
||||
p0 = state.players[0]
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.RELEASE,
|
||||
StationTarget(station=state.stations[1]),
|
||||
)
|
||||
assert state.players[0].carrying is None
|
||||
p0 = state.players[0]
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.WASH,
|
||||
StationTarget(station=state.stations[0]),
|
||||
)
|
||||
state = held(state, "p0")
|
||||
state, washed = run(state, round(WASH_SECONDS / STEP_DT), action=True)
|
||||
assert state.counters["pots_clean"] == 2
|
||||
assert state.counters["pots_dirty"] == 0
|
||||
assert any(e.kind is EventKind.WASHED for e in washed)
|
||||
|
||||
|
||||
def test_the_sink_wash_round_trips_the_plate_stock() -> None:
|
||||
"""The sink's half of the loop: a carried `DIRTY` plate into the sink
|
||||
(a 1.2 s wash) comes back clean — `plates_clean` up by one,
|
||||
`plates_dirty` down by one, the item's form the washed one — and the
|
||||
sink is `IDLE` again (the `DIRTY -> IDLE` the task names)."""
|
||||
state = make_state(
|
||||
stations=(
|
||||
station(StationKind.SINK, x=5, y=4),
|
||||
),
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.PLATE, ItemForm.DIRTY)),),
|
||||
counters={"plates_clean": 2, "plates_dirty": 1},
|
||||
)
|
||||
p0 = state.players[0]
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.WASH,
|
||||
StationTarget(station=state.stations[0]),
|
||||
)
|
||||
assert state.stations[0].state is StationState.WORKING
|
||||
assert state.players[0].carrying is None # the plate is in the sink
|
||||
state = held(state, "p0")
|
||||
state, washed = run(state, round(WASH_SECONDS / STEP_DT), action=True)
|
||||
assert state.stations[0].state is StationState.READY
|
||||
assert state.stations[0].contents[0].form is ItemForm.RAW # the washed plate, waiting
|
||||
assert state.counters["plates_clean"] == 3
|
||||
assert state.counters["plates_dirty"] == 0
|
||||
assert any(e.kind is EventKind.WASHED for e in washed)
|
||||
# The pick is the `DIRTY -> IDLE` the loop names: the sink is clean and empty.
|
||||
p0 = state.players[0]
|
||||
state, _events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.PICK_FROM_STATION,
|
||||
ItemTarget(
|
||||
item=state.stations[0].contents[0],
|
||||
on_station_id=state.stations[0].id,
|
||||
),
|
||||
)
|
||||
state, _events = run(state, 1) # the empty ready surface cools to idle
|
||||
assert state.stations[0].state is StationState.IDLE
|
||||
assert not state.stations[0].dirty
|
||||
|
||||
|
||||
def test_a_zero_stock_plate_dish_is_the_clean_refusal() -> None:
|
||||
"""The runtime safety: a zero plate stock is a named refusal
|
||||
(`NOTHING_HERE` with `why=no_plate`), and the stock's floor is 0 —
|
||||
`plates_clean` never goes below it (the game is not over: the wash is
|
||||
the fix, and the validator's `STATION_LIMIT_ZERO` rule keeps a zero-
|
||||
plate level out of the campaign)."""
|
||||
state = make_state(
|
||||
stations=(station(StationKind.PREP, x=5, y=4),),
|
||||
players=(
|
||||
cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.COOKED)),
|
||||
),
|
||||
counters={"plates_clean": 0, "plates_dirty": 2},
|
||||
)
|
||||
p0 = state.players[0]
|
||||
state, events = apply_station_verb(
|
||||
state,
|
||||
p0,
|
||||
Verb.PLATE_DISH,
|
||||
StationTarget(station=state.stations[0]),
|
||||
)
|
||||
denied = [e for e in events if e.kind is EventKind.DENIED]
|
||||
assert len(denied) == 1
|
||||
payload = dict(denied[0].payload)
|
||||
assert payload["reason"] == "nothing_here"
|
||||
assert payload["why"] == "no_plate"
|
||||
assert state.counters["plates_clean"] == 0 # the floor: never below 0
|
||||
assert state.players[0].carrying is not None # the dish stays in the hand
|
||||
|
||||
|
||||
# --- no-op neutrality: a kitchen with nothing decaying is untouched -------------------------
|
||||
|
||||
|
||||
def test_no_prepared_food_is_a_no_op_on_the_state() -> None:
|
||||
"""Hash neutrality (the goldens' premise): a kitchen with no decaying
|
||||
item — a raw shelf stock, an idle board, an empty hand — comes back
|
||||
the *same object* from `step_freshness`: no replacement, no events, no
|
||||
hash movement."""
|
||||
state = make_state(
|
||||
stations=(
|
||||
station(
|
||||
StationKind.SPAWN_SHELF,
|
||||
contents=(item(ItemKind.TOMATO, ItemForm.RAW),),
|
||||
),
|
||||
station(StationKind.CHOP_BOARD),
|
||||
station(StationKind.STOVE),
|
||||
),
|
||||
players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),),
|
||||
)
|
||||
new_state, events = step_freshness(state, STEP_DT)
|
||||
assert new_state is state
|
||||
assert events == ()
|
||||
|
||||
|
||||
def test_the_freshness_stage_sits_between_stations_and_orders() -> None:
|
||||
"""The wiring, pinned by an event sequence: one substep that completes
|
||||
the board's work (stage 4's `CHOPPED`) and then decays the sitting
|
||||
item (stage 4b, the quality drop) and then spawns (stage 5, an order's
|
||||
clock) — the `CHOPPED` event before any decay effect, the decay before
|
||||
the tick's bookkeeping, one substep, the documented order."""
|
||||
state = make_state(
|
||||
stations=(
|
||||
station(
|
||||
StationKind.CHOP_BOARD,
|
||||
state=StationState.WORKING,
|
||||
contents=(item(ItemKind.ONION, ItemForm.RAW),),
|
||||
worker_id="p0",
|
||||
progress=1.0 - STEP_DT / 0.9,
|
||||
duration=0.9,
|
||||
),
|
||||
),
|
||||
players=(cook(Vec2(5.5, 5.5)),),
|
||||
)
|
||||
state, events = step(
|
||||
state,
|
||||
STEP_DT,
|
||||
(PlayerInput(player_id="p0", action=True, received_at_tick=0),),
|
||||
)
|
||||
assert [e.kind for e in events] == [EventKind.CHOPPED, EventKind.READY]
|
||||
board = state.stations[0]
|
||||
assert board.state is StationState.READY
|
||||
# The decay started in the same substep as the flip (stage 4b runs
|
||||
# after stage 4, before stage 5): the quality is already 1 - 1/400.
|
||||
assert board.contents[0].quality == pytest.approx(1.0 - STEP_DT / ROT_S, abs=1e-9)
|
||||
assert board.contents[0].quality < 1.0
|
||||
|
||||
|
||||
# --- the 1 000-tick scripted sequence: conservation asserted per tick -----------------------
|
||||
|
||||
|
||||
def test_clean_plus_dirty_is_conserved_every_tick_of_a_1000_tick_run() -> None:
|
||||
"""The task's 1 000-tick scripted sequence: the worst-case policy
|
||||
(serve everything possible, never wash until forced) on the one-plate,
|
||||
one-pot kitchen. Per tick: `…_clean + …_dirty` is the limit, the
|
||||
counters never go negative, the cook is alive, and the run actually
|
||||
cooked (a served dish, a forced wash) — so the conservation is
|
||||
asserted on a round that used the loop, not an idle one."""
|
||||
state = build_kitchen(plates=1, pots=1)
|
||||
states, event_lists = run_policy(state, book(), 1000)
|
||||
assert len(states) == 1000
|
||||
for index, s in enumerate(states):
|
||||
assert s.counters["plates_clean"] + s.counters["plates_dirty"] == 1, f"tick {index}"
|
||||
assert s.counters["pots_clean"] + s.counters["pots_dirty"] == 1, f"tick {index}"
|
||||
for key, value in s.counters.items():
|
||||
assert value >= 0, f"tick {index}: {key} = {value}"
|
||||
assert s.players[0].connected # the cook is alive (and can reach the sink)
|
||||
all_events = [e for events in event_lists for e in events]
|
||||
assert any(e.kind is EventKind.SERVED for e in all_events) # it served
|
||||
assert any(e.kind is EventKind.WASHED for e in all_events) # it was forced to wash
|
||||
assert any(
|
||||
e.kind is EventKind.DENIED and dict(e.payload).get("why") == "no_plate"
|
||||
for e in all_events
|
||||
) is False # never an un-washed zero-stock attempt
|
||||
assert states[-1].result is None # the round is not over at tick 1000
|
||||
Reference in New Issue
Block a user