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:
2026-10-07 02:46:10 -04:00
parent 3426264ffb
commit 4febdfe780
41 changed files with 17690 additions and 23 deletions
+37 -7
View File
@@ -19,7 +19,9 @@
order: `ruff check .` → `pyright` → `undercooked-levels validate` (unconditional:
the same loader + PLAN §8 rules `create_app` runs at startup, over
`$UNDERCOOKED_LEVELS_DIR`) → the **full** `pytest` suite with the coverage floor →
repo-hygiene checks. It runs from the repo root or from `.agents/`.
the slow sim stage (`pytest -m slow tests/sim`: the replay goldens and the perf
smoke, named on their own) → repo-hygiene checks. It runs from the repo root
or from `.agents/`.
- New work is authored by the `phase-authoring` skill as a phase directory — not
implemented straight from chat.
- **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`.
@@ -123,15 +125,37 @@
server mints one, `crypto.getRandomValues` + base64url in `app/static/js/app.js`,
which is the same entropy and the only option in a browser) held in
`sessionStorage`. Names are sanitized, length-capped, non-authoritative.
- **Movement is swept, never corrected.** Stage 3 (`sim/movement.py`) integrates one axis
at a time and clamps each axis with `sim/collision.sweep_axis()` to the greatest legal
delta, so a body is stopped *before* it intrudes — a cook never ends a tick inside a
blocked tile, at any `dt` the kernel can hand it, and sliding needs no special case. Two
rules belong to `collision.py` alone and must not be duplicated in a caller: which tiles
and fixtures block (`PASS_THROUGH_STATIONS` = serve/sink/trash; shelf, chop board, stove
and prep block), and which cells a body covers (`body_cells()`, `_edge_cells()`) — the
footprint and the sweep have to answer with the same arithmetic or a cook ends a tick
1e-16 inside a wall, which is invisible and fatal for a replay golden. Cooks push cooks
positionally only (no velocity is ever injected by a push), and only a genuine terrain
deadlock drops an input — the later-received one, the same arbitration as
`resolve_inputs`. `PlayerInput.move` is untrusted: `intent_direction()` clamps its length,
so a client sending `(50, 0)` walks at `MAX_SPEED` like everyone else.
- **Frame and rate limits are enforced, not suggested**: `max_message_bytes`,
`max_inputs_per_s`, room caps, and message-type whitelists per room state.
- **Protocol changes need a version bump**: editing a wire message means editing
`src/undercooked/server/protocol.py`, `docs/PROTOCOL.md`, and the client in the same task,
and bumping `PROTOCOL_VERSION` (PLAN §11).
- **Determinism is a contract.** `sim/step.py` (phase 03) must produce identical state
given the same seed and input script — the replay tests in `tests/sim/` are the guard
once the tick exists; until then `tests/sim/test_purity.py` holds the A5 boundary that
determinism depends on.
- **Determinism is a contract.** `sim/step.py` advances the round in whole `STEP_DT`
(0.05 s) substeps with `dt` clamped to `[0, MAX_DT]`, a fixed subsystem order, and no
randomness outside `sim/rng.py` (splitmix64, seeded by `KitchenState.rng_state`) — so the
same seed plus the same input script produces identical state. The guard is
`tests/sim/test_rng.py` (published golden vectors) + `tests/sim/test_step_kernel.py`
(clamping, accumulation, purity, cross-run hash equality); the committed replay
goldens (`tests/goldens/*.golden.json` against `tests/sim/test_replay.py`, the
input-script format and the `python -m undercooked.sim.replay record|check` CLI
in `src/undercooked/sim/replay.py`) and the perf smoke (`tests/sim/test_perf_smoke.py`,
`slow`) close it out. `tests/sim/test_purity.py` holds the A5 boundary the
whole thing rests on. The contract — constants, the 8-stage order, what each hook still
owes — is `docs/SIMULATION.md`; change the tick, the RNG or the step order and that file
and the goldens move in the same commit.
- **ntfy is outbound-only.** Never use it to ask questions or wait for approval.
If a task is ambiguous, make the best judgment consistent with PLAN.md, or stop
and let the user rewrite the task.
@@ -148,6 +172,10 @@
|---|---|
| Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` |
| Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` |
| The fixed-step tick: `step`/`build_state`, the seeded RNG, the subsystem hooks (contract: `docs/SIMULATION.md`) | `src/undercooked/sim/step.py`, `state_build.py`, `rng.py`, `movement.py`, `interact.py`, `carry.py`, `stations.py`, `orders.py`, `hazards.py`, `round_state.py` |
| Movement/collision: velocity integration, facing, stun, soft push, and the pure geometry under it (what blocks, the swept stop, the body footprint) | `src/undercooked/sim/movement.py`, `src/undercooked/sim/collision.py` (rules in `docs/SIMULATION.md` §"Movement and collision") |
| The action button: reach-zone targeting, the verb ladder, named `DENIED` reasons, the one-press-one-event gate, contested arbitration and the long-press trash | `src/undercooked/sim/interact.py` (rules in `docs/SIMULATION.md` §"The action button") |
| The hands: one-item-per-hand carry, the marked set-down and the counter hand-off, the always-available long-press trash (contract: `docs/SIMULATION.md` §"The hand") | `src/undercooked/sim/carry.py` |
| Level/recipe content | `levels/*.yaml` |
| Content schema, ASCII-grid parser, content loader, playability validator (contract: `docs/LEVELS.md`) | `src/undercooked/sim/recipe.py`, `level.py`, `content.py`, `validation.py` |
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` |
@@ -155,6 +183,7 @@
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate | `src/undercooked/server/app.py` |
| Content HTTP API (`/api/levels`, `/api/levels/{id}`, `/api/config`) + strict response models | `src/undercooked/server/routes_content.py` |
| Author CLI `undercooked-levels` (`validate`, `list`, `show`, `new`, `axes`); workflow in `docs/LEVELS.md` | `src/undercooked/tools/levels.py` |
| The replay harness: input scripts, golden hashes, `run_script`/`compare`, the `record`/`check` CLI (contract: `docs/SIMULATION.md` "Replaying and re-blessing goldens") | `src/undercooked/sim/replay.py`, `tests/fixtures/replays/`, `tests/goldens/`, `tests/sim/test_replay.py`, `tests/sim/test_perf_smoke.py` (`slow`) |
| Security headers (CSP `default-src 'self'`, no-sniff, frame-ancestors) | `src/undercooked/server/headers.py` |
| CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` |
| Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` |
@@ -165,8 +194,9 @@
Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests),
`tests/integration/` (in-process app boot, live uvicorn + WebSocket clients), `tests/sim/`
(the pure kernel without a server: grid geometry, entity model, the A5 purity guard,
the level validator — headless determinism and replays join it with the tick in
phase 03), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`). `tests/` is
the level validator, the seeded RNG's golden vectors and the tick's determinism —
plus the replay harness: committed goldens in `tests/goldens/`, scripts in
`tests/fixtures/replays/`, and the `slow` perf smoke), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`). `tests/` is
not a package; shared fixtures live in `tests/conftest.py`, and the autouse fixture there
points `UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` so no test silently reads the
shipped `levels/`.
+97 -13
View File
@@ -10,9 +10,11 @@ email, no password: you create a room, share the 5-character code, play.
## Status
Phases **01** (runnable service scaffold) and **02** (level content, schema and
validation) are **complete**; the next pending phase is
`03_simulation_movement_and_interaction`. What runs today:
Phases **01** (runnable service scaffold), **02** (level content, schema and
validation), and **03** (the deterministic simulation kernel: fixed-step tick,
movement and collision, the action button, the hands, the replay goldens) are
**complete**; the next pending phase is `04_kitchen_pipeline_and_scoring`. What
runs today:
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
single typed, validated `Settings` object (`DEBUGPY` support included), and
@@ -29,13 +31,69 @@ validation) are **complete**; the next pending phase is
`GET /api/levels/{id}` and `GET /api/config` answer from it, and
`undercooked-levels` gives authors the same gate from the command line
(`docs/LEVELS.md` is the schema reference).
- **The tick.** `sim/state_build.py` builds a round from level + seats + seed and
`sim/step.py` advances it in whole `0.05` s substeps (20 Hz, A4) with a splitmix64 RNG
seeded from the round seed — pure (no clock, no `random`, nothing mutated), replayable
from a seed plus an input script, and 100 % covered. The clock, the input contract and
the round's end condition are real. `docs/SIMULATION.md`
is the contract, `tests/sim/test_rng.py` locks the generator's published vectors.
- **Walking the kitchen.** `sim/movement.py` + `sim/collision.py` are stage 3 of the tick:
intent is clamped server-side (`move=(50,0)` walks at 3.5 tiles/s, not 175), accelerated
at 30 tiles/s² and integrated one axis at a time against an axis-aligned sweep, so a cook
stops flush against a wall instead of inside it, slides along it for free, cannot tunnel a
1-tile wall even at the coarsest legal `dt`, and cannot clip a diagonal corner. Walls,
counters, void, off-map and the solid fixtures (`spawn_shelf`, `chop_board`, `stove`,
`prep`) block; `serve`, `sink` and `trash` do not. Cooks soft-push each other apart
symmetrically and positionally — a bump never injects velocity — and only a genuine
terrain deadlock drops an input, the later-received one, exactly like the kernel's
arbitration. Stun (`0.6` s) pins velocity and position and reports one `stunned` event on
entry; facing follows the last non-zero intent, so an action press while standing still
does not spin the cook. Proven by hand-computed geometry in `tests/sim/test_movement.py`,
by a 2000-substep four-cook random walk from the sim's own RNG that re-checks containment,
non-overlap and hash equality after *every* substep
(`tests/sim/test_movement_stress.py`), and by the same walk on the shipped levels through
the whole `step()` (`tests/sim/test_movement_kernel.py`).
- **The action button.** `sim/interact.py` is the second half of stage 2 of the tick:
the one `action` flag per frame becomes one press per rising edge, so however long the
button is held there is one effect — and one named refusal when there is nothing to do
(`DENIED` with `out_of_reach`, `hands_full`, `station_busy`, `contested_lost`, … in the
payload, which the client turns into a nudge; a silent no-op is the failure mode PLAN §5
bans by name). A press resolves a reach zone — the tile in front, then the two
diagonal-front tiles (a 0.6-tile forgiveness beyond the 1-tile reach), then the tile
underfoot — and a ladder picks the verb: a full hand sets its item down on the fixture
(the spawn shelf is the escape hatch), a free hand picks from a ready fixture or shelf
stock, a stunned cook is refused before the target is even read, and a fixture owns
what is on it (an onion on a chop board is *to be chopped*, not grabbable). Two cooks
pressing the same item in the same substep are arbitrated by arrival, `seq`, then seat
index — the loser is told `contested_lost`, never left wondering. The work verbs (chop,
cook, plate, serve, wash) are named by the ladder but no-ops until phase 04's pipeline
spends them (the long-press trash is real — see "The hands" below); `docs/SIMULATION.md`
§"The action button" is the contract. Proven by
`tests/sim/test_targeting.py` (hand-computed reach geometry, the press edge through the
whole `step()`) and `tests/sim/test_verbs.py` (a table that covers every `Verb` and every
`DeniedReason`, plus the gate's contested arbitration).
- **The hands.** `sim/carry.py` is what the verbs *do*: one carried item per cook (a
second pick is `hands_full`), no floor drops in v1 (a press at open floor is the named
`nothing_here`), and a set-down is a *placed object* — `RELEASE` / `PLACE_ON_STATION` /
`PLATE_DISH` put the item on the fixture and stamp it with a mark, so a teammate's pick
of a set-down within 20 ticks is a `HANDED_OFF` with both ids (the sanctioned pass) and
an unmarked pipeline input stays the work press's. The trash is long-press and always
available: holding the button over the can for 8 substeps destroys the carried item
(`TRASHED`, the raw `−2` score nudge in the payload for phase 04's clamped scoring,
`counters["trashed"]` up by one); a short press and a walk-away do nothing. A 100-tick
fuzz of random simultaneous presses re-checks the conservation law
(`hands + stations + trashed == initial`) after every tick, and a scripted two-player
pass-and-contest reaches its committed golden hash. `docs/SIMULATION.md` §"The hand" is
the contract (phase 09's co-op E2E asserts exactly this tie-break). Proven by
`tests/sim/test_carry.py` and `tests/sim/test_replay_carry.py`.
- **The rules are tests, not promises.** The no-external-asset rule is
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
phase pipeline runs after every task.
What does not exist yet: the simulation (phases 03-04), rooms and the WebSocket
What does not exist yet: the rest of the simulation (the cooking pipeline, order spawning
and scoring in phase 04), rooms and the WebSocket
protocol (05-06 — the lobby buttons answer "Not implemented until phase 05.") and the
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
@@ -117,9 +175,19 @@ uv run pytest # everything, coverage floor included
uv run pytest -m "not e2e" # the quick loop: no browser launches
uv run playwright install chromium # once, before the first e2e run
uv run pytest -m e2e # browser suites only
uv run pytest -m slow tests/sim -q --no-cov # the gate's slow stage: goldens + perf smoke
bash .agents/validate.sh # the pipeline gate: full suite + ruff + pyright
```
The replay goldens behind `tests/sim/test_replay.py` are checked the same way a
level is validated: `uv run python -m undercooked.sim.replay check
<tests/fixtures/replays/<name>.json>` runs the committed input script and
gates it on `tests/goldens/<name>.golden.json` (exit `0` match, `1` divergence
with the first divergent tick and the changed fields, `2` missing script/level/
golden). `record` writes a golden — the manual re-bless, legitimate only after a
deliberate sim-contract change recorded in the phase task; the contract is
[docs/SIMULATION.md](docs/SIMULATION.md) ("Replaying and re-blessing goldens").
`pytest` with no `-m` runs the browser suites too, so chromium has to be
installed for the pipeline gate. Those suites start the app themselves on a free
loopback port (`live_server` in `tests/conftest.py`) — nothing needs to be
@@ -130,8 +198,11 @@ running first, and no test ever listens on a routable interface.
`ruff check .`, `pyright`, `undercooked-levels validate` (unconditional: the same
loader and PLAN §8 rules `create_app` runs at startup, so a campaign that cannot
validate cannot commit), the **full** `pytest` suite with the 90 % coverage floor,
then repo-hygiene checks (nothing tracked that `.gitignore` excludes, no log drops or
key material committed). Run it from the repo root or from `.agents/`.
the slow sim stage (`pytest -m slow tests/sim`: the replay goldens and the perf
smoke, named on their own so a quick loop that skips them cannot let them slip
through the gate), then repo-hygiene checks (nothing tracked that `.gitignore`
excludes, no log drops or key material committed). Run it from the repo root or
from `.agents/`.
## Container
@@ -187,9 +258,19 @@ src/undercooked/sim/ pure simulation kernel: entities (`model`), grid geom
(`grid`), content schema + loader (`recipe`, `level`,
`content` — the YAML contract is docs/LEVELS.md), the
level validator (`validation` — PLAN §8 named rules;
rule table in docs/LEVELS.md), and the
A5 purity guard (`purity`, test-enforced). There is no
`step()` yet: the tick arrives with phase 03
rule table in docs/LEVELS.md), the fixed-step tick (`step`,
`state_build`, `rng`, and the subsystem hooks `movement`
(+ `collision`, its pure geometry), `interact` (the action
button — reach zone, verb ladder, named denials, contested
arbitration, the long-press trash), `carry` (the hands — one
item per hand, the marked set-down and counter hand-off,
the always-available trash), `stations`, `orders`, `hazards`,
`round_state` —
contract in docs/SIMULATION.md), and the A5 purity guard
(`purity`, test-enforced)
src/undercooked/sim/replay.py the replay harness: the input-script format, the golden
hashes, `run_script`/`compare`, and the record/check CLI
(docs/SIMULATION.md "Replaying and re-blessing goldens")
src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
show [--grid], new, axes; same loader, same rules, no
hard-coded content list (docs/LEVELS.md "Authoring workflow")
@@ -203,14 +284,17 @@ levels/ data-driven content (YAML): the recipe book (A8) and the
app/static/ browser client: the ES-module shell today; Canvas 2D renderer
and this repo's SVG art arrive with phase 07
tests/ unit/, integration/, e2e/ (one Playwright suite per story) and
sim/ (the pure kernel: grid, entities, purity guard, validation);
sim/ (the pure kernel: grid, entities, purity guard, validation,
the seeded RNG and the tick; plus the replay harness — committed
goldens in goldens/, scripts in fixtures/replays/ — and the
`slow` perf smoke);
fixtures/levels/ is the content dir tests see (a recipe book and no
levels; `_bad/` holds the deliberately-broken campaign the `_`
discovery rule ignores); conftest.py
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
TESTING.md (how this project is verified), DEPLOYMENT.md;
PROTOCOL / SIMULATION / CLIENT land with their own phases
(the index is PLAN.md §13)
SIMULATION.md (the tick contract), TESTING.md (how this project
is verified), DEPLOYMENT.md; PROTOCOL / CLIENT land with their
own phases (the index is PLAN.md §13)
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
```
+616
View File
@@ -0,0 +1,616 @@
# SIMULATION.md — the tick contract
**Status:** current as of phase 03 task 05 (the kernel of task 01, real movement,
collision, facing and stun, the action button: reach-zone targeting, the verb ladder,
named denials and contested arbitration, the hands: one-item-per-hand carry, the
marked set-down with the counter hand-off, and the long-press trash — and the replay
harness: the input-script format, the committed golden hashes, the `record`/`check`
CLI, and the headless perf smoke). **Filled in by
phase 04** for the station and order pipelines, and by phase 10 for hazards — each of
those replaces an identity hook named in the order table below.
## Purpose
This document is the contract between the deterministic game rules and everyone who
calls them: how a tick is born, how much simulation one call may buy, the order subsystems
run in and why that order is gameplay, how player input is turned into authoritative
change, what purity and determinism cost in code, and what a golden hash is allowed to
mean. It exists because the simulation is the single source of truth (A4) — a rule that
reads differently in two places is a bug in the rules, not in the client.
The rules of the kitchen live in `src/undercooked/sim/` and nowhere else. This document
is the contract those modules owe every caller — the room supervisor (phase 05), the
protocol layer (phase 06) and the client (phase 07) included. `.agents/PLAN.md` decides
*what* the simulation is (A4: server-authoritative fixed tick; A5: purity; A21: replay);
this file says how that reads in code.
Write here, not in a docstring, whenever a later phase changes: the tick contract,
movement and collision, the pipeline rules, contested arbitration, or hazard rules.
## The tick
```text
one call -> step(state, dt, inputs) -> (state', events)
│
dt clamped to [0, MAX_DT], added to state.accumulator
│
while accumulator >= STEP_DT: exactly one substep, accumulator -= STEP_DT
```
The simulation is driven in **whole substeps of `STEP_DT` = 0.05 s**, i.e. 20 Hz, the rate
A4 locks. `UNDERCOOKED_TICK_HZ` (`config.py`) decides how many substeps the room asks for
per wall second; it never changes what one substep contains. A caller may hand `step()`
any `dt` it likes — the client's frame time, a batched timer, a test harness — and the
simulation still advances in the same 0.05 s increments, so physics never depends on how
noisy the host's clock is.
Leftover time is not lost: `KitchenState.accumulator` carries the fraction that did not
add up to a whole substep into the next call. 17 calls of 0.05 s are exactly 17 ticks;
two calls of 0.03 s are one tick plus 0.01 s of credit.
### Constants
| Constant | Value | Lives in | Means |
| --- | --- | --- | --- |
| `STEP_DT` | `0.05` s | `sim/step.py` | One substep — one tick. The only dt a subsystem ever sees. |
| `MAX_DT` | `0.1` s | `sim/step.py` | Ceiling on one `step()` call: at most 2 substeps, so a stall cannot spiral. |
| `TICK_EPS` | `1e-9` s | `sim/step.py` | Tolerance on the substep bound; `0.85 − 17 × 0.05` is `2.2e-16`, not `0`. |
| `TIME_EPS` | `1e-9` s | `sim/round_state.py` | Same idea for the round clock: below this the round is over, not "0.0 s left". |
| `SEAT_COLORS` | 4 hex colours | `sim/state_build.py` | The fixed cook palette, indexed by `seat % 4`. |
### Movement constants
The numbers PLAN §5 fixes for a cook, now that stage 3 is real. They live in
`sim/movement.py` (`RADIUS` and `CONTACT_EPS` are the collision geometry, and live in
`sim/collision.py` for the second one because both modules need them):
| Constant | Value | Means |
| --- | --- | --- |
| `MAX_SPEED` | `3.5` tiles/s | Speed ceiling. No diagonal bonus: `DIAGONAL_NORM` makes a diagonal a direction, not extra throttle. |
| `ACCEL` | `30` tiles/s² | How fast intent becomes velocity. |
| `DECEL` | `40` tiles/s² | How fast it goes away when the stick centres — deliberately harder than `ACCEL`: overshooting a station is the mistake players make most. |
| `RADIUS` | `0.35` tile | Half-width of the cook's body **square** (an AABB, not a circle) in tile units. This is PLAN §5's `PLAYER_RADIUS`, named for what the collision module reads it as. |
| `CONTACT_EPS` | `1e-4` tile | The gap a swept body stops short of a blocked cell, and the inset a footprint is measured with. See "One geometry, two questions". |
| `DIAGONAL_NORM` | `True` | Set to `False` and a diagonal is √2 faster. Read at call time so the test can prove which rule is in force. |
| `STUN_S` | `0.6` s | How long a hit keeps a cook still. Whoever stuns writes it to `Player.stun`; `apply_stun()` ticks it down. |
| `STUN_EPS` | `1e-9` s | A stun is over at this remainder: `0.6 − 12 × 0.05` is `1.4e-17`, not `0`. |
| `MOVE_EPS` | `1e-6` tiles/s | Below this the cook is `IDLE`, not `MOVING`. |
| `PUSH_ITERATIONS` | `2` | Soft-push passes. Two propagates a separation through a chain of four cooks. |
| `PUSH_SLOP` | `1e-6` tile | Overlap below this is float noise, not a collision — without it, shoulder-to-shoulder cooks jitter forever. |
PLAN §5 fixes the numbers the rest of this phase implements. They are recorded here
so a constant is written down once, before the code that reads it:
| Constant | Value | Arrives in | Means |
| --- | --- | --- | --- |
| `REACH` | `1.0` tile | task 03 (real) | First targeting tier: the tile in front of the cook, centre-to-centre. |
| `FORGIVE` | `0.6` tile | task 03 (real) | Forgiveness on top of `REACH`: why the diagonal-front tier (√2 ≈ 1.414 centre-to-centre) is in reach and the diagonal behind the far shoulder (2.12) is not. |
| `HANDLING_S` | `0.0` s | task 04 (settled) | Minimum seconds between two accepted actions by one cook: the press edge is the gate — a new press is accepted the substep after a release, so the floor is `0.0` and PLAN's unspecified value is settled here, not in a knob. |
`MAX_DT` exists because a fixed tick without a ceiling is a denial-of-service against
yourself: if the process stalls for ten seconds, "catch up" means 200 substeps in one go,
which takes longer than 200 substeps should, which delays the next call, and so on.
Clamping means a stall costs the round at most 0.1 s of simulation and the round keeps
going — the correct trade for a co-op game where being behind is better than being dead.
## The substep order
Order *is* gameplay: two subsystems reading each other's output produce different rounds
if they run in a different sequence. Every substep runs these stages, in this order, with
no I/O and no reordering:
| # | Stage | Module | Status |
| --- | --- | --- | --- |
| 1 | Clamp `dt`, accumulate, decide how many substeps run | `sim/step.py` | real |
| 2 | Resolve inputs to one frame per seat; `err` for unknown ids | `sim/step.py` | real |
| 2b | Action gate: hold counters, one press per rising edge, verb ladder, named denials, contested arbitration, the long-press trash | `sim/interact.py` + `sim/carry.py` | real (task 03/04) |
| 3 | Movement and collision | `sim/movement.py` | real (task 02) |
| 4 | Station pipelines | `sim/stations.py` | identity → phase 04 |
| 5 | Orders | `sim/orders.py` | identity → phase 04 |
| 6 | Hazards | `sim/hazards.py` | identity → phase 10 |
| 7 | Round timer and end conditions | `sim/round_state.py` | countdown + time-up verdict real; early win and stars → phase 04 |
| 8 | `tick += 1` | `sim/step.py` | real |
Stage 2 happens **once per `step()` call**, not once per substep: intent is resolved, then
the same resolved intent is offered to every substep that call produces. That is what
makes `step(state, 0.1)` and two calls of `step(state, 0.05)` agree, and it matches the
client's side — a cook keeps moving in the direction last pressed until a newer frame
says otherwise.
A resolved frame per seat is chosen by **earliest `received_at_tick`, tie-break lowest
`seq`, first-seen on a full tie**, and the mapping is returned in **seat order**. Two
clients sending inputs for the same seat in the same batch (a retransmit after a
reconnect, a hostile tab) therefore resolve identically on every machine, and effects
that touch more than one cook apply in an order nobody has to guess. An unknown
`player_id` is a `EventKind.ERROR` event with `{"player_id", "reason": "unknown_player",
"seq"}` and nothing else: the round does not care who sent it, and a hostile client
cannot make a step raise.
## Movement and collision
Stage 3 turns the resolved frame into a position, in two halves that never mix: velocity
comes from intent, and position comes from velocity.
```text
PlayerInput.move (whatever the client sent, e.g. Vec2(50, 0))
→ intent_direction() clamp to length ≤ 1, so a hostile value is a direction, not a
teleport, and (1,1) is not √2 faster than (1,0)
→ desired_velocity() accelerate toward it, brake when the stick centres, 0 if stunned
→ sweep_axis() × 2 x then y, each clamped to the greatest legal delta
→ _separate() soft push, twice; a genuine deadlock reverts one input
→ Player(pos, vel, facing, state, stun)
```
The clamp is the server refusing to negotiate (A4): `step()` carries `PlayerInput.move`
verbatim, and `intent_direction()` is the one place it becomes authoritative. A client that
sends `move=(50, 0)` walks exactly as fast as one that sends `(1, 0)` — there is a test for
exactly that, through the whole `step()`, not the movement helper.
A cook with no frame this tick brakes to a stop rather than drifting: `step()` hands every
substep of one call the same frame, so releasing a key stops the cook within one tick
(`DECEL`) no matter how many substeps that call ran.
### What blocks
`sim/collision.py` answers one question — *may a body stand here* — for the whole sim:
| Tile or fixture | Blocks a body |
| --- | --- |
| `wall`, `counter`, `void`, out-of-bounds | yes |
| `floor` | no |
| `spawn_shelf`, `chop_board`, `stove`, `prep` | yes — you walk around a fixture |
| `serve`, `sink`, `trash` | no — you lean over these to use them |
The pass-through set is `collision.PASS_THROUGH_STATIONS`, and it is the collision answer
only: the reach rule (task 03) is allowed to want a *different* set, because reaching a
serve window from the tile in front of it is a separate question from walking over it. A
hazard or a crate adds cells through `cell_blocked(extra_blocked=...)` / `body_blocker(...)`
without either of those rules learning about the other.
A body is a square of side `2 × RADIUS` = 0.7 tiles, so it fits a 1-tile corridor with
0.3 tiles to spare and cannot fit a gap it would have to squeeze through.
### One geometry, two questions
Two things must agree about which cells a body covers: the footprint (`body_cells()`, what
phase 07's reach will ask about) and the sweep (`sweep_axis()`, what decides where a move
ends). They agree because both call `_edge_cells()` on a *centre*, never on a pre-added
edge: `(lead + delta) − eps` and `(centre + delta) + radius − eps` differ by one float ulp
exactly at the moment a body becomes flush with a boundary, and one ulp is enough for the
sweep to say "not entered" while the footprint says "occupied" — a cook 2e-16 inside a
wall, invisible on screen and fatal for a replay golden. So the sweep clamps the
*destination centre* to `cell − radius − CONTACT_EPS`, two epsilons clear of the boundary
that would make the cell occupied, and float noise cannot close that gap.
The sweep tests every cell the body would *newly* overlap, across its whole cross-axis
span — not the destination cell only. That buys three things at once. Nothing can be
walked through: at `MAX_SPEED` and the coarsest call the kernel allows (`MAX_DT` = 0.1 s) a
cook covers 0.35 tiles, which a destination test would catch too, but the sweep does not
care what speed or `dt` a caller hands it, so a doubled ceiling, a longer catch-up tick or a
future hazard that throws a cook across the room still cannot cross a 1-tile wall (there is
a test that doubles `MAX_SPEED` specifically to hold that). A diagonal move cannot clip the
corner of a wall, because the last axis moved is checked against the body's *full* span on
the other one. And a cell the body already sits in is never re-tested, so a cook that starts
a substep somewhere impossible — a spawn a level author got wrong — is not wedged by it.
### Cooks push cooks
Bodies do not collide with terrain and each other in one pass; terrain is resolved on the
way out and cooks afterwards, because a push must never shove a cook *into* a wall.
* **Symmetric.** Each cook gives half the separation, and whatever one cannot take (a
wall behind it) the other takes. Two cooks meeting head-on both move; one pinned by a
wall moves the other the full amount.
* **Positional only.** The push edits `pos`, never `vel`. Being bumped does not fling
anybody, and a bumped cook keeps walking in the direction they pressed.
* **Iterated.** `PUSH_ITERATIONS` passes so a chain of four drains apart instead of leaving
the last cook inside the third.
* **Reverted only on a real deadlock.** When terrain stops *both* cooks and not a tile of
separation is possible — two cooks stacked in a 1×1 alcove is the only layout that can do
it — the later-received input is dropped and that cook is put back where the substep
started. Same arbitration rule `resolve_inputs` uses (earliest `received_at_tick` wins,
tie-break lowest `seq`), and a cook that sent no frame at all is never the loser, because
it has no input to discard. A jam that *could* separate — four cooks in a doorway — is
not a deadlock, it is a queue, and it drains over the next few ticks.
The revert restores the post-`apply_stun` player, so a revert can never undo the stun tick
that already happened.
## The action button
PLAN §5 gives the cook **one button**; `sim/interact.py` decides, purely and
deterministically, what it means *right now* — which entity is targeted, which verb
applies, or the named reason nothing happens. Stage 2b runs it once per substep, in
seat order, on the frames `resolve_inputs` already reduced to one per seat.
### The reach zone: discrete first, continuous second
`candidate_cells()` names the tiles the hand walks, in priority order, deduplicated:
1. the tile directly in front (the `REACH` tier — always in reach from any point of the
tile, worst case √(0.5² + 1.5²) ≈ 1.581);
2. the two diagonal-front tiles (the `FORGIVE` tier exists for them: √2 ≈ 1.414 from the
tile centre, so a purely orthogonal reach would refuse every diagonal press, and a
purely discrete walk would hand over the diagonal behind your far shoulder at 2.12);
3. the tile under the cook — because a serve window, sink and trash can are walkable
(`PASS_THROUGH_STATIONS`), a cook can be standing *on* the thing they want to use with
nothing in front.
`REACH + FORGIVE` from the cook's position to the tile's centre then decides whether the
cook is close enough to the tile the walk found. The two halves answer different
questions: the cell walk is why a fixture two tiles away is never even a candidate (there
is no "through the wall" mode — a wall in the front cell simply means the tile beyond it
is not walked), and the distance check is why the same diagonal fixture is reachable from
the near corner of the cook's tile and not from the far one. A candidate out of reach is
*skipped*, not refused, so a nearer tile still gets its turn. The four facings are 90°
rotations of one row, so a diagonal press feels the same whichever way the cook points.
### The target, then the verb
`select_target()` returns the first usable cell in that walk:
1. a grabbable item, but only with a free hand — a cook who is carrying something is here
to *empty* their hand, so preferring the item would make the placement ladder
unreachable (PLAN §5's "carrying → drop/transfer onto target station" comes first);
2. the serve window, as a `ServeTarget`, so the score path is distinguishable from merely
standing at a fixture;
3. the fixture itself;
4. `None`.
A fixture *owns* what is on it: `station.contents` is not a shelf of grabbable things. A
chop board with an onion on it is an onion *to be chopped* — pressing it works the item,
which is the only way PLAN §5's "hold to chop" can mean anything. An item becomes
grabbable when it is output: on a spawn shelf (stock, even while `contents` is empty —
the kind comes from the level's legend), or on a station whose state says the work is
done (`READY`). That rule lives in one private helper and nowhere else, so phase 04's
pipelines have exactly one place to change their minds. The serve window is never a
set-down target: a full hand at it gets the serve ladder's `WRONG_ITEM`, not a `RELEASE`
(the set-down escape hatch is the spawn shelf, where any carried item can be put down).
`select_verb()` is the priority ladder, with the cook's own condition first. There is no
`(verb, reason)` combination and no unexplained no-op: a legal press is `(verb, None)`, a
refused one is `(Verb.NONE, reason)` — the client shows the reason as a nudge, and the
server never answers a press with a silent nothing.
### The denied reasons
Every refusal names itself. These are the `DeniedReason` constants, and the path that
produces each:
| Reason | String | Produced when |
| --- | --- | --- |
| `STUNNED` | `stunned` | The cook's `stun` is down or their state is `STUNNED`, regardless of target — the client's "you got hit" outranks every other nudge. Checked before the target is even read. |
| `NOTHING_HERE` | `nothing_here` | No station in the reach zone (the whiff). A press at a fixture beyond a wall surfaces as this: the zone has no "through the wall" mode. |
| `OUT_OF_REACH` | `out_of_reach` | The target's centre is more than `REACH + FORGIVE` from the cook's position (re-measured for targets a caller kept from an earlier substep). |
| `NOT_FACING` | `not_facing` | The target's centre is behind the plane of the cook's facing (a target level with the cook — a serve window they are standing on — passes). |
| `HANDS_FULL` | `hands_full` | An item target with a full hand, a ready fixture's output with a full hand, or a loaded work surface with a full hand (one item at a time on a surface). |
| `HANDS_EMPTY` | `hands_empty` | Serving, washing or trashing with nothing in the hand. |
| `WRONG_ITEM` | `wrong_item` | A non-plated item at the serve window, a non-dirty item at the sink. The difference between "go fetch a plate" and "that is not a dish" is `HANDS_EMPTY` vs `WRONG_ITEM`. |
| `STATION_DIRTY` | `station_dirty` | A dirty fixture, whatever is held — the player needs "this board is filthy", not "you are holding the wrong thing". Dirt outranks every other state. |
| `STATION_BUSY` | `station_busy` | A fixture another cook is working, a `BUSY` fixture, or one on cooldown. The cook who started it gets `HOLD_WORK` instead. |
| `INVALID_STATE` | `invalid_state` | An idle fixture with nothing on it, a `BLOCKED` fixture, or an item whose holder is not in the state (task 04's floor drops are named this way until they are liftable). |
| `CONTESTED_LOST` | `contested_lost` | The cook lost a claim another cook arrived for first — the only reason the *gate* produces; the ladder cannot emit it. |
A press that would otherwise succeed emits exactly one event however long the button stays
down, and a refusal emits exactly one `DENIED` with the reason in the payload — never one
per tick.
### One button, one press, one effect
`action_gate()` turns each frame's `action` flag into the cook's hold counters
(`Player.action_held_ticks`, `Player.action_pressed_tick`) and spends the rising edge:
* a frame without `action` clears both counters (the button is up; the next `True` is a
new press);
* the first frame of a hold is the **press**: it is resolved with `held_for = 0.0`;
* every later substep of the same hold increments the counter and passes the seconds the
button has been down — the seam the long-press `TRASH` spends ("The hand"); the
`HOLD_WORK` threshold is phase 04's. Until phase 04, a hold that is not a trash
hold changes nothing: one press, one effect, however long the button is held.
`resolve_action()` spends the pick and the set-down (both of which are
`sim/carry.py`'s now): `PICK_ITEM` takes the item off the fixture and into the hand
(one `PICKED` — one `HANDED_OFF` when the pick lifts a set-down inside the window, see
"The hand"), `RELEASE` / `PLACE_ON_STATION` / `PLATE_DISH` put the carried item on the
fixture (one `DROPPED`, and the set-down's mark), and every denial is one `DENIED`
each. The work verbs — `START_WORK`, `HOLD_WORK`, `SERVE`, `WASH` — are *named* by the
ladder today and deliberately unspent: a press that selects one is a no-op with no
`DENIED`, because the player did nothing wrong and phase 04's pipelines will spend the
press. The `TRASH` press is the same shape at the press: it starts the hold, and the
gate spends it at the 8-tick threshold ("The hand"). The set-down leaves the
fixture's `state` untouched on purpose: what a set-down *means* to a pipeline is
phase 04's transition, and inventing it here is how a golden ends up encoding a rule
nobody chose.
### The hand
The button decides; the hand acts. `sim/carry.py` owns what a verb *does*, and it owns
three constants with it:
| Constant | Value | Means |
| --- | --- | --- |
| `HANDOFF_WINDOW_TICKS` | `20` ticks | The hand-off window, inclusive: a pick of a set-down is a pass while `pick_tick − placed_tick ≤ 20`; one tick past, it is a plain pick. |
| `TRASH_HOLD_TICKS` | `8` substeps | The trash threshold: the 8th substep of one hold is the tick that trashes, and never the 7th. |
| `TRASH_SCORE_DELTA` | `−2` | The raw score nudge a trashed item is worth; the `TRASHED` payload carries it, and phase 04's scoring spends it (clamped at 0 overall). The state's score is not touched before then. |
**One item per hand.** A full hand is the ladder's `HANDS_FULL`; a hand is empty, holds
an item, or trashes it. Items live in hands and on fixtures — there is no floor drop
in v1 (PLAN §5), so a press at open floor with a full hand is the named
`NOTHING_HERE`, not a drop.
**A set-down is a placed object.** `RELEASE` (the shelf), `PLACE_ON_STATION` (a work
surface) and `PLATE_DISH` (the prep counter) all do the same thing: the item goes onto
the fixture's `contents`, the hand empties, and the set-down is **marked** —
`(placer id, placed-at tick)` — in `KitchenState.item_marks`, a tuple parallel to the
fixture's `contents` (`None` where no hand put the entry down: level stock, and phase
04's pipeline inputs and outputs). The mark is state truth (a replay must reproduce
the `HANDED_OFF` it causes) and never a snapshot concern.
**Grabbability is the mark.** A free hand may take off a fixture:
* a spawn shelf — whatever it holds, stock or set-down;
* anything else — a *marked* entry, the moment it is set down (the hand-off exists
for exactly that);
* an unmarked entry only when the station's state is `READY` (a pipeline's output —
phase 04). Everything else — an onion on an idle board — is `START_WORK`'s, not
`PICK_ITEM`'s.
**The hand-off.** A pick that lifts a marked item placed by *another* cook within the
window is a `HANDED_OFF` with both ids (`from`, `to`) instead of a `PICKED` — that is
the sanctioned pass, the whole of "place it on the counter and let a teammate grab
it". A self-pick is a plain `PICKED`; a pick outside the window is a plain `PICKED`;
a same-substep place-then-pick is inside the window (zero ticks old). The mark is
removed with the entry, and a place anywhere stamps a fresh mark, so a pass chains:
A → B (B's pick) → B places (new mark) → C's pick is a B → C pass.
**The trash is long-press and always available.** A `TRASH` press starts a hold; the
hold is re-derived each substep from the *current* state (the press is not remembered —
the counters are the hold), so walking off the can voids it and a release resets it.
On the 8th substep of a hold the carried item is destroyed: one `TRASHED` event whose
payload carries the raw `TRASH_SCORE_DELTA`, `counters["trashed"] += 1`, and an empty
hand. A short press trashes nothing. A threshold met with an empty hand is
`TRASHED_ALREADY_EMPTY` — a named event, never a silent no-op.
### Contested targets
Two cooks pressing the same entity in the same substep are arbitrated the way the
movement deadlock is: `claim_rank` is `received_at_tick`, then `seq`, then seat index —
a tie is impossible because two cooks cannot share a seat. Only a press that would
otherwise *succeed* enters the scan (a stunned or empty-handed cook keeps their own
reason; blaming the other cook for a refusal the stun caused would be a second lie).
And only a press that takes something only one cook can have is a claim at all:
`PICK_ITEM` lifts a fixture's item, `START_WORK` takes its single worker slot, and
`PICK_FROM_STATION` claims the finished thing only while there *is* one in `contents` —
two cooks tapping a shelf with nothing in it both take stock from the level's legend
and neither was ever in the other's way. The winner picks; every loser is told
`CONTESTED_LOST` instead of watching the item vanish for no stated reason. The claim
scan runs against the pre-action state, so every cook is judged on the same kitchen, and
its bookkeeping — `active_claims`, the `target_key → (rank, player_id)` map — is
**substep-local**: built, arbitrated, and discarded at the substep's end, never written
into the state or the snapshot, so a replay reproduces the arbitration from the frames
alone.
### What proves it
`tests/sim/test_targeting.py` pins the reach zone with hand-computed geometry: the
candidate walk per facing (and its invariance to where in the tile the cook stands),
the forgiveness boundary (0.99 in, 1.6 inclusive, 1.61 out), the same diagonal fixture in
and out of reach from the near and far corners, the priority order (item over fixture,
full hand skips the item, a work surface holds its contents, the serve window's own
target kind), the wall case, and the press edge through the whole `step()` — one press,
one named refusal, one hold that changes nothing, one release that clears. `tests/sim/
test_verbs.py` is the table-driven ladder: every `(carrying, target) -> (verb, denial)`
covers every `Verb` and every `DeniedReason` at least once (a coverage test asserts the
table cannot quietly lose a row), the `resolve_action` implementations and no-ops, and
the gate's arbitration with two real cooks in one substep. `tests/sim/test_carry.py`
pins the hands themselves: the grabbability split (a set-down is grabbable at once, an
unmarked pipeline input is not), the one-item-per-hand refusal, the pick/place round
trip with its `Item` identity, the hand-off window (inclusive at 20, a same-tick pass,
a self-pick and a third party outside the window), the trash (destroyed on the 8th
substep and not the 7th, short press, release-resets, walk-away voids, the named
`TRASHED_ALREADY_EMPTY`), the contested pick with the item count conserved, and a
100-tick fuzz of random simultaneous presses that re-checks the conservation law after
every tick. `tests/sim/test_replay_carry.py` runs the scripted two-player pass-and-
contest against its committed golden hash (`tests/goldens/carry_two_player.golden.json`).
### Stun, facing, traction
`apply_stun()` runs at the top of the stage: it ticks `Player.stun` down by `dt`, pins
`vel` to zero while it lasts, holds the position, and forces `PlayerState.STUNNED` — then
`IDLE` again from the substep the stun runs out, so a cook is never stuck in the state. It
emits one `EventKind.STUNNED` per cook *entering* a stun (at the cook's position, payload
`player_id` and the `stun` duration), which is how the client plays the hit once instead of
twenty times a second. Whoever causes a stun — phase 10's moving obstacle — sets
`Player.stun = STUN_S`; this is the only place it is spent.
Facing is a rule about *intent*, never about position:
1. an explicit `face` in the frame (d-pad, touch stick) wins;
2. otherwise the last non-zero intent, dominant axis, ties resolve to horizontal;
3. standing still keeps the facing, so an `action` press with no direction does not spin
the cook around (PLAN §5 "one action button");
4. a pushed cook does not turn: being moved is not the same as deciding to go somewhere.
`MoveModifiers(speed_scale, accel_scale, slide)` is the seam phase 10 plugs into: a spill
scales the ceiling (`speed_scale`) and the grip in both directions (`accel_scale`, which is
a different feel from merely being slower), and `slide` removes braking but not throttle —
so no `if hazard` ever appears in this module. Until hazards are real the kernel passes
none, and the traction a cook gets is the floor.
### What proves it
`tests/sim/test_movement.py` is the table-driven half: hand-computed geometry per rule —
walking into each tile kind, each pass-through and blocking fixture, the double-speed
tunnel attempt, the diagonal-parity check, push symmetry, the alcove deadlock, the four-cook
chain, stun entry and expiry, the facing table. `tests/sim/test_movement_stress.py` is the
half nobody hand-computes: four cooks, 2000 substeps, a scripted random walk from the sim's
own splitmix64 over a pillar maze with doorways, a void tile, a counter run and two
fixtures, asserting containment, non-overlap, bounded per-tick displacement and progress
after *every* substep, plus hash equality between two runs of the same script — at
`STEP_DT` and at `MAX_DT`. `tests/sim/test_movement_kernel.py` runs the same walk through
the whole `step()` on the levels in `levels/`, so authored content cannot quietly become
un-walkable.
## Purity and determinism
A5 makes `sim/` import-free of the outside world; `sim/purity.py` parses the package and
fails on `asyncio`, `fastapi`, `socket`, `time` or `uvicorn`, and
`tests/sim/test_step_kernel.py` runs that guard. On top of it:
* **No wall clock.** Time enters only as the `dt` argument. `time`/`datetime` do not
appear in `sim/` — not even in the replay harness (`replay.py`): it measures wall
time only when a caller *injects* a `clock` callable, and reports `0.0` when none
is, so the module imports nothing the purity guard would refuse and the kernel
stays clock-free by construction.
* **No `random`.** `sim/rng.py` is splitmix64 — 20 lines, no stdlib dependency — because
`random.Random`'s stream is explicitly not portable across Python versions and a replay
must survive an interpreter upgrade. The round's integer lives in
`KitchenState.rng_state`; each substep rebuilds an `Rng` from it and writes the counter
back, so the state stays frozen and hashable while subsystems still get a stream.
`Rng.fork(salt)` derives a child stream without disturbing its parent, which is how
orders and hazards get independent streams that both reproduce from the seed.
* **Nothing is mutated.** `KitchenState` and every entity are frozen dataclasses; a
substep returns `dataclasses.replace(...)` copies. Hand a state to `step()` and keep it:
it is still exactly what it was, hash included.
* **Same seed, same input script, same state.** Two states built by `build_state(level,
seats, seed, book)` and fed the same `(dt, inputs)` script stay bit-identical — which
means the same `state_hash()` — for as long as you like. Different seeds diverge.
```bash
uv run pytest tests/sim -q
uv run python -c "from undercooked.sim.step import step; from undercooked.sim.state_build import build_state; print('kernel ok')"
```
## Building a round
`build_state(level, seats, seed, book)` is the only way a round begins, so a round is a
function of exactly four things: level content, seats, seed, inputs. Seats are placed at
`level.spawns[seat % len(level.spawns)]` — a four-cook round on a two-spawn kitchen seats
everyone — at the tile **centre**, with cooks sharing a spawn offset a quarter tile along
x then y so nobody starts inside anybody else. `StateBuildError` covers the two cases with
no deterministic answer: a level with zero spawns, and a negative seat index.
## Replaying and re-blessing goldens
A round is a function of exactly four things — level, seats, seed, inputs — so a
committed script plus a golden state hash is a reproducible test of the whole kernel,
and `sim/replay.py` is the seam that makes it a one-command contract (A21, PLAN §9
layer 3). Three goldens are committed as of task 05: `carry_two_player` (task 04's
12-tick pass and contested pick, checked by `tests/sim/test_replay_carry.py`),
`walk_solo` (600 ticks of movement-only walking around the level-1 kitchen, every
tick recorded) and `pick_drop_pass` (two cooks on the fixture kitchen in
`tests/fixtures/levels/_replay/`: a pick from the shelf, a set-down on the prep,
a `HANDED_OFF` pass, and a long-press trash), each checked by
`tests/sim/test_replay.py`.
### The input script
`InputScript` is one JSON file — `{seed, level_id, tick_hz, frames}` plus the
optional `name`, `record_every` and `items` — and `InputScript.load(path)` /
`.dump(path)` / `.validate()` are its whole surface. `validate()` refuses anything
the kernel cannot replay, naming every problem at once: a non-`u64` `seed`, a
`tick_hz` that is not `1 / STEP_DT` (the script is replayed in `STEP_DT`
substeps, so any other rate is unrunnable), a non-finite `move`, an unknown
`face`, an unknown `ItemKind`/`ItemForm`; `load()` is strict about the shape,
so a typo cannot quietly become a different script.
* A frame is `{tick, player_id, move, action, face}`; it is fed to the substep
that advances `state.tick` from the frame's `tick` (stamped
`received_at_tick = tick`, `seq` = the frame's order for its player), exactly
the stamping a live room does. A long press is eight consecutive `True` frames.
* The seats are derived from the script alone: the unique `player_id`s in order
of first appearance take seats 0, 1, 2, … at the level's spawns (with
`build_state`'s stacking offset). A replay needs no room object and no token
list — the four things, nothing else.
* `items` pre-place `Item(kind, form)` on named stations before tick 0. Phase 04's
pipelines do not exist yet, so an item can enter the world only this way or in
a hand; the `pick_drop_pass` shelf stock is exactly such an entry.
* `record_every` is the golden's cadence: `1` (the default) records every tick,
`0` records nothing (what a timed run uses to skip the hash cost). The built
state (tick 0) and the final tick are always recorded, whatever the cadence.
* The run length is the last frame's tick + 1, or `max_ticks` when the caller
pins it — the `check` CLI pins it to the golden's tick, so a script edited to
be shorter or longer is still compared on the golden's terms.
`run_script(level, book, script, max_ticks=None, clock=None)` is the pure driver:
`build_state`, the pre-placed items, then `step(state, STEP_DT, frames)` per
tick, recording `(tick, state_hash())` at the cadence. The states at the recorded
ticks ride along in the `ReplayResult` so `compare(result, golden)` can diff a
mismatch without re-running anything: `compare()` checks every golden tick in
order, reports the first divergent tick first, and the first hash mismatch
carries the field-level diff (which player, station or order moved since the
last matching tick — `players[0].pos.y`, `stations[2].contents[0]`, …), so a
broken golden is diagnosable in one read. `missing`/`extra` entries flag a
`record_every` cadence mismatch on either side.
### Goldens and the CLI
```
uv run python -m undercooked.sim.replay check tests/fixtures/replays/walk_solo.json
uv run python -m undercooked.sim.replay record tests/fixtures/replays/walk_solo.json
```
`check` runs the script — its level must be in the content directory the CLI
loads (`UNDERCOOKED_LEVELS_DIR`, default `levels/`; the `pick_drop_pass` kitchen
is a test fixture, so its golden is checked through the harness from
`test_replay.py`) — and compares every recorded hash to
`tests/goldens/<name>.golden.json` (the script's provenance, the final tick and
hash, and every recorded hash). Exit `0` when they all match, `1` when any
diverge (the first bad tick and its changed fields are printed), `2` when the
script, the level or the golden is missing. `record` does the same run and writes
the golden: it is the **manual re-bless**, and it says so on screen.
A golden is re-blessed **only** after a deliberate sim-contract change — a
constant moved, a subsystem reordered, a rule intentionally changed — and the
change is recorded in the phase task that makes it. The hash diff then appears
in the commit, which is the point: a golden that moved *without* that record is
the failure the harness exists to catch, and `record` run to silence a failure
is how the guard gets defeated. The numbers under a golden that has not moved
are locked two levels down, where they cannot drift: `tests/sim/test_rng.py`
pins published splitmix64 vectors (the generator itself can never change
quietly) and `tests/sim/test_step_kernel.py` pins the tick count, the clamping,
the accumulation and cross-run hash equality.
### The perf smoke
`tests/sim/test_perf_smoke.py` (marked `slow`, so the quick loop skips it but
the gate's slow stage runs it): four cooks, 2 000 ticks on level 1, one
direction per cook per tick from the sim's own splitmix64. The budget is
asserted and the measured numbers printed in the report: 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.
### What proves it
`tests/sim/test_replay.py` pins the whole seam: both committed scripts match
their goldens (the `pick_drop_pass` event sequence asserted in code, so a drift
shows *which* rule moved), the one-coordinate mutation (first bad tick named,
changed field named), the record-cadence stability and its `record_every = 0`
escape, the in-process double replay, the clock seam, the CLI's three exit
paths (match, mutation, missing level/golden), the `record`-into-a-temp-dir
re-bless, and the golden files' never being rewritten by a test run. The
`carry_two_player` golden stays with `tests/sim/test_replay_carry.py`, where
task 04 pinned it.
## Not real yet
A hook returning the state unchanged is not a bug, it is this phase's scaffolding — but it
is worth naming out loud so nobody reads a green suite as "the game works". As of task 04
the cooks walk, slide, bump, get stunned and stop, and the hands do something: a free
hand picks an item out of a ready fixture or off a counter a teammate set down (a
`HANDED_OFF` inside the window, a `PICKED` outside it), a full hand sets one back down
on a fixture — shelf, work surface or prep counter — and the set-down is marked for
exactly that, a long press over the trash can destroys the carried item on its 8th
substep (`TRASHED`, the raw score delta in the payload, `counters["trashed"]` up by
one), and two cooks who press the same item in the same substep are arbitrated to a
winner and a `CONTESTED_LOST` loser, the item in exactly one place. Every refusal is a
named `DENIED` event. Still identity: no station progresses (phase 04), no order spawns
(phase 04), no hazard burns or spills (phase 10), and the work verbs — chop, cook,
plate, serve, wash — are named by the ladder but unspent no-ops until phase 04's
pipelines exist to spend them (`HOLD_WORK`'s threshold with them; the long-press
`TRASH`, the hand-off and the `HANDLING_S` floor settled at `0.0` arrived with task
04's carry rules). What is real is the shape they all plug into: the clock, the
accumulator, the ordering, the input contract, the body that cannot enter a wall, the
reach zone that cannot see through a wall, the pair of hands that never hold two items
at once, and the round timer that ends a round 12 seconds after it started, whether or
not anybody cooked.
+2 -2
View File
@@ -20,10 +20,10 @@ layer live in `tests/conftest.py`, and layer-specific fixtures live in that laye
| Directory | Contains | I/O allowed |
|---|---|---|
| `tests/unit/` | pure logic with no I/O: config parsing, header values, CLI precedence, protocol/validator helpers, and the doc/content guards that keep README, `.env.example`, `PLAN.md` and the code agreeing | none (in-process `TestClient` requests are fine) |
| `tests/sim/` | tests of the pure kernel that need no server: grid geometry, the entity model, the A5 import-purity guard, and the level validator (phase 02) — plus the headless determinism/replay suites with golden state hashes (`tests/sim/test_replay_*.py`) that arrive with the tick in phase 03 | none — the sim must stay callable without a server |
| `tests/sim/` | tests of the pure kernel that need no server: grid geometry, the entity model, the A5 import-purity guard, and the level validator (phase 02), plus the tick itself (phase 03) — the seeded RNG's published golden vectors (`test_rng.py`), the kernel's clamping/accumulation/purity/determinism suite (`test_step_kernel.py`), and the movement stage: hand-computed geometry per rule (`test_movement.py`), a scripted 2000-substep four-cook walk on the sim's own RNG that re-checks containment, non-overlap, bounded displacement and hash equality after every substep (`test_movement_stress.py`), and the same walk through the whole `step()` on the shipped levels (`test_movement_kernel.py`), and the action button (phase 03 task 03): the reach-zone geometry and one-press-one-event press edge (`test_targeting.py`), and the table-driven verb ladder that covers every `Verb` and every `DeniedReason` plus the gate's contested arbitration (`test_verbs.py`); the golden-state-hash replay suites — `test_replay_carry.py` (task 04's two-player pass and contest), `test_replay.py` (the committed goldens against `sim/replay.py`'s input-script format, the `record`/`check` CLI and the perf seam) — and the `slow`-marked perf smoke (`test_perf_smoke.py`) | none — the sim must stay callable without a server |
| `tests/integration/` | multi-module, in-process tests: app boot through `create_app()`, HTTP routes, static serving, room/protocol behaviour with real WebSocket clients (room + protocol integration is PLAN §9 layer 4, and lives here) | in-process app, loopback sockets |
| `tests/e2e/` | one Playwright suite per user story, named `test_usNN_*.py`; multiplayer stories drive **two browser contexts** against one room | real browser, real socket |
| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` | none |
| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` (`_bad/` holds the deliberately-broken campaign the `_` discovery rule ignores; `_replay/` holds the `pick_drop_pass` fixture kitchen, ignored the same way). `tests/fixtures/replays/` holds the committed input scripts and `tests/goldens/` the committed golden hashes they are gated on (`sim/replay.py`) | none |
There is **no `tests/server/`**: PLAN §9 counts server/route layers of coverage, not
directories. A route asserted as a pure function (header values, status codes, CLI
+287
View File
@@ -0,0 +1,287 @@
"""The hands: one carried item per cook, the counter hand-off, the always-available trash.
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard library
and `undercooked.sim` imports only — no I/O, no asyncio, no clock.
PLAN §5 gives the cook one button and a pair of hands; `sim/interact.py` decides what
the button *means* and this module is what the hands *do*. Three rules live here and
nowhere else:
* **One item per hand.** A full hand is the ladder's `HANDS_FULL`; a hand is empty,
holds an item, or trashes it — never two at once. Items live in hands and on
fixtures: there is no floor drop in v1 (PLAN §5 "throwing is out of scope"), so a
press at open floor is the ladder's `NOTHING_HERE`, not a drop.
* **A set-down is a placed object.** Placing an item on a fixture — a `RELEASE` on
the spawn shelf, a `PLACE_ON_STATION` on a work surface, the `PLATE_DISH` set-down
on the prep counter — stamps it with a mark, `(who put it down, at which tick)`, in
`KitchenState.item_marks`, parallel to the fixture's `contents`. A pick that lifts a
marked item placed by *another* cook within `HANDOFF_WINDOW_TICKS` is a
`HANDED_OFF` (the sanctioned pass, both ids in the payload) rather than a `PICKED`.
A set-down is *grabbable* the moment it is set down: it is an object on a counter,
not a pipeline input. That is the whole of the "a fixture owns what is on it" split
(`grabbable_entry`): a board the pipeline loaded holds its input for the press that
works it (`START_WORK`), a board a cook set an item down on offers a pick.
* **The trash is long-press and always available.** Holding the button over the trash
can for `TRASH_HOLD_TICKS` substeps destroys the carried item: one `TRASHED` event
whose payload carries the raw score nudge (`TRASH_SCORE_DELTA` — clamping the round
score at 0 is phase 04's scoring rule, so the state's score is not touched here),
`counters["trashed"] += 1`, and an empty hand. A threshold met with an empty hand is
`TRASHED_ALREADY_EMPTY` — a named event, never a silent no-op. The gate
(`interact.action_gate`) re-derives the hold each substep, so a cook who walks away
from the can before the threshold voids the hold, and a release resets it to zero.
The mark is state truth (a replay must reproduce the `HANDED_OFF`), so it lives on the
state and in its hash; it is never a snapshot concern (phase 06's projection decides
what leaves the server, and this field is not it). Contested claims are *not* here:
`interact.action_gate` arbitrates them substep-locally (`active_claims`, never
persisted) by `received_at_tick`, then `seq`, then seat index — and this module only
ever hands an item over, or takes one, one cook at a time, so an item is never in two
places.
"""
from __future__ import annotations
from dataclasses import replace
from typing import Final
from undercooked.sim.model import (
Event,
EventKind,
Item,
KitchenState,
Player,
Station,
StationKind,
StationState,
)
__all__ = [
"HANDOFF_WINDOW_TICKS",
"TRASH_HOLD_TICKS",
"TRASH_SCORE_DELTA",
"first_loose_entry",
"grabbable_entry",
"pick_item",
"place_item",
"trash_effect",
"trash_ready",
]
#: How many ticks after a set-down a pick of that item is still a hand-off rather than
#: a fresh pick, inclusive: a pick at `placed_tick + HANDOFF_WINDOW_TICKS` is a pass,
#: one at `placed_tick + 21` is not.
HANDOFF_WINDOW_TICKS: Final[int] = 20
#: How many substeps of one hold the trash needs: the 8th substep of a hold (the one
#: that takes `Player.action_held_ticks` to 8) is the tick that trashes, and never the
#: 7th. A hold released before it, or a cook who walked off the can, trashes nothing.
TRASH_HOLD_TICKS: Final[int] = 8
#: The raw score nudge a trashed item is worth. Phase 04's scoring spends it (clamped
#: at 0 overall); it is stored in the `TRASHED` payload so the delta is real before
#: the scoring exists to spend it, and the state's score is left alone in the meantime.
TRASH_SCORE_DELTA: Final[int] = -2
#: A set-down mark: `(the player id that placed the item, the tick it was placed at)`.
Mark = tuple[str, int]
def _with_station(stations: tuple[Station, ...], updated: Station) -> tuple[Station, ...]:
"""`stations` with `updated` swapped in by id, order untouched."""
return tuple(updated if station.id == updated.id else station for station in stations)
def _with_player(players: tuple[Player, ...], updated: Player) -> tuple[Player, ...]:
"""`players` with `updated` swapped in by id, order untouched."""
return tuple(updated if player.id == updated.id else player for player in players)
def _marks_for(state: KitchenState, station: Station) -> tuple[Mark | None, ...]:
"""The entries parallel to `station.contents`; `None` where no hand put it down."""
entries = state.item_marks.get(station.id)
if entries is not None:
return entries
return tuple(None for _ in range(len(station.contents)))
def grabbable_entry(state: KitchenState, station: Station, index: int) -> bool:
"""May a free hand take `contents[index]` off this fixture?
The whole of the "a fixture owns what is on it" split:
* a spawn shelf hands over whatever it holds — stock (phase 04 replenishes it from
the level's legend) or a set-down, all of it;
* anywhere else, a *placed* item (one with a mark) is grabbable the moment it is
set down: it is an object on a counter, and the hand-off exists for exactly
that;
* an unmarked item on a non-shelf is a pipeline's (phase 04's) input or output:
grabbable only when the station's state says the work is done (`READY`). That is
the "an onion on the board is to be chopped" rule — `START_WORK`, not
`PICK_ITEM`.
"""
if station.kind is StationKind.SPAWN_SHELF:
return True
if _marks_for(state, station)[index] is not None:
return True
return station.state is StationState.READY
def first_loose_entry(state: KitchenState, station: Station) -> tuple[Item, int] | None:
"""The first entry a free hand may take, with its index, or `None`."""
for index, entry in enumerate(station.contents):
if grabbable_entry(state, station, index):
return entry, index
return None
def place_item(
state: KitchenState, player: Player, station: Station, item: Item
) -> tuple[KitchenState, tuple[Event, ...]]:
"""The hand sets `item` down on `station`: hand → contents, a mark, one `DROPPED`.
The fixture's `state` is left alone on purpose: what a set-down *means* to a
pipeline is phase 04's transition, and inventing it here is how a golden ends up
encoding a rule nobody chose. The mark is the hand-off's whole memory of this
set-down. `player` is carrying `item` — the verb ladder guarantees it.
"""
updated_station = replace(station, contents=(*station.contents, item))
entries = _marks_for(state, station)
updated_player = replace(player, carrying=None)
return (
replace(
state,
stations=_with_station(state.stations, updated_station),
players=_with_player(state.players, updated_player),
item_marks={**state.item_marks, station.id: (*entries, (player.id, state.tick))},
),
(
Event(
kind=EventKind.DROPPED,
x=player.pos.x,
y=player.pos.y,
payload={
"player_id": player.id,
"station": station.id,
"item": str(item.kind),
"form": str(item.form),
},
),
),
)
def pick_item(
state: KitchenState, player: Player, station: Station, item: Item
) -> tuple[KitchenState, tuple[Event, ...]]:
"""The item leaves `station` and enters the hand: one `PICKED` — or one `HANDED_OFF`.
The pick removes the first grabbable entry equal to `item` (the entry the target
names) and, with it, the set-down mark. A mark placed by another cook within
`HANDOFF_WINDOW_TICKS` (inclusive) turns the pick into the sanctioned pass:
`HANDED_OFF` with both ids instead of `PICKED`. A self-pick, a pick outside the
window, or an unmarked item is a plain `PICKED`. The `station` argument is the
target's fixture; when the state holds that id, the state's copy is authoritative
(a caller can hold an older object, and the state is the truth).
"""
in_state = next((s for s in state.stations if s.id == station.id), None)
if in_state is not None:
station = in_state
index = -1
for candidate, entry in enumerate(station.contents):
if entry == item and grabbable_entry(state, station, candidate):
index = candidate
break
if index < 0:
msg = f"item {item!r} is not on {station.id}"
raise ValueError(msg)
contents = list(station.contents)
del contents[index]
updated_station = replace(station, contents=tuple(contents))
updated_player = replace(player, carrying=item)
marks = state.item_marks
existing = state.item_marks.get(station.id)
if existing is not None:
shrunk = list(existing)
del shrunk[index]
if shrunk:
marks = {**state.item_marks, station.id: tuple(shrunk)}
else:
marks = {key: value for key, value in state.item_marks.items() if key != station.id}
mark = existing[index] if existing is not None else None
new_state = replace(
state,
stations=_with_station(state.stations, updated_station),
players=_with_player(state.players, updated_player),
item_marks=marks,
)
if (
mark is not None
and mark[0] != player.id
and state.tick - mark[1] <= HANDOFF_WINDOW_TICKS
):
kind = EventKind.HANDED_OFF
payload: dict[str, object] = {
"from": mark[0],
"to": player.id,
"item": str(item.kind),
"form": str(item.form),
}
else:
kind = EventKind.PICKED
payload = {
"player_id": player.id,
"station": station.id,
"item": str(item.kind),
"form": str(item.form),
}
return new_state, (
Event(kind=kind, x=player.pos.x, y=player.pos.y, payload=payload),
)
def trash_ready(player: Player) -> bool:
"""The hold has reached the long-press threshold: the 8th substep of a hold.
`action_held_ticks` has already been incremented for this substep when the gate
asks, so "reached 8" *is* "the 8th substep" — the 7th substep reads 7 and waits.
"""
return player.action_held_ticks >= TRASH_HOLD_TICKS
def trash_effect(state: KitchenState, player: Player) -> tuple[KitchenState, tuple[Event, ...]]:
"""The threshold is met: the carried item goes to the trash — or the empty hand is named.
`TRASHED`: the hand empties, `counters["trashed"] += 1`, and the payload carries
the raw `TRASH_SCORE_DELTA` (phase 04's scoring spends it, clamped at 0; the
state's score is not touched here). `TRASHED_ALREADY_EMPTY`: the threshold was met
with an empty hand. The gate's re-derivation makes this path defensive (a `TRASH`
verb implies a full hand), but the rule is named rather than silent either way.
"""
if player.carrying is None:
return state, (
Event(
kind=EventKind.TRASHED_ALREADY_EMPTY,
x=player.pos.x,
y=player.pos.y,
payload={"player_id": player.id},
),
)
item = player.carrying
new_state = replace(
state,
players=_with_player(state.players, replace(player, carrying=None)),
counters={**state.counters, "trashed": state.counters.get("trashed", 0) + 1},
)
return new_state, (
Event(
kind=EventKind.TRASHED,
x=player.pos.x,
y=player.pos.y,
payload={
"player_id": player.id,
"item": str(item.kind),
"form": str(item.form),
"score_delta": TRASH_SCORE_DELTA,
},
),
)
+230
View File
@@ -0,0 +1,230 @@
"""Collision geometry for the movement pass: what a body may not enter, how far it may go.
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard library
and `undercooked.sim` imports only — no I/O, no asyncio, no clock. Everything here is a
pure function of the grid, the stations and a position; `sim/movement.py` is the only
caller inside the kernel, and it owns the constants (radius, speeds) this module reads
as arguments so the geometry stays reusable for anything else that needs an answer
(projectile, moving obstacle, the client's prediction mirror).
**A cook is a square, not a circle.** PLAN §5 says "axis-separated AABB resolution",
and the square of half-width `radius` centred on the position is exactly what that
resolves against: the same footprint the renderer draws and the client predicts, with
no per-quadrant sign analysis and no `sqrt` in the hot loop. `body_cells()` reports
the cells that square overlaps.
**`CONTACT_EPS` is why contact is stable.** A body whose edge lands exactly on a cell
boundary belongs to *both* cells for `floor()` purposes, so the next sweep would read
itself as intruding and refuse every direction it was asked about — "flush against a
wall" would be a coin flip. Every limit therefore stops `CONTACT_EPS` (1e-4 tile, a
thousandth of a pixel at 32 px/tile) short of the boundary, and `body_cells()` insets
by the same amount, so the invariant *"the cells a body overlaps are never blocked"*
holds at every tick instead of only most of them.
"""
from __future__ import annotations
import math
from collections.abc import Callable, Sequence
from typing import Final
from undercooked.sim.grid import Grid
from undercooked.sim.model import WALKABLE, Station, StationKind, Vec2
__all__ = [
"AXIS_X",
"AXIS_Y",
"CONTACT_EPS",
"PASS_THROUGH_STATIONS",
"CellBlocker",
"body_blocker",
"body_cells",
"cell_blocked",
"station_body_cells",
"sweep_axis",
]
#: The two axes a sweep can run along (`sweep_axis` needs to be told which one).
AXIS_X: Final[str] = "x"
AXIS_Y: Final[str] = "y"
#: Gap kept between a body and anything it is pressed against. See the module docstring:
#: it is what makes "flush" a stable state rather than a position that reads as intruding.
CONTACT_EPS: Final[float] = 1e-4
#: Fixtures a cook may stand *on*, so that a serve window, sink or trash can is usable
#: from the tile itself and not only from beside it (PLAN §5 interaction model). Every
#: other station is a `COUNTER`-like blocker: nobody walks through a stove or a shelf.
PASS_THROUGH_STATIONS: Final[frozenset[StationKind]] = frozenset(
{StationKind.SERVE, StationKind.SINK, StationKind.TRASH}
)
#: `(cell_x, cell_y) -> True` where a body may not be. Built by `body_blocker()`, or
#: written by hand in a test that only cares about one cell.
CellBlocker = Callable[[int, int], bool]
def cell_blocked(grid: Grid, x: int, y: int, extra_blocked: frozenset[int] = frozenset()) -> bool:
"""True where a cook may not stand: off-map, a non-walkable tile, or `extra_blocked`.
`extra_blocked` holds row-major cell indices (`y * grid.width + x`), the same
convention `Grid.bfs_reachable` uses, so the validator's mover exclusion and this
predicate mean the same thing. Out-of-bounds counts as blocked on purpose: the
world has no edge to fall off, and the containment guarantee is stated in this
predicate rather than in a bounds check somewhere else.
"""
if not grid.in_bounds(x, y):
return True
index = y * grid.width + x
if index in extra_blocked:
return True
return grid.tiles[index] not in WALKABLE
def station_body_cells(station: Station) -> tuple[tuple[int, int], ...]:
"""Cells the station's body blocks movement, or `()` when cooks may stand on it.
A fixture occupies the tile it was authored on; `SERVE`, `SINK` and `TRASH` are the
pass-through exceptions (`PASS_THROUGH_STATIONS`) because the cook has to be able to
reach them with their feet as well as their hands.
"""
if station.kind in PASS_THROUGH_STATIONS:
return ()
return ((int(station.x), int(station.y)),)
def _edge_cells(center: float, radius: float) -> tuple[int, int]:
"""The first and last cell overlapped along one axis by a body centred at `center`.
Inset by `CONTACT_EPS` at both ends, so a body exactly flush with a boundary does not
claim the cell it is only touching. Everything that has to agree about containment —
`body_cells()`, and the decision `sweep_axis()` makes about where a move may end —
goes through this one function, on purpose.
The agreement is not cosmetic. `(lead + delta) - eps` and `(centre + delta) + radius
- eps` differ by one float ulp at exactly the boundary where a body becomes flush,
and one ulp is enough for the sweep to call a cell "not entered" while the footprint
calls the same cell "occupied": the cook ends the substep inside a wall by 2e-16,
which is invisible on screen and fatal for a replay golden. Computing the destination
*centre* first and asking this function about it twice makes the two agree by
construction instead of by luck.
"""
return (
math.floor(center - radius + CONTACT_EPS),
math.floor(center + radius - CONTACT_EPS),
)
def _span(center: float, radius: float) -> range:
"""`_edge_cells()` as an iterable inclusive cell range."""
first, last = _edge_cells(center, radius)
return range(first, last + 1)
def body_cells(pos: Vec2, radius: float) -> tuple[tuple[int, int], ...]:
"""Every cell the body square overlaps, in deterministic row-major order.
A body of radius < 0.5 on a tile centre overlaps exactly one cell; a cook pressed
into a corner can touch four, which is why resolution has to consider the whole
footprint instead of the cell under the feet.
"""
return tuple(
(x, y) for y in _span(pos.y, radius) for x in _span(pos.x, radius)
)
def body_blocker(
grid: Grid,
stations: Sequence[Station] = (),
movable_cells: frozenset[int] = frozenset(),
) -> CellBlocker:
"""Build the `(x, y) -> blocked` predicate one tick of movement resolves against.
Terrain first (`cell_blocked`), then the bodies of the fixtures that are not
pass-through. `movable_cells` — row-major indices — lifts *fixture* blocking off the
named tiles and nothing else: it is how the cook carrying a chop board is allowed to
walk over the board they are carrying (phase 10). A wall never negotiates, so
`movable_cells` cannot open terrain.
The fixture set is frozen into the closure once per tick rather than recomputed per
cell, because this is called a handful of times per cook per substep and the room has
a 2 ms budget (PLAN §10).
"""
fixture_cells = frozenset(cell for station in stations for cell in station_body_cells(station))
width = grid.width
def blocked(x: int, y: int) -> bool:
if cell_blocked(grid, x, y):
return True
index = y * width + x
return (x, y) in fixture_cells and index not in movable_cells
return blocked
def _hit(blocked: CellBlocker, axis: str, cell: int, cross: range) -> bool:
"""True when `cell` blocks anywhere along the body's cross-axis span."""
if axis == AXIS_X:
return any(blocked(cell, other) for other in cross)
return any(blocked(other, cell) for other in cross)
def _flush(cell: int, radius: float, direction: int) -> float:
"""The centre that puts a body's edge against `cell` and not one step inside it.
The body ends `CONTACT_EPS` clear of the cell, and `body_cells()` insets by the same
epsilon, so the clamped position is two epsilons away from the boundary that would
make it occupied: float noise of one ulp can never close that gap.
"""
return cell - radius - CONTACT_EPS if direction > 0 else cell + 1.0 + radius + CONTACT_EPS
def sweep_axis(
pos: Vec2,
delta: float,
radius: float,
blocked: CellBlocker,
axis: str = AXIS_X,
) -> float:
"""Greatest movement along `axis` from `pos` that keeps the body out of `blocked`.
Args:
pos: the body's centre before this axis is moved.
delta: wanted movement along `axis`, in tiles; either sign.
radius: half-width of the body square (movement passes `movement.RADIUS`).
blocked: the cell predicate to resolve against.
axis: `AXIS_X` (default) or `AXIS_Y`.
Returns:
`delta` when the whole move is legal, otherwise the clamped movement that leaves
the body short of the first blocked cell on the way. Never overshoots back
through the start, never flips sign, and returns `0.0` for a `0.0` delta.
Every cell the body would newly overlap is tested, not just the destination one,
across the body's whole cross-axis span — that is why a cook at twice `MAX_SPEED`
cannot cross a 1-tile wall in one substep at any `dt` the caller supplies, and why
a cook cannot clip the corner of a diagonal wall. A cell the body already sits in
is not re-tested, so a cook that starts a substep in an impossible place (a spawn
a level author got wrong) is never wedged by it.
"""
if delta == 0.0:
return 0.0
center, other = (pos.x, pos.y) if axis == AXIS_X else (pos.y, pos.x)
cross = _span(other, radius)
target = center + delta
near, far = _edge_cells(target, radius)
now_near, now_far = _edge_cells(center, radius)
limit = target
if delta > 0.0:
for cell in range(now_far + 1, far + 1):
if _hit(blocked, axis, cell, cross):
limit = min(limit, _flush(cell, radius, 1))
else:
for cell in range(now_near - 1, near - 1, -1):
if _hit(blocked, axis, cell, cross):
limit = max(limit, _flush(cell, radius, -1))
if limit == target:
return delta
return max(0.0, limit - center) if delta > 0.0 else min(0.0, limit - center)
+21
View File
@@ -0,0 +1,21 @@
"""Hazard hook for the fixed-step kernel.
**Identity today.** `step()` runs this as substep stage 6, after orders and
before the round timer. Phase 10 (`10_hazards_and_escalation_mechanics`) fills
it in: fire start/spread/extinguish, spills changing traction, moving
obstacles walking their `path` and stunning on contact, and `rush` order
pressure — all seeded through the round's `rng` so a hazard sequence replays
exactly (`KitchenState.hazards` stays empty until then).
"""
from __future__ import annotations
from undercooked.sim.model import Event, KitchenState
from undercooked.sim.rng import Rng
__all__ = ["step"]
def step(state: KitchenState, dt: float, rng: Rng) -> tuple[KitchenState, tuple[Event, ...]]: # noqa: ARG001 - phase 10 uses all three
"""Advance every active hazard and roll new ones (identity until phase 10)."""
return state, ()
+789
View File
@@ -0,0 +1,789 @@
"""What the one action button means right now: the target, the verb, or the reason nothing happens.
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard library and
`undercooked.sim` imports only — no I/O, no asyncio, no clock. Every function here is a pure
function of the state handed to it; the two things an action may legitimately move are an
item between a hand and a station's `contents`, and the cook's own hold counters.
PLAN §5 gives the cook **one button** and a priority order: a full hand empties onto the
fixture in front, an empty hand takes what is grabbable, otherwise the press works the
fixture, and a press with nothing to act on is a *named* refusal the client can show (a
nudge, a whiff) — never a silent no-op. This module answers the three questions that order
implies, and `step()` runs them as the second half of stage 2:
* `select_target(state, player)` — which entity the reaching hand lands on;
* `select_verb(state, player, target)` — what pressing that entity means, or why it does not;
* `resolve_action(state, player, held_for)` — apply one press and return `(state, events)`.
**The reach zone is discrete first, continuous second.** `candidate_cells()` names the tiles
the hand walks — the tile in front, its two diagonal neighbours, the tile under the cook —
in that order, and `REACH + FORGIVE` then decides whether the cook is actually standing
close enough to the tile it found. The two halves answer different questions and neither
duplicates the other: the cell walk is why a fixture two tiles away is never even a
candidate (a station cannot be "in front and to the left" at the same time), and the
distance check is why the diagonal behind your far shoulder is not reachable from the back
corner of the tile you occupy. `collision.PASS_THROUGH_STATIONS` is deliberately *not*
consulted here: whether a cook may walk over a serve window is a different fact from
whether a cook standing on it can use it.
**A fixture owns what is on it.** `station.contents` is not a shelf of grabbable things: a
chop board with an onion on it is an onion *to be chopped*, and pressing it works the item
rather than taking it back — which is the only way the "hold to chop" rule of PLAN §5 can
mean anything. An item becomes grabbable when it is output: on a spawn shelf (stock), or on
a station whose state says the work is done. That rule lives in `_loose_item()` and nowhere
else, so phase 04's pipelines have exactly one place to change their minds.
**A press is one event, not twenty a second.** `action_gate()` consumes the rising edge of
the `action` flag and hands `resolve_action()` a `held_for` of `0.0` for that substep;
every later substep of the same hold reports the seconds it has been down, and the
long-press `TRASH` spends them at its 8-tick threshold (the `HOLD_WORK` threshold is
phase 04's). What a verb *does* with its target is `sim/carry.py`'s job — the one item
per hand, the set-down and its hand-off mark, the trash — so a target is never acted on
twice in one substep and an item is never in two places. `step_dt` is a parameter rather
than an import from `sim/step.py` because the kernel imports *this* module; the tick
length is passed in so there is exactly one edge between them.
"""
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, replace
from enum import StrEnum
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from undercooked.sim import carry
from undercooked.sim.model import (
Event,
EventKind,
Facing,
Item,
ItemForm,
KitchenState,
Player,
PlayerState,
Station,
StationKind,
StationState,
Vec2,
)
if TYPE_CHECKING: # types only: step.py imports this module at runtime
from undercooked.sim.step import PlayerInput
__all__ = [
"CONTESTED_VERBS",
"FACING_EPS",
"FORGIVE",
"REACH",
"REACH_EPS",
"DeniedReason",
"ItemTarget",
"ServeTarget",
"StationTarget",
"Target",
"Verb",
"action_gate",
"candidate_cells",
"cell_center",
"claim_rank",
"claims_entity",
"denied_event",
"faces_target",
"resolve_action",
"select_target",
"select_verb",
"station_cells",
"target_center",
"target_key",
"within_reach",
]
#: How far a cook's hand reaches, in tiles: the tile directly in front is exactly
#: `REACH` away measured centre-to-centre (PLAN §5 "1 tile in front").
REACH: Final[float] = 1.0
#: How much further the reach still forgives (PLAN §5 "then radius 0.6"). It exists for the
#: diagonal-front tiles: their centres are √2 ≈ 1.414 tiles away, so a purely orthogonal
#: reach would refuse every diagonal press, and a purely discrete walk would hand over the
#: diagonal behind your shoulder from the far corner of your own tile (2.12 tiles).
FORGIVE: Final[float] = 0.6
#: Tolerance on the reach comparison, so a target at exactly `REACH + FORGIVE` is in reach
#: and float noise in a position that was *computed* to be there cannot flip the answer.
REACH_EPS: Final[float] = 1e-9
#: A target is `NOT_FACING` once its centre is this far behind the plane of the cook's
#: facing. Anything level with the cook (a serve window they are standing on) is fine.
FACING_EPS: Final[float] = 1e-9
class DeniedReason(StrEnum):
"""Why a press did nothing — the string the client turns into a nudge.
Every refusal names itself. A silent no-op is the failure mode PLAN §5 calls out by
name ("never a silent no-op"): the player cannot tell a bug from a rule, and a support
report cannot tell a hostile client from a level with a bad layout.
"""
OUT_OF_REACH = "out_of_reach"
HANDS_FULL = "hands_full"
HANDS_EMPTY = "hands_empty"
STATION_BUSY = "station_busy"
STATION_DIRTY = "station_dirty"
NOTHING_HERE = "nothing_here"
NOT_FACING = "not_facing"
STUNNED = "stunned"
WRONG_ITEM = "wrong_item"
INVALID_STATE = "invalid_state"
CONTESTED_LOST = "contested_lost"
class Verb(StrEnum):
"""What the button means for one target, chosen by `select_verb()`.
The verbs are a contract with phase 04 (which implements the kitchen pipeline behind
them) as much as with the client (which animates them). `NONE` is not a failure: it is
the absence of a verb, always paired with a `DeniedReason`.
"""
PICK_ITEM = "pick_item"
PICK_FROM_STATION = "pick_from_station"
START_WORK = "start_work"
HOLD_WORK = "hold_work"
PLACE_ON_STATION = "place_on_station"
PLATE_DISH = "plate_dish"
SERVE = "serve"
WASH = "wash"
TRASH = "trash"
RELEASE = "release"
NONE = "none"
#: The verbs that can claim something another cook may be reaching for. Whether a given
#: press actually claims anything — as opposed to taking from infinite stock — is
#: `claims_entity()`; the two cooks tapping a spawn shelf are not in each other's way.
CONTESTED_VERBS: Final[frozenset[Verb]] = frozenset(
{Verb.PICK_ITEM, Verb.PICK_FROM_STATION, Verb.START_WORK}
)
@dataclass(frozen=True)
class StationTarget:
"""A fixture the cook is standing at, with nothing loose to take off it."""
station: Station
@dataclass(frozen=True)
class ServeTarget:
"""The serve window, split out because it is the only target that ends a round's score."""
station: Station
@dataclass(frozen=True)
class ItemTarget:
"""A grabbable item: the hand takes it, so nothing else is considered this press.
Attributes:
item: the item itself (a value — the hand receives this exact object).
on_station_id: the fixture holding it, which is where `resolve_action` takes it
*out* of. `None` means the item is loose on the floor, which the model cannot
hold yet (PLAN §5 has no floor drops: "throwing is out of scope v1"), so
`select_target` never produces one; task 04's hand-off gives it a home.
at: where the item is when its station is not in the state — stamped on every
target `select_target` builds so a target kept from a previous substep can
still answer the reach question. An item target with neither is treated as
already in the outstretched hand (there is no world position to measure).
"""
item: Item
on_station_id: str | None = None
at: Vec2 | None = None
#: Everything the button can land on, or `None` for "nothing at all in the reach zone".
Target = StationTarget | ItemTarget | ServeTarget
#: The reach zone per facing: the tile in front, then the two tiles diagonal-forward from
#: it, then the tile under the cook. Rotating any row 90° clockwise (in screen space, where
#: +y is *down*) gives the next facing's row, so the four facings behave identically rather
#: than each picking its own tie-break — which is what makes a diagonal press feel the same
#: whichever way the cook is pointing.
_CANDIDATE_OFFSETS: Final[Mapping[Facing, tuple[tuple[int, int], ...]]] = MappingProxyType(
{
Facing.UP: ((0, -1), (-1, -1), (1, -1), (0, 0)),
Facing.RIGHT: ((1, 0), (1, -1), (1, 1), (0, 0)),
Facing.DOWN: ((0, 1), (1, 1), (-1, 1), (0, 0)),
Facing.LEFT: ((-1, 0), (-1, 1), (-1, -1), (0, 0)),
}
)
def _cell_of(pos: Vec2) -> tuple[int, int]:
"""The grid cell a continuous position stands in."""
return math.floor(pos.x), math.floor(pos.y)
def cell_center(cell: tuple[int, int]) -> Vec2:
"""The centre of a cell — the point the reach is measured to (`grid` cells are corners)."""
return Vec2(cell[0] + 0.5, cell[1] + 0.5)
def candidate_cells(player: Player) -> tuple[tuple[int, int], ...]:
"""The cells the reaching hand walks, in priority order, deduplicated.
Front tile first (PLAN §5's `REACH`), then the two diagonal-front tiles (which the
`FORGIVE` radius is for), then the tile under the cook — the last one matters because a
serve window, sink and trash can are walkable (`PASS_THROUGH_STATIONS`), so a cook can
be standing *on* the thing they want to use with nothing in front of them.
Cells outside the map are reported as they are: they hold no station, so they cost one
dict lookup and need no bounds check here (the world has no edge to reach).
"""
base = _cell_of(player.pos)
offsets = _CANDIDATE_OFFSETS[player.facing]
return tuple(dict.fromkeys((base[0] + dx, base[1] + dy) for dx, dy in offsets))
def _station_by_id(state: KitchenState, station_id: str) -> Station | None:
"""The station with this id, or `None` when the state no longer has it."""
return next((station for station in state.stations if station.id == station_id), None)
def station_cells(state: KitchenState) -> Mapping[tuple[int, int], Station]:
"""Every fixture keyed by the cell it stands on (its authored tile, not its centre).
Built from `state.stations` because the entity list is the simulation's truth and `Grid`
holds terrain only: phase 04 may move or add a fixture, and the reach has to follow the
entity, not the content file it was born from. Ids are unique per level (PLAN §8), so one
fixture per cell is what the validator guarantees; if a hand-built state disagrees, the
later one in reading order wins, deterministically.
"""
return {(int(station.x), int(station.y)): station for station in state.stations}
def _loose_item(state: KitchenState, station: Station) -> Item | None:
"""The item a free hand may take off this fixture, or `None` (the rule lives in `carry`).
A work surface holds its input for the press that works it (otherwise "press to chop"
would pick the onion back up instead). The whole of the split is
`carry.grabbable_entry`, and nowhere else: a set-down (a marked entry) is grabbable
the moment it is set down, a shelf hands over whatever it holds (stock or set-down),
and a pipeline's unmarked item is grabbable only when the work is done (`READY`).
Phase 04's pipelines have exactly one place to change their minds.
"""
found = carry.first_loose_entry(state, station)
return found[0] if found is not None else None
def _distance(a: Vec2, b: Vec2) -> float:
"""Tile distance between two points (never Manhattan: a hand swings in a circle)."""
return math.hypot(a.x - b.x, a.y - b.y)
def target_center(state: KitchenState, target: Target) -> Vec2 | None:
"""The point a cook has to be near to touch this target, or `None` if it has no position.
A fixture's centre, always (`station.x`/`y` are the cell's lower-left corner, so the
centre is `+0.5` on each axis). An item's position is its fixture's centre while the
fixture is in the state; otherwise the `at` the target was built with; otherwise `None`,
which `within_reach` reads as "in the hand already".
"""
if isinstance(target, ItemTarget):
if target.on_station_id is not None:
station = _station_by_id(state, target.on_station_id)
if station is not None:
return cell_center((int(station.x), int(station.y)))
return target.at
return cell_center((int(target.station.x), int(target.station.y)))
def within_reach(state: KitchenState, player: Player, target: Target) -> bool:
"""Is `target` close enough to this cook's hand to be acted on at all?
`REACH + FORGIVE` from the cook's position to the target's centre, inclusive. Called by
`select_target` (to skip candidates it cannot reach) *and* by `select_verb` (to refuse a
target a caller kept from an earlier substep), so a stale target can never be honoured.
"""
center = target_center(state, target)
if center is None:
return True # an item target with no station and no position is already in the hand
return _distance(player.pos, center) <= REACH + FORGIVE + REACH_EPS
def faces_target(state: KitchenState, player: Player, target: Target) -> bool:
"""Is `target` in front of the cook, rather than behind them?
The test is the half-plane of the cook's facing, not an angle: `select_target` only ever
returns tiles in front, so this exists for the target a caller handed over from the
previous substep — a cook who turned, or a cook the soft push slid round. A target level
with the cook (a serve window they are standing on) passes: `along` is 0, and refusing
that would make the window unusable from the tile itself.
"""
center = target_center(state, target)
if center is None:
return True
delta = center - player.pos
axis = player.facing.delta()
along = delta.x * axis.x + delta.y * axis.y
return along > -FACING_EPS
def select_target(state: KitchenState, player: Player) -> Target | None:
"""The entity the action button is pointed at, or `None` when nothing is.
Walks `candidate_cells()` in priority order and returns the first usable one:
1. a grabbable item, but only with a free hand — a cook who is carrying something is
here to *empty* their hand, so preferring the item would make the placement ladder
unreachable (PLAN §5's "carrying → drop/transfer onto target station" comes first);
2. the serve window as a `ServeTarget`, so the score path is distinguishable from
merely standing at a fixture;
3. the fixture itself;
4. nothing — the next candidate cell, then `None`.
A candidate whose centre is more than `REACH + FORGIVE` away is skipped rather than
refused, so the nearer tile still gets its chance: the front tile is always within it
(the worst case, a cook in the far corner, is √(0.5² + 1.5²) ≈ 1.581), which is what
makes the front tier unconditional and the diagonal tier forgiving.
"""
stations = station_cells(state)
free_hand = player.carrying is None
for cell in candidate_cells(player):
station = stations.get(cell)
if station is None:
continue
center = cell_center(cell)
if _distance(player.pos, center) > REACH + FORGIVE + REACH_EPS:
continue
if free_hand:
item = _loose_item(state, station)
if item is not None:
return ItemTarget(item=item, on_station_id=station.id, at=center)
if station.kind is StationKind.SERVE:
return ServeTarget(station=station)
return StationTarget(station=station)
return None
def _free_hand(player: Player, verb: Verb) -> tuple[Verb, DeniedReason | None]:
"""A verb that needs an empty hand: a full one is the only thing that can refuse it."""
return (Verb.NONE, DeniedReason.HANDS_FULL) if player.carrying is not None else (verb, None)
def _needs_hand(player: Player, verb: Verb) -> tuple[Verb, DeniedReason | None]:
"""A verb that consumes what is held: empty hands are the only thing that can refuse it."""
return (Verb.NONE, DeniedReason.HANDS_EMPTY) if player.carrying is None else (verb, None)
def _needs_form(player: Player, form: ItemForm, verb: Verb) -> tuple[Verb, DeniedReason | None]:
"""A verb that consumes one specific form: nothing held is `HANDS_EMPTY`, anything else is
`WRONG_ITEM`, which is the difference between "go fetch a plate" and "that is not a dish".
"""
if player.carrying is None:
return Verb.NONE, DeniedReason.HANDS_EMPTY
if player.carrying.form is not form:
return Verb.NONE, DeniedReason.WRONG_ITEM
return verb, None
def _working_verb(player: Player, station: Station) -> tuple[Verb, DeniedReason | None] | None:
"""The answer for a fixture mid-job: the cook who started it keeps holding, everyone
else is told it is busy. `None` when the station is not working at all.
"""
if station.state is not StationState.WORKING:
return None
if station.worker_id is not None and station.worker_id != player.id:
return Verb.NONE, DeniedReason.STATION_BUSY
# A working fixture with no worker recorded is phase 04's to attribute; until then the
# press that keeps it working is not refused.
return Verb.HOLD_WORK, None
def _verb_for_work_surface(
player: Player, station: Station
) -> tuple[Verb, DeniedReason | None]:
"""Chop board, stove, prep: the press acts on what is on the surface.
* something on it + a free hand → `START_WORK` (this is the hold-to-chop press);
* something on it + a full hand → `STATION_BUSY`: one item at a time on a surface;
* empty surface + a full hand → `PLATE_DISH` at the prep counter, `PLACE_ON_STATION`
everywhere else (phase 04's acceptance rules may still answer `WRONG_ITEM`);
* empty surface + empty hand → `INVALID_STATE`: the fixture is idle and has nothing, so
no verb applies to it (`NOTHING_HERE` means no *entity* was found at all).
"""
if station.contents:
if player.carrying is not None:
return Verb.NONE, DeniedReason.STATION_BUSY
return Verb.START_WORK, None
if player.carrying is None:
return Verb.NONE, DeniedReason.INVALID_STATE
if station.kind is StationKind.PREP:
return Verb.PLATE_DISH, None
return Verb.PLACE_ON_STATION, None
def _verb_for_idle_station(
player: Player, station: Station
) -> tuple[Verb, DeniedReason | None]:
"""The ladder for a fixture that is idle (or cooling was already refused above), by kind."""
kind = station.kind
if kind is StationKind.SERVE:
return _needs_form(player, ItemForm.PLATED, Verb.SERVE)
if kind is StationKind.SINK:
return _needs_form(player, ItemForm.DIRTY, Verb.WASH)
if kind is StationKind.TRASH:
return _needs_hand(player, Verb.TRASH)
if kind is StationKind.SPAWN_SHELF:
# A shelf is stock, not a work surface: an empty hand takes one of whatever it holds
# (even with `contents` empty — the item kind comes from the level's legend, which is
# phase 04's replenishment), and a full hand can always set one down. That set-down is
# the escape hatch PLAN §5 implies but never names: without it a cook holding a burnt
# dish has nowhere to put it except the trash's penalty.
if player.carrying is not None:
return Verb.RELEASE, None
return Verb.PICK_FROM_STATION, None
return _verb_for_work_surface(player, station)
def _verb_for_station(player: Player, station: Station) -> tuple[Verb, DeniedReason | None]:
"""The ladder for a fixture: station state first, because state outranks kind.
A dirty board is refused whatever you are holding (the fix is phase 04's wash rule, and
the reason the player needs is "this board is filthy", not "you are holding the wrong
thing"); a working one is held by its own cook and busy for everyone else; a blocked one
admits no verb at all; a cooling or occupied one is busy; a ready one hands its output
back. Only then does the kind decide the verb.
"""
if station.dirty or station.state is StationState.DIRTY:
return Verb.NONE, DeniedReason.STATION_DIRTY
working = _working_verb(player, station)
if working is not None:
return working
if station.state is StationState.BLOCKED:
return Verb.NONE, DeniedReason.INVALID_STATE
if station.state is StationState.BUSY or station.cooldown > 0.0:
return Verb.NONE, DeniedReason.STATION_BUSY
if station.state is StationState.READY:
# A ready fixture with a loose item was already returned as an `ItemTarget`, so this
# is the other shape of "ready": the state says finished, the item comes from phase 04.
return _free_hand(player, Verb.PICK_FROM_STATION)
return _verb_for_idle_station(player, station)
def _verb_for_item(
state: KitchenState, player: Player, target: ItemTarget
) -> tuple[Verb, DeniedReason | None]:
"""Take the item — with one hand per cook, and a holder the state can actually empty."""
if player.carrying is not None:
return Verb.NONE, DeniedReason.HANDS_FULL
if target.on_station_id is None or _station_by_id(state, target.on_station_id) is None:
# Nothing in the model holds a loose item except a fixture, so an item target with no
# station there is task 04's floor drop: named, reachable, and not yet liftable.
return Verb.NONE, DeniedReason.INVALID_STATE
return Verb.PICK_ITEM, None
def _placement_refusal(
state: KitchenState, player: Player, target: Target
) -> DeniedReason | None:
"""The refusals a target answers with before its own rules are even read.
Both are re-checked here rather than trusted from `select_target`, because `select_verb`
takes a target a caller may have kept from the previous substep: the reach has to be
re-measured and the facing re-asked, or a cook who turned around could still empty their
hand onto the fixture behind them.
"""
if not within_reach(state, player, target):
return DeniedReason.OUT_OF_REACH
if not faces_target(state, player, target):
return DeniedReason.NOT_FACING
return None
def select_verb(
state: KitchenState, player: Player, target: Target | None
) -> tuple[Verb, DeniedReason | None]:
"""What pressing means for `target`, or the reason it means nothing.
Returns:
`(verb, None)` when the press is legal, `(Verb.NONE, reason)` when it is not. There
is no `(verb, reason)` combination and no `(None, None)`: a client that shows a
nudge needs the reason, and a test that pins the ladder needs the verb to be the
whole answer.
The order is PLAN §5's priority list, with the cook's own condition first: a stunned
cook is `STUNNED` regardless of what they are pointing at, because the client's "you got
hit" is more useful than "you are holding the wrong thing" — and a cook pointing at
nothing is `NOTHING_HERE` before anything else can be said about the press.
"""
if player.stun > 0.0 or player.state is PlayerState.STUNNED:
return Verb.NONE, DeniedReason.STUNNED
if target is None:
return Verb.NONE, DeniedReason.NOTHING_HERE
refusal = _placement_refusal(state, player, target)
if refusal is not None:
return Verb.NONE, refusal
if isinstance(target, ItemTarget):
return _verb_for_item(state, player, target)
# `StationTarget` and `ServeTarget` share the fixture ladder: a window that is dirty,
# cooling or blocked refuses a dish exactly as a board would, and the split between the
# two target types is about what the target *is* (the score path), not about which rules
# it obeys.
return _verb_for_station(player, target.station)
def denied_event(
player: Player, reason: DeniedReason, verb: Verb = Verb.NONE
) -> Event:
"""The one event a refused press produces, at the cook, naming the reason and the verb."""
return Event(
kind=EventKind.DENIED,
x=player.pos.x,
y=player.pos.y,
payload={"player_id": player.id, "reason": str(reason), "verb": str(verb)},
)
def _station_of(target: Target | None) -> Station | None:
"""The fixture a set-down would land on, or `None` for an item target / nothing.
A set-down is never a floor drop (PLAN §5), and the ladder only answers `RELEASE` at
a fixture — so the window is the only target that can reach a set-down verb.
"""
if isinstance(target, (StationTarget, ServeTarget)):
return target.station
return None
def _holder_station(state: KitchenState, target: Target | None) -> Station | None:
"""The fixture an `ItemTarget` would be taken out of, or `None`.
A helper for `resolve_action`'s dispatch so the pick branch has a `Station` it can
empty; `_verb_for_item` has already refused an item target whose station is missing, so
in practice this is either that station or `None` on a path that cannot be reached.
"""
if not isinstance(target, ItemTarget) or target.on_station_id is None:
return None
return _station_by_id(state, target.on_station_id)
def resolve_action(
state: KitchenState, player: Player, held_for: float = 0.0
) -> tuple[KitchenState, tuple[Event, ...]]:
"""Resolve one cook's action button against `state`; never mutates the state or the cook.
Args:
state: the kitchen as of this substep.
player: the cook pressing, as the state holds them (`carrying` and `stun` are what
the ladder reads; the object itself is not stored anywhere).
held_for: how long the button has been down *before* this substep, in seconds.
`0.0` is the press substep itself; the later substeps of a hold are the
gate's to spend (the long-press `TRASH`), so a *direct* call with a positive
`held_for` is the documented no-op — one press, one effect, however long the
button is held.
Returns:
The new state and the events this press produced: exactly one `PICKED` for a pick
(one `HANDED_OFF` when the pick lifts a set-down inside the hand-off window),
one `DROPPED` for a set-down, one `DENIED` for a refusal (whose payload carries
the `DeniedReason`), and nothing at all for a hold, or for a verb the pipeline
does not spend yet (`START_WORK`, `HOLD_WORK`, `SERVE`, `WASH`; the `TRASH`
press starts its hold — the gate spends it at the threshold).
"""
if held_for > 0.0:
return state, ()
target = select_target(state, player)
verb, denial = select_verb(state, player, target)
if denial is not None:
return state, (denied_event(player, denial, verb),)
holder = _holder_station(state, target)
if verb is Verb.PICK_ITEM and isinstance(target, ItemTarget) and holder is not None:
return carry.pick_item(state, player, holder, target.item)
if player.carrying is not None:
station = _station_of(target)
if station is not None and verb in (
Verb.RELEASE,
Verb.PLACE_ON_STATION,
Verb.PLATE_DISH,
):
return carry.place_item(state, player, station, player.carrying)
# TODO(phase 04): START_WORK / HOLD_WORK / SERVE / WASH. `select_verb` names every
# one of them today, so the ladder is pinned by tests now; each of these presses is
# a deliberate no-op until the station pipeline exists to spend it. A no-op is not
# a refusal: no `DENIED`, because the player did nothing wrong. The `TRASH` press
# is the same shape: it starts the hold, and the gate spends it at the 8-tick
# threshold (`_trash_hold`).
return state, ()
def target_key(target: Target) -> tuple[object, ...]:
"""The identity two cooks' claims are compared on.
An item on a fixture is claimed *as that fixture's stock*, not as one entry of it: two
hands reaching into a shelf with two onions in it are reaching for the same pile, and
arbitrating the pile is both what actually happens on screen and what stays stable while
phase 04 reshuffles `contents`. A floor item (task 04) is claimed by its position.
"""
if isinstance(target, ItemTarget):
if target.on_station_id is not None:
return ("item", target.on_station_id)
return ("floor", target.at.KEY if target.at is not None else None)
return ("station", target.station.id)
def claim_rank(player: Player, frame: PlayerInput) -> tuple[int, int, int]:
"""How early this cook's press arrived: `received_at_tick`, then `seq`, then seat.
The same arbitration as `step.resolve_inputs` and the movement deadlock revert, and the
seat index is what makes a tie impossible: two cooks cannot share a seat, so the winner
of a contested press is decided without ever consulting a clock or a dict order.
"""
return (frame.received_at_tick, frame.seq, player.seat)
def action_gate(
state: KitchenState,
frames: Mapping[str, PlayerInput],
step_dt: float,
) -> tuple[KitchenState, tuple[Event, ...]]:
"""Stage 2's second half: turn each frame's `action` flag into holds, and act on the presses.
Runs once per substep, in seat order, on the frames `resolve_inputs` already reduced to
one per seat:
* a frame without `action` clears the cook's `action_held_ticks` / `action_pressed_tick`
(the button is up; the next `True` is a new press);
* the first frame with `action` is the **press**: `action_pressed_tick` records the tick
and the press is queued with `held_for = 0.0`;
* every later frame of the same hold increments `action_held_ticks` and passes the
seconds the button has been down — which the long-press `TRASH` spends at its
8-tick threshold (`_trash_hold`); the `HOLD_WORK` threshold is phase 04's. A
release resets the counters, so a later `True` is a fresh press.
Presses are then resolved in seat order against the running state, so the first cook to
*arrive* (not the first cook iterated) gets a contested item: the claim scan runs against
the pre-action state so every cook is judged on the same kitchen, the earliest frame wins
with the seat index as tie-break, and each loser is told `CONTESTED_LOST` (PLAN §5's
"never a silent no-op") instead of watching the item vanish for no stated reason.
Args:
state: the kitchen at the start of the substep; never mutated.
frames: one resolved `PlayerInput` per player id, in seat order as the kernel built it.
step_dt: the substep length the kernel is running at (`step.STEP_DT`). A parameter so
this module does not import `sim/step.py`, which imports this one.
Returns:
The state with updated counters and any pick/set-down applied (a long-press
trash at its threshold included), plus every event in seat order — at most one
effect per pressing cook, whatever the hold.
"""
updates: dict[str, Player] = {}
held: list[tuple[Player, float]] = []
for player in sorted(state.players, key=lambda seat: (seat.seat, seat.id)):
frame = frames.get(player.id)
if frame is None or not frame.action:
if player.action_held_ticks or player.action_pressed_tick:
updates[player.id] = replace(player, action_held_ticks=0, action_pressed_tick=0)
continue
pressing = player.action_held_ticks == 0
held.append((player, player.action_held_ticks * step_dt))
updates[player.id] = replace(
player,
action_held_ticks=player.action_held_ticks + 1,
action_pressed_tick=state.tick if pressing else player.action_pressed_tick,
)
working = state.with_players(tuple(updates.get(player.id, player) for player in state.players))
if not held:
return working, ()
losers = _contested(state, held, frames)
events: list[Event] = []
for player, held_for in held:
current = updates[player.id]
if player.id in losers:
events.append(denied_event(current, DeniedReason.CONTESTED_LOST))
continue
if held_for > 0.0:
working, produced = _trash_hold(working, current)
events.extend(produced)
continue
working, produced = resolve_action(working, current, held_for)
events.extend(produced)
return working, tuple(events)
def _trash_hold(
state: KitchenState, player: Player
) -> tuple[KitchenState, tuple[Event, ...]]:
"""A hold continuation: the one verb that spends a hold is the long-press `TRASH`.
The press itself is not remembered: the hold is re-derived against the *current*
state. That is what makes a walk-away void the hold (the button is no longer at
the trash can, so the verb is no longer `TRASH`), and a release reset it — the
counters, not a memory, are the hold. `TRASH` with a full hand is the only shape
that continues, and the threshold is `action_held_ticks` having reached
`carry.TRASH_HOLD_TICKS` after this substep's increment: the 8th substep of a hold
is the tick that trashes, and never the 7th.
"""
target = select_target(state, player)
verb, _denial = select_verb(state, player, target)
if verb is not Verb.TRASH or not carry.trash_ready(player):
return state, ()
return carry.trash_effect(state, player)
def claims_entity(verb: Verb, target: Target) -> bool:
"""Does this verb, on this target, take something only one cook can have?
`PICK_ITEM` lifts the item a fixture holds, `START_WORK` takes the single worker slot on
it, and `PICK_FROM_STATION` claims the finished thing only while there *is* a thing in
`contents`. A shelf pick with nothing in `contents` is not a claim: that shelf's stock is
the level's legend, which phase 04 replenishes, so two cooks tapping it in the same
substep both get an onion and neither was ever in anyone's way. Without this distinction
the kernel would answer a legal simultaneous take with `CONTESTED_LOST` — a refusal that
lies about why nothing happened, which PLAN §5 forbids in the same breath as the silent
no-op.
"""
if verb not in CONTESTED_VERBS:
return False
if isinstance(target, ItemTarget):
return True
return isinstance(target, StationTarget) and bool(target.station.contents)
def _contested(
state: KitchenState,
held: Sequence[tuple[Player, float]],
frames: Mapping[str, PlayerInput],
) -> frozenset[str]:
"""The ids of the cooks who lost a claim on an entity another cook pressed for first.
Only a press that would otherwise *succeed* enters the running (`claims_entity`, then no
denial): a cook who is stunned, empty-handed or pointing at nothing is refused by their
own reason, and handing them `CONTESTED_LOST` would blame the other cook for a refusal
that had nothing to do with them.
"""
claims: dict[tuple[object, ...], tuple[tuple[int, int, int], str]] = {}
losers: set[str] = set()
for player, _held_for in held:
target = select_target(state, player)
if target is None:
continue
verb, denial = select_verb(state, player, target)
if denial is not None or not claims_entity(verb, target):
continue
rank = claim_rank(player, frames[player.id])
key = target_key(target)
best = claims.get(key)
if best is None:
claims[key] = (rank, player.id)
elif rank < best[0]:
losers.add(best[1])
claims[key] = (rank, player.id)
else:
losers.add(player.id)
return frozenset(losers)
+22 -1
View File
@@ -222,7 +222,13 @@ class PlayerState(StrEnum):
@dataclass(frozen=True)
class Player:
"""One seat in the kitchen; `id` is the session token, `seat` is stable."""
"""One seat in the kitchen; `id` is the session token, `seat` is stable.
`action_held_ticks` and `action_pressed_tick` are the kernel's memory of the one action
button: how many consecutive substeps it has been down, and the tick that hold began on.
`sim/interact.py`'s `action_gate` is the only writer, and a frame without `action` clears
both — which is what turns a held flag arriving twenty times a second into one press.
"""
id: str
pos: Vec2 = Vec2()
@@ -235,6 +241,8 @@ class Player:
dishes_served: int = 0
connected: bool = True
seat: int = 0
action_held_ticks: int = 0
action_pressed_tick: int = 0
@dataclass(frozen=True)
@@ -331,7 +339,12 @@ class EventKind(StrEnum):
STUNNED = "stunned"
WASHED = "washed"
TRASHED = "trashed"
PICKED = "picked" # an item left a fixture and entered a hand (sim/interact.py)
DROPPED = "dropped" # a hand set its item down on a fixture (sim/interact.py)
HANDED_OFF = "handed_off" # a set-down picked up in the window: the pass (sim/carry.py)
TRASHED_ALREADY_EMPTY = "trashed_already_empty" # an empty hand at threshold (sim/carry.py)
TICK_SLOW = "tick_slow"
ERROR = "err" # a malformed or unattributable input, never an exception
@dataclass(frozen=True)
@@ -350,6 +363,12 @@ class KitchenState:
Frozen: phases 03/04 advance the round by *returning* a new state from
`step()`; the `with_*` helpers keep every mutation in one obvious place.
`item_marks` is the hand-off's memory (sim/carry.py): per station id, a tuple
parallel to that station's `contents` whose entries are the set-down mark
`(placer id, placed-at tick)` — or `None` for an entry no hand put down.
It is state truth (a replay must reproduce the `HANDED_OFF` it produces)
and never a snapshot concern (phase 06's projection does not project it).
"""
level_id: str
@@ -366,7 +385,9 @@ class KitchenState:
streak: int = 0
result: RoundResult | None = None
counters: Mapping[str, int] = field(default_factory=dict)
item_marks: Mapping[str, tuple[tuple[str, int], ...]] = field(default_factory=dict)
rng_state: int = 0
accumulator: float = 0.0 # seconds not yet worth a whole STEP_DT substep
def state_hash(self) -> str:
"""sha256 over the canonical form of every field (A21 replay guard).
+458
View File
@@ -0,0 +1,458 @@
"""Movement, collision, facing and stun — substep stage 3 (PLAN §5).
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard library
and `undercooked.sim` imports only. `step_movement()` is a pure function of
(`state`, `dt`, `intent`, `modifiers`): no clock, no `random`, nothing mutated.
`step()` runs this in slot 3, and it owns two guarantees that the rest of the game is
allowed to assume without re-checking:
1. **A cook never ends a tick inside a blocked tile** — wall, counter, void, off-map,
or a fixture that is not a pass-through (`sim/collision.py` says which). Movement is
integrated one axis at a time, each axis clamped by `collision.sweep_axis()` to the
greatest legal delta, so a body is stopped *before* it intrudes rather than pushed
out afterwards. That is also why sliding along a wall needs no special case, and why
a cook at twice `MAX_SPEED` still cannot cross a 1-tile wall: the sweep tests every
cell between the old and the new edge.
2. **Two cooks never end a tick overlapped**, unless the kitchen physically cannot
separate them. The soft push is symmetric (mass 1: each cook gives half of the
separation, and whatever one cannot take the other takes), positional only — it
never injects velocity, so being bumped does not fling anyone — and it runs twice so
a chain of four propagates instead of leaving the last cook inside the third. On a
genuine deadlock (both cooks boxed by terrain, e.g. stacked in a 1x1 alcove) the
**later-received input** is what gets dropped, which is the same arbitration rule
`step.resolve_inputs` uses: the cook whose frame arrived first keeps their move.
Facing follows the last non-zero intent, never the position: an action pressed while
standing still must not spin the cook (PLAN §5 "one action button"), and an explicit
d-pad/touch `face` beats the vector.
`MoveModifiers` is the traction seam phase 10 needs: a spill scales the ceiling and the
grip, and `slide` removes the braking entirely, without a single `if hazard` here.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Final
from undercooked.sim.collision import (
AXIS_X,
AXIS_Y,
CONTACT_EPS,
CellBlocker,
body_blocker,
sweep_axis,
)
from undercooked.sim.model import (
Event,
EventKind,
Facing,
KitchenState,
Player,
PlayerState,
Vec2,
)
if TYPE_CHECKING: # types only: step.py imports this module at runtime
from undercooked.sim.step import PlayerInput
__all__ = [
"ACCEL",
"DECEL",
"DIAGONAL_NORM",
"MAX_SPEED",
"MOVE_EPS",
"PUSH_ITERATIONS",
"PUSH_SLOP",
"RADIUS",
"STUN_S",
"MoveModifiers",
"apply_stun",
"desired_velocity",
"intent_direction",
"resolve_moves",
"step_movement",
"update_facing",
]
#: Speed ceiling in tiles/s. PLAN §5: no diagonal bonus — `intent_direction()` makes a
#: diagonal a *direction*, not extra throttle.
MAX_SPEED: Final[float] = 3.5
#: How fast intent becomes velocity (tiles/s²), and how much faster it stops again.
#: Deceleration is the larger of the two on purpose: overshooting a station is the
#: mistake players make most, and a cook that stops late feels broken.
ACCEL: Final[float] = 30.0
DECEL: Final[float] = 40.0
#: Half-width of the cook's body square, in tiles (`collision` resolves against it).
RADIUS: Final[float] = 0.35
#: `True` (PLAN §5) keeps the intent's *length* at or below 1, so (1, 1) is the same
#: speed as (1, 0). `False` clamps each axis instead — a legacy input model where a
#: diagonal really is 41 % faster. Read at call time, so it is a switch, not a comment.
DIAGONAL_NORM: Final[bool] = True
#: Seconds a hit keeps a cook still (PLAN §5, `moving_obstacle`). Whoever stuns sets
#: `Player.stun` to this; `apply_stun()` is what ticks it down and enforces it.
STUN_S: Final[float] = 0.6
#: A stun is over once the remainder is this small. `0.6 - 12 * 0.05` is not zero in
#: binary floats (it is 1.4e-17), and a cook frozen for good by arithmetic residue is the
#: worst possible version of a 0.6 s stagger. `STUN_S` is an exact multiple of `STEP_DT`,
#: so the only thing that can leave a remainder is float error.
STUN_EPS: Final[float] = 1e-9
#: Below this speed a cook is standing still, for the purposes of `Player.state`.
MOVE_EPS: Final[float] = 1e-6
#: Soft-push passes. Two is enough to propagate a separation through a chain of four
#: cooks without ever moving one cook twice in a way another order would not.
PUSH_ITERATIONS: Final[int] = 2
#: Overlap below this is float noise from a settled stack, not a collision worth
#: correcting — without it, cooks standing shoulder to shoulder jitter forever.
PUSH_SLOP: Final[float] = 1e-6
@dataclass(frozen=True)
class MoveModifiers:
"""Traction overrides for one cook for one substep (phase 10 spills use these).
Attributes:
speed_scale: multiplier on `MAX_SPEED` (0.0 roots the cook, 0.5 is a walk).
accel_scale: multiplier on both `ACCEL` and `DECEL` — a spill has less grip in
both directions, which is a different feel from merely being slower.
slide: nothing brakes the cook when the intent is zero. Velocity is kept, so an
icy tile throws you into the next room instead of stopping you on it.
"""
speed_scale: float = 1.0
accel_scale: float = 1.0
slide: bool = False
def intent_direction(intent: Vec2) -> Vec2:
"""The authoritative velocity direction: never longer than 1, whatever was sent.
A hostile or buggy client can put 50 in `PlayerInput.move`; the server does not
negotiate, it clamps. With `DIAGONAL_NORM` the whole vector is scaled into the unit
disc, so a partial stick still means a partial walk (analog control) while a
diagonal buys nothing.
"""
if DIAGONAL_NORM:
length = intent.length
if length > 1.0:
return intent.scale(1.0 / length)
return intent
return Vec2(
max(-1.0, min(1.0, intent.x)),
max(-1.0, min(1.0, intent.y)),
)
def desired_velocity(
player: Player,
intent: Vec2,
dt: float,
modifiers: MoveModifiers | None = None,
) -> Vec2:
"""One substep of acceleration toward `intent`, capped and gripped.
Args:
player: the cook, whose current `vel` is the starting point.
intent: raw `PlayerInput.move`; clamped by `intent_direction()`, so a zero
vector means "brake" and anything longer than 1 tile/s of throttle is a
client's optimism, not a speed boost.
dt: seconds for this substep (always `step.STEP_DT` from the kernel).
modifiers: traction for this substep; `None` is the ordinary floor.
Returns:
The new velocity. Never mutated input, never a speed above
`MAX_SPEED * speed_scale` from a standing start, and always a vector *toward*
the intent — the position it produces is `resolve_moves()`'s business.
"""
mods = MoveModifiers() if modifiers is None else modifiers
target = intent_direction(intent).scale(MAX_SPEED * mods.speed_scale)
delta = target - player.vel
if target.length >= player.vel.length:
grip = ACCEL * mods.accel_scale
elif mods.slide:
grip = 0.0
else:
grip = DECEL * mods.accel_scale
limit = grip * dt
if delta.length > limit:
delta = delta.normalized().scale(limit)
return player.vel + delta
def update_facing(
player: Player,
intent: Vec2,
action: bool = False, # noqa: ARG001 - part of the contract: a press never turns
face: Facing | None = None,
) -> Facing:
"""The facing for this substep: explicit turn, then last non-zero intent, then hold.
The dominant axis wins, and a perfect 45° press resolves to the horizontal axis so
that the same input always produces the same facing. Zero intent keeps the facing
exactly — including when the cook pressed the action button, which is the whole
point of the `action` argument: picking something up must never spin you around.
"""
if face is not None:
return face
if intent.x == 0.0 and intent.y == 0.0:
return player.facing
if abs(intent.x) >= abs(intent.y):
return Facing.RIGHT if intent.x > 0.0 else Facing.LEFT
return Facing.DOWN if intent.y > 0.0 else Facing.UP
def apply_stun(
players: Sequence[Player],
dt: float,
) -> tuple[tuple[Player, ...], tuple[Event, ...]]:
"""Tick every cook's stun down, pin the stunned ones, and report entries.
Returns:
The players with `stun` reduced (floored at 0), `vel` pinned to zero while
stunned, and `state` reflecting it — `STUNNED` while any stun remains, `IDLE`
from the substep the stun runs out (so a cook is never stuck in the state) —
plus one `EventKind.STUNNED` per cook *entering* a stun, which is how the
client plays the hit once instead of twenty times a second.
Whoever causes a stun (phase 10's moving obstacle) sets `Player.stun = STUN_S`;
this is the only place it is spent.
"""
updated: list[Player] = []
events: list[Event] = []
for player in players:
if player.stun <= 0.0:
updated.append(
player
if player.state is not PlayerState.STUNNED
else replace(player, state=PlayerState.IDLE)
)
continue
if player.state is not PlayerState.STUNNED:
events.append(
Event(
kind=EventKind.STUNNED,
x=player.pos.x,
y=player.pos.y,
payload={"player_id": player.id, "stun": player.stun},
)
)
remaining = player.stun - dt
revived = remaining <= STUN_EPS
updated.append(
replace(
player,
stun=0.0 if revived else remaining,
vel=Vec2(),
state=PlayerState.IDLE if revived else PlayerState.STUNNED,
)
)
return tuple(updated), tuple(events)
def _slide(
pos: Vec2,
axis: str,
delta: float,
blocked: CellBlocker,
) -> tuple[Vec2, float]:
"""Move along one axis as far as the tiles allow; return (new position, distance moved)."""
allowed = sweep_axis(pos, delta, RADIUS, blocked, axis)
if axis == AXIS_X:
return Vec2(pos.x + allowed, pos.y), allowed
return Vec2(pos.x, pos.y + allowed), allowed
def _integrate(pos: Vec2, vel: Vec2, dt: float, blocked: CellBlocker) -> Vec2:
"""Axis-separated position update: sweep X, then sweep Y from where X left you.
Order matters and is the contract: the same velocity against the same corner always
leaves the cook in the same place, whichever way the client happened to be moving.
"""
pos, _moved = _slide(pos, AXIS_X, vel.x * dt, blocked)
pos, _moved = _slide(pos, AXIS_Y, vel.y * dt, blocked)
return pos
def _contacts(players: Sequence[Player]) -> list[tuple[int, int]]:
"""Indices of every overlapping pair, in the deterministic order they were built."""
hits: list[tuple[int, int]] = []
for i, first in enumerate(players):
for index in range(i + 1, len(players)):
other = players[index]
overlapped_x = 2 * RADIUS - abs(other.pos.x - first.pos.x)
overlapped_y = 2 * RADIUS - abs(other.pos.y - first.pos.y)
if overlapped_x > PUSH_SLOP and overlapped_y > PUSH_SLOP:
hits.append((i, index))
return hits
def _push_pair(players: list[Player], i: int, j: int, blocked: CellBlocker) -> bool:
"""Separate one overlapped pair: half each, whatever one cannot take the other takes.
Returns:
`True` when the pair is deadlocked — terrain took *both* cooks, so not a tile
of separation was possible. That, and not merely "still touching", is what
earns the later input a revert: a jam of four cooks in an open kitchen is not a
deadlock, it is a queue, and reverting it every tick would fuse the cooks
together forever instead of letting the queue drain.
The minimum-penetration axis is the one that moves the least, and a body of
`2 * RADIUS` in a 1-tile corridor means that is almost always the corridor's own
axis. Coincident centres break the tie by iteration order, which is seat order —
never by the sign of a `0.0` float.
"""
a, b = players[i], players[j]
delta_x = b.pos.x - a.pos.x
delta_y = b.pos.y - a.pos.y
overlap_x = 2 * RADIUS - abs(delta_x)
overlap_y = 2 * RADIUS - abs(delta_y)
axis = AXIS_X if overlap_x <= overlap_y else AXIS_Y
need = (overlap_x if axis == AXIS_X else overlap_y) + CONTACT_EPS
sign = 1.0 if (delta_x if axis == AXIS_X else delta_y) >= 0.0 else -1.0
half = need / 2.0
a_pos, moved_a = _slide(a.pos, axis, -sign * half, blocked)
remaining = need - abs(moved_a)
b_pos, moved_b = _slide(b.pos, axis, sign * remaining, blocked)
remaining -= abs(moved_b)
if remaining > 0.0:
a_pos, extra = _slide(a_pos, axis, -sign * remaining, blocked)
moved_a += extra
players[i] = replace(a, pos=a_pos)
players[j] = replace(b, pos=b_pos)
return abs(moved_a) + abs(moved_b) <= PUSH_SLOP
def _input_key(player: Player, intent: Mapping[str, PlayerInput]) -> tuple[int, int, int]:
"""How "recent" this cook's input is: a cook with no frame this tick is never later."""
frame = intent.get(player.id)
if frame is None:
return (-1, -1, player.seat)
return (frame.received_at_tick, frame.seq, player.seat)
def _separate(
players: list[Player],
started: Mapping[str, Player],
intent: Mapping[str, PlayerInput],
blocked: CellBlocker,
) -> None:
"""Soft-push the cooks apart, twice for chains; revert the later input on a deadlock.
The push corrects positions only — `vel` of both cooks is untouched, so a bump
never throws anybody and a cook who was walking keeps walking. A pair the terrain
refuses to separate (a 1x1 alcove holding two cooks is the only layout that can do
it) is a deadlock, and the cook whose input was received later is restored to where
the tick started: the same earliest-frame-wins arbitration the kernel uses for
contested targets. A pair that merely has not finished untangling — a jam of four
in a doorway — is left to the next tick rather than reverted, so it drains.
"""
stuck: list[tuple[int, int]] = []
for _ in range(PUSH_ITERATIONS):
contacts = _contacts(players)
if not contacts:
return
stuck = [(i, j) for i, j in contacts if _push_pair(players, i, j, blocked)]
losers: set[str] = set()
for i, j in stuck:
first, second = players[i], players[j]
loser = first if _input_key(first, intent) >= _input_key(second, intent) else second
losers.add(loser.id)
if losers:
for index, player in enumerate(players):
if player.id in losers:
players[index] = started[player.id]
def _frames(
intent: Mapping[str, PlayerInput] | Sequence[PlayerInput] | None,
) -> Mapping[str, PlayerInput]:
"""Index a frame by player id, whichever shape the caller handed over.
The kernel hands `resolve_moves` the mapping `resolve_inputs` built, and a test or a
future subsystem tends to hand a plain list of `PlayerInput`. Keying by `player_id`
makes the two identical; a duplicate id keeps the later entry, which is the same
"last write wins" a dict literal would have given anyway.
"""
if intent is None:
return {}
if isinstance(intent, Mapping):
return intent
return {frame.player_id: frame for frame in intent}
def resolve_moves(
state: KitchenState,
dt: float,
intent: Mapping[str, PlayerInput] | Sequence[PlayerInput] | None = None,
modifiers: Mapping[str, MoveModifiers] | None = None,
) -> tuple[tuple[Player, ...], tuple[Event, ...]]:
"""Resolve every cook's position for one substep, in seat order.
Args:
state: the state at the start of the substep; never mutated.
dt: seconds for this substep (always `step.STEP_DT` from the kernel; a `0.0`
or negative `dt` still ticks stun down by nothing and moves nobody).
intent: one `PlayerInput` per player id, as `step.resolve_inputs` ordered it (a
mapping or a sequence both work) — cooks with no frame simply brake to a stop.
modifiers: per-cook traction, keyed by player id; missing means the floor.
Returns:
The new `Player` tuple — same length, same order, `pos`/`vel`/`facing`/`state`
updated — and the events (only stun entries; a step of walking is not an event).
Guarantees: nobody ends inside a blocked tile, and nobody ends overlapped with
another cook unless both are physically boxed in, in which case the later input
was reverted.
"""
frames = _frames(intent)
tractions: Mapping[str, MoveModifiers] = {} if modifiers is None else modifiers
blocked = body_blocker(state.grid, state.stations)
pinned, events = apply_stun(state.players, dt)
started = {player.id: player for player in pinned} # a revert never undoes a stun tick
moved: list[Player] = []
for player in pinned:
frame = frames.get(player.id)
want = Vec2() if frame is None else frame.move
if player.stun > 0.0:
moved.append(player)
continue
vel = desired_velocity(player, want, dt, tractions.get(player.id))
moved.append(
replace(
player,
pos=_integrate(player.pos, vel, dt, blocked),
vel=vel,
facing=update_facing(
player,
want,
face=None if frame is None else frame.face,
),
state=PlayerState.MOVING if vel.length > MOVE_EPS else PlayerState.IDLE,
)
)
_separate(moved, started, frames, blocked)
return tuple(moved), events
def step_movement(
state: KitchenState,
dt: float,
intent: Mapping[str, PlayerInput] | Sequence[PlayerInput] | None = None,
modifiers: Mapping[str, MoveModifiers] | None = None,
) -> tuple[KitchenState, tuple[Event, ...]]:
"""Substep stage 3: walk the cooks and hand the new tuple back to the kernel."""
players, events = resolve_moves(state, dt, intent, modifiers)
return state.with_players(players), events
+21
View File
@@ -0,0 +1,21 @@
"""Order lifecycle hook for the fixed-step kernel.
**Identity today.** `step()` runs this as substep stage 5. Phase 04 fills it in:
spawning orders from `Level.orders` (`pool`, `concurrent`, `spawn_interval`,
`deadline`, `penalty`) drawing from `rng`, counting `Order.time_left` down,
and the `served`/`order_missed`/`score` events with the streak multiplier of
PLAN §5. The `rng` argument is the substep's stream, so an order sequence is
reproducible from the round seed alone.
"""
from __future__ import annotations
from undercooked.sim.model import Event, KitchenState
from undercooked.sim.rng import Rng
__all__ = ["step"]
def step(state: KitchenState, dt: float, rng: Rng) -> tuple[KitchenState, tuple[Event, ...]]: # noqa: ARG001 - phase 04 uses all three
"""Spawn, tick and retire orders (identity until phase 04)."""
return state, ()
+764
View File
@@ -0,0 +1,764 @@
"""The replay harness: input scripts, golden hashes, and the record/check CLI (A21).
A round is a function of exactly four things — the `Level`, the seats dealt, the
seed, and the input script — so a committed script plus its golden state hash is a
reproducible test of the whole kernel: `build_state`, `step()`, and every subsystem
in the documented order. This module is the seam that makes that a one-command
contract (PLAN §9 layer 3, A21):
* `InputScript` — the reusable script format. `{seed, level_id, tick_hz, frames}`
plus optional `name`, `record_every` and `items`; `InputScript.load(path)`,
`.dump(path)` and `.validate()` are its whole surface. A frame is
`{tick, player_id, move, action, face}`; the seats are derived from the script
alone (the unique `player_id`s in first-appearance order take seats 0, 1, 2, …),
so a replay needs no room object and no token list. `tick_hz` must be
`1 / STEP_DT` (20): the script is replayed in `STEP_DT` substeps, so any other
rate is a script the kernel cannot run.
* `run_script(level, book, script, max_ticks=None, clock=None)` — the pure driver:
build the state, apply the script's pre-placed `items`, feed each frame to the
substep that advances `state.tick` from the frame's `tick` (stamping
`received_at_tick = tick`, `seq` = the frame's order for its player), and record
`(tick, state_hash())` at the script's `record_every` cadence (tick 0 and the
final tick always recorded; `record_every = 0` records nothing, which is what a
timed run uses to skip the hash cost). The wall clock is *injected*, never
imported — the kernel stays clock-free (A5), and `wall_seconds` is `0.0` unless
a `clock` callable is passed. The states at the recorded ticks ride along in the
result so `compare()` can diff them without re-running anything.
* `compare(result, golden)` — every golden tick against the run's hashes, first
divergent tick first; the first hash mismatch carries the field-level diff
(which player, station or order moved since the last matching tick), so a
broken golden is diagnosable in one read.
* The CLI: `python -m undercooked.sim.replay check <script.json>` runs the script
(its level must be in the content directory the CLI loads —
`UNDERCOOKED_LEVELS_DIR`, default `levels/`) and compares it to
`tests/goldens/<name>.golden.json`; exit `0` on a match, `1` on a divergence
(the first bad tick and its changed fields are printed), `2` when the script,
the level or the golden is missing. `record` does the same run and writes the
golden: it is the manual re-bless — legitimate only after a deliberate
sim-contract change recorded in the phase task, never to silence a failure
(docs/SIMULATION.md, "Replaying and re-blessing goldens").
Purity note (A5): this module imports no clock and no I/O library at top level —
`json`/`argparse` are stdlib, and the content load the CLI needs (`sim.content`,
which the server uses at startup) is imported inside the CLI path, so the library
core stays `undercooked.sim` + stdlib.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field, fields, is_dataclass, replace
from pathlib import Path
from typing import Final
from undercooked.sim.level import Level
from undercooked.sim.model import Event, Facing, Item, ItemForm, ItemKind, KitchenState, Vec2
from undercooked.sim.recipe import RecipeBook
from undercooked.sim.state_build import SeatSpec, build_state
from undercooked.sim.step import STEP_DT, PlayerInput, step
__all__ = [
"EXIT_MISMATCH",
"EXIT_OK",
"EXIT_UNUSABLE",
"EXPECTED_TICK_HZ",
"FACING_VALUES",
"Frame",
"HashMismatch",
"InputScript",
"ReplayError",
"ReplayResult",
"ScriptItem",
"compare",
"main",
"run_script",
]
#: `python -m undercooked.sim.replay` exits `0` on a match, `1` on a divergence,
#: `2` when it could not act (the same convention `undercooked-levels` uses).
EXIT_OK: Final = 0
EXIT_MISMATCH: Final = 1
EXIT_UNUSABLE: Final = 2
#: The one rate the kernel runs at: a script at any other `tick_hz` is unrunnable.
EXPECTED_TICK_HZ: Final[float] = 1.0 / STEP_DT
#: The splitmix64 width a `seed` may occupy (`KitchenState.rng_state` is a u64).
MAX_SEED: Final[int] = 1 << 64
#: The four facing values a frame's `face` may carry (the `Facing` values, as strings).
FACING_VALUES: Final[frozenset[str]] = frozenset({"up", "right", "down", "left"})
#: Where the goldens live: `tests/goldens/<script-stem>.golden.json`.
GOLDENS_DIR: Final[Path] = Path(__file__).resolve().parents[3] / "tests" / "goldens"
class ReplayError(ValueError):
"""A script (or a script/level/golden combination) the harness cannot act on."""
def _frame_problems(index: int, frame: Frame) -> list[str]:
"""The problems with one frame (the `validate()` contract, one frame at a time)."""
where = f"frame {index}"
problems: list[str] = []
if isinstance(frame.tick, bool) or not isinstance(frame.tick, int) or frame.tick < 0:
problems.append(f"{where}: tick must be a non-negative integer, got {frame.tick!r}")
if not isinstance(frame.player_id, str) or not frame.player_id:
problems.append(f"{where}: player_id must be a non-empty string")
if (
len(frame.move) != 2
or any(
isinstance(v, bool) or not isinstance(v, int | float) or not math.isfinite(v)
for v in frame.move
)
):
problems.append(f"{where}: move must be two finite numbers, got {frame.move!r}")
if not isinstance(frame.action, bool):
problems.append(f"{where}: action must be a bool, got {frame.action!r}")
if frame.face is not None and frame.face not in FACING_VALUES:
problems.append(
f"{where}: face must be one of {sorted(FACING_VALUES)} or null, got {frame.face!r}"
)
return problems
def _item_problems(index: int, entry: ScriptItem) -> list[str]:
"""The problems with one pre-placed item (the `validate()` contract, one at a time)."""
where = f"item {index}"
problems: list[str] = []
if not isinstance(entry.station, str) or not entry.station:
problems.append(f"{where}: station must be a non-empty string")
if entry.item not in _ITEM_KIND_VALUES:
problems.append(f"{where}: item must be an ItemKind value, got {entry.item!r}")
if entry.form not in _ITEM_FORM_VALUES:
problems.append(f"{where}: form must be an ItemForm value, got {entry.form!r}")
return problems
@dataclass(frozen=True)
class ScriptItem:
"""One item pre-placed on a station before tick 0.
Phase 04's station pipelines do not exist yet, so an item can enter the world
only this way or in a hand: `run_script` appends `Item(kind, form)` to the
named station's `contents` in script order. A shelf's stock is exactly this
entry (phase 04's replenishment will join it).
"""
station: str
item: str # an `ItemKind` value
form: str # an `ItemForm` value
@dataclass(frozen=True)
class Frame:
"""One input frame: fed to the substep that advances `tick` -> `tick + 1`.
Attributes:
tick: the `KitchenState.tick` the frame is fed at (its `received_at_tick`).
player_id: the seat it names; unknown ids become `err` events through
`step()`, exactly as they do for a live client.
move: the movement vector as sent; `movement.intent_direction()` makes it
authoritative (length clamped to <= 1) the same way it does on the wire.
action: the one-button flag; the action gate turns it into holds, so a
long press is eight consecutive `True` frames.
face: an explicit facing, or `None` for "derive from `move`".
"""
tick: int
player_id: str
move: tuple[float, float]
action: bool = False
face: str | None = None
@dataclass(frozen=True)
class InputScript:
"""One replay, as authored: seed, level, rate, and the frames that drive it.
`validate()` refuses anything the kernel cannot replay (a non-u64 seed, a
`tick_hz` that is not 20, a non-finite `move`, an unknown `face`, an unknown
`ItemKind`/`ItemForm`), naming every problem at once. `load()` is strict about
the JSON shape — unknown top-level keys are an error, so a typo cannot quietly
become a different script.
"""
seed: int
level_id: str
tick_hz: float
frames: tuple[Frame, ...] = ()
record_every: int = 1
items: tuple[ScriptItem, ...] = ()
name: str = ""
# -- the contract ---------------------------------------------------------
def validate(self) -> None:
"""Raise `ReplayError` naming every problem with this script at once."""
problems: list[str] = []
if isinstance(self.seed, bool) or not isinstance(self.seed, int) or not (
0 <= self.seed < MAX_SEED
):
problems.append(f"seed must be an integer in [0, 2**64), got {self.seed!r}")
if not isinstance(self.level_id, str) or not self.level_id:
problems.append("level_id must be a non-empty string")
if (
isinstance(self.tick_hz, bool)
or not isinstance(self.tick_hz, int | float)
or not math.isfinite(self.tick_hz)
or abs(self.tick_hz - EXPECTED_TICK_HZ) > 1e-9
):
problems.append(
f"tick_hz must be {EXPECTED_TICK_HZ:g} (the kernel is {EXPECTED_TICK_HZ:g} Hz), "
f"got {self.tick_hz!r}"
)
if isinstance(self.record_every, bool) or not isinstance(self.record_every, int):
problems.append(
f"record_every must be an integer (0 = record nothing), got {self.record_every!r}"
)
elif self.record_every < 0:
problems.append(f"record_every must be >= 0, got {self.record_every!r}")
for index, frame in enumerate(self.frames):
problems.extend(_frame_problems(index, frame))
for index, entry in enumerate(self.items):
problems.extend(_item_problems(index, entry))
if problems:
label = self.name or "<unnamed>"
msg = f"script {label} is invalid: " + "; ".join(problems)
raise ReplayError(msg)
# -- the file form --------------------------------------------------------
@classmethod
def load(cls, path: Path | str) -> InputScript:
"""Read one script JSON file (unknown top-level keys are an error)."""
data = json.loads(Path(path).read_text(encoding="utf-8"))
return cls._from_mapping(data, fallback_name=Path(path).stem)
@classmethod
def _frame_from_mapping(cls, index: int, raw: object) -> Frame:
if not isinstance(raw, dict):
msg = f"frame {index} must be a JSON object"
raise ReplayError(msg)
unknown = sorted(set(raw) - {"tick", "player_id", "move", "action", "face"})
if unknown:
msg = f"frame {index} has unknown key(s): {', '.join(unknown)}"
raise ReplayError(msg)
for required in ("tick", "player_id", "move"):
if required not in raw:
msg = f"frame {index} is missing required key {required!r}"
raise ReplayError(msg)
move = raw["move"]
if not isinstance(move, list) or len(move) != 2:
msg = f"frame {index}: move must be a two-element array"
raise ReplayError(msg)
return Frame(
tick=raw["tick"],
player_id=raw["player_id"],
move=(move[0], move[1]),
action=raw.get("action", False),
face=raw.get("face"),
)
@classmethod
def _item_from_mapping(cls, index: int, raw: object) -> ScriptItem:
if not isinstance(raw, dict):
msg = f"item {index} must be a JSON object"
raise ReplayError(msg)
unknown = sorted(set(raw) - {"station", "item", "form"})
if unknown:
msg = f"item {index} has unknown key(s): {', '.join(unknown)}"
raise ReplayError(msg)
for required in ("station", "item", "form"):
if required not in raw:
msg = f"item {index} is missing required key {required!r}"
raise ReplayError(msg)
return ScriptItem(station=raw["station"], item=raw["item"], form=raw["form"])
@classmethod
def _from_mapping(cls, data: object, *, fallback_name: str = "") -> InputScript:
if not isinstance(data, dict):
msg = f"a script file must be a JSON object, got {type(data).__name__}"
raise ReplayError(msg)
known = {"name", "seed", "level_id", "tick_hz", "frames", "items", "record_every"}
unknown = sorted(set(data) - known)
if unknown:
msg = f"unknown script key(s): {', '.join(unknown)} (known: {', '.join(sorted(known))})"
raise ReplayError(msg)
for required in ("seed", "level_id", "tick_hz"):
if required not in data:
msg = f"script is missing required key {required!r}"
raise ReplayError(msg)
frames = tuple(
cls._frame_from_mapping(index, raw)
for index, raw in enumerate(data.get("frames", ()))
)
items = tuple(
cls._item_from_mapping(index, raw) for index, raw in enumerate(data.get("items", ()))
)
return cls(
name=data.get("name", fallback_name) or fallback_name,
seed=data["seed"],
level_id=data["level_id"],
tick_hz=data["tick_hz"],
frames=frames,
record_every=data.get("record_every", 1),
items=items,
)
def dump(self, path: Path | str) -> None:
"""Write this script to `path` in the canonical form (`validate()` first)."""
self.validate()
data: dict[str, object] = {
"seed": self.seed,
"level_id": self.level_id,
"tick_hz": self.tick_hz,
"record_every": self.record_every,
"items": [
{"station": entry.station, "item": entry.item, "form": entry.form}
for entry in self.items
],
"frames": [
{
"tick": frame.tick,
"player_id": frame.player_id,
"move": list(frame.move),
"action": frame.action,
"face": frame.face,
}
for frame in self.frames
],
}
if self.name:
data["name"] = self.name
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
@dataclass(frozen=True)
class ReplayResult:
"""One replay, as run: the end state, the recorded hashes, every event.
Attributes:
state: the state after the last substep (its `tick` is the run length).
hashes: `(tick, state_hash())` at the script's `record_every` cadence —
tick 0 and the final tick always included (when recording at all).
events: every event the run produced, in substep order.
wall_seconds: wall time of the whole run, measured only when a `clock`
callable was injected into `run_script` (else `0.0`).
states: the states at the recorded ticks, kept so `compare()` can diff a
mismatch against the last matching tick without re-running the script.
"""
state: KitchenState
hashes: tuple[tuple[int, str], ...]
events: tuple[Event, ...]
wall_seconds: float
states: Mapping[int, KitchenState] = field(default_factory=dict)
def _seats_from(script: InputScript) -> tuple[SeatSpec, ...]:
"""The seats a script names: first appearance order takes seat order.
A replay must be reproducible from the script alone, so the seat table is
derived from the frames, not passed in: `p0` (whichever id appears first) is
seat 0 at the first spawn, the next new id is seat 1, and so on. The display
name is the id itself — a headless replay has no HUD, and the id is the
anonymous identity by design.
"""
order: list[str] = []
for frame in script.frames:
if frame.player_id not in order:
order.append(frame.player_id)
return tuple(
SeatSpec(player_id=player_id, seat=index, display_name=player_id)
for index, player_id in enumerate(order)
)
def _apply_items(state: KitchenState, items: tuple[ScriptItem, ...]) -> KitchenState:
"""Pre-place the script's items on their stations, in script order."""
if not items:
return state
for entry in items:
station = next((s for s in state.stations if s.id == entry.station), None)
if station is None:
msg = f"script item names station {entry.station!r} which the state does not have"
raise ReplayError(msg)
placed = Item(kind=ItemKind(entry.item), form=ItemForm(entry.form))
state = state.with_stations(
tuple(
replace(s, contents=(*s.contents, placed)) if s.id == station.id else s
for s in state.stations
)
)
return state
def run_script(
level: Level,
book: RecipeBook,
script: InputScript,
max_ticks: int | None = None,
*,
clock: Callable[[], float] | None = None,
) -> ReplayResult:
"""Run one committed script against the kernel; the replay contract, as a driver.
Args:
level: the level the script names; a mismatch with `script.level_id` is a
`ReplayError`, because a golden recorded against one kitchen checked
against another is a comparison that means nothing.
book: the recipe book `build_state` takes (unused by the phase-03
subsystems; the parameter is there so phase 04 changes no call site).
script: the committed script (`validate()` runs first; a bad script never
reaches the kernel).
max_ticks: pin the run length (the `check` CLI pins it to the golden's
tick, so a script edited to be shorter or longer is still compared on
the golden's terms). Frames past the pin are never fed. `None` runs
to the last frame's tick + 1 (or zero ticks for a frameless script).
clock: a monotonic clock for `wall_seconds`; the kernel itself never
touches a clock (A5), so `None` measures nothing and reports `0.0`.
Returns:
The `ReplayResult`: end state, the recorded `(tick, hash)` pairs, every
event in substep order, the wall time (when measured), and the states at
the recorded ticks.
Raises:
ReplayError: the script is invalid, the level ids disagree, or an item
names a station the state does not have.
"""
script.validate()
if script.level_id != level.id:
msg = (
f"script {script.name or '<unnamed>'} names level {script.level_id!r} "
f"but was given {level.id!r}"
)
raise ReplayError(msg)
if max_ticks is not None and (isinstance(max_ticks, bool) or max_ticks < 0):
msg = f"max_ticks must be None or a non-negative integer, got {max_ticks!r}"
raise ReplayError(msg)
started = clock() if clock is not None else 0.0
state = build_state(level, _seats_from(script), script.seed, book)
state = _apply_items(state, script.items)
total = (
max_ticks
if max_ticks is not None
else (max(frame.tick for frame in script.frames) + 1 if script.frames else 0)
)
hashes: list[tuple[int, str]] = []
states: dict[int, KitchenState] = {}
events: list[Event] = []
if script.record_every > 0:
hashes.append((0, state.state_hash()))
states[0] = state
by_tick: dict[int, list[Frame]] = {}
for frame in script.frames:
by_tick.setdefault(frame.tick, []).append(frame)
seqs: dict[str, int] = {}
for tick in range(total):
inputs = [
PlayerInput(
player_id=frame.player_id,
seq=seqs.get(frame.player_id, 0),
move=Vec2(frame.move[0], frame.move[1]),
action=frame.action,
face=Facing(frame.face) if frame.face is not None else None,
received_at_tick=tick,
)
for frame in by_tick.get(tick, ())
]
for frame in by_tick.get(tick, ()):
seqs[frame.player_id] = seqs.get(frame.player_id, 0) + 1
state, produced = step(state, STEP_DT, inputs)
events.extend(produced)
done = tick + 1
if script.record_every > 0 and (done % script.record_every == 0 or done == total):
hashes.append((done, state.state_hash()))
states[done] = state
wall_seconds = clock() - started if clock is not None else 0.0
return ReplayResult(
state=state,
hashes=tuple(hashes),
events=tuple(events),
wall_seconds=wall_seconds,
states=states,
)
@dataclass(frozen=True)
class HashMismatch:
"""One tick where the run and the golden disagree (or one that is on one side only).
Attributes:
tick: the tick the disagreement is about.
kind: `"hash"` (both sides recorded it, values differ), `"missing"` (the
golden recorded a tick the run did not — a `record_every` cadence
mismatch) or `"extra"` (the run recorded a tick the golden does not
have — the other half of the same mismatch).
expected: the golden's hash, or `None` for `extra`.
actual: the run's hash, or `None` for `missing`.
changed: for the first hash mismatch, the field paths that moved since
the last matching tick (which player, station or order) — the diff
that makes a broken golden diagnosable in one read.
"""
tick: int
kind: str
expected: str | None
actual: str | None
changed: tuple[str, ...] = ()
def compare(result: ReplayResult, golden: Mapping[int, str]) -> list[HashMismatch]:
"""Compare a run's recorded hashes against a golden's, first divergence first.
The golden is a `tick -> hash` mapping (the `hashes` of a
`tests/goldens/<name>.golden.json`). Every golden tick is checked in order;
the first hash mismatch carries the field-level diff, computed from the run's
own states (the mismatched tick's state against the last matching tick's —
or the built state when nothing matched yet). `missing` and `extra` entries
follow, in tick order, so a cadence mismatch is visible even when every
shared tick still agrees.
"""
recorded = dict(result.hashes)
mismatches: list[HashMismatch] = []
last_match: int | None = None
for tick in sorted(golden):
actual = recorded.get(tick)
if actual is None:
mismatches.append(HashMismatch(tick, "missing", golden[tick], None))
continue
if actual == golden[tick]:
last_match = tick
continue
baseline = last_match if last_match is not None else 0
# recorded ticks and their states are recorded in lockstep, so both are here:
changed = _field_diff(result.states[baseline], result.states[tick])
mismatches.append(HashMismatch(tick, "hash", golden[tick], actual, changed))
mismatches.extend(
HashMismatch(tick, "extra", None, recorded[tick])
for tick in sorted(recorded)
if tick not in golden
)
return mismatches
_MISSING: Final = object()
def _field_diff(a: object, b: object) -> tuple[str, ...]:
"""The field paths on which two states differ, in deterministic order."""
found: list[str] = []
_diff("", a, b, found)
return tuple(found)
def _diff(prefix: str, a: object, b: object, found: list[str]) -> None:
if a is b:
return
if (
is_dataclass(a)
and not isinstance(a, type)
and is_dataclass(b)
and not isinstance(b, type)
and type(a) is type(b)
):
for f in fields(a):
child = f"{prefix}.{f.name}" if prefix else f.name
_diff(child, getattr(a, f.name), getattr(b, f.name), found)
return
if isinstance(a, Mapping) and isinstance(b, Mapping):
for key in sorted(set(a) | set(b), key=repr):
_diff(f"{prefix}[{key!r}]", a.get(key, _MISSING), b.get(key, _MISSING), found)
return
if isinstance(a, (tuple, list)) and isinstance(b, (tuple, list)):
for i in range(max(len(a), len(b))):
_diff(
f"{prefix}[{i}]",
a[i] if i < len(a) else _MISSING,
b[i] if i < len(b) else _MISSING,
found,
)
return
if not _equal(a, b):
found.append(prefix or "(root)")
def _equal(a: object, b: object) -> bool:
if a is _MISSING or b is _MISSING:
return a is b
return a == b
def _item_kind_values() -> frozenset[str]:
return frozenset(kind.value for kind in ItemKind)
def _item_form_values() -> frozenset[str]:
return frozenset(form.value for form in ItemForm)
_ITEM_KIND_VALUES: Final[frozenset[str]] = _item_kind_values()
_ITEM_FORM_VALUES: Final[frozenset[str]] = _item_form_values()
class _CliError(RuntimeError):
"""The CLI could not act: content would not load, or the level is not in it."""
def _load_level(script: InputScript) -> tuple[Level, RecipeBook]:
"""The script's level and the recipe book, from the content directory.
Same resolution `undercooked-levels` uses (`Settings`-driven, anchored to
this checkout): `UNDERCOOKED_LEVELS_DIR` beats the built-in `levels`. The
imports are here, not at module top, so the library core stays
`undercooked.sim` + stdlib.
"""
from undercooked.config import load_settings # noqa: PLC0415 - deferred on purpose
from undercooked.sim.content import ContentError, load_content # noqa: PLC0415
from undercooked.tools.levels import resolve_content_paths # noqa: PLC0415
levels_dir, recipes_path = resolve_content_paths(load_settings())
try:
bundle = load_content(levels_dir, recipes_path)
except ContentError as exc:
msg = f"content at {levels_dir} would not load: {exc}"
raise _CliError(msg) from exc
level = bundle.by_id.get(script.level_id)
if level is None:
msg = (
f"level {script.level_id!r} is not in {levels_dir} — the script's level must be in "
"the content directory the CLI loads (UNDERCOOKED_LEVELS_DIR, default levels/)"
)
raise _CliError(msg)
return level, bundle.recipe_book
def _write_golden(
path: Path, name: str, script: InputScript, result: ReplayResult
) -> None:
"""The golden file: the script's provenance, the run's end tick and hash, every hash."""
data = {
"name": name,
"level_id": script.level_id,
"seed": script.seed,
"tick_hz": script.tick_hz,
"record_every": script.record_every,
"tick": result.state.tick,
"hash": result.state.state_hash(),
"hashes": {str(tick): digest for tick, digest in result.hashes},
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
def main(
argv: Sequence[str] | None = None, *, goldens_dir: Path | None = None
) -> int:
"""`record` / `check` for one committed script (the docstring is the contract)."""
parser = argparse.ArgumentParser(
prog="python -m undercooked.sim.replay",
description=(
"Replay a committed input script against the simulation kernel and gate it on "
"its golden hash. `check` is the CI path (it never writes); `record` writes "
"tests/goldens/<name>.golden.json and is the manual re-bless — legitimate only "
"after a deliberate sim-contract change recorded in the phase task, never to "
"silence a failure."
),
)
sub = parser.add_subparsers(dest="command", required=True)
record = sub.add_parser("record", help="run <script.json> and write its golden")
record.add_argument("script", type=Path)
check = sub.add_parser("check", help="run <script.json> and compare it to its golden")
check.add_argument("script", type=Path)
args = parser.parse_args(list(argv) if argv is not None else None)
goldens = goldens_dir if goldens_dir is not None else GOLDENS_DIR
name = args.script.stem
golden_path = goldens / f"{name}.golden.json"
try:
script = InputScript.load(args.script)
except ReplayError as exc:
print(f"error: {exc}", file=sys.stderr)
return EXIT_UNUSABLE
try:
level, book = _load_level(script)
except _CliError as exc:
print(f"error: {exc}", file=sys.stderr)
return EXIT_UNUSABLE
if args.command == "record":
result = run_script(level, book, script)
if golden_path.exists():
print(
f"note: overwriting {golden_path} — a re-bless is legitimate only after a "
"deliberate sim-contract change recorded in the phase task"
)
_write_golden(golden_path, name, script, result)
print(
f"wrote {golden_path}: {len(result.hashes)} hashes, "
f"final tick {result.state.tick} {result.state.state_hash()}"
)
return EXIT_OK
if not golden_path.exists():
print(
f"error: no golden at {golden_path}; run "
f"`python -m undercooked.sim.replay record {args.script.name}` first",
file=sys.stderr,
)
return EXIT_UNUSABLE
golden = json.loads(golden_path.read_text(encoding="utf-8"))
result = run_script(level, book, script, max_ticks=int(golden["tick"]))
mismatches = compare(result, {int(tick): digest for tick, digest in golden["hashes"].items()})
if not mismatches:
print(
f"ok: {len(result.hashes)} hashes match {golden_path.name} "
f"(final tick {result.state.tick} {result.state.state_hash()})"
)
return EXIT_OK
if golden.get("record_every") != script.record_every:
print(
f"note: the golden was recorded at record_every={golden.get('record_every')}, "
f"this script says {script.record_every}"
)
diverged = [mismatch for mismatch in mismatches if mismatch.kind == "hash"]
first = mismatches[0]
print(f"MISMATCH in {args.script.name}: first divergent tick {first.tick}")
if first.kind == "hash":
print(f" expected {first.expected}")
print(f" actual {first.actual}")
if first.changed:
print(f" changed fields since the last matching tick: {', '.join(first.changed)}")
else:
print(" (no field diff: the run recorded no states, or nothing matched before it)")
elif first.kind == "missing":
print(
" a golden tick this run did not record — a record_every cadence mismatch"
)
print(
f" {len(diverged)} of {len(result.hashes)} recorded hashes diverged "
f"(of {len(golden['hashes'])} golden hashes)"
)
print(
" regenerate with `record` only after a deliberate sim-contract change recorded in "
"the phase task — never to silence a failure (docs/SIMULATION.md, "
'"Replaying and re-blessing goldens")'
)
return EXIT_MISMATCH
if __name__ == "__main__": # pragma: no cover - the module entry point
raise SystemExit(main())
+123
View File
@@ -0,0 +1,123 @@
"""A pure, seedable PRNG: splitmix64 (Steele/Lea/Venc 2014), no stdlib `random`.
`random` is deliberately unused. Its Mersenne Twister stream is documented as
"not portable across Python versions and may change without notice", and the
replay contract (PLAN.md A21) needs a stream that is byte-identical on every
interpreter, so the simulation carries its own 21-line generator instead.
Splitmix64 is the same construction Java's `SplittableRandom` uses: a 64-bit
counter stepped by the golden-ratio constant, passed through a bijective
mixing function. It is not cryptographic (nothing here is — see
`secrets.token_urlsafe` for identity), but every output is a pure function of
the seed, it has no global state, and it is far faster than `random.Random`.
`Rng` is a mutable counter box on purpose: it is the *only* mutable object in
`sim/`, and it never lives inside `KitchenState`. The state keeps the integer
in `KitchenState.rng_state`, each `step()` substep rebuilds the `Rng` from it,
and the substep writes the counter back — so a KitchenState stays hashable,
freezable, and replayable while subsystems still get a stream to draw from.
`fork()` returns a *new* generator derived from the current counter plus a
salt and never advances the parent, which is what makes "one stream per
subsystem" (orders, hazards) reproducible.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Final, TypeVar
__all__ = ["GOLDEN_GAMMA", "MASK64", "Rng", "mix64"]
#: Every intermediate value wraps at 2**64, the width splitmix64 is defined for.
MASK64: Final[int] = (1 << 64) - 1
#: The golden-ratio step: irrational relative to 2**64, so the counter cycle is full.
GOLDEN_GAMMA: Final[int] = 0x9E37_79B9_7F4A_7C15
#: splitmix64's output-mixing constants (both odd, so each multiply is a bijection).
_MIX_A: Final[int] = 0xBF58_476D_1CE4_E5B9
_MIX_B: Final[int] = 0x94D0_49BB_1331_11EB
#: 53 bits of mantissa is all a double can carry, so the shift keeps [0,1) uniform.
_FLOAT_SHIFT: Final[int] = 11
_UNIT: Final[float] = 2.0**-53
_T = TypeVar("_T")
def mix64(value: int) -> int:
"""splitmix64's finalizer: xor-shift, multiply, xor-shift, multiply, xor-shift.
Each step is invertible modulo 2**64, so the mix is a permutation — two
different counters can never produce the same output.
"""
mixed = ((value ^ (value >> 30)) * _MIX_A) & MASK64
mixed = ((mixed ^ (mixed >> 27)) * _MIX_B) & MASK64
return (mixed ^ (mixed >> 31)) & MASK64
@dataclass
class Rng:
"""A splitmix64 stream over `state`; the counter is the whole generator.
The constructor accepts any integer (negative, wider than 64 bits) and
reduces it, so a seed coming from YAML or a room id is always usable. A
zero seed is fine here — unlike xorshift, splitmix64 steps before mixing,
so it never gets stuck at a fixed point.
"""
state: int = 0
def __post_init__(self) -> None:
self.state = int(self.state) & MASK64
def next_u64(self) -> int:
"""Advance the counter once and return its mixed 64-bit value."""
self.state = (self.state + GOLDEN_GAMMA) & MASK64
return mix64(self.state)
def next_float(self) -> float:
"""A uniform draw in [0, 1) from the top 53 bits (the double's mantissa)."""
return (self.next_u64() >> _FLOAT_SHIFT) * _UNIT
def range_int(self, lo: int, hi: int) -> int:
"""A uniform integer with ``lo <= result <= hi`` (both ends inclusive).
Raises:
ValueError: when ``hi < lo`` — an empty range has no answer, and
silently swapping would hide a caller bug in a replay.
"""
if hi < lo:
msg = f"empty integer range {lo}..{hi}"
raise ValueError(msg)
return lo + int(self.next_u64() % (hi - lo + 1))
def choice(self, seq: Sequence[_T]) -> _T:
"""One element of `seq` by index, uniform over its length.
`seq` must be indexable and sized (tuple/list): a set has no portable
iteration order, and an unstable order would be a nondeterminism bug.
Raises:
ValueError: when `seq` is empty.
"""
if len(seq) == 0:
msg = "cannot choose from an empty sequence"
raise ValueError(msg)
return seq[self.range_int(0, len(seq) - 1)]
def fork(self, salt: int) -> Rng:
"""A new, independent stream derived from this counter and `salt`.
The parent's counter is untouched, so re-forking the same salt yields
an identical child — subsystems can each hold their own stream (orders
vs hazards) and a replay reproduces all of them.
The salt is stepped by `GOLDEN_GAMMA` before it is mixed because
`mix64` fixes 0: without the step, `Rng(0).fork(0)` would hand back a
generator with the parent's exact stream, which is the one collision a
fork must never have.
"""
key = mix64((int(salt) + GOLDEN_GAMMA) & MASK64)
return Rng(mix64(self.state ^ key))
+42
View File
@@ -0,0 +1,42 @@
"""Round timer and end conditions for the fixed-step kernel.
This stage is only partly a hook: the **countdown and the time-up verdict are
real now**, because a round that never ends cannot be tested, and PLAN §5 makes
the verdict depend on nothing else (`score` against `target_score`). Phase 04
extends the same function with the early win the moment `target_score` is
reached, the `stars` grading from remaining time, and the order-driven loss.
A state that already carries a `result` is left alone here — gating whether a
finished round keeps ticking at all is the room supervisor's decision (phase
05), not this stage's.
"""
from __future__ import annotations
from dataclasses import replace
from typing import Final
from undercooked.sim.model import Event, KitchenState, RoundResult
__all__ = ["TIME_EPS", "step"]
#: Countdown residue below this is float noise from repeated `STEP_DT` subtraction
#: (240 x 0.05 does not land exactly on 12.0), and would otherwise leave a round
#: showing "0.0 s" without ever ending.
TIME_EPS: Final[float] = 1e-9
def step(state: KitchenState, dt: float) -> tuple[KitchenState, tuple[Event, ...]]:
"""Count `dt` off the clock and decide the round when the clock runs out.
Returns:
The state with `time_left` reduced (never below zero), carrying
`RoundResult.WON`/`LOST` on the substep that reaches zero, and no events.
"""
remaining = state.time_left - dt
if remaining <= TIME_EPS:
if state.result is not None:
return replace(state, time_left=0.0), ()
outcome = RoundResult.WON if state.score >= state.target_score else RoundResult.LOST
return replace(state, time_left=0.0, result=outcome), ()
return replace(state, time_left=remaining), ()
+145
View File
@@ -0,0 +1,145 @@
"""Building the first `KitchenState` of a round from content, seats and a seed.
`step()` needs a state to step; this is the only place one is ever created, so
one round's entire history is a function of four things: the `Level`, the seats
that were dealt, the seed, and the input script. Nothing else — no wall clock,
no room object, no `random` (A5, A21).
Spawn placement follows PLAN §5: a seat takes `level.spawns[seat % len(spawns)]`
so a four-cook round on a two-spawn kitchen still seats everyone, and cooks who
land on the same tile are offset by a quarter tile along x then y so nobody
starts inside each other (movement's soft-push would separate them anyway, but
starting overlapped makes the first tick order-dependent and reads as a bug).
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Final
from undercooked.sim.level import Level
from undercooked.sim.model import Facing, KitchenState, Player, Vec2
from undercooked.sim.recipe import RecipeBook
__all__ = ["SEAT_COLORS", "SeatSpec", "StateBuildError", "build_state", "color_for_seat"]
#: The four cook colours, in seat order. Fixed rather than configured so the
#: client's sprites, the HUD chips and a screenshot all agree with no state
#: carried between them (PLAN §5 "identity = seat colour + name").
SEAT_COLORS: Final[tuple[str, ...]] = ("#e0603c", "#3d9ad6", "#57b260", "#e5b53c")
class StateBuildError(ValueError):
"""Raised when a round cannot be built at all (a level with no spawn)."""
@dataclass(frozen=True)
class SeatSpec:
"""What the room supervisor knows about one seat when the round starts.
Attributes:
player_id: the session token that `PlayerInput.player_id` must match.
seat: the stable seat index (0..`config.max_players`-1); it decides the
spawn and the colour, and survives a reconnect.
display_name: sanitized, non-authoritative label for the HUD.
color: hex colour for the renderer; empty means "use the palette entry
for this seat", which is what the room passes today.
"""
player_id: str
seat: int
display_name: str
color: str = ""
def __post_init__(self) -> None:
if self.seat < 0:
msg = f"seat index must be non-negative, got {self.seat}"
raise StateBuildError(msg)
@property
def resolved_color(self) -> str:
"""This seat's colour: the explicit one when given, else the palette."""
return self.color or color_for_seat(self.seat)
def color_for_seat(seat: int) -> str:
"""The palette colour for a seat index, wrapping past the fourth seat."""
return SEAT_COLORS[seat % len(SEAT_COLORS)]
def _spawn_point(spawn: tuple[int, int], stacked_index: int) -> Vec2:
"""Centre of a spawn cell, nudged for the n-th cook sharing it.
Cells are addressed by their lower-left corner in the grid but positions are
tile centres, so a lone cook sits at (x + 0.5, y + 0.5); a second shares the
cell at +0.25 in x, a third at +0.25 in y, a fourth at both.
"""
x, y = spawn
return Vec2(
x + 0.5 + 0.25 * (stacked_index % 2),
y + 0.5 + 0.25 * (stacked_index // 2),
)
def build_state(
level: Level,
seats: Sequence[SeatSpec],
seed: int,
book: RecipeBook, # noqa: ARG001 - the order book arrives with phase 04
) -> KitchenState:
"""Assemble tick 0 of a round.
Args:
level: the built level (grid, stations and spawns already validated).
seats: one `SeatSpec` per cook; order does not matter, the state is
built in seat order so two rooms dealt the same seats match.
seed: the round seed — stored on the state and used as the initial
`rng_state`, so a replay needs nothing else to reproduce the round.
book: the recipe book the level was validated against. Unused until
phase 04 materialises the order queue from `level.orders.pool`;
it is a parameter now so that phase changes no call signature.
Returns:
A `KitchenState` at `tick=0` with `time_left=level.time_limit`,
`target_score=level.target_score`, no orders and no hazards (phase 04
and phase 10 own those collections).
Raises:
StateBuildError: the level defines no spawn tile, or a seat index is
negative — there is no deterministic place to put that cook.
"""
if not level.spawns:
msg = f"level {level.id!r} defines no spawns"
raise StateBuildError(msg)
ordered = sorted(seats, key=lambda seat: (seat.seat, seat.player_id))
used: dict[tuple[int, int], int] = {}
players: list[Player] = []
for seat in ordered:
spawn = level.spawns[seat.seat % len(level.spawns)]
stacked_index = used.get(spawn, 0)
used[spawn] = stacked_index + 1
players.append(
Player(
id=seat.player_id,
pos=_spawn_point(spawn, stacked_index),
facing=Facing.DOWN,
seat=seat.seat,
)
)
return KitchenState(
level_id=level.id,
seed=seed,
grid=level.grid,
stations=level.stations,
players=tuple(players),
orders=(),
hazards=(),
target_score=level.target_score,
time_left=level.time_limit,
result=None,
counters={},
rng_state=seed,
)
+19
View File
@@ -0,0 +1,19 @@
"""Station pipeline hook for the fixed-step kernel.
**Identity today.** `step()` runs this as substep stage 4. Phase 04
(`04_kitchen_pipeline_and_scoring`) fills it in: chopping/cooking progress,
`StationState` transitions, burning and quality decay, sink washing, shelf
replenishment from `Level.station_items`, and the `chopped`/`burnt`/`washed`
events. Until then a station's `progress`, `state` and `contents` do not move.
"""
from __future__ import annotations
from undercooked.sim.model import Event, KitchenState
__all__ = ["step"]
def step(state: KitchenState, dt: float) -> tuple[KitchenState, tuple[Event, ...]]: # noqa: ARG001 - phase 04 uses both
"""Advance every station's pipeline by `dt` seconds (identity until phase 04)."""
return state, ()
+198
View File
@@ -0,0 +1,198 @@
"""The fixed-step kernel — `step(state, dt, inputs)` is the whole game loop (PLAN §5).
Everything true about a round advances through this module, and only in the
order documented here and in `docs/SIMULATION.md`:
1. clamp `dt` into `[0, MAX_DT]` and add it to `state.accumulator`, so a slow
frame can never buy extra simulation and the sim advances in exact
`STEP_DT` substeps (20 Hz at 0.05 s);
2. apply inputs — one per player, earliest `received_at_tick` wins, ties by
lowest `seq`; application order is seat order, and an unknown `player_id`
produces an `err` event instead of an exception. The second half of the
stage is the **action gate** (`interact.action_gate`): it turns each frame's
`action` flag into the cook's hold counters and spends the rising edge on a
target, so one press produces one effect and one refusal (`sim/interact.py`);
3. movement and collision (`movement.step_movement`, phase 03 task 02);
4. station pipelines (`stations.step`, phase 04);
5. orders (`orders.step`, phase 04);
6. hazards (`hazards.step`, phase 10);
7. the round timer and end conditions (`round_state.step` — the countdown and
the time-up verdict already live there);
8. `tick += 1`.
Two properties hold by construction and are the reason later phases can trust
it. **Purity:** the input `KitchenState` is never mutated — every substep
returns a freshly built one via `dataclasses.replace`, so a caller can keep the
pre-step state and compare it. **Determinism:** given the same state, the same
`dt` sequence and the same inputs, `step()` returns an identical state and the
same events, because nothing here reads a clock, a global, or `random` — the
only randomness comes from `state.rng_state` through `sim.rng.Rng`.
A resolved input is re-applied to *every* substep of one `step()` call: intent
persists until the client sends newer intent, which is what makes
`step(state, 0.1)` and two calls of `step(state, 0.05)` agree. The `action`
flag is the exception in kind, not in mechanism — it is re-applied too, but the
action gate remembers the hold in `Player.action_held_ticks`, so the re-applied
flag *continues* a press instead of starting a second one.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field, replace
from typing import Final
from undercooked.sim import hazards, interact, movement, orders, round_state, stations
from undercooked.sim.model import Event, EventKind, Facing, KitchenState, Vec2
from undercooked.sim.rng import Rng
__all__ = ["MAX_DT", "STEP_DT", "TICK_EPS", "PlayerInput", "resolve_inputs", "step"]
#: One simulation substep in seconds: the 20 Hz tick of A4. `config.tick_hz`
#: decides how many of these the room supervisor asks for each second.
STEP_DT: Final[float] = 0.05
#: Hard ceiling on one `step()` call. Without it a stalled process (a laptop
#: lid, a GC pause) would be repaid with a burst of substeps that spirals
#: slower every call; with it one call runs at most `MAX_DT / STEP_DT` of them.
MAX_DT: Final[float] = 0.1
#: `0.85 - 17 * 0.05` is `2.2e-16`, not zero, so the loop bound needs a tolerance
#: or the seventeenth tick of 0.85 s silently goes missing.
TICK_EPS: Final[float] = 1e-9
@dataclass(frozen=True)
class PlayerInput:
"""One frame of intent from one client, as the room received it.
Attributes:
player_id: the seat's session token; unknown ids are rejected as an
`err` event, never trusted or looked up any other way.
seq: the client's monotonically increasing input counter, used to drop
retransmits and to break an arrival-order tie deterministically.
move: the movement vector in tile space, taken as sent and made
authoritative by `movement.intent_direction()`, which clamps its length
to <= 1 — a client cannot buy speed 50, and a diagonal is not a boost.
action: whether the primary action (use/pick/drop, phase 03/04) is
requested this frame.
face: an explicit facing from a d-pad or keyboard turn; `None` means
"derive from `move`".
received_at_tick: the `KitchenState.tick` the room stamped it with, so
ordering does not depend on asyncio scheduling.
"""
player_id: str
seq: int = 0
move: Vec2 = field(default_factory=Vec2)
action: bool = False
face: Facing | None = None
received_at_tick: int = 0
def _is_earlier(candidate: PlayerInput, current: PlayerInput) -> bool:
"""True when `candidate` was received before `current` (arrival, then `seq`)."""
return (candidate.received_at_tick, candidate.seq) < (current.received_at_tick, current.seq)
def resolve_inputs(
state: KitchenState,
inputs: Sequence[PlayerInput],
) -> tuple[dict[str, PlayerInput], tuple[Event, ...]]:
"""Reduce a batch of raw inputs to one winner per seat, plus `err` events.
Returns:
A mapping of `player_id -> PlayerInput` **in seat order** (so any effect
this intent has is applied in a deterministic order regardless of how the
network delivered the frames), and the events for inputs naming a player
this kitchen does not have.
"""
known = {player.id for player in state.players}
winners: dict[str, PlayerInput] = {}
events: list[Event] = []
for received in inputs:
if received.player_id not in known:
events.append(
Event(
kind=EventKind.ERROR,
payload={
"player_id": received.player_id,
"reason": "unknown_player",
"seq": received.seq,
},
)
)
continue
current = winners.get(received.player_id)
if current is None or _is_earlier(received, current):
winners[received.player_id] = received
ordered = {
player.id: winners[player.id]
for player in sorted(state.players, key=lambda seat: (seat.seat, seat.id))
if player.id in winners
}
return ordered, tuple(events)
def _substep(
state: KitchenState,
intent: dict[str, PlayerInput],
) -> tuple[KitchenState, tuple[Event, ...]]:
"""Run stages 2b-8 once: the only place a tick is ever born."""
events: list[Event] = []
rng = Rng(state.rng_state)
state, sub_events = interact.action_gate(state, intent, STEP_DT)
events.extend(sub_events)
state, sub_events = movement.step_movement(state, STEP_DT, intent)
events.extend(sub_events)
state, sub_events = stations.step(state, STEP_DT)
events.extend(sub_events)
state, sub_events = orders.step(state, STEP_DT, rng)
events.extend(sub_events)
state, sub_events = hazards.step(state, STEP_DT, rng)
events.extend(sub_events)
state, sub_events = round_state.step(state, STEP_DT)
events.extend(sub_events)
return replace(state, tick=state.tick + 1, rng_state=rng.state), tuple(events)
def step(
state: KitchenState,
dt: float,
inputs: Sequence[PlayerInput] = (),
) -> tuple[KitchenState, tuple[Event, ...]]:
"""Advance `state` by `dt` seconds of wall time and return the new state.
Args:
state: the round as of `state.tick`. Never mutated.
dt: seconds elapsed since the previous call. Clamped to
`[0, MAX_DT]`; a negative or NaN value advances nothing.
inputs: intent received since the previous call, in any order.
Returns:
The state after however many whole `STEP_DT` substeps the accumulated
time bought (possibly zero of them), and every event those substeps
produced, in substep order. The leftover fraction is carried in
`state.accumulator` for the next call.
"""
raw = dt if dt >= 0.0 else 0.0 # a NaN comparison is False, so NaN -> 0.0
delta = min(raw, MAX_DT)
intent, rejected = resolve_inputs(state, inputs)
events: list[Event] = list(rejected)
accumulated = state.accumulator + delta
while accumulated >= STEP_DT - TICK_EPS:
accumulated -= STEP_DT
state, sub_events = _substep(state, intent)
events.extend(sub_events)
return (
replace(state, accumulator=0.0 if accumulated < 0.0 else accumulated),
tuple(events),
)
+46
View File
@@ -0,0 +1,46 @@
# tests/fixtures/levels/_replay/pick_drop_pass.yaml — the fixture kitchen for the
# pick_drop_pass replay golden (phase 03 task 05). Not campaign content: it is
# loaded by `tests/sim/test_replay.py` through `load_level_file`, and the `_`
# prefix keeps the content loader's discovery (and `undercooked-levels validate`)
# from reading it. It carries one of every fixture kind the hand rules need —
# a spawn shelf, a prep counter, a serve window, a trash can — and nothing a
# golden must not depend on (no hazards, no pipeline inputs, nothing a phase-04
# rule would reinterpret).
# Schema: docs/LEVELS.md
id: 99_replay_pick_drop_pass
name: Replay Kitchen
order: 99
difficulty: 1
time_limit: 300
target_score: 1
blurb: >-
The committed kitchen for the pick_drop_pass replay golden: one shelf, one
prep counter, one serve window and one trash can, open floor, two spawns.
grid:
- "############"
- "#T.....P...#"
- "#..........#"
- "#....X.....#"
- "#..........#"
- "#..G.......#"
- "#..........#"
- "############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"G": {kind: floor, station: trash}
spawns: [[2, 6], [9, 6]]
orders:
pool: [soup]
concurrent: 1
spawn_interval: [12, 16]
deadline: 60
hazards: []
station_limits: {plates: 99, pots: 99}
escalation_notes: >-
Not an authored campaign level: the fixture kitchen the pick_drop_pass replay
golden (phase 03 task 05) is recorded on, checked by tests/sim/test_replay.py.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
{
"name": "carry_two_player",
"description": "Phase 03 task 04: a scripted two-player sequence — one pass (a set-down picked up inside the hand-off window) and one contested pick — reaching the golden state hash after 12 ticks. Regenerate only on a deliberate sim-contract change, never to silence a failure (the re-bless path is formalised by task 05's replay harness).",
"tick": 12,
"hash": "6952e2a139770605f56ee188f988f3d5cfad044b6b9024578583662793553e00"
}
+151
View File
@@ -0,0 +1,151 @@
{
"name": "pick_drop_pass",
"level_id": "99_replay_pick_drop_pass",
"seed": 24301,
"tick_hz": 20,
"record_every": 1,
"tick": 139,
"hash": "9d580cb87be31b959336883d90c9023d8add1f589436d219d5b52e47998777b7",
"hashes": {
"0": "e60663c7ef05691d1a57a651107883bfa03efa6a9328a4c67c3d94bdbc04de11",
"1": "d01f3eabab5be03c78854c25813a7d00aabadcd1507a6d6b3f8b8ee79e5d1d75",
"2": "7ff500659d5b7a172f971dc1346b9babe3286ee8c62b5a5410b102ece1d0d5c1",
"3": "e5cc0227f291684b8fae99df579f1c77c25b6ca38e1a7123abf2654108383b52",
"4": "a354fd79d257157a6fe9e6c673ef08129f16caf6d25824b8c4385b7e85f6977f",
"5": "5518ef0776f1e0639145c963307af322f1f6f2b61afe7e33bead87f33566938c",
"6": "3275d1263aebdb26d59e65b117f398d1e320ae24b9dc0c394ec1f5087dadbe5f",
"7": "e560b01f13d9ec8e032d8817a0d6cd1dc808fa126f95db9616f87d49d2c5252c",
"8": "3ed1e0c20b9271962b7a62ca630fcc2bf1198eeea943c6828aa7bbca37ed0d99",
"9": "7bff758f25567adafc019ed95f08721da9c6201389f12df65265d04ceab1361f",
"10": "7ebe678a1b643a7f421b2357a9c55c4a404e416020c1f6b1f6cd50dc00022f34",
"11": "ac049e864c4e5f76351830401ccb63e4983a909ce263d0aa6b498c89fe2715ef",
"12": "27f723d651c9c4e7c4210cdf899dea008c445c8159bb6bfc91173d9a0ab8cdc9",
"13": "a5faaca7f13fdba41bd072104bcfeb49c41e7effdf34584442611d959582f31b",
"14": "cf99d26ad2fb8136c96d774504cc11b1ea3741e375be8171a4e8ca1635089524",
"15": "15c37c0608fc5c3090dbd90b10067044a700e58cafaaec8b9599bc4d0da1c1d9",
"16": "b37198229f13a0d9c0e7c44cb307fccf1a6e8282bfcb26a462072960fa293a2b",
"17": "30c549ef5af56a59c5e872c41bb03419416a5c23ae4f32609ca7287a2659a0bc",
"18": "93361439c1a043f822972a824f857f2d1289be416e4dccd6d64a9687ffd30203",
"19": "fa943153750091dff80c99f8b5cfeb1dd44e40f31664ac684f41e06365816054",
"20": "f0ffc4e130fe2ded9382a666b79166576a8ce201f59b69bdb004f6a189e41ca3",
"21": "5764f8f49fb14a73ce1d38e5fcf57549d965ca9484a5311ddb2bf9f4e20ac6ad",
"22": "c4957b341b6b69b0c322e059ce38b5b5fea48402502dbd8e3ca0822b7fe3300d",
"23": "485810da13e46839aa2a569645ca98c455fa878f6565d9856a4df139824fab84",
"24": "59991c9e2d535af5ea6511b536300eb0196bc3ba70d5b8cd9d18451ad4a3a2d4",
"25": "597df9d310b0f857f2dbaeea5996f9eecb04ba7c021ded42796d08eb3182abb0",
"26": "dd4bb0127c745969141e7c98a9bc94c02e746c71fbd25c679d949902536e406f",
"27": "c3d5464bac84de45dbc167d16cabd42df31783488cb34d6f298000a70a2e2cca",
"28": "5eb4ff0eb246505260329334fc2032fa8bd33213e4bd3e0ed2f0946f4a6b8299",
"29": "0953bcab5e4f4a9f9517125e70425b55e5dc6f101e99533d59b2bfae5f89e33f",
"30": "b0224db100c2591acaa5f82a87064e5a273432e57a812a4430e7f11a6b7069c0",
"31": "c6e23c7715bedf693d4aaa58c281853aba9cff7fc645af609da8d33c1f8c7d95",
"32": "f4c01f3abc9d04d09e4499749c95d699858d6ec9ff11a1975e977a6023d7617d",
"33": "63c05536886f2145ae264c465e7265c542fc0aebe17d8b4e1e4a26e0b5a79bf9",
"34": "5aa0a46418baa86dd39d37a14b146f01c3ee07e94c5fd185f33ed2191a1fa666",
"35": "c187807971fcb8388027cce9bf4eac08f34d26e15d231625110291cd0a82752f",
"36": "a877f3a9b051bf0992629a1e95e0ed419c802b74befdb3c79cdded5521701ba8",
"37": "0c7b2d9b412be16f96f36be3fe5d200bfe18c78a68a950ab5dae770b0342819e",
"38": "70e3c68c87440d08de51308af9b9539d5538e1f3dc878d1033035cb164e4e9b2",
"39": "f6205bf5c5b178de5abb7a4a1e96bc7205d8f5dd34b67b7f3fb2899259a65ae3",
"40": "75f4c828f487a2cf9280eb4bb8552d1583122e9cd79c1db32394217561664181",
"41": "d7e24eb0dd4ed15013c548b5f7b149c730000841b49ad7f20c6a577008c3ad4a",
"42": "b65aa60ff083143dd32df17965f5f5675d262dc9c29e7708a44af9e893030c3b",
"43": "e9606bf7c347dacdf7ceb791fe87608bfa858d539c86ab2405ea47c582f79616",
"44": "5afd9e92c3e4cfc47bf27f174dd96680496f014ee8e49e39fb3f4a9868db8532",
"45": "af4c18803a5cef7b1ada454844c1a6e0cd05aff851f81dfd6a542b612a86746f",
"46": "f8bcc76f753e369b9ff0870a918d4733015cc7bb166ec6451dea870e77e894ab",
"47": "2847fd10ef05f59eaae2d1e1f4cc664bcada4fb8ce393ed3034fd021b0395784",
"48": "46c6358485d1a6df9c049fbbb2647144cbfcba3d111e0e187fb226a21c3186f9",
"49": "3c88c093900788eb0f6a3b2ba96ba1af28f451e13ded4a3f40e88b62d7ec26d0",
"50": "4582e8685eb51f35f0d37d0767300abe7150ff32a127938ccc29767c1e79dd48",
"51": "db666451defd17419e989ce471b394846e0f3eacbf69f0c9445f64c19144bae9",
"52": "85d3ddeaa614d979efbff9501c46ff0ac436297e057d04072a690d588729d1bb",
"53": "2495ce0509ebfeade949275d83c5102fad887b54fac7d6e9fcd1f000070df2f3",
"54": "4f9b3c44808770e47966fe6c86191b1e5ef4bf13ea3e2783430367657e9f9808",
"55": "f7981717965fc1866238fa4c2971c5d3890ce4500060de8d8c3e100c80bbf9c3",
"56": "1aebc15f5e3cc8c93d2fb24ccba1b306be4ecf3135e1a22a5dbee99211579816",
"57": "19d74eb9c9b067fb15b910585b53deea24378b9740a11266948c3efef8d8bd36",
"58": "45d9f7ab88fa74f7cf6f2d9c5d7598e9546b00d8fbb289636a91f16256a3ce19",
"59": "128480f9faafe3f565c07255883c24f66c428959d63108776e375b85d12cb3fb",
"60": "9c801c40b21f665ad02d5ec6f79b472721f039fc7cb57bc7c916a502b127bd51",
"61": "0758a34c2fc8c2d6de981c79794fa4ae609624454794d8304d7c48e406916a1c",
"62": "eeb506e5d668df7ddeb7954dbb6d0cfdc8793c752359ca854492172489c4a48e",
"63": "a507fb780e5db7fad6a1a37344375795440b7dec9f4d26a68ff5f3c748faa504",
"64": "c33fabbad15182d80982111077b1c7acd8417aad6f062a402bd17b3bb9d1d765",
"65": "60925ce607094d6f05f45ba5ee84d8eb0412624ff9999a0dc47655ec6ae283d6",
"66": "f040e5ff29a6390609632293a83eba48602e73c0bb212e6407b259b62579efe4",
"67": "353e047c12a35cf63f4904e5264c58fab59af3000208d575c8ffbfc173f9d7b3",
"68": "9209d24088f61525eb9398249aa0c6b7da7d25c304ae72e5b577af894dab6048",
"69": "570af619166215bab8cbc8ee2519d810fa784bc2af611b6986a372beba57edee",
"70": "bda87b1fd48148b2ac252a93d9e3e3a061cfa895b80c5d76d037636877d09c37",
"71": "ae9ffbece9e478acc5abe56cd7aef1672d36f3c5b5e32c088c05609f06e2cf63",
"72": "dd88a647cc58fbd9c5901887fc707ad431ba5af0595e3c4e0cc80e56c4d8ca99",
"73": "972998fd5d4cf1b3937d9c73ce299bcb9f07f3bdcf6db3a2cc6b30c7c5090eb4",
"74": "ebdfb27c5ed5bb12a823876f7852a6853cf0e9c738cb24353118d57b282101aa",
"75": "5a629cb59f3bfc786403dd69c0283162dbd80bdaedb79968bbc24b969c127003",
"76": "a0dd4d647a4bb2ccdcd938ffa5746cc622ec64a718108e848958cd1d13fb36eb",
"77": "dcf47168d56ef932f42bcfbd9e68b2d5126fda6ba4473130b56229ea2530533d",
"78": "1afa944df7462ff17bb29f5cc90b5079b93cb443f28ae797e8dafbaf428ce577",
"79": "42a49098546b454636dd084531960e65c7137bce0fa607ca1d5fa163d4c439fb",
"80": "b65b270b4a047b353e6b1e6b221a9be8373a99e6fe7f6f97226e0cf69c79e273",
"81": "c6eaee5ba8e79be6a5c283664c89acce8aac38b52a120c875350f78ef9595da9",
"82": "3393c520c7b3c2f16ed0958a03ba921e55bcf2aa12db8f9c0076e9f2b3ea1167",
"83": "ec97faff3dcb283cd7937bf9a5f902b7c8421a84fd438381f5a1e4cd69c25792",
"84": "bece02dd3c8d7c0a3dbc0d93ad30fc1cf43a2539fc076a1bd003e4c48757b04c",
"85": "94cfa8553fc8ee6e2a5a0a59192168c3cfca08313637405f609d87399bf1457b",
"86": "1bce2c11840289ee6b2a7a50ade178bf72dd0b6cd40a18797801ae13285dede1",
"87": "2efb75a35a220c56389f9637c94e5525eb252a06fefe3e41419447b6475d9e85",
"88": "382d2e89dd7da93d53b6221cdc94e66a43c5856f61581e74c81fbc6afaf6f921",
"89": "e9bd7999cd2b1643e9e6f56d17d9224c618e68ba1ab282b230a2932f08f2b9e6",
"90": "1cf9b7c72cfe7db1c1a5db84373e359b38f4f8d6e390695ce49226095397fe52",
"91": "7b70a858d6f416cfbab6385ae71e8aaba3142c2a197e2236e3a3a6089bd43292",
"92": "9d7405cd981fcb8dc60eb86e9518a8d18e4dfd98e504a19f4716806def964d9a",
"93": "be25bcf4e4e1df8cbc1919350099cf09f89b5968ed2f050c6a9494eb337bdd35",
"94": "b6c540b5e9324202ccd9d514f348be2bb7b1cc40fc49019c099d20b4054c8ee8",
"95": "1a82a2a1077959ad525d76eb1a98823391c8f94ad95c6618c5f4bee15853296d",
"96": "232868f8d5f0139acc41eaa3c451e7c725842d8602a699cdf59d59b9dbfaf0f7",
"97": "fd585d38e91d6fc1fd282beb77afc7cd0403a03d737ca25efddae03d9fa56eed",
"98": "733512424a1140985c7a1b30c927b1d3b7ae6481ebdfeee052ce75bc54ba15a7",
"99": "7fd2a3466837c6b34fe7def3598032b4ee13d169842ce77d513fea6d25da0e7a",
"100": "dd56c6df308dd77824476a30c8145e059fb87ccfbadae4e7e974ca00206003e5",
"101": "1effe26eda11c8ecb3fbf0d8fc7e256572c5b46e7684ad19aa502854783b6b30",
"102": "a23eca4f48dbcee6896ef1a620ff35e9ad3ed1254906a27ba4c8373afdc1e005",
"103": "2eecb65b7cb68bf6c330aa7e4beee8c4ab0b33a5035ff8ee391e1fb8bc93475c",
"104": "7d6ca6926397417e3e2e57b28aed80e2e0a96a4e6d5bd5a760051900e09f7105",
"105": "3dcfeb49c46d29c35b9eb4982520ec4fda9eab10c24d1a630da89db240cf6b85",
"106": "9e45c3dcf7d32a4717df781ee7afe5fefd8247533dcaa80079dc6cf4386e4a17",
"107": "d33c582f02d2e6fe23e3189e6adae706b49e23d237c9eb640dff1ef72dadf846",
"108": "b2bb4a339373f98228955bd325f3b920f45ed87b964822f361490144d40ed8a2",
"109": "58163aa41dcbca4e846bbf91e1fe605685d45dea18baf0363f1f3cfdf7df3e50",
"110": "5fe4d1ca79ba2552c7b79284d9e0c5909d23e82ef2d98756bb278d8fa0e31fd4",
"111": "0311821df15d58320ba5accd0881d923c736eb32eea3df7ba4a9da28f89383d4",
"112": "fe0c90408929dae669e6a56f0721ff132d69a31202fc04c742b1d222fe727ca6",
"113": "0f4eee893d08d2dfc095a5ead57c6a14ebd3c04d02da88074f430baa6135b2c1",
"114": "0b9a4e1cb8a44b1b5496378451ce66f75298185ab19c325d36fab0730f90bb6f",
"115": "b600152b057664eedaab660dcf6eb41915b29ca744a23c4ac2e93630797c207d",
"116": "06e4467c64ee979c5cc7b3bd9b528cc35e784d4f5ba4a92f04bbb9ba2fd9f132",
"117": "790e9f73e6aaeb10f5201ce3b32ea07b7961b4e4315786ace221807b27e3ac10",
"118": "a0f911e5ac26ce7014e59893f8a022a301c5585a3fc19ca250bbc8edf16cfa2f",
"119": "a28b511f7e3a1df69966f5c1dda0facc96e8949da9a45f9f7ea8785274d9b6d8",
"120": "0e0f90e34dc24ae6f9d3f24c714dd78e56197722d2feb55c8746a193b49420bc",
"121": "01567af813d5ccdd2a58797824e9d875115cea4ed5f660225ba843c82df417fe",
"122": "09285092ae8070e39a3f7c0f7d2d4853f0457aa5f3a2e2792775d49030b860c0",
"123": "2a2c29f0b05d84b6c2cdeb94d6e37aec771e0f148f1857503afcb7a1777b793c",
"124": "d63ad56c19d5d358d6330f066f6c9b5f870c97868d0c4b5e66f45d84f766f1de",
"125": "b1b007855def3ae205a27201bd1710a003606402ea82306640a7d28c884d4504",
"126": "34b7f6580b83a2ef6c6df2fb9b49fc510f82b2dcd4653ebec5b440f82cd5af37",
"127": "b4ec65269959f743cc9aa4223753c8ff0de45fe15e6e9aada64ef7c27f09dfb9",
"128": "1a977572f010669cc3491b27e86ae1fee237645bdc09c17dfa13403c31fe47eb",
"129": "9483930702e96f4c7a6850e027d2903c7c9d1e3c6b51faef58b44bf865261ce8",
"130": "eeb732a16a2e7908672f61ad5f2df76961796827e08374f3bd94e65675663068",
"131": "e85821425270ae2fd71b3a4afbffd68488cbc9a3bc23e0ca48805110c2181b63",
"132": "e76323b6790848bf27aabf21629f80e7810fa9a2bbc6774625994ed654c4d92b",
"133": "84477d85b1836004097cad23df02bf95f6250324ae59c23f0f19e98ff56124fc",
"134": "bf4e8b790be8758f5878f909e705527565c16c75ef5a2b95c3f3592cfbca22dc",
"135": "f93764383a8fd7b5d9448f91d686ddf17a11487a717badae49fd23a0640a8e76",
"136": "b5408f3cef5b6d72a6b2a1bd80a70b694991b8d48747f303eb9eaa8828aa795e",
"137": "af84e1b1e86023c79c87e228640c80d0ef928107d8c67230de225c4e6243ef28",
"138": "cbd16d486a3ff67aeccca06c156ed9479c75c3f7d0e4f2b79c9b866defd39826",
"139": "9d580cb87be31b959336883d90c9023d8add1f589436d219d5b52e47998777b7"
}
}
+612
View File
@@ -0,0 +1,612 @@
{
"name": "walk_solo",
"level_id": "01_first_kitchen",
"seed": 336,
"tick_hz": 20,
"record_every": 1,
"tick": 600,
"hash": "7038d049a6adb89bf759aa74868e9c2a12a5d48fd07f216a6557d9d2eb0629f1",
"hashes": {
"0": "d01b9501e91ede3c3842561fc939a8d7e9c9d350875e3c95ee6c3ef5d9ec33f9",
"1": "ed2ffb258677df2f5bcd77e0125a7bfaba16918c952d2d4368496de33f019809",
"2": "cbe90483aae91b1ce6a3be98507ccbcd75d0c58c2262c10b10c9737f97183d34",
"3": "0dfc963d1669549c3357dd9e8220d04eac934fd4097f47c9ecf2068ff46e6a8f",
"4": "6437e40e850133c067e6f6289525c6b59410dc4b382c23f87db6ecd93e275f85",
"5": "36191ce45a4b7734fa6f59c1fa3b991657f70576b031bc2f0fece692a79ac309",
"6": "8e4cd2fb715e76320ce9c717b91db3078d1016d0a5a4fa0066f461a489c39edd",
"7": "d44f0f04fed8606a45f3e210c2ea591912c2043578dabce5b14d57358966f75f",
"8": "b41f43b464520a10d0019068ef332ec4ed2643539eb6d7ce76bf91e72fc5663f",
"9": "7406dc6262c41b6eef6df56e393a2aa2dae6ac12d17627e14e8b4284267afff0",
"10": "915e3131e1c5d7c8b93065fcc5a5ff703a53d6cb502cf996c2cc29d5a7b00dbd",
"11": "4c453a0ba85375e4ba1d673f3f1399b77b79c50296b9f85c7938b991a722cbc8",
"12": "bdcb3dc0afe2eab758956704b2d56d2edca1cf9aa881f9c92ea41b2159a31a7c",
"13": "1fd49080f48adfe33453a42681fe789bfde5f8bd13c832ffb4937d51115b98dd",
"14": "469733ed1b25cad99f0d188d4fcab47d342b352a60afd9b199e436b5ae5ad487",
"15": "450be736a4fd10cebfe0a5ec42a5be1dcb04131c59dfc453b6315a1f4a070c7a",
"16": "2a24240aaac0006f82f34e990ba412edc6e6a300f935394ae6e105196f81fe07",
"17": "5d79bc60f19bc03c746d3c92f33ded071c738f12e900c26410c783a233a9f8c5",
"18": "34dc84d5da85d188e2d431a94494be8cdf432f3604a46c5d973687c0585a4349",
"19": "32b18651ca34900e7ccd680af5122cb61da90a305d865410c256edd9fe095322",
"20": "48123833336f8a0acc520540f601f79b324a0a96bf95d27e91855af55c9dd563",
"21": "b3919b63e4e264d70ee02668a08bfaf6e4aab84c7c68f415c948a0264db251d4",
"22": "7632a6b7dbbbd9047aa6206e4e2f2e17907b15e5a0b90a807dd5869d7d9b0df3",
"23": "e9009b9ca8ef2b268babdb08c90350e92e21db25ad309e9a9a3d3426645ba64e",
"24": "1f7f90589bd2d4c2890f28aabde7b6beba00053d7fe55d82e792968b57690122",
"25": "c54b2009dd4ec8cb1b028f35e9fdaf4c8c1ab6c775e47cf39c9e3796198d8f5d",
"26": "da9fb48ec6888cb61320bca87a4ce7e10184a9d2ef609f16db558f6191dfddcc",
"27": "c5b31defef3e320debe326a0465f9011365e52a5be943820d207897796a15edc",
"28": "f9843ea13359c26167c3599fe6c8c048ae3e1ba57629a845d5faa4fe87e39bce",
"29": "34c3e79ef06e38ff0a47fb539c05c8f706bc3782e7ec15f82b7838a60b3cd0ae",
"30": "adb1ca68c7d2d3fc5806c583113a1ea656d55f8777d6c03c6367e3f2f8a4c8ca",
"31": "c9be967f6a6f7dbbcc1acf36c0b7d6f0b8f62a124e27d6fceda07599a4fd2767",
"32": "eed3475a74a7013872bebb82895ea9a038ff84b773b21a3359c3565f0902793f",
"33": "6af517dfdd460b2c243b650e928bb46a7899fb70f445f8def557e5a3bdeadad6",
"34": "307cbe4c1cff3265a54fbac26a5a5f47b7a722cb8326b4fde4dc772090afb0d7",
"35": "528831422a6067d7b0be84826b9db12dfef8a0fd1ca7e0e17c32bd2d848e8e9a",
"36": "cb2db91b2a3605d09ec286501254ef57e99da3ab57b5fae688fc3888de44ad96",
"37": "e07a0e4b2eee6eb37e35b15793aa840f6589dd2c6bd80d758e9fac009ce39926",
"38": "6557540b685a2bd11f6fadbb1eab1fdc6e3eaf6afc81dc30cc697affcab87be7",
"39": "b01260c725f5ee84ac75c362fac7192749e926772c745ffe777d786d605ba425",
"40": "f486ff9b4a74bb257e613c86bfdb9f6935f6132ed4b78ff6403155c4699470bc",
"41": "0e4dcb469b3c0f1943c2438131e12d2d3912f79a9c1e44c489111c6cff7d178c",
"42": "c1fe4a80b02ea372568f9f7941820fcd3e15f8d353a5f34252d65286b6938fff",
"43": "9d2539aa6a163dc9c3fa890a4fd48dc9fcf6cb70c3da8b0c33856b64edea24f9",
"44": "205feef74d9f9d6cce6490c7dab79daea6b44665378632d885e2172be48587a3",
"45": "305bc51d81847657a2724ae32834ed683efaa5027b0cfee93cf51b438d043a47",
"46": "c641e75c08a301068c7ca2a148429ab31936091e65df833f2904945ac0b5281f",
"47": "0de968b9716f0574b5870becf2bf31317b4bc5a397b60944864c4364e143d59a",
"48": "f8c40e24d0cba876e63f4a859dd242cdf00fbbf0f9c1668120d355f5014fa172",
"49": "1c0a5352a6998489761a434c5dd9fd146ae670beab0286e15c49ec07ee3d32fb",
"50": "325bfcdbccd438555845bb78207c93462fb329feb871fb6b048fc5a8ea9154b6",
"51": "8e2a46ff7a9122f1e0fd8dcff20b6535bf60004d8134b8b4b35d8c40a1733430",
"52": "26fdb89d8389848654cff00d2fde6977bc9447f00d0786e670ac165267f89310",
"53": "b450c73aa15be721d640b09cb727d4367d57c4837d0c00a4cd2eca0b8c2f65aa",
"54": "bfb78312a5e8c03124634f0425ab174b8d1b15bd4930bf76efd5313318d4619a",
"55": "96ba9c24bfd0c6affc00b708abc712c086888ab431c0b2635424c1ddae0a8cdc",
"56": "eb369933ddd4f90c3774941d04a96e123f43b466c87204e968db384dd0f56deb",
"57": "f5f66906a9c32cfa85d33f04611db87c1e865513987e426e5b180701aed4ebcc",
"58": "c65af500d12ef6a66237fa4f0347ee4b819bb4658dcd585ea9adc705f601e9b7",
"59": "14336200d7e1c602835f790babeb4155e3e1bf732fd18e6a6bead07404cfb776",
"60": "ca814c33c74f01ea85c2d55a4f0e15e758cbecd054475c1f32e534cda4f2a164",
"61": "5bd9ecedf3da67fe51928cceeec3d00fe2353cab9649f8641806dae410692d5e",
"62": "decc64d7dc92a4d7538f7d0262fc0666f435060e9de33cee28eb7054868418c6",
"63": "caecdec151880834544c1f268b0270679f4595f45b91eb5099db098d64695960",
"64": "5f34d76af6062e5831893815c1231997229ba82fcb5b9bbee667eb967684cf4e",
"65": "07e04f5bf6a75663162ec02ab907897241ebe35bf3fb40668a1d8c852f9eea82",
"66": "045c4d80044b9f832502e8ee7f1e72663f0773c87918681fff2d4a3ef8813d72",
"67": "51a78bfb6c507dec66d8f1020e9d5a7f95d98c948d60c9cc85775ab98ba10870",
"68": "85d21ab061bca1655f18754392eb00fc27a242e32572071c6838447e0c4b6288",
"69": "56a100e0a8f0de36e37b186907dc3a10e3eb478e8aed97d0ccb24f390acdf2e0",
"70": "589dbabf11fc198da3c7f8ec12272abc467323b060fec7270b58d697acb57c96",
"71": "a30cebc65671da46c00c34edff560f6108043482cb469d388a0c25e7046591bc",
"72": "8e84fd18da4caec52f1a49b6136036e598afb323bea53775b2526f4461d7c082",
"73": "ae1a99e325223f51577473eacffa7b7c25e599071af365d5567ab6455f54bec6",
"74": "d0243bd0e5b97c61c267eb325b9b1b656cce642508b4b8a928a2b5fb3b1cacfa",
"75": "edf1c0187cd94590bdae9d7864a5e582570884473f0bc20912fb97d76bc8741f",
"76": "41db46328e7c0b8d7d8f0fc95c1c153f3e4fbcf21620eb097b9a4cda17b0bf28",
"77": "b81d78ad83780a947c826e3bb74ffc66ba53957a581779a2570b43d0c910e676",
"78": "bc3fa0fe738e72d317e16cb68841b3d6d53415870aa3f9526b28e7ce18dffe37",
"79": "4f23bf35c1ff3b80a9b475abb233f4d6dba173bd119c7716459f15da609a19c1",
"80": "0492fc7d44e13615a2f257058c63bc86341398ae0ec0f68e4cd9c4735a405e7b",
"81": "daf556bb53ad253a93a9ecb0656a36880793c77396897e3c2a676eb3f1db8a72",
"82": "2c38645fe7557934aaa0014b9bbd7cc930d3ed9e2c4bea74bfbf30d4f27c27c0",
"83": "59caf4e84bccf3f9c65378ff4ee0acc2dc196ef241bf19c78e3a8dac922a1aad",
"84": "17f95b8dcae6e2eac5a90a040bf583f2d53ce4524f517014ce8bfabf6cec75f7",
"85": "17afb4b428c00a92cc17dc140eff704f8117c371b200327057ec74803bacb6b2",
"86": "e84a611eec8de8526cf63ea61956ad1f6338a61a445d6c4ff3d8bb25db6956a5",
"87": "f72f94aeb6e74e1272c797ff26a41f7e5a8db99a472b426797ed6bc27509f1b9",
"88": "4f24b2ba60ff2fe935114d4eafa308defdce4374647c2a39b86541c1b9208943",
"89": "a3fc5acc85514200ac8239a3edd4532a99ccc5f08f50bff928c876f3304e2f9c",
"90": "25d8bbb4fc1065ce1df0fb7350ceba6c58440b27c1ded9de05eba0688a24981b",
"91": "ea7407cc010b03a9f0ffb94093aa49ba639b8a6ca9b618bd61bf0e0802ecc36a",
"92": "9ab2b8b92252297ff9d4d0f028e0e0d2f9d66dfd3e2ca84c46cb7522f0f97c16",
"93": "f3d17094c109c49f25cab886dd52be4583148e7abf4db1f24d743b64e2b99034",
"94": "c6d53b3407dda17ab47227b6ddde6e2d0083dc5c8557582677a40c4df6121410",
"95": "038ce780f79ba11ad16600790c2e93e49603567ce23a9577f25d04112fab233d",
"96": "8712621000808c35c61376621c0cfbd9cd45200fc7be2fb2e4506b5803b79bcb",
"97": "bd8a8746eb18adfb4fe4f4f50c03e0bfb013c58859b36b629fc5bfdad6969af2",
"98": "421ada81aa8d5adfb4632ae3a317096a6b186ca95b744f540d26db2e0e8e3271",
"99": "8c7682dfa4f1928b579a4396f9a8ae908141ae8da6a898dd9b3aa0a40f0366ec",
"100": "7592be86af7cac18414e7f103da9f51c75b5443f4701b2f57c35c9e4650c2648",
"101": "746e44db400a520a6b9a74de6fde44da3609c0337f3c83876443575dbb69e716",
"102": "8cde025b8bceb2ae76a5909b08ba0296da8f6b0fcecb0168a04ef5e05411ae8e",
"103": "74a8a17384f066c93b60ca9e9d64432c62d5c218df04419011a7d134c78fd8e3",
"104": "968159410755844d138e3f5021e1b19609d9ed6f0bcd928a20a0acabc1463819",
"105": "790365bec727b9c673ed4a3a5f2b63f75f9f7dfecad1c4917d7c87cc0672739d",
"106": "5b4acdc945bda4efffefc30babd9a19db31c26b624e1a33743bcd93927963ca3",
"107": "a3b3a41a194a5059f5db31c6e3bc35bd60628399729967721d09b8f3dce34f18",
"108": "cbfda32a9dd3766b7a6699894df13a38f7d6bec446e87adcb45a7c9c0cee6d3c",
"109": "1b339403de5c6e326d092203d6f9115a5c3e1a0083e2e66b36955612c1055c3a",
"110": "8b4a726ce04fbe026f054bf0c3f54a409d29f35ba8f1205dcd965abeab6d4055",
"111": "dabe6120922a873d16fc622b64108078eb7c661f67dfe9860f9a6b16e2f752be",
"112": "fb18fdabe7750baa272f6832f39fac3edea3501f9fed6e23ca1214333a04b7e4",
"113": "782316ccb106671ef28a2a816ea859d2d0d744c5cf19dc303f33248b435b03f3",
"114": "9ea875117355f0ddb7adb8ca59e27885f5bd13bfa4e2880350cb7877c2f15c46",
"115": "cef559aa27f540d9c125a0d1a3219b26cbef6d6a53e4c5ecc1e506c100425fe3",
"116": "17477c03ccba60df2a94dfdf3ad5b60f63bc54220c55e450a94622d428d3fee9",
"117": "22bf8b5930e503b8126e0b0ab70abbffa7b32e0832bf6419915a08eaa2ca1582",
"118": "2e4481288c84e62eb6a7f9b81f11dc110d1dfa093e65336b8c5f4607c8d78c85",
"119": "64f3ecc519a130f67237ea2169fa2fec22b7a8fad2c09e27fab17471fc2bdc45",
"120": "543ae4d26f2725d71b9e2dff94f27f6435d34fad164bc65713fae6e346e52c04",
"121": "de0e27d6596ec501ecad6c25f3179bd2172179f0e01890a62b48b4d18695610b",
"122": "5720ea4a5c69f90bf2dfaa669e1743c7b2706e178616e7516d17192aec4fdfe1",
"123": "fb6b89a4ccc2d4edbf334330d75dc75a98a9252a8191f3757cb7adeaff768cc3",
"124": "326ecfac0314a0167a5ad710eb60cc0e04490b26b166df4af2a2b96e56b705f5",
"125": "c7604a66c5e579c1541cc5554b64b203ecd8df2f063e61788c3174dd4a4fb47c",
"126": "bf94dab30c5d50abfbd037762b459134f5b5591e8e0a3e0bc15e89106137bd75",
"127": "7c81965aa4b26ac9ccb9f331fd3c8e4da0201fc7300dd2fc445fbf630d6fbfed",
"128": "c2f17944d97c265362d479702205a8396fee1e9933de96c8445b6a4ab1885dbc",
"129": "ce05686fbb227c6d6757bfefcdfeeef7e7ec632d64cdbb3afe78bfd01a5abf6d",
"130": "85ed2f81a2f94a323b9a123ee652a20aadd16f6103f8d1503d27d5403b67085e",
"131": "b7f258f810c5d94a3293acc93705ae60d64a106886224867b9e1ffffc688d560",
"132": "953fbe5482f8502a04b701c96ea46748d6e11716de56b42f4997d72b92977d4e",
"133": "6d51e2c0a296a4b5f52ce4fcb36bdf99028920c02e5d6d2cc1bbdd0af128e9e6",
"134": "a8a616c7259ccb851b0b34af88626d3c3acdef60fea1effe71950d772a1e6ee6",
"135": "fde295e71fa889ea4748717ee7273cc44ddb0fabf4f5d730911571414aef388a",
"136": "253d9a9a7f10bcc6b6c0bc8b8b81458da4a9696c50b712a9aa832ffb67f94f16",
"137": "2cc0d43168ed1c233bd8ef0d34de867d8efc1df955e987214941f1194032d08a",
"138": "24239f688786f05304561f4af42ec131e6843841ba26958ca164e131f7908fce",
"139": "773b6deaa75c2990e138edd2cc4e107818488d96dc965f8272d260db411173ab",
"140": "577f100c64e669fa7df191e21b2a0b128ec4dd0665859c93ea42fb386bdd4d7a",
"141": "bb07f67b4920dce93f3311ee7ccec00be5fc8d2ce2ebacb0de0212798c532392",
"142": "d9cfa629aa9cee52a2a2f8afea2fd6e1ac845b7ab0e664937ae03e3e293ec0a1",
"143": "24c62fbc5c26b6762f45b0aba105d34989fe69151d1e9be4fd62feca8eeb91db",
"144": "3d884a5734ade60a4b78b00025bfaf43e6b5421c0d6db26aeecc7868dea42837",
"145": "c4a0132c68e2b6462217b3cf5e98f19b505f14e3bdd07788746e21943833f77e",
"146": "cc29147e5941c4ebea543fabca3d49b714b13edf5060a7c4b4ae66ece1aa4469",
"147": "7c6df6101b384a9ef76efcaae6f73189edf6b7a3c3a0165ca97ba890ceee7094",
"148": "05410190158e4354e2b5b4776e5c23622a8485a48955bf8601b02ede2cb59e21",
"149": "1bf4f4d6e6428d844077d00a22376509532a8af05c8612ea8cc9cbba3fa09325",
"150": "0a5259e26f61e911820003d9fe92ad38035190a966461186f72ee891c80e765d",
"151": "1a5892ea4a5b741b8caf78f7fdbe99de4d23fcce41d2be8532a4e6a0be0897e4",
"152": "a3947241dbe1e28f990bacd6c52b8333e409f9401fc35feb35dcf4423c41c093",
"153": "d7a43947656d6326980c8c5ba4d75694ad39568cc8c1625490a41717cca794c4",
"154": "843963a85adfd6ab6ff0f73aa39d1e93ccfb9542b1ec6e6fa7bcdb5a66593052",
"155": "bd456f1c4d5045776aef138f3c0890c23af97e19429906ff1ee09cccad9a09ad",
"156": "2b58b6188f4ebf6055e1b47a923f0dced96af032295136b61c527385ba3df7fa",
"157": "cff6a3f5657b88a0ea0c9e99fd270344291daa3e1eb669845cda787ea1c1bf3e",
"158": "040c8c82d61c6503b81ffa9ea331570ad22da7bafbe8360243a99aa562c093f7",
"159": "e0747e331564ab030c2eee3a748a4297cec064322931a1a6cd572dcd4da31e74",
"160": "97f43f383a8299f5e57e32565a307ab7f06324d0424cc97b7d902ed93763d2d2",
"161": "7fbce90b7b643b7ce93385500092c0925cb27e7a55b1a585e8ccf7abe44b3bc3",
"162": "c188d2e4087868cde87f0ff1e5262d9b9b0e98e1c8ba51351a52c504ffa42aa3",
"163": "596e8079a7b37eb5644e05af686898414834188b05b94f1c41c6fa0cf9794ccd",
"164": "9a1525f122c8ffe53a5bf95302cc32c9186af56b3fbc264df3519bf62062d2e8",
"165": "15acf2842be9f8db79f046b97c734cb3cdb29f13c2fc830cb0495377baace337",
"166": "418e039e7fc209b1409abfdce9b84f5b5ff803bee983ebc88316a328af458aec",
"167": "b55eed845e89db1631dea36de08da8dc9cdd78aa92afefa14ab9dc0c71edb413",
"168": "5d9ca6d17df00fa61a8daed37f17e337a504e8a8e033d8f61b0d1c3eeb0f9477",
"169": "593f70673e9aefa45d05e3b5ac6223780970f550127ba384e08c72f8d88a966b",
"170": "963c183a2e0b2d3d7ea755610e66bd8492d314ad03bd70c9ea10f289b2a3794b",
"171": "404b14c18020c3e9bfce174e69783a23f2963f6b8356521009c51b60da4bca41",
"172": "1ed2cf3b1849e4f8f888208b9b6b2b137a41662bcaf1770b0d4a3592901270ea",
"173": "1a9b2f6c76a780622992442bdfc29f71a61c4e6e9d6fde22ed1ffba28b09e253",
"174": "0734ab5ec48cedc01026c8757c0e15f3b53356965f05777396bb5ee680e154ad",
"175": "78ad72a81d47a758941c043245778757b1e4f6b4e72b94121593a38f252fb67b",
"176": "873d23682e6dfe51791d2c7d42a78fc8321ad75af4da4091d48cd768d6862346",
"177": "242c896b79dca24c48fd69c6b9988f16a058aa86f9ed002fa06485f1382c107c",
"178": "7957747cb34b82f29ecf82891f2590e1fef30f805213067f2dd39b357f948fe6",
"179": "605992872da4520179890a8c3e48caf1833cf7a38fc8e32850e968e07cc37a4e",
"180": "88eb098577ea4f6664004a96b6bfb2d884dac7906892058640378ced741bca64",
"181": "a8699cac0dc2f7d992dd7eb5de5d794cd7f5ed1c42202629229832bd1965f8ed",
"182": "d4585441b512f898f0231e09c0ed4548d0ca6ac25855c70dee2028becf3d2e55",
"183": "ca4696c3964ca48ce7c29f4999ecbb41117da2e4c2de50f6a63c661b0bfb132e",
"184": "2c783cf6f4cba1a223e9fdfa86e822a9eee5fd377f5694c997f8c0f2cb1b204d",
"185": "5104bee1846a3f20ba592179b4d94d33686ccfea3ecc4482bd09187793e2fec9",
"186": "b780a411b581b494108941fba2f5945c3d7fcabe6d658321a1963bf729f83b14",
"187": "952d416aa6657b3ebbdf8ecfaeda33c4d4ae0060b707b1bb90ed62b1bdfa172d",
"188": "990595481ec8ecb500bd8f83fadbb7df7b7eca696215f7395d379f60b223b79d",
"189": "563ae0d0f4e5dfd306fa5154efc13ad310f02679e1741912480e469ec8be3fc7",
"190": "1dd7b1d1f8bb8f610480b0299159bdde4bcc16814eecc5f13c1d83cb2aaa1205",
"191": "e8fbd7d496878fde952c3fa1ef944ec2d6b4a0655e16d1113be88b6d9e44b13e",
"192": "22a577becc2190484ff1d485faeaf8ca96d0cb850eec72ec733ac8118ad2e842",
"193": "86d1576ddefeb9f7092df2584a17c83acfa118ca9d20c197b7d62b93a2222347",
"194": "7c7b0c75ded6aaa94359d75666112a050a29ae2eab91493b037b9cc421a2ef15",
"195": "1704f4ef93efe3caa8bb0586e008fa9de3249b048c39dad6db7760548ec52948",
"196": "e01f5f7a3b0a71ea0c4d68c99f81ea853db3397f36fe3c37c021dbc218c201c2",
"197": "990cee00681394d3a6fde6a5d03a93959a2a00d5150994448f2237ec120c0472",
"198": "b5e047c400d83c84a18d2471f194da0215c4cac7e9dc356546989157bb6a13ab",
"199": "ee5f9f804671f58ccd7401d7695b21fd6663259b57d3638cf37015aa30174831",
"200": "9177add10943a7ae2f902865d5c22fb84ed43657473e961541a0c2bf0ecf4e2d",
"201": "b30070181883b3e5a215161aa11e95e36efdebd74e5a75792740a625d95c454d",
"202": "48e930517e73ac3b379eef9a94e1ea760149184ee99d163fc302f91efe3d44f8",
"203": "bbeeeb350af58279a1a1047d849492433a59cfc31ddb3148b433c613654dcfb5",
"204": "872b913c5ad228d4d8e499201a083f87baa35489098b10d723e572ca32844400",
"205": "5fa7db039a14e366ea38c7d4451bc141f67bc43f287c33396f331c83077157f1",
"206": "765293375e4a75733216ec8bff7ecdfb3df774acdaadec1ba311bacd9db86e0e",
"207": "746a5e78ad8dbb20c641ba4cccc6857c2f39e647490824a8b93777765e3a5617",
"208": "bf804008be553221c49517404821a06c9bf3ca19a8f11222e3983519e5dbe12f",
"209": "ad521daee1668a74caaea42124b3b7549697326a187d3410eb92380c5c819dea",
"210": "365dbf7cc935a42e886663a5023d60b165b9276557eaff97df6869182f1ff890",
"211": "19fc9ddd3e6d75a8d1ac2e47e8289b4a337cf0383affc196bfb6989707841520",
"212": "7ccf3f83c74709da8b23825d1e62f4347a80ef1a08fe64856a1cb2c331607673",
"213": "30a4fe1da020977c60e2804e54e696195992a7a067b2691422a5deb552f36d3d",
"214": "a378177df35981a28ffc7c9dc9c3e07e638a175e6b183eb1932107e18641251a",
"215": "ec559f63669bcc09c20a7f7d2ec04d10170e610231244f37d28a0dfd93647e03",
"216": "5582a0ef29cdb600865330ffe9b7628aeb7ad2d0c9be06225c90ab67d93815c5",
"217": "4b5f4875e028913da8a6ad65de48fe7de281dafd7362a2be43a53d3061a6d7e1",
"218": "ee4c7e484d83211373f99ebcf7825072f90ce1783bc8034188d162e560725f16",
"219": "cf15182203f28559b3e1556df7b4cd0a0b9c1c4fc969471df3d2ee2bf3c46875",
"220": "572cfe1cdb5da6fde0e9d4ae4383231149e8ea519d88343c44c37363f7c92d66",
"221": "4744c7b8c96681e86b970e992b20b8d1c9c2a3ecc6b94d2e431be4458484c31c",
"222": "b1fd61f3bc75891ccb90fc3bae8778c34d8b406f665aa0453ff85fe00a74f187",
"223": "a6dd8a01141e8e3f711473c9af0ec85663ee96bbe722b4b92db7a4f69f0b6a77",
"224": "1390d017598a17ba3090dedcc1b32f10c0eaf98124344895f30d27d67b1aaf07",
"225": "fe5dca66b3e1236a73739d15e281012d948006d59d552a8530cf0528f5fe859c",
"226": "42363a078c943a3cf5a4aa4c6d06844f3bd3e22ee9db5aaadbfdff6cd1b42a55",
"227": "a8cce5835d4b8a7b938b9faf32bb3ac7cac7d91833a1c02585148383075eeb8d",
"228": "090625867de43be2f49b8bda1da2f71042779fe1f411af614f1b466f3b081bef",
"229": "2fe62ba4ec06b78c07ea11bd5d2713658ad19174825c1142798506d885882067",
"230": "bde5a534a55bf83235094c8038fc9e6c64f72309ec3b1cc85a8168a79792bf58",
"231": "fc4f206a10f97712dd72931e2287f15938f9a3bda96e196cf8159837e3348ef8",
"232": "10edf604b767ac263086cff145800cff564c7a704f7dd19d98f4bde8d83314c5",
"233": "c1aac07bc586103ceff227012bb4c5e50fec142d8d50bf72886b7e6e1fc53581",
"234": "24e4eb30ee46feedd9c2cc1946457bbc93c88641ab8b381d7117575ab84ea930",
"235": "17df30fad789586067ed3a6a47abb6257fdc85ea61f98e0861d02ade1f5a4679",
"236": "535c9f6334871ce365159fcc15d5be272903b290c4050e74b0f6146cb7a5d677",
"237": "ee0b039ca7c493b5b5d87465980212645a59c9f4b93faa68bff60428edb5feba",
"238": "d0d28e7150bb9c15105c62b9b0189be1263ad9ba2e9b332c01aa70954e297d0f",
"239": "37014b538ac029cf429b3cbebeaca51f6101a5e20f429965559abbebd5934a08",
"240": "a9d694b0e01a95db6ef28352ff598effc1c33010d08fa403760d0f0f37e66d4a",
"241": "17931cb5f3072868020673a95e4ba6926224771f6cf4ded74f8f84347d71dcb2",
"242": "a2f5b139b7c0d858ee79f693f0408f782fc13cf3bb6f10952e261bc0a9b0b6ee",
"243": "24894e9967cccf8b53560626aade1d312fcc01b3ff93f81cf8bcec5ec94d9781",
"244": "ffd7f30a45f34824a127bf36bdef3753af5e65f2613552ca082da5e82681bfe6",
"245": "9a8cdb5030e8b996347895494c267430562ef8e21de78991ee4eb4fe8112a355",
"246": "500ec97b1052cf8ea4a4330ca4648c6540fddba0a16619b44324d448aa3053d8",
"247": "0247aac22cfd3ca87b2e6d3845ad97e801f35c7a72a250bc041c9768942a5668",
"248": "da684178b037c5d41fd416675ba99108961e56a0c02aee1e19916c042d4445be",
"249": "3c679c8b98f58c36e652758254982945c4d238340678d7be09819e2b17ebb762",
"250": "7fcd02116703c6d3a81331470e295d03e35e8c7cbc2bd45e858b5b61aeab67e1",
"251": "34abeb2944c0fb41f735590ba6313fcf7b8797e1c649ecd790964bd5f1ed2586",
"252": "512c16176e77d7ad07016c485a1055690197e7dca7c093a3d85f650e109df56c",
"253": "23e802dc0b27613ab15c73ec577ce102f54836fdee27fec41a25925053c7e6d0",
"254": "8e24e0a584e441d657700cb36fadb7dea39a1bba3edcb2cbcf1cee6ea7ae8e01",
"255": "439f61a48e832899a56c95de3f659d863371211f1e18442dd7d1ca410962470f",
"256": "08bed4b2febe6b122409d2f714fcefafc106f51f6fad6dd8c9ad432e85a6be46",
"257": "36a29f044741ecaf2a2d173cbffb7e521c69ed4702d756ec1a819162ecad1e69",
"258": "932d750fac6e97abecfb147a559dc49ab9a7db2c326ad878a3aa2598f6db824b",
"259": "7e0ad0a7445687b0a769d94ad899e0f63f29c9b02b173db32d8ea64632bc292a",
"260": "cef87ed4575ee73e9010dc215b6df135e902be9f7409d3815940d29815999fe2",
"261": "1c0355aa9811e25cde9cd353b3f32288e7fa6e28f6edacc3e879e1a8788a1004",
"262": "21184255fbc4aa88bda74009c09ab654a6aee6c420a2b9f209219f3f5dedf7a4",
"263": "9fadfca376b65717eb84a64a597f024ba5b53d4585eb9a37ed82438a8161bdc4",
"264": "c4256c4822b9e11ce2daf6f40fe7966bdc825db4c0600726931ab07e4d85f571",
"265": "4b8cc90825971ed0d553cc87fed9263cad671af7ebb4019d209328ffeec3b0c4",
"266": "77c4e37e778eec51bcdaa5fb540e545ae6e0cf33ded812b4961003c154ad223e",
"267": "3dfb085cbbedf53c3e71ee8d21ba124b25dcdb15b55ed226b4e2b6a6337b916b",
"268": "ae23634ca0b2c0c770ea11026a40677cba2fa08aa34a539b1edd804708d9aef1",
"269": "763278b1430cbe557886b95f60617b24ada9d87ca686404c25506f9dcf1ee0e5",
"270": "52832bbbacbe722541eed1093ac7d71cbe719e1287801ef99df0f55c0ab22583",
"271": "38227d4633aa964e50982b308a32d997a0945406f01f997697b06ca7cc6235e0",
"272": "bd3c04fc65f454600590cc57c4464debf0b7c158b2e47eaf72ebe560072f3537",
"273": "60919b035f86b843c1ea4705469506a6afed07bf41b71f821ffd01f234d3e3c8",
"274": "5afe970a88f0fd101b9cd467f68fcc55c486a78d99ba1684fe2f33e79e5db214",
"275": "e56f74b3960e127a98e1678940b9f5303afffabd1cfcae6625639bbd8aab9cf4",
"276": "5a6e4b3ee2a768df8120ad33d5ebf22b3af73afd5004d9bb0539303468ddcf2c",
"277": "8b943c823446fd6c65801200341670f8d8db26fb5cc446b1dd8f2580979ddcfb",
"278": "4316770ae2a51f0fee105c37d0e9b208869095499500a3557aa855f6b6f3b995",
"279": "08255f5dd16a5bf357f9398f24af39a478ec4b94914db1259f33ac47dcdc020f",
"280": "074efc56072e970d877df0a6c223485ed4016624681db7f479a715a89a860b57",
"281": "410191293a48a78e1dcfab6a7a1ea6f1c3b3a24e6d0bf5ba58b824452e42a198",
"282": "fc734547936d23b3d5295161fd0522a3f3f4b77b2f41fa800131eb986055b80d",
"283": "457deca0be28c727dc8e7a5f097bb20244ec2c07feb6697b030d5ef066f5df59",
"284": "cbdb31908c2483a318f671b1e2fe95bafc5ca872af895b11b67b0e7854791308",
"285": "b5d6610556d5eda6139dbd1ef7bcb0582918d9d185abcfe9103fbfa3b0c97dbb",
"286": "7fc90b784fd9b9b2581f4cfe0df00c3fb2c5b9a2aca3b3510ae961b1291d98af",
"287": "97fd82e1ced2b5bca9326e27becb637bfa47f14af7ba79716b24e8f8d4cfcea9",
"288": "4cc481600bb417c71e869dfca8b93b0baf661281190882852c8078cf0dab1748",
"289": "cd2d9cad8bab35385f01e797d7b262f01559cd53b29d59c52d930f1a65fbff27",
"290": "de8ac142e15482adcc8b1c11b3e3221b1bbdf3b043d80f85bccebb6507b081a3",
"291": "d62ff908aea616f350bbab0cf48ca608915d08b216a8fee1f9ffb78c4120df54",
"292": "d2fa6c3a4220081bdc3e93aea8ec5b434e8a3fa85f253762b5ddc418fc826e27",
"293": "008a51efb469e2d972fb110c796b696591c6c110f357f4daa63dc4d38edbecbf",
"294": "15aa77046a4b49c80c71056d1fdbc8e33c1e228163ca0fe1140575c73d4dcadd",
"295": "1a1b8ceec12037c0d1ef4ec6cd187b64ab77396a56e1bf632bed44a9cd247a09",
"296": "6b1c465d05b56f865af349e9375598f496c719faaf1f1c382fb257cb559d2f39",
"297": "d94539046b1ba96d439f425aead31a8835549c730127e41cd0c82470a2cca7c4",
"298": "345414bbf570dbf5a45a67d1e11db4822cc3201d2a5ba369eab5cec3f9f896f9",
"299": "2a906905f5dc6d07b1f877e2e34b0324346bb8ed8fb3acca8d70fed5ea5f2bad",
"300": "539b34fcc0617eacb0b7bfe820b3ccda6a3340b02fac809de0f411f54b0e8d1b",
"301": "40107145cbf46f7c487711d7e57fa2188ddae64b40e6a013957e5577472d5b15",
"302": "4da87084047568af745623d09b499ac505d029b6fd4b98668486ce1b324300f6",
"303": "6294346fa31182b8819317428e446cd3e4fc9077a8b9b156c32b8a46e039acf3",
"304": "1359c3743857692a1a04dc441b57af4985de674fef9e2351ce7b150c91e2f69c",
"305": "033a27b3d4635473e9aaa594f1e18c063991da3470c29b397839522aaa4f1096",
"306": "b2567e2d3c11b661abc73fbe3c0889619a5c5a47c30cdb978845d57add689ad0",
"307": "aafcb8d7798717f04833c0edf61a49b49f754ca0f632558da1aa12a3bb032b45",
"308": "aa3a57f710b2d097ab76126316b061a7f8eccb924e5eb27ebf2a10cb0a7c6cd2",
"309": "027dda6533093c985d4cd7c2fb4663dd898528ac07ba26c502063249a1ad5b9c",
"310": "2dcb014aa570ed01283ef2c47858aeed27990142b01cfa5551be42bdf0829c60",
"311": "bfc490ce9cf253c1d472376527f30f3305b64fff24ffee2e9f52850899b808c0",
"312": "0c8d3b4f65d8b674f6cd24a582ff542ecfb6d880344f1ad89d31b686ccd93f4b",
"313": "076e6a5d63162cfd7c90862b6931e2810c375e75a8c1e2f18e4322898181d73e",
"314": "273c1c6b0b9496e9b3b445b5627176c1368bdac852e4b2568f8972be4d4cbeb1",
"315": "607cf38fdfb9fc027877db06ce94c5f2a57dbd51a4422692e4f53400b48e0a2d",
"316": "a3522fe26401ca0e341b93832d56445493875ba0c6e1265acca74ca2f53b77b4",
"317": "bef8b6cff3c14a719aec9933f43525e4b9917327617d849c6b7e0567b768fde5",
"318": "62b56d8e9b7fa1df9cc8be877968b8f0457406f92fcafcdc3967e0eee1244eb5",
"319": "c5255c6347bcfc2bd522bb1c7c17c8f954e8eb569cd55e56104708a360bf9c75",
"320": "a21734a05c5fe614d5f3fd4cab7f9c5fb1b099e34b0e0a1f4eae139ff06ec2b5",
"321": "ff4771568a25f688746048f1e6390ac38fdf2785e5c2e2fecf012428409b46be",
"322": "3f15422e675c5dc13384f766781b2104bb993b4178ac0b86dd4abefc70dcfbed",
"323": "8876a824e7c4228691949036197fababb1d2c703a80edf5f3b5df6a106690f6e",
"324": "14c26f66f0a2faa994875a23fa8a35cccc74b9bb8f37ba4dda61534fd58a40a9",
"325": "7a1e51909e3aaafd0c37b69a06a2d77f11d94a503873bb6eedb47ae679404658",
"326": "dd952c3eddce5308df451da9a91f33fa837f00fd2dbc6748e46a49c179c91df7",
"327": "ba5ded39e9dc45051692ba34745af1d43af9c0033b574cc521cec7ecf8b4e692",
"328": "7bf770e393b0f5ffa0d0b7470c3fa4511742b3e0923e3c0500fabf24558ff1ef",
"329": "5958141c1ba82f63163ed429dce8a589a7e6a2126ee05c929033e895ed75f05b",
"330": "5351bbeef47b712d588bb0b01afb64f33e338d2483b71faa0570816980d9d064",
"331": "5a95b9c137b4d675be9be4b06d7edd1abe6124cce5051703db794693dc1c8ad2",
"332": "0ca06cf2f8e74247fad1c8e4f84c5f5df98f1287341c906dd03aec0ab0e180e0",
"333": "fe4ef4c7f5dec4301cf14d874135c23b154aab0b1696ae9d3d1bc57d461f2cb6",
"334": "099697fd3bafa06b79cc4ea0b06f78d3af0136842057b5b8d8d13a0938d50053",
"335": "7d6a92cbb55b67ff0577a73203989f8eb5263483e041cda6d4e26358747282d0",
"336": "2ded024ac4706f656303e166fd05c9e4da773a2d63eaaf397f560dcd2e5dae92",
"337": "d53789e3a4dee1f2b412fb5077d32035373324c0340f590656e181fa43c4f07f",
"338": "16655b97a30fde371c4dad93a8b178a0e3702f981ddc1b77ea1dc1930a683e16",
"339": "366e913f20167e27789c808bd7009b81694d472093bc76aefe6adb723d818b1c",
"340": "cba8adc7f330d999294a0c5fcb5730944c421eaa3aaf8b5b8ee89a940683b0bb",
"341": "bbac6bfdeadca56491b2c745c76313e0b09bd9c94a0fc24139ec9446dcbf687d",
"342": "890545cbe2ab6c4a0b64a73ab8003f7f95acc5a2fdf39f4b01b84fcaaaa995a0",
"343": "f163d8f5d4b8d044feb079f09bb473b0621de92f9238fb25199ce6aff4e9e429",
"344": "9062610940b53d11aead7bc5ceb138778d5bd4377dd44df3d9dc1a29d4f7d5d2",
"345": "e1652065871d3306c7208b13447e5c5e42d31a1d840b39d5378686f85d1ad859",
"346": "b975bc2ab053de67e5fe610a08aa6c8aa22dddc5ec59e57866163a05764cd159",
"347": "478a854521a75511cea076bcdb8ddc30d3eed18e83a6f3ccdc9a8c970b5f615f",
"348": "f70d0934dd4c0a33f0790d035f5f35531b2b7c2072295ca11b21509c71129d7c",
"349": "5c31721d7143dc6ea598847800ee3d44357a40770bfa8df67056c3aa1d7f3711",
"350": "94d4a2dade5d3c17137264679912bff598df92fb574a2acd371cc1dbaf63da82",
"351": "eb9f91df7303e3ef52b581988a499eb56684a317498d8a43d6edc186dcbc64c7",
"352": "acbfa1a6de3e89a31399bc6a75c0841b319c4bd010dfb90566a2c52d1b0fe914",
"353": "c68e64560a8810a2d2b46d3d50211f87978aa66418e3b81c77bff90650694767",
"354": "d4f18f17429b63a0a49e8a7ee51fdbf69bdc46d2b8c0fd7bd08a2658d6ba7f54",
"355": "7820d65d46a500e68f69d3417ab687de6a1663fed3ea3e982c029519b44edace",
"356": "b870eb97060a73cac0ca1cb30034f0dd96ecb8919b0392810a42e54ec51b172d",
"357": "7d73bd089946a5d812553e75874ffc7dd690cf4bc8c4ab8ae620b1e5ec0b2888",
"358": "2c164bc1386130a227942e1932d071e6c48eeb2b1d4bfbcadf2bc1230b674a77",
"359": "aaec667f04c0b080ad97202b32e5f7c6f866def2b46d14e640c1ed0a6ddd4067",
"360": "bed282e3b2663a577acaa5dd12d724969412f5933626c1ca2f34c448bf2511d8",
"361": "1bf8bb18ea1643e387f71d0a986b18be08e5b747725da25bef8f6243a8a4f95f",
"362": "3fa924c900bd6e22a694aa741f36a86398f38240515bbe5f5682b99443b4137f",
"363": "a2d524cfad6c267c986a2d4c48bd3c87b132f74f17b764a070b55abae0e2517d",
"364": "a46276181ee029fe806d21d19a92a67b08fc012c3380fec336f0d4da8eb4df55",
"365": "2ab65d875468cc7e27e0be81680f0cb2b288bc2e495b7dd813f316eddb81fb0f",
"366": "5c6439220514c8927eb6494c6b7544026f24f2cd8172ecd2c4acd2162e1ae528",
"367": "7f9d23c312c0bb9c71bb4217f1f0c5ac5f12505867d3402b02832a01409b65ea",
"368": "a0bc2be4d01f917abd1d32d3e2d9dc4c73c93551e13627be727e9fb7558f9aab",
"369": "c0ebe332ac10f73b8c95145984f673c37fb46db57867a9170178b28ba526b68c",
"370": "e2b2a5ebdf3b0c701e0f16c374cbeef6927f79e53e1c62a802a3f3c4f0a099c3",
"371": "e136d381e1ed697cb702e5b09754545a48a4bbf4a8c48ee8e2b7c77e93c00fd9",
"372": "85b56bc4c2255303d790a6e73bc09bdd3278205e4ab029c1393eb97c6e766383",
"373": "f5c47dc95d2ea91c6a3f3dec705ab4a822235d876d11666aa2e882c9a01bdc66",
"374": "cb3b6933e8da34c0001783b33e01ac63e30d55c27de33573d9b63c4369136a0e",
"375": "c1273a3c61c3d65805b09a896e4eb33714a0a7f292c48e0813a2b5dcff0474f5",
"376": "87fa9620b96fec24aaed6bcca34a2e52285a985b2012569f3b5cf1904adb39da",
"377": "47fee06b3c93430d56fe0236f345ab1a14e2fb00ea41246e4ebd980746cf4515",
"378": "8b01106610577b327732cfdce531ea660174c5f945b06cf2fcab810f34b249ac",
"379": "fabbcef00e791de0b4e995cca59b0084d18c1d6d71d8df4a99be95cff7ce5af9",
"380": "15138ef042f3214a2357d4bbe68dd0da0a31dd19faf432ef4570ae9bf25feca6",
"381": "b12d09049e47335e0aa7b3727d20e70e8085671f703295ba26da542b449e772b",
"382": "55687ae90ec45801af52c3d2fb98f37c149c4a37f38e8a920e88ff307d50fd66",
"383": "319e6857f078169d465e1e5f6e92c6d7bed9509de9f27fec32c27e7b8a12b81f",
"384": "ab3a3798efb81ab64e9d2627c97046f65a4a7910a85dc0d912dae77b39e7dae9",
"385": "f5df07c768bfebb34cc637813e5628e25265d4bbdb76c7c869acec911b8e4321",
"386": "67a5f0ca4a91a3b81f2a420790d35f04cf6174fc82793bc921681f52fec89952",
"387": "84ababbe6593cbeae7468b7fc3ce4ea3b1b64f0d0df7e7a0cd3586e16dc5e5b7",
"388": "e4405b6b53467bd718102a604f4f1e538333650c5c075fb6fbd446c3a69e6290",
"389": "44c46199dad4c8ff91680ea1a9cc4221652d245a5afa913666acac8c39d00bab",
"390": "5d30473558467fbeed4042cc0868b807f2a3d8b4aff305444c5fd4f829f86732",
"391": "76dc36380c94f7bd46b3516d127670e0ba9bc1a9cf0908bbf59d001fe76d32a2",
"392": "623eba9d521d95e8c1bbab3f3f4d170172517195702b29df67948c7627faf3ae",
"393": "0488c6eb4d2d0cf042e4c13ab36f89909b33c85ee41093391b93f5eec9f2d29d",
"394": "91ac5d54ab6f15a76621a8fc5989ab2a4e228e824746b07e539cb0f8bc870ea4",
"395": "58a61845e7591d5a8b60b8bbbf9c617c418d9bd139d02b447ccb642dac66203a",
"396": "457613f5fae065c7a2eb1928049b7a4f1c9638d1e38c287f58fc7822943ed9d2",
"397": "2ae3736fae6ab534cb0a82dc7f583af44e379db1f8c94eeadd7fcd471cb823b1",
"398": "645c563cf4ddb52039374ba8020df484307f0735e24303236f16ea8026271c46",
"399": "a39b07c53004ea394db72637e091098e545175c6f62d1ce9b2d2a5fe4cde42aa",
"400": "4ee68846677a079c640617b2fb07630daee13633d4224b21512e29ae6107955b",
"401": "aa967a0505960c43951ac3acf23037b0a05a7993f6ebf00d9c2c4b2ad8f2d985",
"402": "a9ae443f92a67d417c33b70818b584ed3f411dd2f32c5e6c2e154e713ab1af6f",
"403": "147a7ef0d9b42e2c35e9b5b4435b8db030644749d67dc64e841a21139c71514b",
"404": "dd9c6a371bf378a38a76386b4d3cb61b02615857298a2bd685560032012c1d76",
"405": "f5b1f5472d4b80f1fe051ee5a092cdf32fa8fa71fea8c12eb430f4dde614c184",
"406": "0a3278172f1349cf16b25d991bde2fde1f2d4d02271d6866c471df2e9dccaca3",
"407": "9910ed6be874f5d4a231d6effcb3126833e90303f048eb7367781b8eda967413",
"408": "0f93a29ba3fc3b6f9ed875e132125f705732731cbd7710c0a3ad9e49cc1c35c0",
"409": "5c66eedce16ec09e613e5223d22e56e5a3492b0cfc4be6d8ef3ecb7409ce5abf",
"410": "92240c2dd70931bef585f070985852b47595097b75fa1efae17b7305ae2a45e2",
"411": "0b0d111d316eedd4fcbee5f8d5b3209752d4f0fd106cb143b2a0db6fcad4cc8c",
"412": "34ace0343a5e841f9243807174c402ed7a4a7904691249bad03a3444fd12dda7",
"413": "e9ad7c1adf1479a125a6ad07939a76c8cc539bb1c9b8daa3297a6d9944fbc4f4",
"414": "0beca092b44f26b9b1bd0747827fc20a36e1c39543cac6333bf9bd51cd54f89e",
"415": "5f35bf67d9ea62171323ba05541d3690ec999c8347ea8a153e4814971cb25e7c",
"416": "0770feb3e275910527534690715659995d3ded6fdee80efc3cb89e3333cd04c5",
"417": "56a8f8640ff87ad295b0b3e59c5c012ed1eb220d1c21c722e00e9975a0693657",
"418": "99f01fc27d803adeee736144e857d151d32661e6117fdb30bddd8d6b34cbb100",
"419": "9145362b470803e215bb0e59bf8932ec4ea9869415dbaef128c288fd9379cf58",
"420": "5f2045e6569c616cf7727b192a20715e360557aeeda292add1ee9893e7759dce",
"421": "a6a4413c26b534968009fd1a38187946dcf0610685f2b183a919b8fe2c7a64a0",
"422": "fba464fe63f826c6f2876c26ab3f52ed03bffcaf640f638cfda68d6a5d6b99ac",
"423": "1ed0b7b53f5ebfac07e368b393cc40b4489d650d7aa31a59889ef8c1dc1491ac",
"424": "f8064d5d67db705d13b1c7cc74be8017c05510c3fb372db1f43cc43042e4e3f0",
"425": "9bc3d3226612c1c27a188683a0def9aef4af35a306c476bfb849e9f56c611a30",
"426": "0228edb80450550bcc068f9f1fbf9656bdf13e463577aa03aa588fa61ea75952",
"427": "7ee205a053f7078333652ffd2769c454037b29f0934257bf1e1774a05ef95e26",
"428": "8082043f8b8bbc7d4f4d32f1eb70d380e1917f2b0fc6ff574fdcceb3ed7114e2",
"429": "e77c25d8cfae427d22d7ad19703fcb3c888ce52750515f5f3a2d27c96b641e3b",
"430": "876ff04f19e37c3f20c3e7391e8e9e91564d19314e7240d83822410c7f3afe42",
"431": "ba1fc18823372ee09fa81518b15fcc60d96eab53edc9c9576a44290ad0ece266",
"432": "c4f86f5ca31101b4c07f405fa9397e8da6b95782631f08a733f23d1741c60123",
"433": "ab74ced7a53c70a29706bccc94b3fc100036a5b89f5d1523b6598e317e93746d",
"434": "04fe1446edecdcbcb2c6dd316b34ceb4612c51aecd486fea228c2c39fe49c97e",
"435": "27b8bcd0f77685c8507f1e6177d3b511953d63d199f4da2b244430110cb54fc8",
"436": "87f6a7871d0160f8286a1a8a94bfd423624873417072ba84049385bed87855ae",
"437": "fdeab7e21cb59a8116a5f48b133c30259219c0bf64085cd86c67c6401f04fc2f",
"438": "c002095868d0b701aa641041476f528530695d5ec3fcd14e8afad6454d2604c1",
"439": "fd30f559d2bd7548c793e705fdba560da0972ad78e59198f56ec2f9c80a62e2a",
"440": "72f3982c1e33276fe659324516800b876b40386f71e4f64bc6be9e78734bc567",
"441": "719346ef6f2c38913f17960d776a6a64ab45a95374a817dcf74db7ddc357ea78",
"442": "c3721525b19326e5ae2506b5040bc252a538f287a6cdf864ee39d1a3e11da3cb",
"443": "2bb0a3238175dff454a903a93cb368dd7004e236a567acd2c0ca4f033d779e5a",
"444": "98f4cab72778199dd80a638b007ac8a91493e11892ac767f1fc95af944cfdff3",
"445": "82e5c7ba0b10ca28d749bd0ca7ef040024dc1f166a9dd43881a171b24202a8dd",
"446": "343bd6c1674f4b44b5abb333973eccfe31c7b58304ed3484a8a7a236e7d428e4",
"447": "2e157a5f91c1c807cb51bc635d9eac578b3e9f3355da77f6c5f77608d168fb77",
"448": "79d130538a7822cd40c7b43991c11876d4672a9f5aad6c026087d4d152842a83",
"449": "b26071d7feb3b1d5cf3d6c6478d0b1f804bad00bee667968a5ea7ea1c1ff4e8f",
"450": "dbfb0fc8081ff332bbae32dd29e600e0fa1a205535ade6c133150422da63cbf8",
"451": "fbcd3917b1c5ebe0ac1e6872293fa761b42c401eb9a3513647776947844b79bb",
"452": "32fa773e34ce35912c0e1c45e70cbfcc77a69e159890a9e2cdf84fd170cd22c3",
"453": "7220c3916872c4a7cd2a870930f538f7645f8fb0f850027981ae7db795833a7b",
"454": "fdd6afc81dee8c1deaf66657e579a7c5ff4a70dadeeffe55f5dd9d5a8a00477a",
"455": "74e18b6a0745436de0fa80e74d11377604b904f8ee3d62669534955bc11e3207",
"456": "a0ec0dd713fb29b900a04234d33fed5e54a6aab0b645debc56316197066acf8d",
"457": "ad66a70cc802a244eb92000cd4f67f95447b9c0cc661c02b8c9b9151d9e9aa49",
"458": "af80e62a2520cef5357a4756e8fd202e0aac9ccfa1947dac5da3c50805cc4188",
"459": "6a35a991df73fbfb9f5b733875c757c84fda1b330983f93e6afc66bf18c4a38d",
"460": "160f83a85be80b16bac7782afee7e1faf3958ad6e87149d7c39cd15b2fce6343",
"461": "0f6462422de305f9d3670014179a70b49f0d66789bb43784e33bfb4cd9a6f557",
"462": "0a1832f3c2fce1aa3430ac21e03e1a79bb16b81cb87f1af08a5808443f84163f",
"463": "356d8df28330a5321c985db0a86cacdc84c326d42cdb057287c9cff3ec301bd9",
"464": "e6714428a713b031b946fad05ad59e6f5eda45810177cc0e06431cdb9459c34b",
"465": "3efc667f45c51c28d4c6df4e364fa826566ec0ee542bafdb1350580352ecba1a",
"466": "424bdfd941d550b0944d857d9e126af06e31b4bb7b84a94419c7a4c50d90a188",
"467": "bc6ae1918192766e23fc5238fef3740f1c9211a9c7d39cd60f5d269906e20b95",
"468": "de2d54002a576351c716bc3e3a062c16e18c5287567690523da2a1c05538e215",
"469": "e41d8d17f064cf76723b1c8295b1d2b753d4ac5aba5d14306c655f440a95bc45",
"470": "5e90b39c9cc3ee30ea334dadbef11f22f093b086a3ec78a7abeb5ac2b5aecd50",
"471": "b790dae7dcd09a2b802b9331c4cb858ceb257dde00c148d5be1dbba3272bd4fa",
"472": "e3477872726afe5b2f221bcdc6dc650a31e8316ee7d1701b8f646525ba3f4921",
"473": "e94ec6c6e6a4cd6b1baea0403700594fdd5ed4361807111959cda2fcf502e4cc",
"474": "c45b5e30802fab044d0b97e438f96c4a40b00714643b7e0321efa75e74b39977",
"475": "f6492e6c3f794c32e8e20d79a9bf01a7fb65bb79af038e79697c1fa240d249b6",
"476": "6187e01e203c7394fc2f6866c2e7cd56b415230ef8b3678ece2d22428491c903",
"477": "5850a79b2a8f63d288a7c2390953846ebac547ee4d63ddcf40ed14fda6dc9e9a",
"478": "c8d72e6fe8e91ce842a800e400bc653845bcc2647f0745a494e3e6cf7c62a9ba",
"479": "1eea0faa4e0c5f47f22c980064af4031680435a928b5521c3752f4d366ba708a",
"480": "e617fba622acc0a3a9846501f34d3cd823aba0182d147c5e0ec31d3886cf6bc8",
"481": "cb7b10f265c3f43b6f4e78d317a8c038b42db7b16837c4fffe16a6f003eae36a",
"482": "c0941e9770144b6dd160a99c85028c30dec85e4d693e101b73b2991d2ac015bc",
"483": "4143a50371719a84e7b59ad62c8935089449f2d118d2ee0cfff0e8f54b17bae2",
"484": "937b8a194a19f27405b8586fa20e21b695cdb4fcd3a7bfc1dffe5ca63c76897d",
"485": "e6d9b8deee950ae7080997b624ab4e303e395ca7a66b4f53862c9a3d8f5a4773",
"486": "1f7741c608bba8a9c197896e23db1bf39289857899c405dd7d660942d2b8d2a5",
"487": "ab82e178df29e7beb20c574741194e601842e994f3aabc282708d182071389f4",
"488": "c6e26b93fc71200e6738b08a0b2ecf13c73baeb00cae3f6137651c5bc28a9dc0",
"489": "0489b189a748288a612286d5dfb516e7299cf224052764eb1cd47f34abbf1a1b",
"490": "470821314b458a4cc8cf93fbd993002d578ab413679cad2ee92826df94fc7bcf",
"491": "eba40a78e6beb17e8a7ab3dd6c3963894a4243a2205ab829dfb23fe060ffcfd1",
"492": "1df5eebc07a3fc345399b4701e62f2359a492a6974a63257e9079ffc5e0e4d3f",
"493": "ba889154c39a1a402a0a0a531b1bd7f6d076a9b0d871b8e9fcefae44c8cd1ff3",
"494": "157153a7f76903a18ab07a39b9ff009116cdd9f918b4d3404cb15da9bf1a1d3f",
"495": "2f61748f31963341d77e2401ad889dc6794205bd966611779af560a774141a17",
"496": "1f6ef2160abe7625d227dfd89d155a3a4760b44d16872d0e751ab7bc76cb53e8",
"497": "cec84a9606f8fe89f6ed60eb557e8376d535bba156b491368afed753f5c8323a",
"498": "efb8613d3831727ff8cb455cc2facb519f67aa00dee4faba3f22903ac90a4d22",
"499": "c29f204fd5eeba926b29bfc5ffdab9a6db8759835ce76eb8c3c295b8fbe1b6ac",
"500": "74152e193b83bf7b07850d3e7d338e15b2341b3da0d8da7852113723d713ac93",
"501": "14ce82a67cf62c301799241c17a8466ad0a2d04547e083e1c0af1846b0742da1",
"502": "a33968c8906fcabad71814904dbdaf51104c5f483623734b6700a3a3c876e1ec",
"503": "e23e9316fe3a66d257021758a279ec44b2f3bcd2de5bbcf8e0e5da7ef8a9589d",
"504": "303b8f37d427664fe5700123933d04b75cc794ab3c0bce70346d272ee8686985",
"505": "cf1eac1614ba43b76d9a6db69db0403e784771784efa111cf9d19277254c6187",
"506": "d0c679ccc63557aea139f9123c82e593b98edb6fce503d112ef5395a1e36b0f6",
"507": "9c5581e4f1d4e9f38e2eb2b8a43056c2612fdfcefa79735e07252a19d3649667",
"508": "d9ace05b1a12eaf98654678b23f6998bbeac81623aaa9cfe328234a26856e61f",
"509": "0477716fbfa111f7e47d322dd507f03af21511c3200a55e05a08a470bb9233ea",
"510": "b541515e444f941f4a90c67f7bdaaad5588a2dad84d3af85d3641ad13e4c240f",
"511": "42e7cd704dd34f16e2d45add2ce7fe3119918010d9fca374883ac1538d451c5a",
"512": "626fe444e4e6198d1f8907580b1ee86b8529a006435aaab9b09646c98f2075fd",
"513": "3e649eefbc00968a8e7bd6b5f75613d9919972030fbc337beb3a22c7a407fbc4",
"514": "5275dc6b0c37f3072ef6b1020139aa77453961ccbc341485cb706ede8daaca01",
"515": "aee98510a95848e65e4fdecea1f4eef2031d5b8057b6c7a23d54173a1131bb27",
"516": "64422acaf1d6321cbde088dff49473037ede88c99b2029207f6cec77013f1868",
"517": "a311766475485660732f6f168d60318dae621a7e5a3fe5967569db178c6dfe26",
"518": "b4ab2d72dec7a70e1915bc9a61eeddd195d1a22081d0e81a657db32884e94d01",
"519": "9864f54db192ddd424ca33091ae5ddd7dd705061aa2c2f44d6d0d0036b5462d5",
"520": "ddc6b101dbcf776c6d267c51d4b57d07feb4c8573ee225e37ded0f3c64ebf8ce",
"521": "e467755a261618fa864ca5dcd9d43df95b7c3ba6a466e4c3afb81e167d09659f",
"522": "169eb9fd6e7bb6de8ff338124902bcb87788ba350fc4b547b8d229135fa05198",
"523": "83b0d0e88cfc60450a348f89af52d44dc30784b5e3b8d1aebbe45aaa49a19f87",
"524": "708445526750d2aebedb46ad40cdf7c5dceecaeaa27e64c87de29eaec1ddef4f",
"525": "cd70df5d9bd7be0305c19ae63aebed939e7640f294692c767e40ca2bb57a45a1",
"526": "8caf5258447e464554b5eb64e4e67cf013f2788cbeb5224fa8c97db768988657",
"527": "7bd9d6d022abc237fabe7c679940d51efa690e88fe53b570acac8d597408b984",
"528": "94aa6b43381c9a495ed33435d225d1f762859bdd6a788c81628ce4520357e202",
"529": "fad63c2d055c8242401b7c6ce64b45a70182b9774636029c55cd1a928380fd99",
"530": "db498051375571a7389ad5e0c96669cd47bd79df4968d5ae6b99e3fa89972ec1",
"531": "4c02d713d4f29eed5a4ba778687cea7da3686a8794aaa7b92980485ff3d2e832",
"532": "f0ad84686979b844eae2e431ca91ddf2276eb4c1c8b1b5af0ae7bca68fbe704d",
"533": "84d126b10ba381a348a47fe7e74a53845fbd50ceb19c3cbc0294375794c1140e",
"534": "6a4b13861ad76a1154a674148540d4c14bb350f6f9633395830dce2f67715595",
"535": "bd321ffd934cacb305dfefe5876efe1f18f8c22c55ae218d0311686f12c1151a",
"536": "94af1fbf727cf52a29ea8a6a534dd184bcb6cda7cbc786732823edccb4f5525b",
"537": "c65cd573d337f375a5f281996011834004a16ff36970a869cece044a558bad22",
"538": "6d6e6c1cefc88117b00ed3b2f6bbcff26fd2d32fabf5961dc01e19f0bc3cbce5",
"539": "749a7e0291039cee137c68164a9ccada6d0428cd442e5f62790db2e2728d51ce",
"540": "0b90285d48e154700a6323a28e74b3b090db9a77856c233cbe0296ec244b620d",
"541": "144f71c1b479241229c8ec62ddb8a137a026296017d334ab4fdcf34c035980e5",
"542": "5c52d32ec9e8f658a600e18ec5c55a29b8258992d29a22fac22803fe2549e466",
"543": "f542722c2ed4edaae7cde2e5aeab0c032170868abd4495583153564d49838099",
"544": "a9ad6c4836013cc6e24c33acef8a53d11028bf10b0c4b0cd8b70bcd109f832a5",
"545": "357eba8ba5349fcb1748793ea97abe674d270ee6daac2afc0db763e221b7ab39",
"546": "c1a7524e58cd1503dd80d2a06c538f5f6f54c407f89c1db97ad2d3cfc153fa99",
"547": "ba39083e3c73555891715e6ad0cd9e5adcc3c98b8b698fccf399cbe529022c54",
"548": "63dedf2515a0e9323d0dbac9b014183770e9e2183e051295a7dfeb491c8fef6e",
"549": "1e18dc70f475b23387b9f0f5c05ad718953c193b701fdc3b7a9a3871bbe06450",
"550": "864706933c6aa2e097a1ab2f0c406fa439c2ec5d8277c7a63fee86b3e9365406",
"551": "ec36c05862ecac2d063f25f77bd27b7028f4f9b21d809ea3797be697c2d84368",
"552": "a9f65836106426a40779b1a8cd064694ea02174cfe9e280de320f8f519f00fe7",
"553": "f496c8102f3e99463398946494852f571b0540ea283b0a7c1d87012a072d0ee7",
"554": "a1a88d09d25c6950a12fd191f99a9f6acc09f429ef893d07933d640f71efe45c",
"555": "1cea167e6d53269e718ec2a99c7a2c26d949c19999fae203225efb5c8306d1a3",
"556": "49a7f5031e6bb454ccbfbf94802762bd8053311b682d42d51498621fe4852950",
"557": "14147fce7ab960cf2a85a2e2590020a6820349b8dcbfe6fa290c20e2e0888275",
"558": "5668eb67d29d89ed277b47f27eeed843e3914fab7f52f830d77562ac51bb70a4",
"559": "1bd229e6d5361de8b5c282f3adb428aafc6b8576f079e2c96c8558d2cb63905b",
"560": "f8efff848a4b6809bde2b99a6f44885cacce1d660841d07237d47775d22ecf32",
"561": "56022009e7e47b83bdbaf775bbc3478abd171811c7893f656331f1ff577a621a",
"562": "682806cff6e1587125aee658cce53a30e2de9725f5af54b2af39f98b0dc08ba7",
"563": "e11167c9c630a3b09226a2521e13ad581802dc5954bef51aba9e2f9ea04f4b75",
"564": "d2df70ae10314780d9738c92b3f293937248c087f1019ae6a2a5adca0151d85b",
"565": "5135fede0e2117804c776b35da39b477f5b913444bafe565ced20486630a138c",
"566": "bf96033436cf66dc621d631219fc579510dcba04a6ad3b740b58aff45e5e49cb",
"567": "b718c303b67f5d2676e7e3a0d3b242cb26dce305e3c0bf912757b390af08537d",
"568": "680c4b5b6df22227a99099a7e144c055fefb4233d4b3983cc911b9e79e9f44d0",
"569": "d4554511f47526795ef796dec96dadd446d3ea89b4a67c00d8994738161a31fe",
"570": "59fface3a47d42509977e9e42b840e09843eae165e163612b08b7cc16884ace7",
"571": "e893062022228edbf1741450d3fd5526f6ea12fe2e62d7c2f2e16def5ec6971c",
"572": "78565159fe4e8d70df3a9a3030ece976747fdaba7cfd70a59b375fd8bc65b7da",
"573": "dfcf34ac9447a3106ad470716d5ca27640099021f9b11a29cf736fe0366615e9",
"574": "67580de8620d29c02836822a4f54e8a2bc0c50824ee31a2140d7bfb07a095f89",
"575": "0c2af3d8bc4888f45244c611ab8ddef0b99ffa2f78fc112a6e3ff93f2f39275b",
"576": "962e064fa4aece36d173b79ce568c6eaf42f589bb551e917fa3af04f94cdf213",
"577": "431d348dcbed1986ea325443cbc197b8d21afb02afe0660f78c4d3b2f0231bb2",
"578": "ee1926298d7e9def6984728515460ebbe2583d1b208240834dda0a239721a947",
"579": "1203491ec9a7b3e392362b5c7a019a3933bc628c7e9c53ef92fa7dc8ab3f9709",
"580": "cf0c0e97db308037ca4b3b7f62c8c087ead0159304d92570c3a90a41430dd6f6",
"581": "17047d0c2b6f8ec84a232d292d63036d386dc433ddc66befc4d4c0d5f376429d",
"582": "5d1ab417528b72d7b95c032066453486ac5ba94a237b4dad7d75123f6b681c8c",
"583": "bc33b2404d94c10168be7ea3b30b942a4cac6e32501b49808e56905c77669d00",
"584": "853e6194aea8f0bb0384173716d82d1b99a3d7e9fb24e646b3db9ad1e5104ccf",
"585": "7c1eaa16629ab960bbca17da6f14d39f9f6c402f7590074246bc8fe85d920780",
"586": "8dee90325d600ce4e38c2b1edabaaa07332bc7a07664964860dc0b0c6e034f4f",
"587": "85b06dddd60cde14101016174a3a2e555f3dc905f0e0db3cd709b31f1845d1c7",
"588": "05ba1898d97a576df9837ef16fb5d9c3587ef576e962b19b37fcf213e26c8ad9",
"589": "447b01bae8bbe12b6a84de93bcac76205298ed366f48ecc5b2e03eb38ade39da",
"590": "1c5622add47ce4adc879cda40ad4226831eb14a78ae856ed4a709fee038fc14a",
"591": "88caa1d64066f261776d6e3df86385e6ce2840d60927ac99674be30e0b0f4aba",
"592": "b2bd47360bce53a02ebdc6ecf4d47a993241a544929efe7e133a5f1ea6a80825",
"593": "4cec7bd384bc000c52c2ecd2945b053db4c59d72f143ef3bd30b6e4acf0f4a2c",
"594": "3defaf08b5d52c5b9e1922762e5a1f4c4fa69f3d4b62c85c52b33358005abf60",
"595": "c95fcff56138b79799d382dd14a9cff5c9cf553066567b4578dc984091232cbb",
"596": "9838f49f7d4bbe87282af3153fa787822ae787192e87420d7e2c2c812fecdd01",
"597": "dfc93dd04800409d7892a89b6e7069f57faf57825440065d2539e398a7b25d5c",
"598": "3b143c158a7fdaeea3732b9e4e2ad793384fa865b0af55bb4fc75de46b2277a5",
"599": "454a430b4113c2114696c806cc8f2f6c5b074cc1846ce9ad990f031f613793f5",
"600": "7038d049a6adb89bf759aa74868e9c2a12a5d48fd07f216a6557d9d2eb0629f1"
}
}
+611
View File
@@ -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()
+881
View File
@@ -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)
+170
View File
@@ -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"
+230
View File
@@ -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())
+103
View File
@@ -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
+585
View File
@@ -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 == ()
+153
View File
@@ -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
+218
View File
@@ -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
+587
View File
@@ -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
+495
View File
@@ -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
+804
View File
@@ -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
+1
View File
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
"docs/SIMULATION.md",
"docs/TESTING.md",
}