diff --git a/.agents/phases/todo/03_simulation_movement_and_interaction/00_phase.md b/.agents/phases/complete/03_simulation_movement_and_interaction/00_phase.md similarity index 100% rename from .agents/phases/todo/03_simulation_movement_and_interaction/00_phase.md rename to .agents/phases/complete/03_simulation_movement_and_interaction/00_phase.md diff --git a/.agents/phases/todo/03_simulation_movement_and_interaction/01_fixed_step_kernel.md b/.agents/phases/complete/03_simulation_movement_and_interaction/01_fixed_step_kernel.md similarity index 100% rename from .agents/phases/todo/03_simulation_movement_and_interaction/01_fixed_step_kernel.md rename to .agents/phases/complete/03_simulation_movement_and_interaction/01_fixed_step_kernel.md diff --git a/.agents/phases/todo/03_simulation_movement_and_interaction/02_movement_and_collision.md b/.agents/phases/complete/03_simulation_movement_and_interaction/02_movement_and_collision.md similarity index 100% rename from .agents/phases/todo/03_simulation_movement_and_interaction/02_movement_and_collision.md rename to .agents/phases/complete/03_simulation_movement_and_interaction/02_movement_and_collision.md diff --git a/.agents/phases/todo/03_simulation_movement_and_interaction/03_interaction_targeting.md b/.agents/phases/complete/03_simulation_movement_and_interaction/03_interaction_targeting.md similarity index 100% rename from .agents/phases/todo/03_simulation_movement_and_interaction/03_interaction_targeting.md rename to .agents/phases/complete/03_simulation_movement_and_interaction/03_interaction_targeting.md diff --git a/.agents/phases/todo/03_simulation_movement_and_interaction/04_pickup_carry_drop.md b/.agents/phases/complete/03_simulation_movement_and_interaction/04_pickup_carry_drop.md similarity index 100% rename from .agents/phases/todo/03_simulation_movement_and_interaction/04_pickup_carry_drop.md rename to .agents/phases/complete/03_simulation_movement_and_interaction/04_pickup_carry_drop.md diff --git a/.agents/phases/todo/03_simulation_movement_and_interaction/05_replay_harness_and_goldens.md b/.agents/phases/complete/03_simulation_movement_and_interaction/05_replay_harness_and_goldens.md similarity index 100% rename from .agents/phases/todo/03_simulation_movement_and_interaction/05_replay_harness_and_goldens.md rename to .agents/phases/complete/03_simulation_movement_and_interaction/05_replay_harness_and_goldens.md diff --git a/AGENTS.md b/AGENTS.md index 0e0e9cb..cf4d807 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`. diff --git a/README.md b/README.md index 6836869..7f09907 100644 --- a/README.md +++ b/README.md @@ -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 +.json>` runs the committed input script and +gates it on `tests/goldens/.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 ``` diff --git a/docs/SIMULATION.md b/docs/SIMULATION.md new file mode 100644 index 0000000..940dc09 --- /dev/null +++ b/docs/SIMULATION.md @@ -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/.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. diff --git a/docs/TESTING.md b/docs/TESTING.md index 1ecf4fe..eb16acc 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 diff --git a/src/undercooked/sim/carry.py b/src/undercooked/sim/carry.py new file mode 100644 index 0000000..a8f115b --- /dev/null +++ b/src/undercooked/sim/carry.py @@ -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, + }, + ), + ) diff --git a/src/undercooked/sim/collision.py b/src/undercooked/sim/collision.py new file mode 100644 index 0000000..e7728a0 --- /dev/null +++ b/src/undercooked/sim/collision.py @@ -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) diff --git a/src/undercooked/sim/hazards.py b/src/undercooked/sim/hazards.py new file mode 100644 index 0000000..5c2b0b4 --- /dev/null +++ b/src/undercooked/sim/hazards.py @@ -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, () diff --git a/src/undercooked/sim/interact.py b/src/undercooked/sim/interact.py new file mode 100644 index 0000000..31db615 --- /dev/null +++ b/src/undercooked/sim/interact.py @@ -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) diff --git a/src/undercooked/sim/model.py b/src/undercooked/sim/model.py index 1c435d1..a180dbf 100644 --- a/src/undercooked/sim/model.py +++ b/src/undercooked/sim/model.py @@ -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). diff --git a/src/undercooked/sim/movement.py b/src/undercooked/sim/movement.py new file mode 100644 index 0000000..a2dfd72 --- /dev/null +++ b/src/undercooked/sim/movement.py @@ -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 diff --git a/src/undercooked/sim/orders.py b/src/undercooked/sim/orders.py new file mode 100644 index 0000000..2b6c1a8 --- /dev/null +++ b/src/undercooked/sim/orders.py @@ -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, () diff --git a/src/undercooked/sim/replay.py b/src/undercooked/sim/replay.py new file mode 100644 index 0000000..50940dd --- /dev/null +++ b/src/undercooked/sim/replay.py @@ -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 ` 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/.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/.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 "" + 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 ''} 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/.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/.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 and write its golden") + record.add_argument("script", type=Path) + check = sub.add_parser("check", help="run 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()) diff --git a/src/undercooked/sim/rng.py b/src/undercooked/sim/rng.py new file mode 100644 index 0000000..3e1e82c --- /dev/null +++ b/src/undercooked/sim/rng.py @@ -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)) diff --git a/src/undercooked/sim/round_state.py b/src/undercooked/sim/round_state.py new file mode 100644 index 0000000..995fdcd --- /dev/null +++ b/src/undercooked/sim/round_state.py @@ -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), () diff --git a/src/undercooked/sim/state_build.py b/src/undercooked/sim/state_build.py new file mode 100644 index 0000000..4d6144c --- /dev/null +++ b/src/undercooked/sim/state_build.py @@ -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, + ) diff --git a/src/undercooked/sim/stations.py b/src/undercooked/sim/stations.py new file mode 100644 index 0000000..7644441 --- /dev/null +++ b/src/undercooked/sim/stations.py @@ -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, () diff --git a/src/undercooked/sim/step.py b/src/undercooked/sim/step.py new file mode 100644 index 0000000..86630f2 --- /dev/null +++ b/src/undercooked/sim/step.py @@ -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), + ) diff --git a/tests/fixtures/levels/_replay/pick_drop_pass.yaml b/tests/fixtures/levels/_replay/pick_drop_pass.yaml new file mode 100644 index 0000000..92d9eda --- /dev/null +++ b/tests/fixtures/levels/_replay/pick_drop_pass.yaml @@ -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. diff --git a/tests/fixtures/replays/pick_drop_pass.json b/tests/fixtures/replays/pick_drop_pass.json new file mode 100644 index 0000000..193cef3 --- /dev/null +++ b/tests/fixtures/replays/pick_drop_pass.json @@ -0,0 +1,2156 @@ +{ + "seed": 24301, + "level_id": "99_replay_pick_drop_pass", + "tick_hz": 20, + "record_every": 1, + "items": [ + { + "station": "spawn_shelf_0", + "item": "tomato", + "form": "raw" + } + ], + "frames": [ + { + "tick": 0, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 0, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 1, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 1, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 2, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 2, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 3, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 3, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 4, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 4, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 5, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 5, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 6, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 6, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 7, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 7, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 8, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 8, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 9, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 9, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 10, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 10, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 11, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 11, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 12, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 12, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 13, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 13, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 14, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 14, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 15, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 15, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 16, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 16, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 17, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 17, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 18, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 18, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 19, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 19, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 20, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 20, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 21, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 21, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 22, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 22, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 23, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 23, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 24, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 24, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 25, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 25, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 26, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 26, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 27, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 27, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 28, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 28, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 29, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 29, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 30, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 30, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 31, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 31, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 32, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 32, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 33, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 33, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 34, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 34, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 35, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 35, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 36, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 36, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 37, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 37, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 38, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 38, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 39, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 39, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 40, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 40, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 41, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 41, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 42, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 42, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 43, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 43, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 44, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 44, + "player_id": "p1", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 45, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 45, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 46, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + }, + { + "tick": 47, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 48, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 48, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 49, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 49, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 50, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 50, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 51, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 51, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 52, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 52, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 53, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 53, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 54, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 54, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 55, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 55, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 56, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 56, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 57, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 57, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 58, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 58, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 59, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 59, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 60, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 60, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 61, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 61, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 62, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 62, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 63, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 63, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 64, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 64, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 65, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 65, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 66, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 66, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 67, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 67, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 68, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 68, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 69, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 69, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 70, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 70, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 71, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 71, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 72, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 72, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 73, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 73, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 74, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 74, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 75, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 75, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": "left" + }, + { + "tick": 76, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": null + }, + { + "tick": 77, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 77, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + }, + { + "tick": 78, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 79, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 80, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 81, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 82, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 83, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 84, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 85, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 86, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 87, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 88, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 89, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 90, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 91, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 92, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 93, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 94, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 95, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 96, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 97, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 98, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 99, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 100, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 101, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 102, + "player_id": "p1", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 103, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 104, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 105, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 106, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 107, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 108, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 109, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 110, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 111, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 112, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 113, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 114, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 115, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 116, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 117, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 118, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 119, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 120, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 121, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 122, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 123, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 124, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 125, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 126, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 127, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 128, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 129, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 130, + "player_id": "p1", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 131, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + }, + { + "tick": 132, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + }, + { + "tick": 133, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + }, + { + "tick": 134, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + }, + { + "tick": 135, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + }, + { + "tick": 136, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + }, + { + "tick": 137, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + }, + { + "tick": 138, + "player_id": "p1", + "move": [ + 0.0, + 0.0 + ], + "action": true, + "face": "left" + } + ], + "name": "pick_drop_pass" +} diff --git a/tests/fixtures/replays/walk_solo.json b/tests/fixtures/replays/walk_solo.json new file mode 100644 index 0000000..863ec41 --- /dev/null +++ b/tests/fixtures/replays/walk_solo.json @@ -0,0 +1,6010 @@ +{ + "seed": 336, + "level_id": "01_first_kitchen", + "tick_hz": 20, + "record_every": 1, + "items": [], + "frames": [ + { + "tick": 0, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 1, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 2, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 3, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 4, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 5, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 6, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 7, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 8, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 9, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 10, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 11, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 12, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 13, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 14, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 15, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 16, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 17, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 18, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 19, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 20, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 21, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 22, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 23, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 24, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 25, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 26, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 27, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 28, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 29, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 30, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 31, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 32, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 33, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 34, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 35, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 36, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 37, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 38, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 39, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 40, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 41, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 42, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 43, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 44, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 45, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 46, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 47, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 48, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 49, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 50, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 51, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 52, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 53, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 54, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 55, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 56, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 57, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 58, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 59, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 60, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 61, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 62, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 63, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 64, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 65, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 66, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 67, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 68, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 69, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 70, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 71, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 72, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 73, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 74, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 75, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 76, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 77, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 78, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 79, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 80, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 81, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 82, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 83, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 84, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 85, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 86, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 87, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 88, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 89, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 90, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 91, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 92, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 93, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 94, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 95, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 96, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 97, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 98, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 99, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 100, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 101, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 102, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 103, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 104, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 105, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 106, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 107, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 108, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 109, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 110, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 111, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 112, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 113, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 114, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 115, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 116, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 117, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 118, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 119, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 120, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 121, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 122, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 123, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 124, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 125, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 126, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 127, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 128, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 129, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 130, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 131, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 132, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 133, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 134, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 135, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 136, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 137, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 138, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 139, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 140, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 141, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 142, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 143, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 144, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 145, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 146, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 147, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 148, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 149, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 150, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 151, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 152, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 153, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 154, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 155, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 156, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 157, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 158, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 159, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 160, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 161, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 162, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 163, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 164, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 165, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 166, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 167, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 168, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 169, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 170, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 171, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 172, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 173, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 174, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 175, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 176, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 177, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 178, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 179, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 180, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 181, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 182, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 183, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 184, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 185, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 186, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 187, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 188, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 189, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 190, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 191, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 192, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 193, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 194, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 195, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 196, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 197, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 198, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 199, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 200, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 201, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 202, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 203, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 204, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 205, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 206, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 207, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 208, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 209, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 210, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 211, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 212, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 213, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 214, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 215, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 216, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 217, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 218, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 219, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 220, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 221, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 222, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 223, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 224, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 225, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 226, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 227, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 228, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 229, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 230, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 231, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 232, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 233, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 234, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 235, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 236, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 237, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 238, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 239, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 240, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 241, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 242, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 243, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 244, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 245, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 246, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 247, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 248, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 249, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 250, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 251, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 252, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 253, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 254, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 255, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 256, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 257, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 258, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 259, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 260, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 261, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 262, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 263, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 264, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 265, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 266, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 267, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 268, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 269, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 270, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 271, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 272, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 273, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 274, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 275, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 276, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 277, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 278, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 279, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 280, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 281, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 282, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 283, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 284, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 285, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 286, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 287, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 288, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 289, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 290, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 291, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 292, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 293, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 294, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 295, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 296, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 297, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 298, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 299, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 300, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 301, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 302, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 303, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 304, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 305, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 306, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 307, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 308, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 309, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 310, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 311, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 312, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 313, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 314, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 315, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 316, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 317, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 318, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 319, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 320, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 321, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 322, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 323, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 324, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 325, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 326, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 327, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 328, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 329, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 330, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 331, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 332, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 333, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 334, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 335, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 336, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 337, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 338, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 339, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 340, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 341, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 342, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 343, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 344, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 345, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 346, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 347, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 348, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 349, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 350, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 351, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 352, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 353, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 354, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 355, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 356, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 357, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 358, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 359, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 360, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 361, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 362, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 363, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 364, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 365, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 366, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 367, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 368, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 369, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 370, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 371, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 372, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 373, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 374, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 375, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 376, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 377, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 378, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 379, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 380, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 381, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 382, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 383, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 384, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 385, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 386, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 387, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 388, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 389, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 390, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 391, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 392, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 393, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 394, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 395, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 396, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 397, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 398, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 399, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 400, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 401, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 402, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 403, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 404, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 405, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 406, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 407, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 408, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 409, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 410, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 411, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 412, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 413, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 414, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 415, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 416, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 417, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 418, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 419, + "player_id": "p0", + "move": [ + 0.0, + -1.0 + ], + "action": false, + "face": null + }, + { + "tick": 420, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 421, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 422, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 423, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 424, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 425, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 426, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 427, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 428, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 429, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 430, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 431, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 432, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 433, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 434, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 435, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 436, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 437, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 438, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 439, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 440, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 441, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 442, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 443, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 444, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 445, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 446, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 447, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 448, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 449, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 450, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 451, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 452, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 453, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 454, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 455, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 456, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 457, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 458, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 459, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 460, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 461, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 462, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 463, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 464, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 465, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 466, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 467, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 468, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 469, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 470, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 471, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 472, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 473, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 474, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 475, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 476, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 477, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 478, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 479, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 480, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 481, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 482, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 483, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 484, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 485, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 486, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 487, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 488, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 489, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 490, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 491, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 492, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 493, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 494, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 495, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 496, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 497, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 498, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 499, + "player_id": "p0", + "move": [ + -1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 500, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 501, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 502, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 503, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 504, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 505, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 506, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 507, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 508, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 509, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 510, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 511, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 512, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 513, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 514, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 515, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 516, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 517, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 518, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 519, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 520, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 521, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 522, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 523, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 524, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 525, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 526, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 527, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 528, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 529, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 530, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 531, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 532, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 533, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 534, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 535, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 536, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 537, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 538, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 539, + "player_id": "p0", + "move": [ + 1.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 540, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 541, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 542, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 543, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 544, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 545, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 546, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 547, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 548, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 549, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 550, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 551, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 552, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 553, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 554, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 555, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 556, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 557, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 558, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 559, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 560, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 561, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 562, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 563, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 564, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 565, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 566, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 567, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 568, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 569, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 570, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 571, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 572, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 573, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 574, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 575, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 576, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 577, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 578, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 579, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 580, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 581, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 582, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 583, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 584, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 585, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 586, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 587, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 588, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 589, + "player_id": "p0", + "move": [ + 0.0, + 1.0 + ], + "action": false, + "face": null + }, + { + "tick": 590, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 591, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 592, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 593, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 594, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 595, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 596, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 597, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 598, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + }, + { + "tick": 599, + "player_id": "p0", + "move": [ + 0.0, + 0.0 + ], + "action": false, + "face": null + } + ], + "name": "walk_solo" +} diff --git a/tests/goldens/carry_two_player.golden.json b/tests/goldens/carry_two_player.golden.json new file mode 100644 index 0000000..7c7352a --- /dev/null +++ b/tests/goldens/carry_two_player.golden.json @@ -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" +} diff --git a/tests/goldens/pick_drop_pass.golden.json b/tests/goldens/pick_drop_pass.golden.json new file mode 100644 index 0000000..3e367f0 --- /dev/null +++ b/tests/goldens/pick_drop_pass.golden.json @@ -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" + } +} diff --git a/tests/goldens/walk_solo.golden.json b/tests/goldens/walk_solo.golden.json new file mode 100644 index 0000000..a932fe3 --- /dev/null +++ b/tests/goldens/walk_solo.golden.json @@ -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" + } +} diff --git a/tests/sim/test_carry.py b/tests/sim/test_carry.py new file mode 100644 index 0000000..d7730bd --- /dev/null +++ b/tests/sim/test_carry.py @@ -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() diff --git a/tests/sim/test_movement.py b/tests/sim/test_movement.py new file mode 100644 index 0000000..adbdc3b --- /dev/null +++ b/tests/sim/test_movement.py @@ -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) diff --git a/tests/sim/test_movement_kernel.py b/tests/sim/test_movement_kernel.py new file mode 100644 index 0000000..7ca0ead --- /dev/null +++ b/tests/sim/test_movement_kernel.py @@ -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" diff --git a/tests/sim/test_movement_stress.py b/tests/sim/test_movement_stress.py new file mode 100644 index 0000000..5569a26 --- /dev/null +++ b/tests/sim/test_movement_stress.py @@ -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()) diff --git a/tests/sim/test_perf_smoke.py b/tests/sim/test_perf_smoke.py new file mode 100644 index 0000000..3b67c02 --- /dev/null +++ b/tests/sim/test_perf_smoke.py @@ -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 diff --git a/tests/sim/test_replay.py b/tests/sim/test_replay.py new file mode 100644 index 0000000..a14fd53 --- /dev/null +++ b/tests/sim/test_replay.py @@ -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/.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 == () diff --git a/tests/sim/test_replay_carry.py b/tests/sim/test_replay_carry.py new file mode 100644 index 0000000..1f00dd2 --- /dev/null +++ b/tests/sim/test_replay_carry.py @@ -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 diff --git a/tests/sim/test_rng.py b/tests/sim/test_rng.py new file mode 100644 index 0000000..fd15ea2 --- /dev/null +++ b/tests/sim/test_rng.py @@ -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 diff --git a/tests/sim/test_step_kernel.py b/tests/sim/test_step_kernel.py new file mode 100644 index 0000000..1f3a0a9 --- /dev/null +++ b/tests/sim/test_step_kernel.py @@ -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 diff --git a/tests/sim/test_targeting.py b/tests/sim/test_targeting.py new file mode 100644 index 0000000..da8b6b6 --- /dev/null +++ b/tests/sim/test_targeting.py @@ -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 diff --git a/tests/sim/test_verbs.py b/tests/sim/test_verbs.py new file mode 100644 index 0000000..1d9d3e7 --- /dev/null +++ b/tests/sim/test_verbs.py @@ -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 diff --git a/tests/unit/test_docs_index.py b/tests/unit/test_docs_index.py index cfb68aa..48e6601 100644 --- a/tests/unit/test_docs_index.py +++ b/tests/unit/test_docs_index.py @@ -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", }