phase: 03_simulation_movement_and_interaction
**Final verification pass — phase 03 `simulation_movement_and_interaction` (all 5 task files already in `complete/`)** - Verified all phase-03 deliverables exist and work: `sim/` kernel (`step`, `state_build`, `rng`, `movement`, `collision`, `interact`, `carry`, `replay`), replay fixtures + 3 committed goldens, `docs/SIMULATION.md`. - `bash .agents/validate.sh` → **passed**: ruff `All checks passed!`, pyright `0 errors`, `undercooked-levels validate` green, full `pytest` **1033 passed** (coverage **99.71%** ≥ 90% floor), slow sim stage green, repo-hygiene clean. - Perf smoke (4 players, 2 000 ticks): mean **0.049 ms/tick, 20 423 ticks/s** — ≥ 10 000 ticks/s criterion met (test lives in `test_perf_smoke.py`, which the gate's slow stage runs; the overview names it `test_replay.py`). - Completion criteria: import `undercooked.sim.step.step` ✅ · `tests/sim` green incl. determinism + goldens ✅ · perf ✅ · constant-change-fails-by-design ✅ (committed-hash equality + mutation test naming first bad tick/field; goldens never rewritten by tests) · `docs/SIMULATION.md` ✅ (constants, 8-step order, purity/determinism, re-bless) · full suite + coverage ✅ · app launches: `/healthz` 200, `/` 200, `/api/levels` serves content ✅ · replay CLI `check walk_solo.json` exit 0 ✅ · no regression in phases 01/02 (their suites green) ✅. - Documentation: README.md — fixed one stale inconsistency (Status opening listed only 01/02 complete with 03 pending; now lists 01–03 complete, next pending `04_kitchen_pipeline_and_scoring`); all other README claims (CLI flags, config table, shell text, replay CLI exit codes) verified against code. AGENTS.md — verified consistent with the project as it stands, no changes needed. - No deviations. Next pending phase: **`04_kitchen_pipeline_and_scoring`**.
This commit is contained in:
@@ -19,7 +19,9 @@
|
|||||||
order: `ruff check .` → `pyright` → `undercooked-levels validate` (unconditional:
|
order: `ruff check .` → `pyright` → `undercooked-levels validate` (unconditional:
|
||||||
the same loader + PLAN §8 rules `create_app` runs at startup, over
|
the same loader + PLAN §8 rules `create_app` runs at startup, over
|
||||||
`$UNDERCOOKED_LEVELS_DIR`) → the **full** `pytest` suite with the coverage floor →
|
`$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
|
- New work is authored by the `phase-authoring` skill as a phase directory — not
|
||||||
implemented straight from chat.
|
implemented straight from chat.
|
||||||
- **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`.
|
- **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`,
|
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
|
which is the same entropy and the only option in a browser) held in
|
||||||
`sessionStorage`. Names are sanitized, length-capped, non-authoritative.
|
`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`,
|
- **Frame and rate limits are enforced, not suggested**: `max_message_bytes`,
|
||||||
`max_inputs_per_s`, room caps, and message-type whitelists per room state.
|
`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
|
- **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,
|
`src/undercooked/server/protocol.py`, `docs/PROTOCOL.md`, and the client in the same task,
|
||||||
and bumping `PROTOCOL_VERSION` (PLAN §11).
|
and bumping `PROTOCOL_VERSION` (PLAN §11).
|
||||||
- **Determinism is a contract.** `sim/step.py` (phase 03) must produce identical state
|
- **Determinism is a contract.** `sim/step.py` advances the round in whole `STEP_DT`
|
||||||
given the same seed and input script — the replay tests in `tests/sim/` are the guard
|
(0.05 s) substeps with `dt` clamped to `[0, MAX_DT]`, a fixed subsystem order, and no
|
||||||
once the tick exists; until then `tests/sim/test_purity.py` holds the A5 boundary that
|
randomness outside `sim/rng.py` (splitmix64, seeded by `KitchenState.rng_state`) — so the
|
||||||
determinism depends on.
|
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.
|
- **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
|
If a task is ambiguous, make the best judgment consistent with PLAN.md, or stop
|
||||||
and let the user rewrite the task.
|
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` |
|
| 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/` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` |
|
||||||
| Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` |
|
| 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 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/`
|
`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 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
|
the level validator, the seeded RNG's golden vectors and the tick's determinism —
|
||||||
phase 03), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`). `tests/` is
|
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
|
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
|
points `UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` so no test silently reads the
|
||||||
shipped `levels/`.
|
shipped `levels/`.
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ email, no password: you create a room, share the 5-character code, play.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Phases **01** (runnable service scaffold) and **02** (level content, schema and
|
Phases **01** (runnable service scaffold), **02** (level content, schema and
|
||||||
validation) are **complete**; the next pending phase is
|
validation), and **03** (the deterministic simulation kernel: fixed-step tick,
|
||||||
`03_simulation_movement_and_interaction`. What runs today:
|
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
|
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
|
||||||
single typed, validated `Settings` object (`DEBUGPY` support included), and
|
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
|
`GET /api/levels/{id}` and `GET /api/config` answer from it, and
|
||||||
`undercooked-levels` gives authors the same gate from the command line
|
`undercooked-levels` gives authors the same gate from the command line
|
||||||
(`docs/LEVELS.md` is the schema reference).
|
(`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
|
- **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_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_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
|
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
|
||||||
phase pipeline runs after every task.
|
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
|
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
|
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
|
||||||
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
|
(`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 pytest -m "not e2e" # the quick loop: no browser launches
|
||||||
uv run playwright install chromium # once, before the first e2e run
|
uv run playwright install chromium # once, before the first e2e run
|
||||||
uv run pytest -m e2e # browser suites only
|
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
|
bash .agents/validate.sh # the pipeline gate: full suite + ruff + pyright
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The replay goldens behind `tests/sim/test_replay.py` are checked the same way a
|
||||||
|
level is validated: `uv run python -m undercooked.sim.replay check
|
||||||
|
<tests/fixtures/replays/<name>.json>` runs the committed input script and
|
||||||
|
gates it on `tests/goldens/<name>.golden.json` (exit `0` match, `1` divergence
|
||||||
|
with the first divergent tick and the changed fields, `2` missing script/level/
|
||||||
|
golden). `record` writes a golden — the manual re-bless, legitimate only after a
|
||||||
|
deliberate sim-contract change recorded in the phase task; the contract is
|
||||||
|
[docs/SIMULATION.md](docs/SIMULATION.md) ("Replaying and re-blessing goldens").
|
||||||
|
|
||||||
`pytest` with no `-m` runs the browser suites too, so chromium has to be
|
`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
|
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
|
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
|
`ruff check .`, `pyright`, `undercooked-levels validate` (unconditional: the same
|
||||||
loader and PLAN §8 rules `create_app` runs at startup, so a campaign that cannot
|
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,
|
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
|
the slow sim stage (`pytest -m slow tests/sim`: the replay goldens and the perf
|
||||||
key material committed). Run it from the repo root or from `.agents/`.
|
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
|
## Container
|
||||||
|
|
||||||
@@ -187,9 +258,19 @@ src/undercooked/sim/ pure simulation kernel: entities (`model`), grid geom
|
|||||||
(`grid`), content schema + loader (`recipe`, `level`,
|
(`grid`), content schema + loader (`recipe`, `level`,
|
||||||
`content` — the YAML contract is docs/LEVELS.md), the
|
`content` — the YAML contract is docs/LEVELS.md), the
|
||||||
level validator (`validation` — PLAN §8 named rules;
|
level validator (`validation` — PLAN §8 named rules;
|
||||||
rule table in docs/LEVELS.md), and the
|
rule table in docs/LEVELS.md), the fixed-step tick (`step`,
|
||||||
A5 purity guard (`purity`, test-enforced). There is no
|
`state_build`, `rng`, and the subsystem hooks `movement`
|
||||||
`step()` yet: the tick arrives with phase 03
|
(+ `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,
|
src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
|
||||||
show [--grid], new, axes; same loader, same rules, no
|
show [--grid], new, axes; same loader, same rules, no
|
||||||
hard-coded content list (docs/LEVELS.md "Authoring workflow")
|
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
|
app/static/ browser client: the ES-module shell today; Canvas 2D renderer
|
||||||
and this repo's SVG art arrive with phase 07
|
and this repo's SVG art arrive with phase 07
|
||||||
tests/ unit/, integration/, e2e/ (one Playwright suite per story) and
|
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
|
fixtures/levels/ is the content dir tests see (a recipe book and no
|
||||||
levels; `_bad/` holds the deliberately-broken campaign the `_`
|
levels; `_bad/` holds the deliberately-broken campaign the `_`
|
||||||
discovery rule ignores); conftest.py
|
discovery rule ignores); conftest.py
|
||||||
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
|
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
|
||||||
TESTING.md (how this project is verified), DEPLOYMENT.md;
|
SIMULATION.md (the tick contract), TESTING.md (how this project
|
||||||
PROTOCOL / SIMULATION / CLIENT land with their own phases
|
is verified), DEPLOYMENT.md; PROTOCOL / CLIENT land with their
|
||||||
(the index is PLAN.md §13)
|
own phases (the index is PLAN.md §13)
|
||||||
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
|
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,616 @@
|
|||||||
|
# SIMULATION.md — the tick contract
|
||||||
|
|
||||||
|
**Status:** current as of phase 03 task 05 (the kernel of task 01, real movement,
|
||||||
|
collision, facing and stun, the action button: reach-zone targeting, the verb ladder,
|
||||||
|
named denials and contested arbitration, the hands: one-item-per-hand carry, the
|
||||||
|
marked set-down with the counter hand-off, and the long-press trash — and the replay
|
||||||
|
harness: the input-script format, the committed golden hashes, the `record`/`check`
|
||||||
|
CLI, and the headless perf smoke). **Filled in by
|
||||||
|
phase 04** for the station and order pipelines, and by phase 10 for hazards — each of
|
||||||
|
those replaces an identity hook named in the order table below.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document is the contract between the deterministic game rules and everyone who
|
||||||
|
calls them: how a tick is born, how much simulation one call may buy, the order subsystems
|
||||||
|
run in and why that order is gameplay, how player input is turned into authoritative
|
||||||
|
change, what purity and determinism cost in code, and what a golden hash is allowed to
|
||||||
|
mean. It exists because the simulation is the single source of truth (A4) — a rule that
|
||||||
|
reads differently in two places is a bug in the rules, not in the client.
|
||||||
|
|
||||||
|
The rules of the kitchen live in `src/undercooked/sim/` and nowhere else. This document
|
||||||
|
is the contract those modules owe every caller — the room supervisor (phase 05), the
|
||||||
|
protocol layer (phase 06) and the client (phase 07) included. `.agents/PLAN.md` decides
|
||||||
|
*what* the simulation is (A4: server-authoritative fixed tick; A5: purity; A21: replay);
|
||||||
|
this file says how that reads in code.
|
||||||
|
|
||||||
|
Write here, not in a docstring, whenever a later phase changes: the tick contract,
|
||||||
|
movement and collision, the pipeline rules, contested arbitration, or hazard rules.
|
||||||
|
|
||||||
|
## The tick
|
||||||
|
|
||||||
|
```text
|
||||||
|
one call -> step(state, dt, inputs) -> (state', events)
|
||||||
|
│
|
||||||
|
dt clamped to [0, MAX_DT], added to state.accumulator
|
||||||
|
│
|
||||||
|
while accumulator >= STEP_DT: exactly one substep, accumulator -= STEP_DT
|
||||||
|
```
|
||||||
|
|
||||||
|
The simulation is driven in **whole substeps of `STEP_DT` = 0.05 s**, i.e. 20 Hz, the rate
|
||||||
|
A4 locks. `UNDERCOOKED_TICK_HZ` (`config.py`) decides how many substeps the room asks for
|
||||||
|
per wall second; it never changes what one substep contains. A caller may hand `step()`
|
||||||
|
any `dt` it likes — the client's frame time, a batched timer, a test harness — and the
|
||||||
|
simulation still advances in the same 0.05 s increments, so physics never depends on how
|
||||||
|
noisy the host's clock is.
|
||||||
|
|
||||||
|
Leftover time is not lost: `KitchenState.accumulator` carries the fraction that did not
|
||||||
|
add up to a whole substep into the next call. 17 calls of 0.05 s are exactly 17 ticks;
|
||||||
|
two calls of 0.03 s are one tick plus 0.01 s of credit.
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
| Constant | Value | Lives in | Means |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `STEP_DT` | `0.05` s | `sim/step.py` | One substep — one tick. The only dt a subsystem ever sees. |
|
||||||
|
| `MAX_DT` | `0.1` s | `sim/step.py` | Ceiling on one `step()` call: at most 2 substeps, so a stall cannot spiral. |
|
||||||
|
| `TICK_EPS` | `1e-9` s | `sim/step.py` | Tolerance on the substep bound; `0.85 − 17 × 0.05` is `2.2e-16`, not `0`. |
|
||||||
|
| `TIME_EPS` | `1e-9` s | `sim/round_state.py` | Same idea for the round clock: below this the round is over, not "0.0 s left". |
|
||||||
|
| `SEAT_COLORS` | 4 hex colours | `sim/state_build.py` | The fixed cook palette, indexed by `seat % 4`. |
|
||||||
|
|
||||||
|
### Movement constants
|
||||||
|
|
||||||
|
The numbers PLAN §5 fixes for a cook, now that stage 3 is real. They live in
|
||||||
|
`sim/movement.py` (`RADIUS` and `CONTACT_EPS` are the collision geometry, and live in
|
||||||
|
`sim/collision.py` for the second one because both modules need them):
|
||||||
|
|
||||||
|
| Constant | Value | Means |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `MAX_SPEED` | `3.5` tiles/s | Speed ceiling. No diagonal bonus: `DIAGONAL_NORM` makes a diagonal a direction, not extra throttle. |
|
||||||
|
| `ACCEL` | `30` tiles/s² | How fast intent becomes velocity. |
|
||||||
|
| `DECEL` | `40` tiles/s² | How fast it goes away when the stick centres — deliberately harder than `ACCEL`: overshooting a station is the mistake players make most. |
|
||||||
|
| `RADIUS` | `0.35` tile | Half-width of the cook's body **square** (an AABB, not a circle) in tile units. This is PLAN §5's `PLAYER_RADIUS`, named for what the collision module reads it as. |
|
||||||
|
| `CONTACT_EPS` | `1e-4` tile | The gap a swept body stops short of a blocked cell, and the inset a footprint is measured with. See "One geometry, two questions". |
|
||||||
|
| `DIAGONAL_NORM` | `True` | Set to `False` and a diagonal is √2 faster. Read at call time so the test can prove which rule is in force. |
|
||||||
|
| `STUN_S` | `0.6` s | How long a hit keeps a cook still. Whoever stuns writes it to `Player.stun`; `apply_stun()` ticks it down. |
|
||||||
|
| `STUN_EPS` | `1e-9` s | A stun is over at this remainder: `0.6 − 12 × 0.05` is `1.4e-17`, not `0`. |
|
||||||
|
| `MOVE_EPS` | `1e-6` tiles/s | Below this the cook is `IDLE`, not `MOVING`. |
|
||||||
|
| `PUSH_ITERATIONS` | `2` | Soft-push passes. Two propagates a separation through a chain of four cooks. |
|
||||||
|
| `PUSH_SLOP` | `1e-6` tile | Overlap below this is float noise, not a collision — without it, shoulder-to-shoulder cooks jitter forever. |
|
||||||
|
|
||||||
|
PLAN §5 fixes the numbers the rest of this phase implements. They are recorded here
|
||||||
|
so a constant is written down once, before the code that reads it:
|
||||||
|
|
||||||
|
| Constant | Value | Arrives in | Means |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `REACH` | `1.0` tile | task 03 (real) | First targeting tier: the tile in front of the cook, centre-to-centre. |
|
||||||
|
| `FORGIVE` | `0.6` tile | task 03 (real) | Forgiveness on top of `REACH`: why the diagonal-front tier (√2 ≈ 1.414 centre-to-centre) is in reach and the diagonal behind the far shoulder (2.12) is not. |
|
||||||
|
| `HANDLING_S` | `0.0` s | task 04 (settled) | Minimum seconds between two accepted actions by one cook: the press edge is the gate — a new press is accepted the substep after a release, so the floor is `0.0` and PLAN's unspecified value is settled here, not in a knob. |
|
||||||
|
|
||||||
|
`MAX_DT` exists because a fixed tick without a ceiling is a denial-of-service against
|
||||||
|
yourself: if the process stalls for ten seconds, "catch up" means 200 substeps in one go,
|
||||||
|
which takes longer than 200 substeps should, which delays the next call, and so on.
|
||||||
|
Clamping means a stall costs the round at most 0.1 s of simulation and the round keeps
|
||||||
|
going — the correct trade for a co-op game where being behind is better than being dead.
|
||||||
|
|
||||||
|
## The substep order
|
||||||
|
|
||||||
|
Order *is* gameplay: two subsystems reading each other's output produce different rounds
|
||||||
|
if they run in a different sequence. Every substep runs these stages, in this order, with
|
||||||
|
no I/O and no reordering:
|
||||||
|
|
||||||
|
| # | Stage | Module | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | Clamp `dt`, accumulate, decide how many substeps run | `sim/step.py` | real |
|
||||||
|
| 2 | Resolve inputs to one frame per seat; `err` for unknown ids | `sim/step.py` | real |
|
||||||
|
| 2b | Action gate: hold counters, one press per rising edge, verb ladder, named denials, contested arbitration, the long-press trash | `sim/interact.py` + `sim/carry.py` | real (task 03/04) |
|
||||||
|
| 3 | Movement and collision | `sim/movement.py` | real (task 02) |
|
||||||
|
| 4 | Station pipelines | `sim/stations.py` | identity → phase 04 |
|
||||||
|
| 5 | Orders | `sim/orders.py` | identity → phase 04 |
|
||||||
|
| 6 | Hazards | `sim/hazards.py` | identity → phase 10 |
|
||||||
|
| 7 | Round timer and end conditions | `sim/round_state.py` | countdown + time-up verdict real; early win and stars → phase 04 |
|
||||||
|
| 8 | `tick += 1` | `sim/step.py` | real |
|
||||||
|
|
||||||
|
Stage 2 happens **once per `step()` call**, not once per substep: intent is resolved, then
|
||||||
|
the same resolved intent is offered to every substep that call produces. That is what
|
||||||
|
makes `step(state, 0.1)` and two calls of `step(state, 0.05)` agree, and it matches the
|
||||||
|
client's side — a cook keeps moving in the direction last pressed until a newer frame
|
||||||
|
says otherwise.
|
||||||
|
|
||||||
|
A resolved frame per seat is chosen by **earliest `received_at_tick`, tie-break lowest
|
||||||
|
`seq`, first-seen on a full tie**, and the mapping is returned in **seat order**. Two
|
||||||
|
clients sending inputs for the same seat in the same batch (a retransmit after a
|
||||||
|
reconnect, a hostile tab) therefore resolve identically on every machine, and effects
|
||||||
|
that touch more than one cook apply in an order nobody has to guess. An unknown
|
||||||
|
`player_id` is a `EventKind.ERROR` event with `{"player_id", "reason": "unknown_player",
|
||||||
|
"seq"}` and nothing else: the round does not care who sent it, and a hostile client
|
||||||
|
cannot make a step raise.
|
||||||
|
|
||||||
|
## Movement and collision
|
||||||
|
|
||||||
|
Stage 3 turns the resolved frame into a position, in two halves that never mix: velocity
|
||||||
|
comes from intent, and position comes from velocity.
|
||||||
|
|
||||||
|
```text
|
||||||
|
PlayerInput.move (whatever the client sent, e.g. Vec2(50, 0))
|
||||||
|
→ intent_direction() clamp to length ≤ 1, so a hostile value is a direction, not a
|
||||||
|
teleport, and (1,1) is not √2 faster than (1,0)
|
||||||
|
→ desired_velocity() accelerate toward it, brake when the stick centres, 0 if stunned
|
||||||
|
→ sweep_axis() × 2 x then y, each clamped to the greatest legal delta
|
||||||
|
→ _separate() soft push, twice; a genuine deadlock reverts one input
|
||||||
|
→ Player(pos, vel, facing, state, stun)
|
||||||
|
```
|
||||||
|
|
||||||
|
The clamp is the server refusing to negotiate (A4): `step()` carries `PlayerInput.move`
|
||||||
|
verbatim, and `intent_direction()` is the one place it becomes authoritative. A client that
|
||||||
|
sends `move=(50, 0)` walks exactly as fast as one that sends `(1, 0)` — there is a test for
|
||||||
|
exactly that, through the whole `step()`, not the movement helper.
|
||||||
|
|
||||||
|
A cook with no frame this tick brakes to a stop rather than drifting: `step()` hands every
|
||||||
|
substep of one call the same frame, so releasing a key stops the cook within one tick
|
||||||
|
(`DECEL`) no matter how many substeps that call ran.
|
||||||
|
|
||||||
|
### What blocks
|
||||||
|
|
||||||
|
`sim/collision.py` answers one question — *may a body stand here* — for the whole sim:
|
||||||
|
|
||||||
|
| Tile or fixture | Blocks a body |
|
||||||
|
| --- | --- |
|
||||||
|
| `wall`, `counter`, `void`, out-of-bounds | yes |
|
||||||
|
| `floor` | no |
|
||||||
|
| `spawn_shelf`, `chop_board`, `stove`, `prep` | yes — you walk around a fixture |
|
||||||
|
| `serve`, `sink`, `trash` | no — you lean over these to use them |
|
||||||
|
|
||||||
|
The pass-through set is `collision.PASS_THROUGH_STATIONS`, and it is the collision answer
|
||||||
|
only: the reach rule (task 03) is allowed to want a *different* set, because reaching a
|
||||||
|
serve window from the tile in front of it is a separate question from walking over it. A
|
||||||
|
hazard or a crate adds cells through `cell_blocked(extra_blocked=...)` / `body_blocker(...)`
|
||||||
|
without either of those rules learning about the other.
|
||||||
|
|
||||||
|
A body is a square of side `2 × RADIUS` = 0.7 tiles, so it fits a 1-tile corridor with
|
||||||
|
0.3 tiles to spare and cannot fit a gap it would have to squeeze through.
|
||||||
|
|
||||||
|
### One geometry, two questions
|
||||||
|
|
||||||
|
Two things must agree about which cells a body covers: the footprint (`body_cells()`, what
|
||||||
|
phase 07's reach will ask about) and the sweep (`sweep_axis()`, what decides where a move
|
||||||
|
ends). They agree because both call `_edge_cells()` on a *centre*, never on a pre-added
|
||||||
|
edge: `(lead + delta) − eps` and `(centre + delta) + radius − eps` differ by one float ulp
|
||||||
|
exactly at the moment a body becomes flush with a boundary, and one ulp is enough for the
|
||||||
|
sweep to say "not entered" while the footprint says "occupied" — a cook 2e-16 inside a
|
||||||
|
wall, invisible on screen and fatal for a replay golden. So the sweep clamps the
|
||||||
|
*destination centre* to `cell − radius − CONTACT_EPS`, two epsilons clear of the boundary
|
||||||
|
that would make the cell occupied, and float noise cannot close that gap.
|
||||||
|
|
||||||
|
The sweep tests every cell the body would *newly* overlap, across its whole cross-axis
|
||||||
|
span — not the destination cell only. That buys three things at once. Nothing can be
|
||||||
|
walked through: at `MAX_SPEED` and the coarsest call the kernel allows (`MAX_DT` = 0.1 s) a
|
||||||
|
cook covers 0.35 tiles, which a destination test would catch too, but the sweep does not
|
||||||
|
care what speed or `dt` a caller hands it, so a doubled ceiling, a longer catch-up tick or a
|
||||||
|
future hazard that throws a cook across the room still cannot cross a 1-tile wall (there is
|
||||||
|
a test that doubles `MAX_SPEED` specifically to hold that). A diagonal move cannot clip the
|
||||||
|
corner of a wall, because the last axis moved is checked against the body's *full* span on
|
||||||
|
the other one. And a cell the body already sits in is never re-tested, so a cook that starts
|
||||||
|
a substep somewhere impossible — a spawn a level author got wrong — is not wedged by it.
|
||||||
|
|
||||||
|
### Cooks push cooks
|
||||||
|
|
||||||
|
Bodies do not collide with terrain and each other in one pass; terrain is resolved on the
|
||||||
|
way out and cooks afterwards, because a push must never shove a cook *into* a wall.
|
||||||
|
|
||||||
|
* **Symmetric.** Each cook gives half the separation, and whatever one cannot take (a
|
||||||
|
wall behind it) the other takes. Two cooks meeting head-on both move; one pinned by a
|
||||||
|
wall moves the other the full amount.
|
||||||
|
* **Positional only.** The push edits `pos`, never `vel`. Being bumped does not fling
|
||||||
|
anybody, and a bumped cook keeps walking in the direction they pressed.
|
||||||
|
* **Iterated.** `PUSH_ITERATIONS` passes so a chain of four drains apart instead of leaving
|
||||||
|
the last cook inside the third.
|
||||||
|
* **Reverted only on a real deadlock.** When terrain stops *both* cooks and not a tile of
|
||||||
|
separation is possible — two cooks stacked in a 1×1 alcove is the only layout that can do
|
||||||
|
it — the later-received input is dropped and that cook is put back where the substep
|
||||||
|
started. Same arbitration rule `resolve_inputs` uses (earliest `received_at_tick` wins,
|
||||||
|
tie-break lowest `seq`), and a cook that sent no frame at all is never the loser, because
|
||||||
|
it has no input to discard. A jam that *could* separate — four cooks in a doorway — is
|
||||||
|
not a deadlock, it is a queue, and it drains over the next few ticks.
|
||||||
|
|
||||||
|
The revert restores the post-`apply_stun` player, so a revert can never undo the stun tick
|
||||||
|
that already happened.
|
||||||
|
|
||||||
|
## The action button
|
||||||
|
|
||||||
|
PLAN §5 gives the cook **one button**; `sim/interact.py` decides, purely and
|
||||||
|
deterministically, what it means *right now* — which entity is targeted, which verb
|
||||||
|
applies, or the named reason nothing happens. Stage 2b runs it once per substep, in
|
||||||
|
seat order, on the frames `resolve_inputs` already reduced to one per seat.
|
||||||
|
|
||||||
|
### The reach zone: discrete first, continuous second
|
||||||
|
|
||||||
|
`candidate_cells()` names the tiles the hand walks, in priority order, deduplicated:
|
||||||
|
|
||||||
|
1. the tile directly in front (the `REACH` tier — always in reach from any point of the
|
||||||
|
tile, worst case √(0.5² + 1.5²) ≈ 1.581);
|
||||||
|
2. the two diagonal-front tiles (the `FORGIVE` tier exists for them: √2 ≈ 1.414 from the
|
||||||
|
tile centre, so a purely orthogonal reach would refuse every diagonal press, and a
|
||||||
|
purely discrete walk would hand over the diagonal behind your far shoulder at 2.12);
|
||||||
|
3. the tile under the cook — because a serve window, sink and trash can are walkable
|
||||||
|
(`PASS_THROUGH_STATIONS`), a cook can be standing *on* the thing they want to use with
|
||||||
|
nothing in front.
|
||||||
|
|
||||||
|
`REACH + FORGIVE` from the cook's position to the tile's centre then decides whether the
|
||||||
|
cook is close enough to the tile the walk found. The two halves answer different
|
||||||
|
questions: the cell walk is why a fixture two tiles away is never even a candidate (there
|
||||||
|
is no "through the wall" mode — a wall in the front cell simply means the tile beyond it
|
||||||
|
is not walked), and the distance check is why the same diagonal fixture is reachable from
|
||||||
|
the near corner of the cook's tile and not from the far one. A candidate out of reach is
|
||||||
|
*skipped*, not refused, so a nearer tile still gets its turn. The four facings are 90°
|
||||||
|
rotations of one row, so a diagonal press feels the same whichever way the cook points.
|
||||||
|
|
||||||
|
### The target, then the verb
|
||||||
|
|
||||||
|
`select_target()` returns the first usable cell in that walk:
|
||||||
|
|
||||||
|
1. a grabbable item, but only with a free hand — a cook who is carrying something is here
|
||||||
|
to *empty* their hand, so preferring the item would make the placement ladder
|
||||||
|
unreachable (PLAN §5's "carrying → drop/transfer onto target station" comes first);
|
||||||
|
2. the serve window, as a `ServeTarget`, so the score path is distinguishable from merely
|
||||||
|
standing at a fixture;
|
||||||
|
3. the fixture itself;
|
||||||
|
4. `None`.
|
||||||
|
|
||||||
|
A fixture *owns* what is on it: `station.contents` is not a shelf of grabbable things. A
|
||||||
|
chop board with an onion on it is an onion *to be chopped* — pressing it works the item,
|
||||||
|
which is the only way PLAN §5's "hold to chop" can mean anything. An item becomes
|
||||||
|
grabbable when it is output: on a spawn shelf (stock, even while `contents` is empty —
|
||||||
|
the kind comes from the level's legend), or on a station whose state says the work is
|
||||||
|
done (`READY`). That rule lives in one private helper and nowhere else, so phase 04's
|
||||||
|
pipelines have exactly one place to change their minds. The serve window is never a
|
||||||
|
set-down target: a full hand at it gets the serve ladder's `WRONG_ITEM`, not a `RELEASE`
|
||||||
|
(the set-down escape hatch is the spawn shelf, where any carried item can be put down).
|
||||||
|
|
||||||
|
`select_verb()` is the priority ladder, with the cook's own condition first. There is no
|
||||||
|
`(verb, reason)` combination and no unexplained no-op: a legal press is `(verb, None)`, a
|
||||||
|
refused one is `(Verb.NONE, reason)` — the client shows the reason as a nudge, and the
|
||||||
|
server never answers a press with a silent nothing.
|
||||||
|
|
||||||
|
### The denied reasons
|
||||||
|
|
||||||
|
Every refusal names itself. These are the `DeniedReason` constants, and the path that
|
||||||
|
produces each:
|
||||||
|
|
||||||
|
| Reason | String | Produced when |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `STUNNED` | `stunned` | The cook's `stun` is down or their state is `STUNNED`, regardless of target — the client's "you got hit" outranks every other nudge. Checked before the target is even read. |
|
||||||
|
| `NOTHING_HERE` | `nothing_here` | No station in the reach zone (the whiff). A press at a fixture beyond a wall surfaces as this: the zone has no "through the wall" mode. |
|
||||||
|
| `OUT_OF_REACH` | `out_of_reach` | The target's centre is more than `REACH + FORGIVE` from the cook's position (re-measured for targets a caller kept from an earlier substep). |
|
||||||
|
| `NOT_FACING` | `not_facing` | The target's centre is behind the plane of the cook's facing (a target level with the cook — a serve window they are standing on — passes). |
|
||||||
|
| `HANDS_FULL` | `hands_full` | An item target with a full hand, a ready fixture's output with a full hand, or a loaded work surface with a full hand (one item at a time on a surface). |
|
||||||
|
| `HANDS_EMPTY` | `hands_empty` | Serving, washing or trashing with nothing in the hand. |
|
||||||
|
| `WRONG_ITEM` | `wrong_item` | A non-plated item at the serve window, a non-dirty item at the sink. The difference between "go fetch a plate" and "that is not a dish" is `HANDS_EMPTY` vs `WRONG_ITEM`. |
|
||||||
|
| `STATION_DIRTY` | `station_dirty` | A dirty fixture, whatever is held — the player needs "this board is filthy", not "you are holding the wrong thing". Dirt outranks every other state. |
|
||||||
|
| `STATION_BUSY` | `station_busy` | A fixture another cook is working, a `BUSY` fixture, or one on cooldown. The cook who started it gets `HOLD_WORK` instead. |
|
||||||
|
| `INVALID_STATE` | `invalid_state` | An idle fixture with nothing on it, a `BLOCKED` fixture, or an item whose holder is not in the state (task 04's floor drops are named this way until they are liftable). |
|
||||||
|
| `CONTESTED_LOST` | `contested_lost` | The cook lost a claim another cook arrived for first — the only reason the *gate* produces; the ladder cannot emit it. |
|
||||||
|
|
||||||
|
A press that would otherwise succeed emits exactly one event however long the button stays
|
||||||
|
down, and a refusal emits exactly one `DENIED` with the reason in the payload — never one
|
||||||
|
per tick.
|
||||||
|
|
||||||
|
### One button, one press, one effect
|
||||||
|
|
||||||
|
`action_gate()` turns each frame's `action` flag into the cook's hold counters
|
||||||
|
(`Player.action_held_ticks`, `Player.action_pressed_tick`) and spends the rising edge:
|
||||||
|
|
||||||
|
* a frame without `action` clears both counters (the button is up; the next `True` is a
|
||||||
|
new press);
|
||||||
|
* the first frame of a hold is the **press**: it is resolved with `held_for = 0.0`;
|
||||||
|
* every later substep of the same hold increments the counter and passes the seconds the
|
||||||
|
button has been down — the seam the long-press `TRASH` spends ("The hand"); the
|
||||||
|
`HOLD_WORK` threshold is phase 04's. Until phase 04, a hold that is not a trash
|
||||||
|
hold changes nothing: one press, one effect, however long the button is held.
|
||||||
|
|
||||||
|
`resolve_action()` spends the pick and the set-down (both of which are
|
||||||
|
`sim/carry.py`'s now): `PICK_ITEM` takes the item off the fixture and into the hand
|
||||||
|
(one `PICKED` — one `HANDED_OFF` when the pick lifts a set-down inside the window, see
|
||||||
|
"The hand"), `RELEASE` / `PLACE_ON_STATION` / `PLATE_DISH` put the carried item on the
|
||||||
|
fixture (one `DROPPED`, and the set-down's mark), and every denial is one `DENIED`
|
||||||
|
each. The work verbs — `START_WORK`, `HOLD_WORK`, `SERVE`, `WASH` — are *named* by the
|
||||||
|
ladder today and deliberately unspent: a press that selects one is a no-op with no
|
||||||
|
`DENIED`, because the player did nothing wrong and phase 04's pipelines will spend the
|
||||||
|
press. The `TRASH` press is the same shape at the press: it starts the hold, and the
|
||||||
|
gate spends it at the 8-tick threshold ("The hand"). The set-down leaves the
|
||||||
|
fixture's `state` untouched on purpose: what a set-down *means* to a pipeline is
|
||||||
|
phase 04's transition, and inventing it here is how a golden ends up encoding a rule
|
||||||
|
nobody chose.
|
||||||
|
|
||||||
|
### The hand
|
||||||
|
|
||||||
|
The button decides; the hand acts. `sim/carry.py` owns what a verb *does*, and it owns
|
||||||
|
three constants with it:
|
||||||
|
|
||||||
|
| Constant | Value | Means |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `HANDOFF_WINDOW_TICKS` | `20` ticks | The hand-off window, inclusive: a pick of a set-down is a pass while `pick_tick − placed_tick ≤ 20`; one tick past, it is a plain pick. |
|
||||||
|
| `TRASH_HOLD_TICKS` | `8` substeps | The trash threshold: the 8th substep of one hold is the tick that trashes, and never the 7th. |
|
||||||
|
| `TRASH_SCORE_DELTA` | `−2` | The raw score nudge a trashed item is worth; the `TRASHED` payload carries it, and phase 04's scoring spends it (clamped at 0 overall). The state's score is not touched before then. |
|
||||||
|
|
||||||
|
**One item per hand.** A full hand is the ladder's `HANDS_FULL`; a hand is empty, holds
|
||||||
|
an item, or trashes it. Items live in hands and on fixtures — there is no floor drop
|
||||||
|
in v1 (PLAN §5), so a press at open floor with a full hand is the named
|
||||||
|
`NOTHING_HERE`, not a drop.
|
||||||
|
|
||||||
|
**A set-down is a placed object.** `RELEASE` (the shelf), `PLACE_ON_STATION` (a work
|
||||||
|
surface) and `PLATE_DISH` (the prep counter) all do the same thing: the item goes onto
|
||||||
|
the fixture's `contents`, the hand empties, and the set-down is **marked** —
|
||||||
|
`(placer id, placed-at tick)` — in `KitchenState.item_marks`, a tuple parallel to the
|
||||||
|
fixture's `contents` (`None` where no hand put the entry down: level stock, and phase
|
||||||
|
04's pipeline inputs and outputs). The mark is state truth (a replay must reproduce
|
||||||
|
the `HANDED_OFF` it causes) and never a snapshot concern.
|
||||||
|
|
||||||
|
**Grabbability is the mark.** A free hand may take off a fixture:
|
||||||
|
|
||||||
|
* a spawn shelf — whatever it holds, stock or set-down;
|
||||||
|
* anything else — a *marked* entry, the moment it is set down (the hand-off exists
|
||||||
|
for exactly that);
|
||||||
|
* an unmarked entry only when the station's state is `READY` (a pipeline's output —
|
||||||
|
phase 04). Everything else — an onion on an idle board — is `START_WORK`'s, not
|
||||||
|
`PICK_ITEM`'s.
|
||||||
|
|
||||||
|
**The hand-off.** A pick that lifts a marked item placed by *another* cook within the
|
||||||
|
window is a `HANDED_OFF` with both ids (`from`, `to`) instead of a `PICKED` — that is
|
||||||
|
the sanctioned pass, the whole of "place it on the counter and let a teammate grab
|
||||||
|
it". A self-pick is a plain `PICKED`; a pick outside the window is a plain `PICKED`;
|
||||||
|
a same-substep place-then-pick is inside the window (zero ticks old). The mark is
|
||||||
|
removed with the entry, and a place anywhere stamps a fresh mark, so a pass chains:
|
||||||
|
A → B (B's pick) → B places (new mark) → C's pick is a B → C pass.
|
||||||
|
|
||||||
|
**The trash is long-press and always available.** A `TRASH` press starts a hold; the
|
||||||
|
hold is re-derived each substep from the *current* state (the press is not remembered —
|
||||||
|
the counters are the hold), so walking off the can voids it and a release resets it.
|
||||||
|
On the 8th substep of a hold the carried item is destroyed: one `TRASHED` event whose
|
||||||
|
payload carries the raw `TRASH_SCORE_DELTA`, `counters["trashed"] += 1`, and an empty
|
||||||
|
hand. A short press trashes nothing. A threshold met with an empty hand is
|
||||||
|
`TRASHED_ALREADY_EMPTY` — a named event, never a silent no-op.
|
||||||
|
|
||||||
|
### Contested targets
|
||||||
|
|
||||||
|
Two cooks pressing the same entity in the same substep are arbitrated the way the
|
||||||
|
movement deadlock is: `claim_rank` is `received_at_tick`, then `seq`, then seat index —
|
||||||
|
a tie is impossible because two cooks cannot share a seat. Only a press that would
|
||||||
|
otherwise *succeed* enters the scan (a stunned or empty-handed cook keeps their own
|
||||||
|
reason; blaming the other cook for a refusal the stun caused would be a second lie).
|
||||||
|
And only a press that takes something only one cook can have is a claim at all:
|
||||||
|
`PICK_ITEM` lifts a fixture's item, `START_WORK` takes its single worker slot, and
|
||||||
|
`PICK_FROM_STATION` claims the finished thing only while there *is* one in `contents` —
|
||||||
|
two cooks tapping a shelf with nothing in it both take stock from the level's legend
|
||||||
|
and neither was ever in the other's way. The winner picks; every loser is told
|
||||||
|
`CONTESTED_LOST` instead of watching the item vanish for no stated reason. The claim
|
||||||
|
scan runs against the pre-action state, so every cook is judged on the same kitchen, and
|
||||||
|
its bookkeeping — `active_claims`, the `target_key → (rank, player_id)` map — is
|
||||||
|
**substep-local**: built, arbitrated, and discarded at the substep's end, never written
|
||||||
|
into the state or the snapshot, so a replay reproduces the arbitration from the frames
|
||||||
|
alone.
|
||||||
|
|
||||||
|
### What proves it
|
||||||
|
|
||||||
|
`tests/sim/test_targeting.py` pins the reach zone with hand-computed geometry: the
|
||||||
|
candidate walk per facing (and its invariance to where in the tile the cook stands),
|
||||||
|
the forgiveness boundary (0.99 in, 1.6 inclusive, 1.61 out), the same diagonal fixture in
|
||||||
|
and out of reach from the near and far corners, the priority order (item over fixture,
|
||||||
|
full hand skips the item, a work surface holds its contents, the serve window's own
|
||||||
|
target kind), the wall case, and the press edge through the whole `step()` — one press,
|
||||||
|
one named refusal, one hold that changes nothing, one release that clears. `tests/sim/
|
||||||
|
test_verbs.py` is the table-driven ladder: every `(carrying, target) -> (verb, denial)`
|
||||||
|
covers every `Verb` and every `DeniedReason` at least once (a coverage test asserts the
|
||||||
|
table cannot quietly lose a row), the `resolve_action` implementations and no-ops, and
|
||||||
|
the gate's arbitration with two real cooks in one substep. `tests/sim/test_carry.py`
|
||||||
|
pins the hands themselves: the grabbability split (a set-down is grabbable at once, an
|
||||||
|
unmarked pipeline input is not), the one-item-per-hand refusal, the pick/place round
|
||||||
|
trip with its `Item` identity, the hand-off window (inclusive at 20, a same-tick pass,
|
||||||
|
a self-pick and a third party outside the window), the trash (destroyed on the 8th
|
||||||
|
substep and not the 7th, short press, release-resets, walk-away voids, the named
|
||||||
|
`TRASHED_ALREADY_EMPTY`), the contested pick with the item count conserved, and a
|
||||||
|
100-tick fuzz of random simultaneous presses that re-checks the conservation law after
|
||||||
|
every tick. `tests/sim/test_replay_carry.py` runs the scripted two-player pass-and-
|
||||||
|
contest against its committed golden hash (`tests/goldens/carry_two_player.golden.json`).
|
||||||
|
|
||||||
|
### Stun, facing, traction
|
||||||
|
|
||||||
|
`apply_stun()` runs at the top of the stage: it ticks `Player.stun` down by `dt`, pins
|
||||||
|
`vel` to zero while it lasts, holds the position, and forces `PlayerState.STUNNED` — then
|
||||||
|
`IDLE` again from the substep the stun runs out, so a cook is never stuck in the state. It
|
||||||
|
emits one `EventKind.STUNNED` per cook *entering* a stun (at the cook's position, payload
|
||||||
|
`player_id` and the `stun` duration), which is how the client plays the hit once instead of
|
||||||
|
twenty times a second. Whoever causes a stun — phase 10's moving obstacle — sets
|
||||||
|
`Player.stun = STUN_S`; this is the only place it is spent.
|
||||||
|
|
||||||
|
Facing is a rule about *intent*, never about position:
|
||||||
|
|
||||||
|
1. an explicit `face` in the frame (d-pad, touch stick) wins;
|
||||||
|
2. otherwise the last non-zero intent, dominant axis, ties resolve to horizontal;
|
||||||
|
3. standing still keeps the facing, so an `action` press with no direction does not spin
|
||||||
|
the cook around (PLAN §5 "one action button");
|
||||||
|
4. a pushed cook does not turn: being moved is not the same as deciding to go somewhere.
|
||||||
|
|
||||||
|
`MoveModifiers(speed_scale, accel_scale, slide)` is the seam phase 10 plugs into: a spill
|
||||||
|
scales the ceiling (`speed_scale`) and the grip in both directions (`accel_scale`, which is
|
||||||
|
a different feel from merely being slower), and `slide` removes braking but not throttle —
|
||||||
|
so no `if hazard` ever appears in this module. Until hazards are real the kernel passes
|
||||||
|
none, and the traction a cook gets is the floor.
|
||||||
|
|
||||||
|
### What proves it
|
||||||
|
|
||||||
|
`tests/sim/test_movement.py` is the table-driven half: hand-computed geometry per rule —
|
||||||
|
walking into each tile kind, each pass-through and blocking fixture, the double-speed
|
||||||
|
tunnel attempt, the diagonal-parity check, push symmetry, the alcove deadlock, the four-cook
|
||||||
|
chain, stun entry and expiry, the facing table. `tests/sim/test_movement_stress.py` is the
|
||||||
|
half nobody hand-computes: four cooks, 2000 substeps, a scripted random walk from the sim's
|
||||||
|
own splitmix64 over a pillar maze with doorways, a void tile, a counter run and two
|
||||||
|
fixtures, asserting containment, non-overlap, bounded per-tick displacement and progress
|
||||||
|
after *every* substep, plus hash equality between two runs of the same script — at
|
||||||
|
`STEP_DT` and at `MAX_DT`. `tests/sim/test_movement_kernel.py` runs the same walk through
|
||||||
|
the whole `step()` on the levels in `levels/`, so authored content cannot quietly become
|
||||||
|
un-walkable.
|
||||||
|
|
||||||
|
## Purity and determinism
|
||||||
|
|
||||||
|
A5 makes `sim/` import-free of the outside world; `sim/purity.py` parses the package and
|
||||||
|
fails on `asyncio`, `fastapi`, `socket`, `time` or `uvicorn`, and
|
||||||
|
`tests/sim/test_step_kernel.py` runs that guard. On top of it:
|
||||||
|
|
||||||
|
* **No wall clock.** Time enters only as the `dt` argument. `time`/`datetime` do not
|
||||||
|
appear in `sim/` — not even in the replay harness (`replay.py`): it measures wall
|
||||||
|
time only when a caller *injects* a `clock` callable, and reports `0.0` when none
|
||||||
|
is, so the module imports nothing the purity guard would refuse and the kernel
|
||||||
|
stays clock-free by construction.
|
||||||
|
* **No `random`.** `sim/rng.py` is splitmix64 — 20 lines, no stdlib dependency — because
|
||||||
|
`random.Random`'s stream is explicitly not portable across Python versions and a replay
|
||||||
|
must survive an interpreter upgrade. The round's integer lives in
|
||||||
|
`KitchenState.rng_state`; each substep rebuilds an `Rng` from it and writes the counter
|
||||||
|
back, so the state stays frozen and hashable while subsystems still get a stream.
|
||||||
|
`Rng.fork(salt)` derives a child stream without disturbing its parent, which is how
|
||||||
|
orders and hazards get independent streams that both reproduce from the seed.
|
||||||
|
* **Nothing is mutated.** `KitchenState` and every entity are frozen dataclasses; a
|
||||||
|
substep returns `dataclasses.replace(...)` copies. Hand a state to `step()` and keep it:
|
||||||
|
it is still exactly what it was, hash included.
|
||||||
|
* **Same seed, same input script, same state.** Two states built by `build_state(level,
|
||||||
|
seats, seed, book)` and fed the same `(dt, inputs)` script stay bit-identical — which
|
||||||
|
means the same `state_hash()` — for as long as you like. Different seeds diverge.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/sim -q
|
||||||
|
uv run python -c "from undercooked.sim.step import step; from undercooked.sim.state_build import build_state; print('kernel ok')"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building a round
|
||||||
|
|
||||||
|
`build_state(level, seats, seed, book)` is the only way a round begins, so a round is a
|
||||||
|
function of exactly four things: level content, seats, seed, inputs. Seats are placed at
|
||||||
|
`level.spawns[seat % len(level.spawns)]` — a four-cook round on a two-spawn kitchen seats
|
||||||
|
everyone — at the tile **centre**, with cooks sharing a spawn offset a quarter tile along
|
||||||
|
x then y so nobody starts inside anybody else. `StateBuildError` covers the two cases with
|
||||||
|
no deterministic answer: a level with zero spawns, and a negative seat index.
|
||||||
|
|
||||||
|
## Replaying and re-blessing goldens
|
||||||
|
|
||||||
|
A round is a function of exactly four things — level, seats, seed, inputs — so a
|
||||||
|
committed script plus a golden state hash is a reproducible test of the whole kernel,
|
||||||
|
and `sim/replay.py` is the seam that makes it a one-command contract (A21, PLAN §9
|
||||||
|
layer 3). Three goldens are committed as of task 05: `carry_two_player` (task 04's
|
||||||
|
12-tick pass and contested pick, checked by `tests/sim/test_replay_carry.py`),
|
||||||
|
`walk_solo` (600 ticks of movement-only walking around the level-1 kitchen, every
|
||||||
|
tick recorded) and `pick_drop_pass` (two cooks on the fixture kitchen in
|
||||||
|
`tests/fixtures/levels/_replay/`: a pick from the shelf, a set-down on the prep,
|
||||||
|
a `HANDED_OFF` pass, and a long-press trash), each checked by
|
||||||
|
`tests/sim/test_replay.py`.
|
||||||
|
|
||||||
|
### The input script
|
||||||
|
|
||||||
|
`InputScript` is one JSON file — `{seed, level_id, tick_hz, frames}` plus the
|
||||||
|
optional `name`, `record_every` and `items` — and `InputScript.load(path)` /
|
||||||
|
`.dump(path)` / `.validate()` are its whole surface. `validate()` refuses anything
|
||||||
|
the kernel cannot replay, naming every problem at once: a non-`u64` `seed`, a
|
||||||
|
`tick_hz` that is not `1 / STEP_DT` (the script is replayed in `STEP_DT`
|
||||||
|
substeps, so any other rate is unrunnable), a non-finite `move`, an unknown
|
||||||
|
`face`, an unknown `ItemKind`/`ItemForm`; `load()` is strict about the shape,
|
||||||
|
so a typo cannot quietly become a different script.
|
||||||
|
|
||||||
|
* A frame is `{tick, player_id, move, action, face}`; it is fed to the substep
|
||||||
|
that advances `state.tick` from the frame's `tick` (stamped
|
||||||
|
`received_at_tick = tick`, `seq` = the frame's order for its player), exactly
|
||||||
|
the stamping a live room does. A long press is eight consecutive `True` frames.
|
||||||
|
* The seats are derived from the script alone: the unique `player_id`s in order
|
||||||
|
of first appearance take seats 0, 1, 2, … at the level's spawns (with
|
||||||
|
`build_state`'s stacking offset). A replay needs no room object and no token
|
||||||
|
list — the four things, nothing else.
|
||||||
|
* `items` pre-place `Item(kind, form)` on named stations before tick 0. Phase 04's
|
||||||
|
pipelines do not exist yet, so an item can enter the world only this way or in
|
||||||
|
a hand; the `pick_drop_pass` shelf stock is exactly such an entry.
|
||||||
|
* `record_every` is the golden's cadence: `1` (the default) records every tick,
|
||||||
|
`0` records nothing (what a timed run uses to skip the hash cost). The built
|
||||||
|
state (tick 0) and the final tick are always recorded, whatever the cadence.
|
||||||
|
* The run length is the last frame's tick + 1, or `max_ticks` when the caller
|
||||||
|
pins it — the `check` CLI pins it to the golden's tick, so a script edited to
|
||||||
|
be shorter or longer is still compared on the golden's terms.
|
||||||
|
|
||||||
|
`run_script(level, book, script, max_ticks=None, clock=None)` is the pure driver:
|
||||||
|
`build_state`, the pre-placed items, then `step(state, STEP_DT, frames)` per
|
||||||
|
tick, recording `(tick, state_hash())` at the cadence. The states at the recorded
|
||||||
|
ticks ride along in the `ReplayResult` so `compare(result, golden)` can diff a
|
||||||
|
mismatch without re-running anything: `compare()` checks every golden tick in
|
||||||
|
order, reports the first divergent tick first, and the first hash mismatch
|
||||||
|
carries the field-level diff (which player, station or order moved since the
|
||||||
|
last matching tick — `players[0].pos.y`, `stations[2].contents[0]`, …), so a
|
||||||
|
broken golden is diagnosable in one read. `missing`/`extra` entries flag a
|
||||||
|
`record_every` cadence mismatch on either side.
|
||||||
|
|
||||||
|
### Goldens and the CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
uv run python -m undercooked.sim.replay check tests/fixtures/replays/walk_solo.json
|
||||||
|
uv run python -m undercooked.sim.replay record tests/fixtures/replays/walk_solo.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`check` runs the script — its level must be in the content directory the CLI
|
||||||
|
loads (`UNDERCOOKED_LEVELS_DIR`, default `levels/`; the `pick_drop_pass` kitchen
|
||||||
|
is a test fixture, so its golden is checked through the harness from
|
||||||
|
`test_replay.py`) — and compares every recorded hash to
|
||||||
|
`tests/goldens/<name>.golden.json` (the script's provenance, the final tick and
|
||||||
|
hash, and every recorded hash). Exit `0` when they all match, `1` when any
|
||||||
|
diverge (the first bad tick and its changed fields are printed), `2` when the
|
||||||
|
script, the level or the golden is missing. `record` does the same run and writes
|
||||||
|
the golden: it is the **manual re-bless**, and it says so on screen.
|
||||||
|
|
||||||
|
A golden is re-blessed **only** after a deliberate sim-contract change — a
|
||||||
|
constant moved, a subsystem reordered, a rule intentionally changed — and the
|
||||||
|
change is recorded in the phase task that makes it. The hash diff then appears
|
||||||
|
in the commit, which is the point: a golden that moved *without* that record is
|
||||||
|
the failure the harness exists to catch, and `record` run to silence a failure
|
||||||
|
is how the guard gets defeated. The numbers under a golden that has not moved
|
||||||
|
are locked two levels down, where they cannot drift: `tests/sim/test_rng.py`
|
||||||
|
pins published splitmix64 vectors (the generator itself can never change
|
||||||
|
quietly) and `tests/sim/test_step_kernel.py` pins the tick count, the clamping,
|
||||||
|
the accumulation and cross-run hash equality.
|
||||||
|
|
||||||
|
### The perf smoke
|
||||||
|
|
||||||
|
`tests/sim/test_perf_smoke.py` (marked `slow`, so the quick loop skips it but
|
||||||
|
the gate's slow stage runs it): four cooks, 2 000 ticks on level 1, one
|
||||||
|
direction per cook per tick from the sim's own splitmix64. The budget is
|
||||||
|
asserted and the measured numbers printed in the report: mean step **< 0.2 ms**
|
||||||
|
and total **< 400 ms** — 5 000+ ticks/s, two and a half orders of magnitude
|
||||||
|
more than the 20 Hz the rooms run at.
|
||||||
|
|
||||||
|
### What proves it
|
||||||
|
|
||||||
|
`tests/sim/test_replay.py` pins the whole seam: both committed scripts match
|
||||||
|
their goldens (the `pick_drop_pass` event sequence asserted in code, so a drift
|
||||||
|
shows *which* rule moved), the one-coordinate mutation (first bad tick named,
|
||||||
|
changed field named), the record-cadence stability and its `record_every = 0`
|
||||||
|
escape, the in-process double replay, the clock seam, the CLI's three exit
|
||||||
|
paths (match, mutation, missing level/golden), the `record`-into-a-temp-dir
|
||||||
|
re-bless, and the golden files' never being rewritten by a test run. The
|
||||||
|
`carry_two_player` golden stays with `tests/sim/test_replay_carry.py`, where
|
||||||
|
task 04 pinned it.
|
||||||
|
|
||||||
|
## Not real yet
|
||||||
|
|
||||||
|
A hook returning the state unchanged is not a bug, it is this phase's scaffolding — but it
|
||||||
|
is worth naming out loud so nobody reads a green suite as "the game works". As of task 04
|
||||||
|
the cooks walk, slide, bump, get stunned and stop, and the hands do something: a free
|
||||||
|
hand picks an item out of a ready fixture or off a counter a teammate set down (a
|
||||||
|
`HANDED_OFF` inside the window, a `PICKED` outside it), a full hand sets one back down
|
||||||
|
on a fixture — shelf, work surface or prep counter — and the set-down is marked for
|
||||||
|
exactly that, a long press over the trash can destroys the carried item on its 8th
|
||||||
|
substep (`TRASHED`, the raw score delta in the payload, `counters["trashed"]` up by
|
||||||
|
one), and two cooks who press the same item in the same substep are arbitrated to a
|
||||||
|
winner and a `CONTESTED_LOST` loser, the item in exactly one place. Every refusal is a
|
||||||
|
named `DENIED` event. Still identity: no station progresses (phase 04), no order spawns
|
||||||
|
(phase 04), no hazard burns or spills (phase 10), and the work verbs — chop, cook,
|
||||||
|
plate, serve, wash — are named by the ladder but unspent no-ops until phase 04's
|
||||||
|
pipelines exist to spend them (`HOLD_WORK`'s threshold with them; the long-press
|
||||||
|
`TRASH`, the hand-off and the `HANDLING_S` floor settled at `0.0` arrived with task
|
||||||
|
04's carry rules). What is real is the shape they all plug into: the clock, the
|
||||||
|
accumulator, the ordering, the input contract, the body that cannot enter a wall, the
|
||||||
|
reach zone that cannot see through a wall, the pair of hands that never hold two items
|
||||||
|
at once, and the round timer that ends a round 12 seconds after it started, whether or
|
||||||
|
not anybody cooked.
|
||||||
+2
-2
@@ -20,10 +20,10 @@ layer live in `tests/conftest.py`, and layer-specific fixtures live in that laye
|
|||||||
| Directory | Contains | I/O allowed |
|
| 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/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/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/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
|
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
|
directories. A route asserted as a pure function (header values, status codes, CLI
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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, ()
|
||||||
@@ -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)
|
||||||
@@ -222,7 +222,13 @@ class PlayerState(StrEnum):
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Player:
|
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
|
id: str
|
||||||
pos: Vec2 = Vec2()
|
pos: Vec2 = Vec2()
|
||||||
@@ -235,6 +241,8 @@ class Player:
|
|||||||
dishes_served: int = 0
|
dishes_served: int = 0
|
||||||
connected: bool = True
|
connected: bool = True
|
||||||
seat: int = 0
|
seat: int = 0
|
||||||
|
action_held_ticks: int = 0
|
||||||
|
action_pressed_tick: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -331,7 +339,12 @@ class EventKind(StrEnum):
|
|||||||
STUNNED = "stunned"
|
STUNNED = "stunned"
|
||||||
WASHED = "washed"
|
WASHED = "washed"
|
||||||
TRASHED = "trashed"
|
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"
|
TICK_SLOW = "tick_slow"
|
||||||
|
ERROR = "err" # a malformed or unattributable input, never an exception
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -350,6 +363,12 @@ class KitchenState:
|
|||||||
|
|
||||||
Frozen: phases 03/04 advance the round by *returning* a new state from
|
Frozen: phases 03/04 advance the round by *returning* a new state from
|
||||||
`step()`; the `with_*` helpers keep every mutation in one obvious place.
|
`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
|
level_id: str
|
||||||
@@ -366,7 +385,9 @@ class KitchenState:
|
|||||||
streak: int = 0
|
streak: int = 0
|
||||||
result: RoundResult | None = None
|
result: RoundResult | None = None
|
||||||
counters: Mapping[str, int] = field(default_factory=dict)
|
counters: Mapping[str, int] = field(default_factory=dict)
|
||||||
|
item_marks: Mapping[str, tuple[tuple[str, int], ...]] = field(default_factory=dict)
|
||||||
rng_state: int = 0
|
rng_state: int = 0
|
||||||
|
accumulator: float = 0.0 # seconds not yet worth a whole STEP_DT substep
|
||||||
|
|
||||||
def state_hash(self) -> str:
|
def state_hash(self) -> str:
|
||||||
"""sha256 over the canonical form of every field (A21 replay guard).
|
"""sha256 over the canonical form of every field (A21 replay guard).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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, ()
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
"""The replay harness: input scripts, golden hashes, and the record/check CLI (A21).
|
||||||
|
|
||||||
|
A round is a function of exactly four things — the `Level`, the seats dealt, the
|
||||||
|
seed, and the input script — so a committed script plus its golden state hash is a
|
||||||
|
reproducible test of the whole kernel: `build_state`, `step()`, and every subsystem
|
||||||
|
in the documented order. This module is the seam that makes that a one-command
|
||||||
|
contract (PLAN §9 layer 3, A21):
|
||||||
|
|
||||||
|
* `InputScript` — the reusable script format. `{seed, level_id, tick_hz, frames}`
|
||||||
|
plus optional `name`, `record_every` and `items`; `InputScript.load(path)`,
|
||||||
|
`.dump(path)` and `.validate()` are its whole surface. A frame is
|
||||||
|
`{tick, player_id, move, action, face}`; the seats are derived from the script
|
||||||
|
alone (the unique `player_id`s in first-appearance order take seats 0, 1, 2, …),
|
||||||
|
so a replay needs no room object and no token list. `tick_hz` must be
|
||||||
|
`1 / STEP_DT` (20): the script is replayed in `STEP_DT` substeps, so any other
|
||||||
|
rate is a script the kernel cannot run.
|
||||||
|
* `run_script(level, book, script, max_ticks=None, clock=None)` — the pure driver:
|
||||||
|
build the state, apply the script's pre-placed `items`, feed each frame to the
|
||||||
|
substep that advances `state.tick` from the frame's `tick` (stamping
|
||||||
|
`received_at_tick = tick`, `seq` = the frame's order for its player), and record
|
||||||
|
`(tick, state_hash())` at the script's `record_every` cadence (tick 0 and the
|
||||||
|
final tick always recorded; `record_every = 0` records nothing, which is what a
|
||||||
|
timed run uses to skip the hash cost). The wall clock is *injected*, never
|
||||||
|
imported — the kernel stays clock-free (A5), and `wall_seconds` is `0.0` unless
|
||||||
|
a `clock` callable is passed. The states at the recorded ticks ride along in the
|
||||||
|
result so `compare()` can diff them without re-running anything.
|
||||||
|
* `compare(result, golden)` — every golden tick against the run's hashes, first
|
||||||
|
divergent tick first; the first hash mismatch carries the field-level diff
|
||||||
|
(which player, station or order moved since the last matching tick), so a
|
||||||
|
broken golden is diagnosable in one read.
|
||||||
|
* The CLI: `python -m undercooked.sim.replay check <script.json>` runs the script
|
||||||
|
(its level must be in the content directory the CLI loads —
|
||||||
|
`UNDERCOOKED_LEVELS_DIR`, default `levels/`) and compares it to
|
||||||
|
`tests/goldens/<name>.golden.json`; exit `0` on a match, `1` on a divergence
|
||||||
|
(the first bad tick and its changed fields are printed), `2` when the script,
|
||||||
|
the level or the golden is missing. `record` does the same run and writes the
|
||||||
|
golden: it is the manual re-bless — legitimate only after a deliberate
|
||||||
|
sim-contract change recorded in the phase task, never to silence a failure
|
||||||
|
(docs/SIMULATION.md, "Replaying and re-blessing goldens").
|
||||||
|
|
||||||
|
Purity note (A5): this module imports no clock and no I/O library at top level —
|
||||||
|
`json`/`argparse` are stdlib, and the content load the CLI needs (`sim.content`,
|
||||||
|
which the server uses at startup) is imported inside the CLI path, so the library
|
||||||
|
core stays `undercooked.sim` + stdlib.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field, fields, is_dataclass, replace
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from undercooked.sim.level import Level
|
||||||
|
from undercooked.sim.model import Event, Facing, Item, ItemForm, ItemKind, KitchenState, Vec2
|
||||||
|
from undercooked.sim.recipe import RecipeBook
|
||||||
|
from undercooked.sim.state_build import SeatSpec, build_state
|
||||||
|
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EXIT_MISMATCH",
|
||||||
|
"EXIT_OK",
|
||||||
|
"EXIT_UNUSABLE",
|
||||||
|
"EXPECTED_TICK_HZ",
|
||||||
|
"FACING_VALUES",
|
||||||
|
"Frame",
|
||||||
|
"HashMismatch",
|
||||||
|
"InputScript",
|
||||||
|
"ReplayError",
|
||||||
|
"ReplayResult",
|
||||||
|
"ScriptItem",
|
||||||
|
"compare",
|
||||||
|
"main",
|
||||||
|
"run_script",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: `python -m undercooked.sim.replay` exits `0` on a match, `1` on a divergence,
|
||||||
|
#: `2` when it could not act (the same convention `undercooked-levels` uses).
|
||||||
|
EXIT_OK: Final = 0
|
||||||
|
EXIT_MISMATCH: Final = 1
|
||||||
|
EXIT_UNUSABLE: Final = 2
|
||||||
|
|
||||||
|
#: The one rate the kernel runs at: a script at any other `tick_hz` is unrunnable.
|
||||||
|
EXPECTED_TICK_HZ: Final[float] = 1.0 / STEP_DT
|
||||||
|
|
||||||
|
#: The splitmix64 width a `seed` may occupy (`KitchenState.rng_state` is a u64).
|
||||||
|
MAX_SEED: Final[int] = 1 << 64
|
||||||
|
|
||||||
|
#: The four facing values a frame's `face` may carry (the `Facing` values, as strings).
|
||||||
|
FACING_VALUES: Final[frozenset[str]] = frozenset({"up", "right", "down", "left"})
|
||||||
|
|
||||||
|
#: Where the goldens live: `tests/goldens/<script-stem>.golden.json`.
|
||||||
|
GOLDENS_DIR: Final[Path] = Path(__file__).resolve().parents[3] / "tests" / "goldens"
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayError(ValueError):
|
||||||
|
"""A script (or a script/level/golden combination) the harness cannot act on."""
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_problems(index: int, frame: Frame) -> list[str]:
|
||||||
|
"""The problems with one frame (the `validate()` contract, one frame at a time)."""
|
||||||
|
where = f"frame {index}"
|
||||||
|
problems: list[str] = []
|
||||||
|
if isinstance(frame.tick, bool) or not isinstance(frame.tick, int) or frame.tick < 0:
|
||||||
|
problems.append(f"{where}: tick must be a non-negative integer, got {frame.tick!r}")
|
||||||
|
if not isinstance(frame.player_id, str) or not frame.player_id:
|
||||||
|
problems.append(f"{where}: player_id must be a non-empty string")
|
||||||
|
if (
|
||||||
|
len(frame.move) != 2
|
||||||
|
or any(
|
||||||
|
isinstance(v, bool) or not isinstance(v, int | float) or not math.isfinite(v)
|
||||||
|
for v in frame.move
|
||||||
|
)
|
||||||
|
):
|
||||||
|
problems.append(f"{where}: move must be two finite numbers, got {frame.move!r}")
|
||||||
|
if not isinstance(frame.action, bool):
|
||||||
|
problems.append(f"{where}: action must be a bool, got {frame.action!r}")
|
||||||
|
if frame.face is not None and frame.face not in FACING_VALUES:
|
||||||
|
problems.append(
|
||||||
|
f"{where}: face must be one of {sorted(FACING_VALUES)} or null, got {frame.face!r}"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _item_problems(index: int, entry: ScriptItem) -> list[str]:
|
||||||
|
"""The problems with one pre-placed item (the `validate()` contract, one at a time)."""
|
||||||
|
where = f"item {index}"
|
||||||
|
problems: list[str] = []
|
||||||
|
if not isinstance(entry.station, str) or not entry.station:
|
||||||
|
problems.append(f"{where}: station must be a non-empty string")
|
||||||
|
if entry.item not in _ITEM_KIND_VALUES:
|
||||||
|
problems.append(f"{where}: item must be an ItemKind value, got {entry.item!r}")
|
||||||
|
if entry.form not in _ITEM_FORM_VALUES:
|
||||||
|
problems.append(f"{where}: form must be an ItemForm value, got {entry.form!r}")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScriptItem:
|
||||||
|
"""One item pre-placed on a station before tick 0.
|
||||||
|
|
||||||
|
Phase 04's station pipelines do not exist yet, so an item can enter the world
|
||||||
|
only this way or in a hand: `run_script` appends `Item(kind, form)` to the
|
||||||
|
named station's `contents` in script order. A shelf's stock is exactly this
|
||||||
|
entry (phase 04's replenishment will join it).
|
||||||
|
"""
|
||||||
|
|
||||||
|
station: str
|
||||||
|
item: str # an `ItemKind` value
|
||||||
|
form: str # an `ItemForm` value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Frame:
|
||||||
|
"""One input frame: fed to the substep that advances `tick` -> `tick + 1`.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
tick: the `KitchenState.tick` the frame is fed at (its `received_at_tick`).
|
||||||
|
player_id: the seat it names; unknown ids become `err` events through
|
||||||
|
`step()`, exactly as they do for a live client.
|
||||||
|
move: the movement vector as sent; `movement.intent_direction()` makes it
|
||||||
|
authoritative (length clamped to <= 1) the same way it does on the wire.
|
||||||
|
action: the one-button flag; the action gate turns it into holds, so a
|
||||||
|
long press is eight consecutive `True` frames.
|
||||||
|
face: an explicit facing, or `None` for "derive from `move`".
|
||||||
|
"""
|
||||||
|
|
||||||
|
tick: int
|
||||||
|
player_id: str
|
||||||
|
move: tuple[float, float]
|
||||||
|
action: bool = False
|
||||||
|
face: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InputScript:
|
||||||
|
"""One replay, as authored: seed, level, rate, and the frames that drive it.
|
||||||
|
|
||||||
|
`validate()` refuses anything the kernel cannot replay (a non-u64 seed, a
|
||||||
|
`tick_hz` that is not 20, a non-finite `move`, an unknown `face`, an unknown
|
||||||
|
`ItemKind`/`ItemForm`), naming every problem at once. `load()` is strict about
|
||||||
|
the JSON shape — unknown top-level keys are an error, so a typo cannot quietly
|
||||||
|
become a different script.
|
||||||
|
"""
|
||||||
|
|
||||||
|
seed: int
|
||||||
|
level_id: str
|
||||||
|
tick_hz: float
|
||||||
|
frames: tuple[Frame, ...] = ()
|
||||||
|
record_every: int = 1
|
||||||
|
items: tuple[ScriptItem, ...] = ()
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
# -- the contract ---------------------------------------------------------
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Raise `ReplayError` naming every problem with this script at once."""
|
||||||
|
problems: list[str] = []
|
||||||
|
if isinstance(self.seed, bool) or not isinstance(self.seed, int) or not (
|
||||||
|
0 <= self.seed < MAX_SEED
|
||||||
|
):
|
||||||
|
problems.append(f"seed must be an integer in [0, 2**64), got {self.seed!r}")
|
||||||
|
if not isinstance(self.level_id, str) or not self.level_id:
|
||||||
|
problems.append("level_id must be a non-empty string")
|
||||||
|
if (
|
||||||
|
isinstance(self.tick_hz, bool)
|
||||||
|
or not isinstance(self.tick_hz, int | float)
|
||||||
|
or not math.isfinite(self.tick_hz)
|
||||||
|
or abs(self.tick_hz - EXPECTED_TICK_HZ) > 1e-9
|
||||||
|
):
|
||||||
|
problems.append(
|
||||||
|
f"tick_hz must be {EXPECTED_TICK_HZ:g} (the kernel is {EXPECTED_TICK_HZ:g} Hz), "
|
||||||
|
f"got {self.tick_hz!r}"
|
||||||
|
)
|
||||||
|
if isinstance(self.record_every, bool) or not isinstance(self.record_every, int):
|
||||||
|
problems.append(
|
||||||
|
f"record_every must be an integer (0 = record nothing), got {self.record_every!r}"
|
||||||
|
)
|
||||||
|
elif self.record_every < 0:
|
||||||
|
problems.append(f"record_every must be >= 0, got {self.record_every!r}")
|
||||||
|
for index, frame in enumerate(self.frames):
|
||||||
|
problems.extend(_frame_problems(index, frame))
|
||||||
|
for index, entry in enumerate(self.items):
|
||||||
|
problems.extend(_item_problems(index, entry))
|
||||||
|
if problems:
|
||||||
|
label = self.name or "<unnamed>"
|
||||||
|
msg = f"script {label} is invalid: " + "; ".join(problems)
|
||||||
|
raise ReplayError(msg)
|
||||||
|
|
||||||
|
# -- the file form --------------------------------------------------------
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path | str) -> InputScript:
|
||||||
|
"""Read one script JSON file (unknown top-level keys are an error)."""
|
||||||
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
return cls._from_mapping(data, fallback_name=Path(path).stem)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _frame_from_mapping(cls, index: int, raw: object) -> Frame:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
msg = f"frame {index} must be a JSON object"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
unknown = sorted(set(raw) - {"tick", "player_id", "move", "action", "face"})
|
||||||
|
if unknown:
|
||||||
|
msg = f"frame {index} has unknown key(s): {', '.join(unknown)}"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
for required in ("tick", "player_id", "move"):
|
||||||
|
if required not in raw:
|
||||||
|
msg = f"frame {index} is missing required key {required!r}"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
move = raw["move"]
|
||||||
|
if not isinstance(move, list) or len(move) != 2:
|
||||||
|
msg = f"frame {index}: move must be a two-element array"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
return Frame(
|
||||||
|
tick=raw["tick"],
|
||||||
|
player_id=raw["player_id"],
|
||||||
|
move=(move[0], move[1]),
|
||||||
|
action=raw.get("action", False),
|
||||||
|
face=raw.get("face"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _item_from_mapping(cls, index: int, raw: object) -> ScriptItem:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
msg = f"item {index} must be a JSON object"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
unknown = sorted(set(raw) - {"station", "item", "form"})
|
||||||
|
if unknown:
|
||||||
|
msg = f"item {index} has unknown key(s): {', '.join(unknown)}"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
for required in ("station", "item", "form"):
|
||||||
|
if required not in raw:
|
||||||
|
msg = f"item {index} is missing required key {required!r}"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
return ScriptItem(station=raw["station"], item=raw["item"], form=raw["form"])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_mapping(cls, data: object, *, fallback_name: str = "") -> InputScript:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
msg = f"a script file must be a JSON object, got {type(data).__name__}"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
known = {"name", "seed", "level_id", "tick_hz", "frames", "items", "record_every"}
|
||||||
|
unknown = sorted(set(data) - known)
|
||||||
|
if unknown:
|
||||||
|
msg = f"unknown script key(s): {', '.join(unknown)} (known: {', '.join(sorted(known))})"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
for required in ("seed", "level_id", "tick_hz"):
|
||||||
|
if required not in data:
|
||||||
|
msg = f"script is missing required key {required!r}"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
frames = tuple(
|
||||||
|
cls._frame_from_mapping(index, raw)
|
||||||
|
for index, raw in enumerate(data.get("frames", ()))
|
||||||
|
)
|
||||||
|
items = tuple(
|
||||||
|
cls._item_from_mapping(index, raw) for index, raw in enumerate(data.get("items", ()))
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
name=data.get("name", fallback_name) or fallback_name,
|
||||||
|
seed=data["seed"],
|
||||||
|
level_id=data["level_id"],
|
||||||
|
tick_hz=data["tick_hz"],
|
||||||
|
frames=frames,
|
||||||
|
record_every=data.get("record_every", 1),
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
def dump(self, path: Path | str) -> None:
|
||||||
|
"""Write this script to `path` in the canonical form (`validate()` first)."""
|
||||||
|
self.validate()
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"seed": self.seed,
|
||||||
|
"level_id": self.level_id,
|
||||||
|
"tick_hz": self.tick_hz,
|
||||||
|
"record_every": self.record_every,
|
||||||
|
"items": [
|
||||||
|
{"station": entry.station, "item": entry.item, "form": entry.form}
|
||||||
|
for entry in self.items
|
||||||
|
],
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"tick": frame.tick,
|
||||||
|
"player_id": frame.player_id,
|
||||||
|
"move": list(frame.move),
|
||||||
|
"action": frame.action,
|
||||||
|
"face": frame.face,
|
||||||
|
}
|
||||||
|
for frame in self.frames
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if self.name:
|
||||||
|
data["name"] = self.name
|
||||||
|
target = Path(path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReplayResult:
|
||||||
|
"""One replay, as run: the end state, the recorded hashes, every event.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
state: the state after the last substep (its `tick` is the run length).
|
||||||
|
hashes: `(tick, state_hash())` at the script's `record_every` cadence —
|
||||||
|
tick 0 and the final tick always included (when recording at all).
|
||||||
|
events: every event the run produced, in substep order.
|
||||||
|
wall_seconds: wall time of the whole run, measured only when a `clock`
|
||||||
|
callable was injected into `run_script` (else `0.0`).
|
||||||
|
states: the states at the recorded ticks, kept so `compare()` can diff a
|
||||||
|
mismatch against the last matching tick without re-running the script.
|
||||||
|
"""
|
||||||
|
|
||||||
|
state: KitchenState
|
||||||
|
hashes: tuple[tuple[int, str], ...]
|
||||||
|
events: tuple[Event, ...]
|
||||||
|
wall_seconds: float
|
||||||
|
states: Mapping[int, KitchenState] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _seats_from(script: InputScript) -> tuple[SeatSpec, ...]:
|
||||||
|
"""The seats a script names: first appearance order takes seat order.
|
||||||
|
|
||||||
|
A replay must be reproducible from the script alone, so the seat table is
|
||||||
|
derived from the frames, not passed in: `p0` (whichever id appears first) is
|
||||||
|
seat 0 at the first spawn, the next new id is seat 1, and so on. The display
|
||||||
|
name is the id itself — a headless replay has no HUD, and the id is the
|
||||||
|
anonymous identity by design.
|
||||||
|
"""
|
||||||
|
order: list[str] = []
|
||||||
|
for frame in script.frames:
|
||||||
|
if frame.player_id not in order:
|
||||||
|
order.append(frame.player_id)
|
||||||
|
return tuple(
|
||||||
|
SeatSpec(player_id=player_id, seat=index, display_name=player_id)
|
||||||
|
for index, player_id in enumerate(order)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_items(state: KitchenState, items: tuple[ScriptItem, ...]) -> KitchenState:
|
||||||
|
"""Pre-place the script's items on their stations, in script order."""
|
||||||
|
if not items:
|
||||||
|
return state
|
||||||
|
for entry in items:
|
||||||
|
station = next((s for s in state.stations if s.id == entry.station), None)
|
||||||
|
if station is None:
|
||||||
|
msg = f"script item names station {entry.station!r} which the state does not have"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
placed = Item(kind=ItemKind(entry.item), form=ItemForm(entry.form))
|
||||||
|
state = state.with_stations(
|
||||||
|
tuple(
|
||||||
|
replace(s, contents=(*s.contents, placed)) if s.id == station.id else s
|
||||||
|
for s in state.stations
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def run_script(
|
||||||
|
level: Level,
|
||||||
|
book: RecipeBook,
|
||||||
|
script: InputScript,
|
||||||
|
max_ticks: int | None = None,
|
||||||
|
*,
|
||||||
|
clock: Callable[[], float] | None = None,
|
||||||
|
) -> ReplayResult:
|
||||||
|
"""Run one committed script against the kernel; the replay contract, as a driver.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: the level the script names; a mismatch with `script.level_id` is a
|
||||||
|
`ReplayError`, because a golden recorded against one kitchen checked
|
||||||
|
against another is a comparison that means nothing.
|
||||||
|
book: the recipe book `build_state` takes (unused by the phase-03
|
||||||
|
subsystems; the parameter is there so phase 04 changes no call site).
|
||||||
|
script: the committed script (`validate()` runs first; a bad script never
|
||||||
|
reaches the kernel).
|
||||||
|
max_ticks: pin the run length (the `check` CLI pins it to the golden's
|
||||||
|
tick, so a script edited to be shorter or longer is still compared on
|
||||||
|
the golden's terms). Frames past the pin are never fed. `None` runs
|
||||||
|
to the last frame's tick + 1 (or zero ticks for a frameless script).
|
||||||
|
clock: a monotonic clock for `wall_seconds`; the kernel itself never
|
||||||
|
touches a clock (A5), so `None` measures nothing and reports `0.0`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The `ReplayResult`: end state, the recorded `(tick, hash)` pairs, every
|
||||||
|
event in substep order, the wall time (when measured), and the states at
|
||||||
|
the recorded ticks.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReplayError: the script is invalid, the level ids disagree, or an item
|
||||||
|
names a station the state does not have.
|
||||||
|
"""
|
||||||
|
script.validate()
|
||||||
|
if script.level_id != level.id:
|
||||||
|
msg = (
|
||||||
|
f"script {script.name or '<unnamed>'} names level {script.level_id!r} "
|
||||||
|
f"but was given {level.id!r}"
|
||||||
|
)
|
||||||
|
raise ReplayError(msg)
|
||||||
|
if max_ticks is not None and (isinstance(max_ticks, bool) or max_ticks < 0):
|
||||||
|
msg = f"max_ticks must be None or a non-negative integer, got {max_ticks!r}"
|
||||||
|
raise ReplayError(msg)
|
||||||
|
|
||||||
|
started = clock() if clock is not None else 0.0
|
||||||
|
state = build_state(level, _seats_from(script), script.seed, book)
|
||||||
|
state = _apply_items(state, script.items)
|
||||||
|
total = (
|
||||||
|
max_ticks
|
||||||
|
if max_ticks is not None
|
||||||
|
else (max(frame.tick for frame in script.frames) + 1 if script.frames else 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
hashes: list[tuple[int, str]] = []
|
||||||
|
states: dict[int, KitchenState] = {}
|
||||||
|
events: list[Event] = []
|
||||||
|
if script.record_every > 0:
|
||||||
|
hashes.append((0, state.state_hash()))
|
||||||
|
states[0] = state
|
||||||
|
|
||||||
|
by_tick: dict[int, list[Frame]] = {}
|
||||||
|
for frame in script.frames:
|
||||||
|
by_tick.setdefault(frame.tick, []).append(frame)
|
||||||
|
seqs: dict[str, int] = {}
|
||||||
|
for tick in range(total):
|
||||||
|
inputs = [
|
||||||
|
PlayerInput(
|
||||||
|
player_id=frame.player_id,
|
||||||
|
seq=seqs.get(frame.player_id, 0),
|
||||||
|
move=Vec2(frame.move[0], frame.move[1]),
|
||||||
|
action=frame.action,
|
||||||
|
face=Facing(frame.face) if frame.face is not None else None,
|
||||||
|
received_at_tick=tick,
|
||||||
|
)
|
||||||
|
for frame in by_tick.get(tick, ())
|
||||||
|
]
|
||||||
|
for frame in by_tick.get(tick, ()):
|
||||||
|
seqs[frame.player_id] = seqs.get(frame.player_id, 0) + 1
|
||||||
|
state, produced = step(state, STEP_DT, inputs)
|
||||||
|
events.extend(produced)
|
||||||
|
done = tick + 1
|
||||||
|
if script.record_every > 0 and (done % script.record_every == 0 or done == total):
|
||||||
|
hashes.append((done, state.state_hash()))
|
||||||
|
states[done] = state
|
||||||
|
|
||||||
|
wall_seconds = clock() - started if clock is not None else 0.0
|
||||||
|
return ReplayResult(
|
||||||
|
state=state,
|
||||||
|
hashes=tuple(hashes),
|
||||||
|
events=tuple(events),
|
||||||
|
wall_seconds=wall_seconds,
|
||||||
|
states=states,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HashMismatch:
|
||||||
|
"""One tick where the run and the golden disagree (or one that is on one side only).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
tick: the tick the disagreement is about.
|
||||||
|
kind: `"hash"` (both sides recorded it, values differ), `"missing"` (the
|
||||||
|
golden recorded a tick the run did not — a `record_every` cadence
|
||||||
|
mismatch) or `"extra"` (the run recorded a tick the golden does not
|
||||||
|
have — the other half of the same mismatch).
|
||||||
|
expected: the golden's hash, or `None` for `extra`.
|
||||||
|
actual: the run's hash, or `None` for `missing`.
|
||||||
|
changed: for the first hash mismatch, the field paths that moved since
|
||||||
|
the last matching tick (which player, station or order) — the diff
|
||||||
|
that makes a broken golden diagnosable in one read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tick: int
|
||||||
|
kind: str
|
||||||
|
expected: str | None
|
||||||
|
actual: str | None
|
||||||
|
changed: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def compare(result: ReplayResult, golden: Mapping[int, str]) -> list[HashMismatch]:
|
||||||
|
"""Compare a run's recorded hashes against a golden's, first divergence first.
|
||||||
|
|
||||||
|
The golden is a `tick -> hash` mapping (the `hashes` of a
|
||||||
|
`tests/goldens/<name>.golden.json`). Every golden tick is checked in order;
|
||||||
|
the first hash mismatch carries the field-level diff, computed from the run's
|
||||||
|
own states (the mismatched tick's state against the last matching tick's —
|
||||||
|
or the built state when nothing matched yet). `missing` and `extra` entries
|
||||||
|
follow, in tick order, so a cadence mismatch is visible even when every
|
||||||
|
shared tick still agrees.
|
||||||
|
"""
|
||||||
|
recorded = dict(result.hashes)
|
||||||
|
mismatches: list[HashMismatch] = []
|
||||||
|
last_match: int | None = None
|
||||||
|
for tick in sorted(golden):
|
||||||
|
actual = recorded.get(tick)
|
||||||
|
if actual is None:
|
||||||
|
mismatches.append(HashMismatch(tick, "missing", golden[tick], None))
|
||||||
|
continue
|
||||||
|
if actual == golden[tick]:
|
||||||
|
last_match = tick
|
||||||
|
continue
|
||||||
|
baseline = last_match if last_match is not None else 0
|
||||||
|
# recorded ticks and their states are recorded in lockstep, so both are here:
|
||||||
|
changed = _field_diff(result.states[baseline], result.states[tick])
|
||||||
|
mismatches.append(HashMismatch(tick, "hash", golden[tick], actual, changed))
|
||||||
|
mismatches.extend(
|
||||||
|
HashMismatch(tick, "extra", None, recorded[tick])
|
||||||
|
for tick in sorted(recorded)
|
||||||
|
if tick not in golden
|
||||||
|
)
|
||||||
|
return mismatches
|
||||||
|
|
||||||
|
|
||||||
|
_MISSING: Final = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _field_diff(a: object, b: object) -> tuple[str, ...]:
|
||||||
|
"""The field paths on which two states differ, in deterministic order."""
|
||||||
|
found: list[str] = []
|
||||||
|
_diff("", a, b, found)
|
||||||
|
return tuple(found)
|
||||||
|
|
||||||
|
|
||||||
|
def _diff(prefix: str, a: object, b: object, found: list[str]) -> None:
|
||||||
|
if a is b:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
is_dataclass(a)
|
||||||
|
and not isinstance(a, type)
|
||||||
|
and is_dataclass(b)
|
||||||
|
and not isinstance(b, type)
|
||||||
|
and type(a) is type(b)
|
||||||
|
):
|
||||||
|
for f in fields(a):
|
||||||
|
child = f"{prefix}.{f.name}" if prefix else f.name
|
||||||
|
_diff(child, getattr(a, f.name), getattr(b, f.name), found)
|
||||||
|
return
|
||||||
|
if isinstance(a, Mapping) and isinstance(b, Mapping):
|
||||||
|
for key in sorted(set(a) | set(b), key=repr):
|
||||||
|
_diff(f"{prefix}[{key!r}]", a.get(key, _MISSING), b.get(key, _MISSING), found)
|
||||||
|
return
|
||||||
|
if isinstance(a, (tuple, list)) and isinstance(b, (tuple, list)):
|
||||||
|
for i in range(max(len(a), len(b))):
|
||||||
|
_diff(
|
||||||
|
f"{prefix}[{i}]",
|
||||||
|
a[i] if i < len(a) else _MISSING,
|
||||||
|
b[i] if i < len(b) else _MISSING,
|
||||||
|
found,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not _equal(a, b):
|
||||||
|
found.append(prefix or "(root)")
|
||||||
|
|
||||||
|
|
||||||
|
def _equal(a: object, b: object) -> bool:
|
||||||
|
if a is _MISSING or b is _MISSING:
|
||||||
|
return a is b
|
||||||
|
return a == b
|
||||||
|
|
||||||
|
|
||||||
|
def _item_kind_values() -> frozenset[str]:
|
||||||
|
return frozenset(kind.value for kind in ItemKind)
|
||||||
|
|
||||||
|
|
||||||
|
def _item_form_values() -> frozenset[str]:
|
||||||
|
return frozenset(form.value for form in ItemForm)
|
||||||
|
|
||||||
|
|
||||||
|
_ITEM_KIND_VALUES: Final[frozenset[str]] = _item_kind_values()
|
||||||
|
_ITEM_FORM_VALUES: Final[frozenset[str]] = _item_form_values()
|
||||||
|
|
||||||
|
|
||||||
|
class _CliError(RuntimeError):
|
||||||
|
"""The CLI could not act: content would not load, or the level is not in it."""
|
||||||
|
|
||||||
|
|
||||||
|
def _load_level(script: InputScript) -> tuple[Level, RecipeBook]:
|
||||||
|
"""The script's level and the recipe book, from the content directory.
|
||||||
|
|
||||||
|
Same resolution `undercooked-levels` uses (`Settings`-driven, anchored to
|
||||||
|
this checkout): `UNDERCOOKED_LEVELS_DIR` beats the built-in `levels`. The
|
||||||
|
imports are here, not at module top, so the library core stays
|
||||||
|
`undercooked.sim` + stdlib.
|
||||||
|
"""
|
||||||
|
from undercooked.config import load_settings # noqa: PLC0415 - deferred on purpose
|
||||||
|
from undercooked.sim.content import ContentError, load_content # noqa: PLC0415
|
||||||
|
from undercooked.tools.levels import resolve_content_paths # noqa: PLC0415
|
||||||
|
|
||||||
|
levels_dir, recipes_path = resolve_content_paths(load_settings())
|
||||||
|
try:
|
||||||
|
bundle = load_content(levels_dir, recipes_path)
|
||||||
|
except ContentError as exc:
|
||||||
|
msg = f"content at {levels_dir} would not load: {exc}"
|
||||||
|
raise _CliError(msg) from exc
|
||||||
|
level = bundle.by_id.get(script.level_id)
|
||||||
|
if level is None:
|
||||||
|
msg = (
|
||||||
|
f"level {script.level_id!r} is not in {levels_dir} — the script's level must be in "
|
||||||
|
"the content directory the CLI loads (UNDERCOOKED_LEVELS_DIR, default levels/)"
|
||||||
|
)
|
||||||
|
raise _CliError(msg)
|
||||||
|
return level, bundle.recipe_book
|
||||||
|
|
||||||
|
|
||||||
|
def _write_golden(
|
||||||
|
path: Path, name: str, script: InputScript, result: ReplayResult
|
||||||
|
) -> None:
|
||||||
|
"""The golden file: the script's provenance, the run's end tick and hash, every hash."""
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"level_id": script.level_id,
|
||||||
|
"seed": script.seed,
|
||||||
|
"tick_hz": script.tick_hz,
|
||||||
|
"record_every": script.record_every,
|
||||||
|
"tick": result.state.tick,
|
||||||
|
"hash": result.state.state_hash(),
|
||||||
|
"hashes": {str(tick): digest for tick, digest in result.hashes},
|
||||||
|
}
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
argv: Sequence[str] | None = None, *, goldens_dir: Path | None = None
|
||||||
|
) -> int:
|
||||||
|
"""`record` / `check` for one committed script (the docstring is the contract)."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="python -m undercooked.sim.replay",
|
||||||
|
description=(
|
||||||
|
"Replay a committed input script against the simulation kernel and gate it on "
|
||||||
|
"its golden hash. `check` is the CI path (it never writes); `record` writes "
|
||||||
|
"tests/goldens/<name>.golden.json and is the manual re-bless — legitimate only "
|
||||||
|
"after a deliberate sim-contract change recorded in the phase task, never to "
|
||||||
|
"silence a failure."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
record = sub.add_parser("record", help="run <script.json> and write its golden")
|
||||||
|
record.add_argument("script", type=Path)
|
||||||
|
check = sub.add_parser("check", help="run <script.json> and compare it to its golden")
|
||||||
|
check.add_argument("script", type=Path)
|
||||||
|
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||||
|
|
||||||
|
goldens = goldens_dir if goldens_dir is not None else GOLDENS_DIR
|
||||||
|
name = args.script.stem
|
||||||
|
golden_path = goldens / f"{name}.golden.json"
|
||||||
|
try:
|
||||||
|
script = InputScript.load(args.script)
|
||||||
|
except ReplayError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return EXIT_UNUSABLE
|
||||||
|
try:
|
||||||
|
level, book = _load_level(script)
|
||||||
|
except _CliError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return EXIT_UNUSABLE
|
||||||
|
|
||||||
|
if args.command == "record":
|
||||||
|
result = run_script(level, book, script)
|
||||||
|
if golden_path.exists():
|
||||||
|
print(
|
||||||
|
f"note: overwriting {golden_path} — a re-bless is legitimate only after a "
|
||||||
|
"deliberate sim-contract change recorded in the phase task"
|
||||||
|
)
|
||||||
|
_write_golden(golden_path, name, script, result)
|
||||||
|
print(
|
||||||
|
f"wrote {golden_path}: {len(result.hashes)} hashes, "
|
||||||
|
f"final tick {result.state.tick} {result.state.state_hash()}"
|
||||||
|
)
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
if not golden_path.exists():
|
||||||
|
print(
|
||||||
|
f"error: no golden at {golden_path}; run "
|
||||||
|
f"`python -m undercooked.sim.replay record {args.script.name}` first",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return EXIT_UNUSABLE
|
||||||
|
golden = json.loads(golden_path.read_text(encoding="utf-8"))
|
||||||
|
result = run_script(level, book, script, max_ticks=int(golden["tick"]))
|
||||||
|
mismatches = compare(result, {int(tick): digest for tick, digest in golden["hashes"].items()})
|
||||||
|
if not mismatches:
|
||||||
|
print(
|
||||||
|
f"ok: {len(result.hashes)} hashes match {golden_path.name} "
|
||||||
|
f"(final tick {result.state.tick} {result.state.state_hash()})"
|
||||||
|
)
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
if golden.get("record_every") != script.record_every:
|
||||||
|
print(
|
||||||
|
f"note: the golden was recorded at record_every={golden.get('record_every')}, "
|
||||||
|
f"this script says {script.record_every}"
|
||||||
|
)
|
||||||
|
diverged = [mismatch for mismatch in mismatches if mismatch.kind == "hash"]
|
||||||
|
first = mismatches[0]
|
||||||
|
print(f"MISMATCH in {args.script.name}: first divergent tick {first.tick}")
|
||||||
|
if first.kind == "hash":
|
||||||
|
print(f" expected {first.expected}")
|
||||||
|
print(f" actual {first.actual}")
|
||||||
|
if first.changed:
|
||||||
|
print(f" changed fields since the last matching tick: {', '.join(first.changed)}")
|
||||||
|
else:
|
||||||
|
print(" (no field diff: the run recorded no states, or nothing matched before it)")
|
||||||
|
elif first.kind == "missing":
|
||||||
|
print(
|
||||||
|
" a golden tick this run did not record — a record_every cadence mismatch"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {len(diverged)} of {len(result.hashes)} recorded hashes diverged "
|
||||||
|
f"(of {len(golden['hashes'])} golden hashes)"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" regenerate with `record` only after a deliberate sim-contract change recorded in "
|
||||||
|
"the phase task — never to silence a failure (docs/SIMULATION.md, "
|
||||||
|
'"Replaying and re-blessing goldens")'
|
||||||
|
)
|
||||||
|
return EXIT_MISMATCH
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - the module entry point
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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))
|
||||||
@@ -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), ()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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, ()
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
+2156
File diff suppressed because it is too large
Load Diff
+6010
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "carry_two_player",
|
||||||
|
"description": "Phase 03 task 04: a scripted two-player sequence — one pass (a set-down picked up inside the hand-off window) and one contested pick — reaching the golden state hash after 12 ticks. Regenerate only on a deliberate sim-contract change, never to silence a failure (the re-bless path is formalised by task 05's replay harness).",
|
||||||
|
"tick": 12,
|
||||||
|
"hash": "6952e2a139770605f56ee188f988f3d5cfad044b6b9024578583662793553e00"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
"""`sim/carry.py` + the action gate's carry verbs — the hands, pinned.
|
||||||
|
|
||||||
|
PLAN §5 gives the cook one button and a pair of hands; this file pins what the
|
||||||
|
hands do:
|
||||||
|
|
||||||
|
* **one item per hand** — a second pick while carrying is `HANDS_FULL`, and a press
|
||||||
|
at open floor with a full hand is the ladder's named `NOTHING_HERE` (v1 has no
|
||||||
|
floor drops);
|
||||||
|
* **the set-down** — `RELEASE` / `PLACE_ON_STATION` / `PLATE_DISH` put the item on
|
||||||
|
the fixture, stamp it with a mark, and leave it grabbable the moment it is set
|
||||||
|
down (while an unmarked pipeline input stays the work press's); a pick-and-place
|
||||||
|
round trip preserves the `Item`'s identity and clears the fixture's cell;
|
||||||
|
* **the hand-off** — a pick of a set-down placed by another cook within
|
||||||
|
`HANDOFF_WINDOW_TICKS` (inclusive) is a `HANDED_OFF` with both ids; a self-pick,
|
||||||
|
or a third party's pick outside the window, is a plain `PICKED`;
|
||||||
|
* **the trash** — a long press over the trash can destroys the carried item on the
|
||||||
|
8th substep of the hold and not the 7th; a short press and a walk-away do
|
||||||
|
nothing; a threshold met with an empty hand is `TRASHED_ALREADY_EMPTY`, never a
|
||||||
|
silent no-op; the raw score delta rides the event payload;
|
||||||
|
* **the contested pick** — one winner, one `CONTESTED_LOST` loser, the item count
|
||||||
|
conserved;
|
||||||
|
* **the conservation law** — a 100-tick fuzz of random simultaneous presses from a
|
||||||
|
fixed seed re-checks `hands + stations + trashed == initial` after *every* tick,
|
||||||
|
and runs bit-identical twice.
|
||||||
|
|
||||||
|
The two-player pass-and-contest script with its committed golden hash is
|
||||||
|
`test_replay_carry.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim.carry import (
|
||||||
|
HANDOFF_WINDOW_TICKS,
|
||||||
|
TRASH_HOLD_TICKS,
|
||||||
|
TRASH_SCORE_DELTA,
|
||||||
|
first_loose_entry,
|
||||||
|
pick_item,
|
||||||
|
place_item,
|
||||||
|
trash_effect,
|
||||||
|
)
|
||||||
|
from undercooked.sim.grid import Grid
|
||||||
|
from undercooked.sim.interact import (
|
||||||
|
ItemTarget,
|
||||||
|
StationTarget,
|
||||||
|
action_gate,
|
||||||
|
resolve_action,
|
||||||
|
select_target,
|
||||||
|
)
|
||||||
|
from undercooked.sim.model import (
|
||||||
|
Event,
|
||||||
|
EventKind,
|
||||||
|
Facing,
|
||||||
|
Item,
|
||||||
|
ItemForm,
|
||||||
|
ItemKind,
|
||||||
|
KitchenState,
|
||||||
|
Player,
|
||||||
|
PlayerState,
|
||||||
|
Station,
|
||||||
|
StationKind,
|
||||||
|
StationState,
|
||||||
|
TileKind,
|
||||||
|
Vec2,
|
||||||
|
)
|
||||||
|
from undercooked.sim.rng import Rng
|
||||||
|
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 13, 9
|
||||||
|
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
|
||||||
|
|
||||||
|
|
||||||
|
# --- fixtures ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(
|
||||||
|
stations: tuple[Station, ...] = (),
|
||||||
|
players: tuple[Player, ...] = (),
|
||||||
|
tick: int = 0,
|
||||||
|
) -> KitchenState:
|
||||||
|
return KitchenState(
|
||||||
|
level_id="03_carry",
|
||||||
|
seed=7,
|
||||||
|
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
|
||||||
|
tick=tick,
|
||||||
|
stations=stations,
|
||||||
|
players=players,
|
||||||
|
target_score=40,
|
||||||
|
time_left=90.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cook(
|
||||||
|
player_id: str = "p0",
|
||||||
|
*,
|
||||||
|
pos: Vec2 = DEFAULT_POS,
|
||||||
|
facing: Facing = Facing.UP,
|
||||||
|
carrying: Item | None = None,
|
||||||
|
state: PlayerState = PlayerState.IDLE,
|
||||||
|
seat: int = 0,
|
||||||
|
) -> Player:
|
||||||
|
return Player(
|
||||||
|
id=player_id,
|
||||||
|
pos=pos,
|
||||||
|
facing=facing,
|
||||||
|
carrying=carrying,
|
||||||
|
state=state,
|
||||||
|
seat=seat,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fixture(
|
||||||
|
kind: StationKind = StationKind.STOVE,
|
||||||
|
*,
|
||||||
|
x: int = 5,
|
||||||
|
y: int = 4,
|
||||||
|
state: StationState = StationState.IDLE,
|
||||||
|
contents: tuple[Item, ...] = (),
|
||||||
|
dirty: bool = False,
|
||||||
|
) -> Station:
|
||||||
|
return Station(
|
||||||
|
id=f"{kind.value}-{x}-{y}",
|
||||||
|
kind=kind,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
state=state,
|
||||||
|
contents=contents,
|
||||||
|
dirty=dirty,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
|
||||||
|
return Item(kind=ItemKind.TOMATO, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
def lettuce() -> Item:
|
||||||
|
return Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW)
|
||||||
|
|
||||||
|
|
||||||
|
def rice() -> Item:
|
||||||
|
return Item(kind=ItemKind.RICE, form=ItemForm.RAW)
|
||||||
|
|
||||||
|
|
||||||
|
def frame(player_id: str, seq: int, action: bool, tick: int) -> PlayerInput:
|
||||||
|
return PlayerInput(player_id=player_id, seq=seq, action=action, received_at_tick=tick)
|
||||||
|
|
||||||
|
|
||||||
|
def kinds(events: tuple[Event, ...]) -> list[EventKind]:
|
||||||
|
return [EventKind(event.kind) for event in events]
|
||||||
|
|
||||||
|
|
||||||
|
def payload(event: Event) -> dict[str, Any]:
|
||||||
|
return dict(event.payload)
|
||||||
|
|
||||||
|
|
||||||
|
def total_items(state: KitchenState) -> int:
|
||||||
|
"""The conservation quantity: hands + station contents + trashed."""
|
||||||
|
in_hands = sum(1 for player in state.players if player.carrying is not None)
|
||||||
|
on_stations = sum(len(station.contents) for station in state.stations)
|
||||||
|
trashed = state.counters.get("trashed", 0)
|
||||||
|
return in_hands + on_stations + trashed
|
||||||
|
|
||||||
|
|
||||||
|
# --- grabbability: a set-down is an object, a pipeline input is work ---------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_set_down_item_is_targeted_by_a_free_hand_on_an_idle_surface() -> None:
|
||||||
|
"""The whole of the hand-off: a placed item on a counter is grabbable at once."""
|
||||||
|
prep = fixture(kind=StationKind.PREP, contents=(lettuce(),))
|
||||||
|
state = make_state(stations=(prep,), players=(cook("p0"),))
|
||||||
|
state = replace(state, item_marks={prep.id: (("placerr", 3),)})
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, ItemTarget)
|
||||||
|
assert target.item == lettuce()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unmarked_loaded_surface_holds_its_item_for_the_work_press() -> None:
|
||||||
|
"""An onion on an idle board is *to be chopped*: no mark, not `READY`, not a shelf."""
|
||||||
|
board = fixture(kind=StationKind.CHOP_BOARD, contents=(tomato(),))
|
||||||
|
state = make_state(stations=(board,), players=(cook("p0"),))
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, StationTarget)
|
||||||
|
assert first_loose_entry(state, board) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_ready_unmarked_fixture_hands_its_output_to_a_free_hand() -> None:
|
||||||
|
ready = fixture(
|
||||||
|
kind=StationKind.CHOP_BOARD,
|
||||||
|
state=StationState.READY,
|
||||||
|
contents=(tomato(ItemForm.CHOPPED),),
|
||||||
|
)
|
||||||
|
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, ItemTarget)
|
||||||
|
assert target.item.form is ItemForm.CHOPPED
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shelf_hands_its_entries_whether_marked_or_stock() -> None:
|
||||||
|
"""Shelf entries are all grabbable: stock (unmarked) first, then set-downs."""
|
||||||
|
shelf = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(), lettuce()))
|
||||||
|
state = make_state(stations=(shelf,), players=(cook("p0"),))
|
||||||
|
entry = first_loose_entry(state, shelf)
|
||||||
|
assert entry is not None and entry[0] == tomato() # the unmarked stock entry is first
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, ItemTarget)
|
||||||
|
assert target.item == tomato()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_marked_set_down_stays_grabbable_after_the_placer_moves_away() -> None:
|
||||||
|
"""The mark outlives the press: the object is on the counter, not in a hand."""
|
||||||
|
station = fixture(kind=StationKind.PREP, contents=(lettuce(),))
|
||||||
|
state = make_state(stations=(station,), players=(cook("p0"),))
|
||||||
|
state = replace(state, item_marks={station.id: (("other", 5),)})
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, ItemTarget)
|
||||||
|
assert target.item == lettuce()
|
||||||
|
|
||||||
|
|
||||||
|
# --- one item per hand -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_full_hand_refuses_a_second_pick_with_one_named_denial() -> None:
|
||||||
|
ready = fixture(state=StationState.READY, contents=(tomato(),))
|
||||||
|
state = make_state(stations=(ready,), players=(cook("p0", carrying=lettuce()),))
|
||||||
|
frames = {"p0": frame("p0", 0, True, 0)}
|
||||||
|
_new_state, events = action_gate(state, frames, STEP_DT)
|
||||||
|
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||||
|
assert len(denied) == 1 # one refusal per press, never per tick
|
||||||
|
assert payload(denied[0])["reason"] == "hands_full"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_press_at_open_floor_with_a_full_hand_is_a_named_nothing_here() -> None:
|
||||||
|
"""v1 has no floor drops: a `RELEASE` at a FLOOR cell is `NOTHING_HERE`, not a drop."""
|
||||||
|
state = make_state(players=(cook("p0", carrying=lettuce()),)) # no stations at all
|
||||||
|
new_state, events = resolve_action(state, state.players[0])
|
||||||
|
assert new_state is state
|
||||||
|
assert len(events) == 1
|
||||||
|
assert payload(events[0])["reason"] == "nothing_here"
|
||||||
|
|
||||||
|
|
||||||
|
# --- pick/place round trip --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_and_place_preserve_the_item_and_clear_the_station_cell() -> None:
|
||||||
|
"""The identity fields survive the whole round trip; the cell is cleared on the pick."""
|
||||||
|
item = Item(kind=ItemKind.TOMATO, form=ItemForm.CHOPPED, quality=0.42, ttl=3.5)
|
||||||
|
ready = fixture(state=StationState.READY, contents=(item,))
|
||||||
|
shelf = fixture(kind=StationKind.SPAWN_SHELF, x=8)
|
||||||
|
state = make_state(stations=(ready, shelf), players=(cook("p0"),))
|
||||||
|
new_state, events = resolve_action(state, state.players[0])
|
||||||
|
assert len(events) == 1 and events[0].kind is EventKind.PICKED
|
||||||
|
carried = new_state.players[0].carrying
|
||||||
|
assert carried is item # value identity: the very object left the fixture
|
||||||
|
assert new_state.stations[0].contents == () # the cell is cleared
|
||||||
|
# the set-down: move the cook to the shelf, press again
|
||||||
|
cook_at_shelf = replace(new_state.players[0], pos=Vec2(8.5, 5.5))
|
||||||
|
new_state2, events2 = resolve_action(new_state, cook_at_shelf)
|
||||||
|
assert len(events2) == 1 and events2[0].kind is EventKind.DROPPED
|
||||||
|
assert new_state2.players[0].carrying is None
|
||||||
|
assert new_state2.stations[1].contents == (item,) # the same object, back on a fixture
|
||||||
|
assert item.quality == 0.42 and item.ttl == 3.5 # the fields never move
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[StationKind.CHOP_BOARD, StationKind.PREP, StationKind.SPAWN_SHELF],
|
||||||
|
ids=["place_on_station", "plate_dish", "release"],
|
||||||
|
)
|
||||||
|
def test_place_verbs_put_the_item_on_the_counter_and_stamp_the_mark(
|
||||||
|
kind: StationKind,
|
||||||
|
) -> None:
|
||||||
|
"""`PLACE_ON_STATION` (board), `PLATE_DISH` (the counter) and `RELEASE` (the shelf)."""
|
||||||
|
held = lettuce()
|
||||||
|
station = fixture(kind=kind)
|
||||||
|
state = make_state(stations=(station,), players=(cook("p0", carrying=held),))
|
||||||
|
new_state, events = resolve_action(state, state.players[0])
|
||||||
|
assert len(events) == 1 and events[0].kind is EventKind.DROPPED
|
||||||
|
assert new_state.players[0].carrying is None
|
||||||
|
assert new_state.stations[0].contents == (held,)
|
||||||
|
# the mark: who put it down, at which tick — the hand-off's whole memory
|
||||||
|
assert new_state.item_marks[station.id] == (("p0", 0),)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the hand-off -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _pass_kitchen() -> KitchenState:
|
||||||
|
"""A at the prep, B at its left diagonal, both facing the counter; A holds an item."""
|
||||||
|
prep = fixture(kind=StationKind.PREP, x=9, y=4)
|
||||||
|
a = cook("cook-a", pos=Vec2(9.5, 5.5), carrying=lettuce(), seat=0)
|
||||||
|
b = cook("cook-b", pos=Vec2(8.5, 5.5), seat=1)
|
||||||
|
return make_state(stations=(prep,), players=(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pass_is_a_handed_off_with_both_ids() -> None:
|
||||||
|
state = _pass_kitchen()
|
||||||
|
# t0: A sets the item down (the marked place)
|
||||||
|
state, events = step(state, STEP_DT, (frame("cook-a", 0, True, 0),))
|
||||||
|
assert kinds(events) == [EventKind.DROPPED]
|
||||||
|
assert payload(events[0])["player_id"] == "cook-a"
|
||||||
|
# t1: A releases; B picks it up -> the sanctioned pass
|
||||||
|
state, events = step(
|
||||||
|
state,
|
||||||
|
STEP_DT,
|
||||||
|
(frame("cook-a", 1, False, 1), frame("cook-b", 1, True, 1)),
|
||||||
|
)
|
||||||
|
handed = [event for event in events if event.kind is EventKind.HANDED_OFF]
|
||||||
|
assert len(handed) == 1
|
||||||
|
assert payload(handed[0]) == {
|
||||||
|
"from": "cook-a",
|
||||||
|
"to": "cook-b",
|
||||||
|
"item": "lettuce",
|
||||||
|
"form": "raw",
|
||||||
|
}
|
||||||
|
assert state.players[1].carrying is not None
|
||||||
|
assert state.players[0].carrying is None
|
||||||
|
assert state.stations[0].contents == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_hand_off_window_is_inclusive_at_the_twentieth_tick() -> None:
|
||||||
|
station = fixture(kind=StationKind.PREP)
|
||||||
|
a = cook("cook-a", carrying=lettuce())
|
||||||
|
b = cook("cook-b", seat=1)
|
||||||
|
state = make_state(stations=(station,), players=(a, b))
|
||||||
|
held = a.carrying
|
||||||
|
assert held is not None
|
||||||
|
placed, _events = place_item(state, a, station, held)
|
||||||
|
at_boundary = placed.with_tick(HANDOFF_WINDOW_TICKS)
|
||||||
|
_new_state, events = pick_item(at_boundary, b, station, lettuce())
|
||||||
|
assert kinds(events) == [EventKind.HANDED_OFF]
|
||||||
|
assert payload(events[0]) == {
|
||||||
|
"from": "cook-a",
|
||||||
|
"to": "cook-b",
|
||||||
|
"item": "lettuce",
|
||||||
|
"form": "raw",
|
||||||
|
}
|
||||||
|
just_after = placed.with_tick(HANDOFF_WINDOW_TICKS + 1)
|
||||||
|
_new_state, events = pick_item(just_after, b, station, lettuce())
|
||||||
|
assert kinds(events) == [EventKind.PICKED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_placer_picking_their_own_item_is_a_plain_pick() -> None:
|
||||||
|
station = fixture(kind=StationKind.PREP)
|
||||||
|
a = cook("cook-a", carrying=lettuce())
|
||||||
|
state = make_state(stations=(station,), players=(a,))
|
||||||
|
held = a.carrying
|
||||||
|
assert held is not None
|
||||||
|
placed, _events = place_item(state, a, station, held)
|
||||||
|
new_state, events = pick_item(placed.with_tick(1), a, station, lettuce())
|
||||||
|
assert kinds(events) == [EventKind.PICKED]
|
||||||
|
assert new_state.players[0].carrying is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_third_party_pick_outside_the_window_is_a_plain_pick() -> None:
|
||||||
|
"""A's set-down, C's pick one tick past the window: a pick, never a pass."""
|
||||||
|
station = fixture(kind=StationKind.PREP)
|
||||||
|
a = cook("cook-a", carrying=lettuce())
|
||||||
|
c = cook("cook-c", seat=2)
|
||||||
|
state = make_state(stations=(station,), players=(a, c))
|
||||||
|
held = a.carrying
|
||||||
|
assert held is not None
|
||||||
|
placed, _events = place_item(state, a, station, held)
|
||||||
|
late = placed.with_tick(HANDOFF_WINDOW_TICKS + 1)
|
||||||
|
new_state, events = pick_item(late, c, station, lettuce())
|
||||||
|
assert kinds(events) == [EventKind.PICKED]
|
||||||
|
assert payload(events[0])["player_id"] == "cook-c"
|
||||||
|
assert new_state.players[1].carrying is not None
|
||||||
|
assert EventKind.HANDED_OFF not in kinds(events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_same_tick_pass_is_inside_the_window() -> None:
|
||||||
|
"""Place and pick in the same substep (seat order): the pass, at zero ticks old."""
|
||||||
|
station = fixture(kind=StationKind.PREP)
|
||||||
|
a = cook("cook-a", carrying=lettuce(), seat=0)
|
||||||
|
b = cook("cook-b", seat=1)
|
||||||
|
state = make_state(stations=(station,), players=(a, b))
|
||||||
|
frames = {"cook-a": frame("cook-a", 0, True, 0), "cook-b": frame("cook-b", 0, True, 0)}
|
||||||
|
new_state, events = action_gate(state, frames, STEP_DT)
|
||||||
|
assert kinds(events) == [EventKind.DROPPED, EventKind.HANDED_OFF]
|
||||||
|
assert payload(events[1]) == {
|
||||||
|
"from": "cook-a",
|
||||||
|
"to": "cook-b",
|
||||||
|
"item": "lettuce",
|
||||||
|
"form": "raw",
|
||||||
|
}
|
||||||
|
assert new_state.players[1].carrying is not None
|
||||||
|
assert new_state.players[0].carrying is None
|
||||||
|
assert new_state.stations[0].contents == ()
|
||||||
|
|
||||||
|
|
||||||
|
# --- the trash ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _trash_kitchen() -> KitchenState:
|
||||||
|
trash = fixture(kind=StationKind.TRASH)
|
||||||
|
return make_state(stations=(trash,), players=(cook("p0", carrying=lettuce()),))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_trash_long_press_destroys_on_the_eighth_substep_not_the_seventh() -> None:
|
||||||
|
state = _trash_kitchen()
|
||||||
|
for substep in range(TRASH_HOLD_TICKS - 1): # substeps 1..7: the hold builds
|
||||||
|
state, events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),))
|
||||||
|
assert state.players[0].carrying is not None
|
||||||
|
assert EventKind.TRASHED not in kinds(events)
|
||||||
|
# the 8th substep of the hold: the item goes
|
||||||
|
state, events = step(
|
||||||
|
state, STEP_DT, (frame("p0", TRASH_HOLD_TICKS - 1, True, state.tick),)
|
||||||
|
)
|
||||||
|
trashed = [event for event in events if event.kind is EventKind.TRASHED]
|
||||||
|
assert len(trashed) == 1
|
||||||
|
assert payload(trashed[0]) == {
|
||||||
|
"player_id": "p0",
|
||||||
|
"item": "lettuce",
|
||||||
|
"form": "raw",
|
||||||
|
"score_delta": TRASH_SCORE_DELTA,
|
||||||
|
}
|
||||||
|
assert state.players[0].carrying is None
|
||||||
|
assert state.counters["trashed"] == 1
|
||||||
|
assert state.score == 0 # the raw delta is phase 04's to spend (clamped at 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_press_does_not_trash() -> None:
|
||||||
|
state = _trash_kitchen()
|
||||||
|
for substep in range(3):
|
||||||
|
state, _events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),))
|
||||||
|
state, _events = step(state, STEP_DT, (frame("p0", 3, False, state.tick),))
|
||||||
|
assert state.players[0].carrying is not None
|
||||||
|
assert "trashed" not in state.counters
|
||||||
|
|
||||||
|
|
||||||
|
def test_releasing_before_the_threshold_starts_a_fresh_hold() -> None:
|
||||||
|
state = _trash_kitchen()
|
||||||
|
plan = (True, True, True, True, True, False) + (True,) * TRASH_HOLD_TICKS
|
||||||
|
for substep, action in enumerate(plan):
|
||||||
|
state, events = step(state, STEP_DT, (frame("p0", substep, action, state.tick),))
|
||||||
|
if substep < len(plan) - 1:
|
||||||
|
assert state.players[0].carrying is not None
|
||||||
|
assert EventKind.TRASHED not in kinds(events)
|
||||||
|
assert state.players[0].carrying is None # the 8th substep of the *second* hold
|
||||||
|
assert state.counters["trashed"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_walking_away_from_the_trash_voids_the_hold() -> None:
|
||||||
|
"""The gate re-derives the hold: off the can, the button is no longer `TRASH`."""
|
||||||
|
state = _trash_kitchen()
|
||||||
|
for substep in range(TRASH_HOLD_TICKS): # the hold's 8 substeps, walking right
|
||||||
|
walking = PlayerInput(
|
||||||
|
player_id="p0",
|
||||||
|
seq=substep,
|
||||||
|
action=True,
|
||||||
|
move=Vec2(1.0, 0.0),
|
||||||
|
received_at_tick=state.tick,
|
||||||
|
)
|
||||||
|
state, events = step(state, STEP_DT, (walking,))
|
||||||
|
assert EventKind.TRASHED not in kinds(events)
|
||||||
|
for substep in range(TRASH_HOLD_TICKS, TRASH_HOLD_TICKS + 6):
|
||||||
|
state, events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),))
|
||||||
|
assert EventKind.TRASHED not in kinds(events)
|
||||||
|
assert state.players[0].carrying is not None # the hold was voided, nothing trashed
|
||||||
|
assert "trashed" not in state.counters
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_trash_threshold_with_an_empty_hand_is_named_not_silent() -> None:
|
||||||
|
"""Defensive path: the threshold met with an empty hand is an event, not a no-op."""
|
||||||
|
trash = fixture(kind=StationKind.TRASH)
|
||||||
|
state = make_state(stations=(trash,), players=(cook("p0"),)) # nothing carried
|
||||||
|
before = state.state_hash()
|
||||||
|
new_state, events = trash_effect(state, state.players[0])
|
||||||
|
assert kinds(events) == [EventKind.TRASHED_ALREADY_EMPTY]
|
||||||
|
assert payload(events[0]) == {"player_id": "p0"}
|
||||||
|
assert new_state is state # nothing moved
|
||||||
|
assert state.state_hash() == before
|
||||||
|
|
||||||
|
|
||||||
|
# --- contested picks (the new paths, conserved) ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _contested_kitchen() -> tuple[KitchenState, dict[str, PlayerInput]]:
|
||||||
|
"""Two cooks on one ready item: seat 0 in front, seat 1 on the left diagonal."""
|
||||||
|
ready = fixture(state=StationState.READY, contents=(tomato(),))
|
||||||
|
state = make_state(
|
||||||
|
stations=(ready,),
|
||||||
|
players=(
|
||||||
|
cook("cook-a", seat=0), # (5, 5) facing UP: front cell (5, 4)
|
||||||
|
cook("cook-b", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
frames = {
|
||||||
|
"cook-a": frame("cook-a", 0, True, 0),
|
||||||
|
"cook-b": frame("cook-b", 0, True, 0),
|
||||||
|
}
|
||||||
|
return state, frames
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_contested_pick_has_exactly_one_winner_and_conserves_items() -> None:
|
||||||
|
state, frames = _contested_kitchen()
|
||||||
|
initial = total_items(state)
|
||||||
|
new_state, events = action_gate(state, frames, STEP_DT)
|
||||||
|
picked = [event for event in events if event.kind is EventKind.PICKED]
|
||||||
|
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||||
|
assert len(picked) == 1 and len(denied) == 1
|
||||||
|
assert payload(picked[0])["player_id"] == "cook-a" # the full tie goes to the seat
|
||||||
|
assert payload(denied[0]) == {
|
||||||
|
"player_id": "cook-b",
|
||||||
|
"reason": "contested_lost",
|
||||||
|
"verb": "none",
|
||||||
|
}
|
||||||
|
assert new_state.players[0].carrying is not None
|
||||||
|
assert new_state.players[1].carrying is None
|
||||||
|
assert new_state.stations[0].contents == ()
|
||||||
|
assert total_items(new_state) == initial # the item is in exactly one place
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_earlier_received_frame_wins_regardless_of_seat() -> None:
|
||||||
|
state, frames = _contested_kitchen()
|
||||||
|
frames["cook-a"] = frame("cook-a", 0, True, 1) # seat 0 arrives one tick later
|
||||||
|
new_state, _events = action_gate(state, frames, STEP_DT)
|
||||||
|
assert new_state.players[1].carrying is not None # the earlier frame wins
|
||||||
|
assert new_state.players[0].carrying is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_contested_start_work_claims_the_worker_slot() -> None:
|
||||||
|
"""A loaded unmarked board: the single worker slot is the claim, not an item."""
|
||||||
|
board = fixture(kind=StationKind.CHOP_BOARD, contents=(tomato(),))
|
||||||
|
a = cook("cook-a", seat=0)
|
||||||
|
b = cook("cook-b", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1)
|
||||||
|
state = make_state(stations=(board,), players=(a, b))
|
||||||
|
frames = {"cook-a": frame("cook-a", 0, True, 0), "cook-b": frame("cook-b", 0, True, 0)}
|
||||||
|
new_state, events = action_gate(state, frames, STEP_DT)
|
||||||
|
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||||
|
assert len(denied) == 1
|
||||||
|
assert payload(denied[0]) == {
|
||||||
|
"player_id": "cook-b",
|
||||||
|
"reason": "contested_lost",
|
||||||
|
"verb": "none",
|
||||||
|
}
|
||||||
|
assert new_state.stations[0].contents == (tomato(),) # the input stays for the work
|
||||||
|
|
||||||
|
|
||||||
|
# --- the conservation law: 100-tick fuzz -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
DIRECTIONS: tuple[Vec2, ...] = (
|
||||||
|
Vec2(0.0, 0.0),
|
||||||
|
Vec2(1.0, 0.0),
|
||||||
|
Vec2(-1.0, 0.0),
|
||||||
|
Vec2(0.0, 1.0),
|
||||||
|
Vec2(0.0, -1.0),
|
||||||
|
Vec2(1.0, 1.0),
|
||||||
|
Vec2(1.0, -1.0),
|
||||||
|
Vec2(-1.0, 1.0),
|
||||||
|
Vec2(-1.0, -1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fuzz_kitchen() -> KitchenState:
|
||||||
|
"""Three cooks, one carried item each, at a shelf / prep / trash in a row."""
|
||||||
|
shelf = fixture(kind=StationKind.SPAWN_SHELF, x=5)
|
||||||
|
prep = fixture(kind=StationKind.PREP, x=7)
|
||||||
|
trash = fixture(kind=StationKind.TRASH, x=9)
|
||||||
|
state = make_state(
|
||||||
|
stations=(shelf, prep, trash),
|
||||||
|
players=(
|
||||||
|
cook("p0", pos=Vec2(5.5, 5.5), carrying=tomato(), seat=0),
|
||||||
|
cook("p1", pos=Vec2(7.5, 5.5), carrying=lettuce(), seat=1),
|
||||||
|
cook("p2", pos=Vec2(9.5, 5.5), carrying=rice(), seat=2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert total_items(state) == 3
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _fuzz_run(seed: int, ticks: int = 100) -> tuple[KitchenState, list[str]]:
|
||||||
|
"""One fuzz run: random move + random press per cook per tick, from `seed`."""
|
||||||
|
rng = Rng(seed)
|
||||||
|
state = _fuzz_kitchen()
|
||||||
|
hashes: list[str] = []
|
||||||
|
for tick in range(ticks):
|
||||||
|
frames = tuple(
|
||||||
|
PlayerInput(
|
||||||
|
player_id=player.id,
|
||||||
|
seq=tick,
|
||||||
|
move=rng.choice(DIRECTIONS),
|
||||||
|
action=rng.next_float() < 0.35,
|
||||||
|
received_at_tick=state.tick,
|
||||||
|
)
|
||||||
|
for player in state.players
|
||||||
|
)
|
||||||
|
state, _events = step(state, STEP_DT, frames)
|
||||||
|
assert total_items(state) == 3, f"item count lost at tick {state.tick}"
|
||||||
|
hashes.append(state.state_hash())
|
||||||
|
return state, hashes
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_item_count_is_conserved_over_a_hundred_tick_fuzz() -> None:
|
||||||
|
"""The per-tick assert is the point: a leak mid-run would surface immediately."""
|
||||||
|
state, _hashes = _fuzz_run(0xC0FFEE)
|
||||||
|
assert total_items(state) == 3 # and at the end, as the task requires
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_fuzz_is_bit_identical_across_two_runs() -> None:
|
||||||
|
first, first_hashes = _fuzz_run(0xC0FFEE)
|
||||||
|
second, second_hashes = _fuzz_run(0xC0FFEE)
|
||||||
|
assert first_hashes == second_hashes # the whole history, tick for tick
|
||||||
|
assert first.state_hash() == second.state_hash()
|
||||||
@@ -0,0 +1,881 @@
|
|||||||
|
"""`sim/collision.py` + `sim/movement.py` — the two promises walking makes (PLAN §5).
|
||||||
|
|
||||||
|
Everything here is table-driven against hand-computed geometry, because "it moved" is
|
||||||
|
not a specification. The numbers come from the shapes, not from the code: a cook is a
|
||||||
|
square of half-width `RADIUS` = 0.35 and a tile is 1.0 wide, so a cook pressed against
|
||||||
|
the wall cell at x=6 sits with its centre at `6 - 0.35 - CONTACT_EPS` = 5.6499. If the
|
||||||
|
implementation ever says otherwise, one of those two is wrong.
|
||||||
|
|
||||||
|
The property groups:
|
||||||
|
|
||||||
|
* **terrain** — walk into every `TileKind` (and off the map), and stop flush;
|
||||||
|
* **speed** — the ceiling, the ramp, the harder brake, no diagonal boost, and the
|
||||||
|
server-side clamp that makes a `move` of 50 worth exactly a `move` of 1;
|
||||||
|
* **other cooks** — the symmetric soft push (two cooks and a chain of four), no
|
||||||
|
velocity injection, the transfer of what a wall refuses, and the deadlock revert;
|
||||||
|
* **stun** — pinned, one event, expires;
|
||||||
|
* **facing** — the last non-zero intent, a press that never turns you, an explicit turn.
|
||||||
|
|
||||||
|
The invariant-only version of all this, over thousands of ticks of nonsense input, is
|
||||||
|
`test_movement_stress.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import replace
|
||||||
|
from itertools import pairwise
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim import movement
|
||||||
|
from undercooked.sim.collision import (
|
||||||
|
AXIS_X,
|
||||||
|
AXIS_Y,
|
||||||
|
CONTACT_EPS,
|
||||||
|
PASS_THROUGH_STATIONS,
|
||||||
|
body_blocker,
|
||||||
|
body_cells,
|
||||||
|
cell_blocked,
|
||||||
|
station_body_cells,
|
||||||
|
sweep_axis,
|
||||||
|
)
|
||||||
|
from undercooked.sim.grid import Grid
|
||||||
|
from undercooked.sim.model import (
|
||||||
|
EventKind,
|
||||||
|
Facing,
|
||||||
|
Hazard,
|
||||||
|
HazardKind,
|
||||||
|
KitchenState,
|
||||||
|
Player,
|
||||||
|
PlayerState,
|
||||||
|
Station,
|
||||||
|
StationKind,
|
||||||
|
TileKind,
|
||||||
|
Vec2,
|
||||||
|
)
|
||||||
|
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, step
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
# --- fixtures ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LEGEND: dict[str, TileKind] = {
|
||||||
|
".": TileKind.FLOOR,
|
||||||
|
"#": TileKind.WALL,
|
||||||
|
"=": TileKind.COUNTER,
|
||||||
|
"o": TileKind.VOID,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 13x5, interior x 1..11 / y 1..3, walls all round.
|
||||||
|
ROOM = (
|
||||||
|
"#############",
|
||||||
|
"#...........#",
|
||||||
|
"#...........#",
|
||||||
|
"#...........#",
|
||||||
|
"#############",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 21x21: big enough that nothing touches an edge for the first twenty ticks of a
|
||||||
|
#: full-speed run from the centre.
|
||||||
|
FIELD = tuple(["#" * 21] + ["#" + "." * 19 + "#"] * 19 + ["#" * 21])
|
||||||
|
|
||||||
|
#: half-width + the epsilon a body stops short of a blocked cell by.
|
||||||
|
FLUSH = movement.RADIUS + CONTACT_EPS
|
||||||
|
|
||||||
|
TWO_TILES = 2 * movement.RADIUS
|
||||||
|
|
||||||
|
|
||||||
|
def grid(rows: Sequence[str], legend: dict[str, TileKind] | None = None) -> Grid:
|
||||||
|
return Grid.from_rows(rows, legend or LEGEND)
|
||||||
|
|
||||||
|
|
||||||
|
def kitchen(
|
||||||
|
players: Sequence[Player],
|
||||||
|
rows: Sequence[str] = ROOM,
|
||||||
|
stations: Sequence[Station] = (),
|
||||||
|
legend: dict[str, TileKind] | None = None,
|
||||||
|
) -> KitchenState:
|
||||||
|
return KitchenState(
|
||||||
|
level_id="movement",
|
||||||
|
seed=1,
|
||||||
|
grid=grid(rows, legend),
|
||||||
|
stations=tuple(stations),
|
||||||
|
players=tuple(players),
|
||||||
|
time_left=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cook(pid: str, x: float, y: float, seat: int = 0, **overrides: Any) -> Player:
|
||||||
|
return replace(Player(id=pid, pos=Vec2(x, y), seat=seat), **overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def press(
|
||||||
|
pid: str,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
*,
|
||||||
|
seq: int = 1,
|
||||||
|
received: int = 0,
|
||||||
|
action: bool = False,
|
||||||
|
face: Facing | None = None,
|
||||||
|
) -> PlayerInput:
|
||||||
|
return PlayerInput(
|
||||||
|
player_id=pid,
|
||||||
|
seq=seq,
|
||||||
|
move=Vec2(x, y),
|
||||||
|
action=action,
|
||||||
|
face=face,
|
||||||
|
received_at_tick=received,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def walked(
|
||||||
|
state: KitchenState, frames: dict[str, PlayerInput], ticks: int, dt: float = STEP_DT
|
||||||
|
) -> KitchenState:
|
||||||
|
"""`state` after `ticks` substeps of `frames`."""
|
||||||
|
current = state
|
||||||
|
for _ in range(ticks):
|
||||||
|
current, _events = movement.step_movement(current, dt, frames)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def occupied_cells(state: KitchenState, index: int = 0) -> tuple[tuple[int, int], ...]:
|
||||||
|
return body_cells(state.players[index].pos, movement.RADIUS)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_body_in_terrain(state: KitchenState) -> None:
|
||||||
|
"""The core guarantee, asserted as geometry instead of as a remembered coordinate."""
|
||||||
|
blocked = body_blocker(state.grid, state.stations)
|
||||||
|
for player in state.players:
|
||||||
|
assert not any(blocked(x, y) for x, y in body_cells(player.pos, movement.RADIUS))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- collision.cell_blocked -------------------------------------------------------------
|
||||||
|
|
||||||
|
KINDS = ("#####", "#.=o#", "#####")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("x", "y", "expected"),
|
||||||
|
[
|
||||||
|
(1, 1, False), # the only walkable cell
|
||||||
|
(2, 1, True), # counter
|
||||||
|
(3, 1, True), # void: blocked, never a fall (PLAN §5)
|
||||||
|
(1, 0, True), # wall above
|
||||||
|
(1, 2, True), # wall below
|
||||||
|
(0, 1, True), # wall left
|
||||||
|
(4, 1, True), # wall right
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_cell_blocked_per_tile_kind(x: int, y: int, expected: bool) -> None:
|
||||||
|
assert cell_blocked(grid(KINDS), x, y) is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("x", "y"), [(-1, 1), (5, 1), (1, -1), (1, 5), (99, 99)])
|
||||||
|
def test_out_of_bounds_is_blocked(x: int, y: int) -> None:
|
||||||
|
assert cell_blocked(grid(KINDS), x, y) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_blocked_uses_row_major_cell_indices() -> None:
|
||||||
|
board = grid(KINDS)
|
||||||
|
assert cell_blocked(board, 1, 1, frozenset()) is False
|
||||||
|
assert cell_blocked(board, 1, 1, frozenset({1 * board.width + 1})) is True
|
||||||
|
# an index for a cell that is blocked anyway changes nothing
|
||||||
|
assert cell_blocked(board, 3, 1, frozenset({3})) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- collision.station_body_cells / body_blocker ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[StationKind.SPAWN_SHELF, StationKind.CHOP_BOARD, StationKind.STOVE, StationKind.PREP],
|
||||||
|
)
|
||||||
|
def test_fixture_stations_block_their_tile(kind: StationKind) -> None:
|
||||||
|
assert station_body_cells(Station(id=f"{kind.value}_0", kind=kind, x=4, y=2)) == ((4, 2),)
|
||||||
|
|
||||||
|
|
||||||
|
def ordered(kinds: frozenset[StationKind]) -> list[StationKind]:
|
||||||
|
"""The pass-through set as a stable list — a set has no order a test can rely on."""
|
||||||
|
return sorted(kinds, key=lambda kind: kind.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ordered(PASS_THROUGH_STATIONS))
|
||||||
|
def test_pass_through_stations_block_nothing(kind: StationKind) -> None:
|
||||||
|
assert kind in {StationKind.SERVE, StationKind.SINK, StationKind.TRASH}
|
||||||
|
assert station_body_cells(Station(id=f"{kind.value}_0", kind=kind, x=4, y=2)) == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_blocker_layers_terrain_fixtures_and_exclusions() -> None:
|
||||||
|
board = grid(("#####", "#...#", "#####"))
|
||||||
|
shelf = Station(id="chop_board_0", kind=StationKind.CHOP_BOARD, x=2, y=1)
|
||||||
|
blocked = body_blocker(board, [shelf])
|
||||||
|
assert blocked(2, 1) is True # floor, but a board stands on it
|
||||||
|
assert blocked(1, 1) is False # plain floor
|
||||||
|
assert blocked(0, 1) is True # terrain
|
||||||
|
assert blocked(2, 0) is True # terrain
|
||||||
|
|
||||||
|
carrying = body_blocker(board, [shelf], frozenset({board.width + 2}))
|
||||||
|
assert carrying(2, 1) is False # the cook holding the board walks over it (phase 10)
|
||||||
|
assert carrying(2, 0) is True # `movable_cells` never opens terrain
|
||||||
|
|
||||||
|
|
||||||
|
# --- collision.body_cells ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("pos", "expected"),
|
||||||
|
[
|
||||||
|
(Vec2(2.5, 2.5), [(2, 2)]),
|
||||||
|
(Vec2(3.0, 3.0), [(2, 2), (3, 2), (2, 3), (3, 3)]), # a corner touches four cells
|
||||||
|
(Vec2(6 - FLUSH, 2.5), [(5, 2)]), # flush left of x=6 claims only cell x=5
|
||||||
|
(Vec2(2.5, 6 - FLUSH), [(2, 5)]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_body_cells(pos: Vec2, expected: list[tuple[int, int]]) -> None:
|
||||||
|
assert body_cells(pos, movement.RADIUS) == tuple(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_small_body_stays_in_one_cell() -> None:
|
||||||
|
assert body_cells(Vec2(3.5, 3.5), 0.1) == ((3, 3),)
|
||||||
|
|
||||||
|
|
||||||
|
# --- collision.sweep_axis ---------------------------------------------------------------
|
||||||
|
|
||||||
|
SWEEP_MAP = (
|
||||||
|
"########",
|
||||||
|
"#......#",
|
||||||
|
"#..#...#",
|
||||||
|
"#......#",
|
||||||
|
"########",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("axis", [AXIS_X, AXIS_Y])
|
||||||
|
def test_a_sweep_of_nothing_moves_nothing(axis: str) -> None:
|
||||||
|
blocked = body_blocker(grid(SWEEP_MAP))
|
||||||
|
assert sweep_axis(Vec2(2.5, 2.5), 0.0, movement.RADIUS, blocked, axis) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("pos", "delta", "axis", "expected"),
|
||||||
|
[
|
||||||
|
# a free corridor: the whole move is legal, both directions
|
||||||
|
(Vec2(1.5, 3.5), 0.25, AXIS_X, 0.25),
|
||||||
|
(Vec2(5.5, 3.5), -0.25, AXIS_X, -0.25),
|
||||||
|
# the pillar at (3,2) stops a rightward move one epsilon short of x=3
|
||||||
|
(Vec2(1.5, 2.5), 3.0, AXIS_X, 3.0 - 1.85 - CONTACT_EPS),
|
||||||
|
# the same pillar met from the right stops one epsilon right of x=4
|
||||||
|
(Vec2(5.5, 2.5), -3.0, AXIS_X, 4.0 + CONTACT_EPS - 5.15),
|
||||||
|
# down into the floor wall at y=4, up into the one at y=0
|
||||||
|
(Vec2(1.5, 1.5), 3.0, AXIS_Y, 4.0 - 1.85 - CONTACT_EPS),
|
||||||
|
(Vec2(1.5, 1.5), -3.0, AXIS_Y, 1.0 + CONTACT_EPS - 1.15),
|
||||||
|
# already flush: no room on that axis, and no bounce back either
|
||||||
|
(Vec2(3 - FLUSH, 2.5), 0.5, AXIS_X, 0.0),
|
||||||
|
(Vec2(4 + FLUSH, 2.5), -0.5, AXIS_X, 0.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_sweep_axis_limits(pos: Vec2, delta: float, axis: str, expected: float) -> None:
|
||||||
|
allowed = sweep_axis(pos, delta, movement.RADIUS, body_blocker(grid(SWEEP_MAP)), axis)
|
||||||
|
assert allowed == pytest.approx(expected, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_huge_sweep_never_crosses_a_one_tile_wall() -> None:
|
||||||
|
"""50 tiles in one call is still stopped by the first wall: every cell on the path is tried."""
|
||||||
|
blocked = body_blocker(grid(SWEEP_MAP))
|
||||||
|
assert sweep_axis(Vec2(1.5, 2.5), 50.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx(
|
||||||
|
1.1499, abs=1e-9
|
||||||
|
)
|
||||||
|
assert sweep_axis(Vec2(6.5, 2.5), -50.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx(
|
||||||
|
-2.1499, abs=1e-9
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sweep_cannot_push_a_body_out_of_the_map() -> None:
|
||||||
|
"""The edge of the map stops a body the way a wall does: its trail edge at `+EPS`."""
|
||||||
|
blocked = body_blocker(grid(("...", "...", "...")))
|
||||||
|
assert sweep_axis(Vec2(1.5, 1.5), -9.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx(
|
||||||
|
CONTACT_EPS - 1.15, abs=1e-9
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- terrain: walking into everything ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kind", "blocked_cell", "expected_cell"),
|
||||||
|
[
|
||||||
|
(TileKind.FLOOR, 5, (4, 1)),
|
||||||
|
(TileKind.WALL, 4, (3, 1)),
|
||||||
|
(TileKind.COUNTER, 4, (3, 1)),
|
||||||
|
(TileKind.VOID, 4, (3, 1)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_walking_into_each_tile_kind(
|
||||||
|
kind: TileKind, blocked_cell: int, expected_cell: tuple[int, int]
|
||||||
|
) -> None:
|
||||||
|
"""One tile of each kind in the path: the cook stops flush against it, never inside it.
|
||||||
|
|
||||||
|
"Flush" is `blocked_cell - RADIUS` to within `CONTACT_EPS`: a sweep stops a body one
|
||||||
|
epsilon short of the blocked cell, but when the moving edge lands exactly *on* the
|
||||||
|
boundary the body is already flush and no clamp is needed at all.
|
||||||
|
"""
|
||||||
|
state = kitchen(
|
||||||
|
[cook("p0", 1.5, 1.5)],
|
||||||
|
rows=["######", "#...K#", "######"],
|
||||||
|
legend={**LEGEND, "K": kind},
|
||||||
|
)
|
||||||
|
out = walked(state, {"p0": press("p0", 1, 0)}, 60)
|
||||||
|
assert out.players[0].pos.x == pytest.approx(blocked_cell - movement.RADIUS, abs=CONTACT_EPS)
|
||||||
|
assert out.players[0].pos.y == pytest.approx(1.5)
|
||||||
|
assert occupied_cells(out) == (expected_cell,)
|
||||||
|
assert_no_body_in_terrain(out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_walk_stops_flush_against_the_room_wall() -> None:
|
||||||
|
state = kitchen([cook("p0", 2.5, 2.5)], rows=ROOM)
|
||||||
|
out = walked(state, {"p0": press("p0", 1, 0)}, 120)
|
||||||
|
assert out.players[0].pos.x == pytest.approx(12 - movement.RADIUS, abs=CONTACT_EPS)
|
||||||
|
assert occupied_cells(out) == ((11, 2),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_corner_catch_slides_instead_of_sticking() -> None:
|
||||||
|
"""A fixture column in the path: X is refused, Y keeps going, and the cook goes around.
|
||||||
|
|
||||||
|
The assertion that matters is per-substep: whenever the pillar is level with the
|
||||||
|
cook, the cook is never *inside* it — which is exactly the corner case a
|
||||||
|
centre-only collision test gets wrong.
|
||||||
|
"""
|
||||||
|
state = kitchen(
|
||||||
|
[cook("p0", 1 + FLUSH, 2.5)],
|
||||||
|
rows=("#######", "#.....#", "#.#...#", "#.#...#", "#.....#", "#######"),
|
||||||
|
)
|
||||||
|
current = state
|
||||||
|
for tick in range(30):
|
||||||
|
current, _events = movement.step_movement(
|
||||||
|
current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)}
|
||||||
|
)
|
||||||
|
assert_no_body_in_terrain(current)
|
||||||
|
level_with_pillar = any(y in (2, 3) for _x, y in occupied_cells(current))
|
||||||
|
if level_with_pillar:
|
||||||
|
assert current.players[0].pos.x <= 2 - movement.RADIUS + CONTACT_EPS
|
||||||
|
out = current.players[0]
|
||||||
|
# slid along the wall instead of into it, and stopped flush with the bottom
|
||||||
|
assert out.pos.y == pytest.approx(5 - movement.RADIUS, abs=CONTACT_EPS)
|
||||||
|
assert out.pos.x > 2.0 # and carried on right once the pillar was behind
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"direction",
|
||||||
|
[(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, 1)],
|
||||||
|
ids=["west", "east", "north", "south", "north-west", "south-east"],
|
||||||
|
)
|
||||||
|
def test_out_of_bounds_containment_in_every_direction(direction: tuple[int, int]) -> None:
|
||||||
|
"""A map with no walls at all: the edge is a wall for a cook, in every direction."""
|
||||||
|
state = kitchen([cook("p0", 2.0, 1.5)], rows=("....", "....", "...."))
|
||||||
|
out = walked(state, {"p0": press("p0", *direction)}, 60).players[0]
|
||||||
|
assert out.pos.x - movement.RADIUS >= -CONTACT_EPS
|
||||||
|
assert out.pos.x + movement.RADIUS <= 4.0 + CONTACT_EPS
|
||||||
|
assert out.pos.y - movement.RADIUS >= -CONTACT_EPS
|
||||||
|
assert out.pos.y + movement.RADIUS <= 3.0 + CONTACT_EPS
|
||||||
|
assert_no_body_in_terrain(replace(state, players=(out,)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_speed_cannot_cross_a_one_tile_wall(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Ceiling doubled and the coarsest legal dt: the sweep still stops the cook flush.
|
||||||
|
|
||||||
|
The per-substep displacement (7 tiles/s x 0.1 s = 0.7) is 70 % of a tile, so a naive
|
||||||
|
"is the destination cell walkable" test would land the cook on the wall's far edge.
|
||||||
|
`sweep_axis` tests every cell between the old and the new edge instead.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(movement, "MAX_SPEED", 2 * movement.MAX_SPEED)
|
||||||
|
state = kitchen([cook("p0", 1.5, 1.5)], rows=("######", "#..#.#", "######"))
|
||||||
|
current = state
|
||||||
|
for tick in range(30):
|
||||||
|
current, _events = movement.step_movement(
|
||||||
|
current, MAX_DT, {"p0": press("p0", 1, 0, seq=tick)}
|
||||||
|
)
|
||||||
|
assert_no_body_in_terrain(current)
|
||||||
|
assert current.players[0].pos.x == pytest.approx(3 - movement.RADIUS, abs=2 * CONTACT_EPS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kind", "blocked"), [(StationKind.STOVE, True), (StationKind.SERVE, False)]
|
||||||
|
)
|
||||||
|
def test_a_stove_stops_you_and_a_serve_window_does_not(kind: StationKind, blocked: bool) -> None:
|
||||||
|
fixture = Station(id=f"{kind.value}_0", kind=kind, x=4, y=1)
|
||||||
|
state = kitchen(
|
||||||
|
[cook("p0", 1.5, 1.5)], rows=("#######", "#.....#", "#######"), stations=[fixture]
|
||||||
|
)
|
||||||
|
out = walked(state, {"p0": press("p0", 1, 0)}, 60).players[0]
|
||||||
|
assert out.pos.x == pytest.approx((4 if blocked else 6) - movement.RADIUS, abs=CONTACT_EPS)
|
||||||
|
assert_no_body_in_terrain(replace(state, players=(out,)))
|
||||||
|
|
||||||
|
|
||||||
|
# --- speed ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_intent_is_clamped_to_the_unit_disc() -> None:
|
||||||
|
assert movement.intent_direction(Vec2(0, 0)) == Vec2(0, 0)
|
||||||
|
assert movement.intent_direction(Vec2(0.5, 0)) == Vec2(0.5, 0) # analog throttle kept
|
||||||
|
diagonal = movement.intent_direction(Vec2(3, 4))
|
||||||
|
assert diagonal.x == pytest.approx(0.6)
|
||||||
|
assert diagonal.y == pytest.approx(0.8)
|
||||||
|
assert movement.intent_direction(Vec2(1, 1)).length == pytest.approx(1.0)
|
||||||
|
assert movement.intent_direction(Vec2(50, 0)).x == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalisation_is_what_makes_the_diagonal_not_faster(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(movement, "DIAGONAL_NORM", False)
|
||||||
|
assert movement.intent_direction(Vec2(2, 2)) == Vec2(1, 1)
|
||||||
|
assert movement.intent_direction(Vec2(50, -50)) == Vec2(1, -1)
|
||||||
|
assert movement.intent_direction(Vec2(0.4, 0.4)) == Vec2(0.4, 0.4)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"intent",
|
||||||
|
[Vec2(1, 0), Vec2(-1, 0), Vec2(0, 1), Vec2(0, -1), Vec2(1, 1), Vec2(-2, -2)],
|
||||||
|
ids=["e", "w", "s", "n", "diag", "diag_overdrive"],
|
||||||
|
)
|
||||||
|
def test_a_diagonal_is_not_a_speed_boost(intent: Vec2) -> None:
|
||||||
|
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
|
||||||
|
travelled = walked(state, {"p0": press("p0", intent.x, intent.y)}, 20).players[0].pos
|
||||||
|
straight = walked(state, {"p0": press("p0", 1, 0)}, 20).players[0].pos
|
||||||
|
start = Vec2(10.5, 10.5)
|
||||||
|
assert (travelled - start).length == pytest.approx((straight - start).length, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_ramp_and_the_harder_brake() -> None:
|
||||||
|
still = cook("p0", 5.5, 2.5)
|
||||||
|
first = movement.desired_velocity(still, Vec2(1, 0), STEP_DT)
|
||||||
|
assert first == pytest.approx(Vec2(movement.ACCEL * STEP_DT, 0))
|
||||||
|
second = movement.desired_velocity(replace(still, vel=first), Vec2(1, 0), STEP_DT)
|
||||||
|
assert second.x == pytest.approx(2 * movement.ACCEL * STEP_DT)
|
||||||
|
third = movement.desired_velocity(replace(still, vel=second), Vec2(1, 0), STEP_DT)
|
||||||
|
assert third.x == pytest.approx(movement.MAX_SPEED) # the ceiling is met, not overshot
|
||||||
|
|
||||||
|
rolling = cook("p0", 5.5, 2.5, vel=Vec2(movement.MAX_SPEED, 0.0))
|
||||||
|
braking = movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT)
|
||||||
|
assert braking.x == pytest.approx(movement.MAX_SPEED - movement.DECEL * STEP_DT)
|
||||||
|
assert movement.DECEL * STEP_DT > movement.ACCEL * STEP_DT # braking is the sharper ramp
|
||||||
|
|
||||||
|
|
||||||
|
def test_speed_never_exceeds_the_ceiling() -> None:
|
||||||
|
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
|
||||||
|
current = state
|
||||||
|
peak = 0.0
|
||||||
|
for tick in range(20):
|
||||||
|
current, _events = movement.step_movement(
|
||||||
|
current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)}
|
||||||
|
)
|
||||||
|
speed = current.players[0].vel.length
|
||||||
|
assert speed <= movement.MAX_SPEED + 1e-12
|
||||||
|
peak = max(peak, speed)
|
||||||
|
assert peak == pytest.approx(movement.MAX_SPEED)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("modifiers", "expected"),
|
||||||
|
[
|
||||||
|
(movement.MoveModifiers(), movement.MAX_SPEED),
|
||||||
|
(movement.MoveModifiers(speed_scale=0.5), movement.MAX_SPEED / 2),
|
||||||
|
(movement.MoveModifiers(speed_scale=0.0), 0.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_speed_scale_moves_the_ceiling(modifiers: movement.MoveModifiers, expected: float) -> None:
|
||||||
|
"""`dt = 1.0` so the grip limit cannot bind: this reads the ceiling itself."""
|
||||||
|
walked = movement.desired_velocity(cook("p0", 1, 1), Vec2(1, 0), 1.0, modifiers)
|
||||||
|
assert walked.x == pytest.approx(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_accel_scale_reaches_the_ceiling_in_half_the_time() -> None:
|
||||||
|
still = cook("p0", 1, 1)
|
||||||
|
assert movement.desired_velocity(still, Vec2(1, 0), STEP_DT).x == pytest.approx(1.5)
|
||||||
|
grippy = movement.MoveModifiers(accel_scale=2.0)
|
||||||
|
assert movement.desired_velocity(still, Vec2(1, 0), STEP_DT, grippy).x == pytest.approx(3.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slide_removes_the_brake_but_not_the_throttle() -> None:
|
||||||
|
rolling = cook("p0", 1, 1, vel=Vec2(2.0, 0.0))
|
||||||
|
icy = movement.MoveModifiers(slide=True)
|
||||||
|
assert movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT, icy) == Vec2(2.0, 0.0)
|
||||||
|
assert movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT).x == pytest.approx(0.0)
|
||||||
|
# pushing on a spill still pushes
|
||||||
|
assert movement.desired_velocity(rolling, Vec2(1, 0), STEP_DT, icy).x > 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_spill_halves_the_cruising_speed() -> None:
|
||||||
|
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
|
||||||
|
frames = {"p0": press("p0", 1, 0)}
|
||||||
|
spill = movement.MoveModifiers(speed_scale=0.5)
|
||||||
|
floor = walked(state, frames, 40).players[0]
|
||||||
|
on_spill = state
|
||||||
|
for _ in range(40):
|
||||||
|
on_spill, _events = movement.step_movement(on_spill, STEP_DT, frames, {"p0": spill})
|
||||||
|
travelled = on_spill.players[0].pos.x - 10.5
|
||||||
|
assert on_spill.players[0].vel.length == pytest.approx(movement.MAX_SPEED / 2, abs=1e-9)
|
||||||
|
assert travelled == pytest.approx(0.5 * (floor.pos.x - 10.5), rel=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_client_cannot_send_speed_fifty() -> None:
|
||||||
|
"""The clamp is server-side and it is in the kernel path, not only in the helper."""
|
||||||
|
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
|
||||||
|
polite = state
|
||||||
|
rude = state
|
||||||
|
for tick in range(40):
|
||||||
|
polite, _ = step(polite, STEP_DT, [press("p0", 1, 0, seq=tick)])
|
||||||
|
rude, _ = step(rude, STEP_DT, [press("p0", 50, 0, seq=tick)])
|
||||||
|
assert rude.players[0].pos == pytest.approx(polite.players[0].pos)
|
||||||
|
assert rude.players[0].vel.length <= movement.MAX_SPEED + 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
# --- other cooks ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_walked_cook_moves_the_other_and_never_throws_them() -> None:
|
||||||
|
corridor = ("#" * 21, "#" + "." * 19 + "#", "#" * 21)
|
||||||
|
state = kitchen([cook("p0", 2.5, 1.5), cook("p1", 3.6, 1.5, seat=1)], rows=corridor)
|
||||||
|
before = state.players[1]
|
||||||
|
current = state
|
||||||
|
for tick in range(30):
|
||||||
|
current, _events = movement.step_movement(
|
||||||
|
current, STEP_DT, {"p0": press("p0", 1, 0, seq=tick)}
|
||||||
|
)
|
||||||
|
assert_no_body_in_terrain(current)
|
||||||
|
pusher, pushed = current.players
|
||||||
|
assert pushed.pos.x > before.pos.x # it moved
|
||||||
|
assert pushed.vel == before.vel == Vec2(0.0, 0.0) # positional correction, no velocity injected
|
||||||
|
assert pushed.state is PlayerState.IDLE
|
||||||
|
assert pusher.pos.x < pushed.pos.x
|
||||||
|
assert pushed.pos.x - pusher.pos.x >= TWO_TILES - movement.PUSH_SLOP
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cook_pinned_by_a_wall_takes_the_push_backstage() -> None:
|
||||||
|
"""B is flush against the wall and A walks into B: A absorbs the whole separation."""
|
||||||
|
wall = 6 - movement.RADIUS
|
||||||
|
state = kitchen(
|
||||||
|
[cook("p0", 2.0, 1.5), cook("p1", wall - CONTACT_EPS, 1.5, seat=1)],
|
||||||
|
rows=("#######", "#.....#", "#######"),
|
||||||
|
)
|
||||||
|
current = state
|
||||||
|
for tick in range(40):
|
||||||
|
current, _events = movement.step_movement(
|
||||||
|
current, STEP_DT, {"p0": press("p0", 1, 0, seq=tick)}
|
||||||
|
)
|
||||||
|
assert_no_body_in_terrain(current)
|
||||||
|
pusher, pinned = current.players
|
||||||
|
assert pinned.pos.x == pytest.approx(wall, abs=CONTACT_EPS) # it never moved
|
||||||
|
# A stopped behind B rather than walking through it
|
||||||
|
assert pusher.pos.x == pytest.approx(wall - TWO_TILES, abs=2 * CONTACT_EPS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_chain_of_four_cooks_drains_apart() -> None:
|
||||||
|
"""A queue of overlapping cooks separates over a few ticks; nothing reverts forever."""
|
||||||
|
packed = [cook(f"p{i}", 2.0 + 0.6 * i, 1.5, seat=i) for i in range(4)]
|
||||||
|
state = kitchen(packed, rows=("#############", "#...........#", "#############"))
|
||||||
|
current = state
|
||||||
|
for _ in range(40):
|
||||||
|
current, _events = movement.step_movement(current, STEP_DT, {})
|
||||||
|
assert_no_body_in_terrain(current)
|
||||||
|
xs = [player.pos.x for player in current.players]
|
||||||
|
assert xs == sorted(xs)
|
||||||
|
for first, second in pairwise(xs):
|
||||||
|
assert second - first == pytest.approx(TWO_TILES, abs=1e-3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bump_separates_on_the_easy_axis() -> None:
|
||||||
|
"""Two cooks offset more in x than in y separate vertically, not horizontally."""
|
||||||
|
room = tuple(["#" * 11] + ["#" + "." * 9 + "#"] * 11)
|
||||||
|
state = kitchen([cook("p0", 2.5, 2.5), cook("p1", 2.7, 3.0, seat=1)], rows=room)
|
||||||
|
before = state.players[1].pos
|
||||||
|
current = state
|
||||||
|
for _ in range(20):
|
||||||
|
current, _events = movement.step_movement(current, STEP_DT, {})
|
||||||
|
assert_no_body_in_terrain(current)
|
||||||
|
moved = current.players[1].pos
|
||||||
|
assert moved.y > before.y
|
||||||
|
assert moved.x == pytest.approx(before.x)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_terrain_deadlock_reverts_the_later_input() -> None:
|
||||||
|
"""Two cooks stacked in a 1x1 alcove: terrain wins, so the later frame is the one dropped."""
|
||||||
|
state = kitchen(
|
||||||
|
[cook("p0", 1.5, 1.5, vel=Vec2(0.2, 0.0)), cook("p1", 1.5, 1.5, seat=1)],
|
||||||
|
rows=("###", "#.#", "###"),
|
||||||
|
)
|
||||||
|
players, events = movement.resolve_moves(
|
||||||
|
state,
|
||||||
|
STEP_DT,
|
||||||
|
{
|
||||||
|
"p0": press("p0", 1, 0, seq=9, received=5), # received later: loses
|
||||||
|
"p1": press("p1", 1, 0, seq=1, received=1), # received earlier: keeps its tick
|
||||||
|
},
|
||||||
|
)
|
||||||
|
late, early = players
|
||||||
|
assert late.pos == state.players[0].pos # exactly where the tick started
|
||||||
|
assert late.vel == Vec2(0.2, 0.0) # and exactly the velocity it started with
|
||||||
|
assert early.vel.x == pytest.approx(movement.ACCEL * STEP_DT)
|
||||||
|
# still touching, never through: the overlap never exceeds one body diameter
|
||||||
|
assert abs(early.pos.x - late.pos.x) <= TWO_TILES + movement.PUSH_SLOP
|
||||||
|
assert events == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pair_that_can_move_is_never_reverted() -> None:
|
||||||
|
"""Two cooks walking abreast into a wall queue up; the revert is only for real deadlocks."""
|
||||||
|
state = kitchen(
|
||||||
|
[cook("p0", 2.5, 1.5), cook("p1", 2.9, 1.5, seat=1)],
|
||||||
|
rows=("#######", "#.....#", "#######"),
|
||||||
|
)
|
||||||
|
current = state
|
||||||
|
for tick in range(20):
|
||||||
|
current, _events = movement.step_movement(
|
||||||
|
current,
|
||||||
|
STEP_DT,
|
||||||
|
{"p0": press("p0", 1, 0, seq=tick), "p1": press("p1", 1, 0, seq=tick)},
|
||||||
|
)
|
||||||
|
assert_no_body_in_terrain(current)
|
||||||
|
first, second = current.players
|
||||||
|
assert second.pos.x - first.pos.x >= TWO_TILES - movement.PUSH_SLOP
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cook_asking_for_the_move_loses_a_deadlock() -> None:
|
||||||
|
"""A cook with no frame this tick is never the loser, because it wants nothing.
|
||||||
|
|
||||||
|
The revert is not free — it puts a cook back where the substep started — so who gets it
|
||||||
|
matters. Only one cook sent input, and only that cook can be told "not this tick": the
|
||||||
|
idle one is not asking to be anywhere and has no input to discard, so it keeps the
|
||||||
|
position the push left it in and the cook with the joystick is the one refused.
|
||||||
|
"""
|
||||||
|
state = kitchen(
|
||||||
|
[cook("p0", 1.5, 1.5), cook("p1", 1.5, 1.5, seat=1)],
|
||||||
|
rows=("###", "#.#", "###"),
|
||||||
|
)
|
||||||
|
|
||||||
|
players, events = movement.resolve_moves(state, STEP_DT, {"p0": press("p0", 1, 0)})
|
||||||
|
|
||||||
|
framed, frameless = players
|
||||||
|
assert framed.pos == state.players[0].pos # the one that asked is the one that's refused
|
||||||
|
# The idle cook keeps the shove it never asked for — and it was shoved *away* from the
|
||||||
|
# cook that walked into it, which is the only reason the two are not stacked again.
|
||||||
|
assert frameless.pos != state.players[1].pos
|
||||||
|
assert frameless.pos.x < state.players[1].pos.x
|
||||||
|
assert events == ()
|
||||||
|
assert_no_body_in_terrain(state.with_players(players))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_jam_drains_over_a_few_ticks_instead_of_fusing_forever() -> None:
|
||||||
|
"""Four cooks packed into an open kitchen are a queue, not a deadlock.
|
||||||
|
|
||||||
|
A jam of overlapping bodies unties itself over a handful of ticks as the pushes
|
||||||
|
propagate, so each of those ticks must be legal: nobody inside terrain, nobody's
|
||||||
|
position reverted, because there *is* a way out and reverting would refuse a move that
|
||||||
|
was perfectly possible.
|
||||||
|
"""
|
||||||
|
state = kitchen(
|
||||||
|
[
|
||||||
|
cook("p0", 5.5, 2.5),
|
||||||
|
cook("p1", 5.7, 2.5, seat=1),
|
||||||
|
cook("p2", 5.5, 2.7, seat=2),
|
||||||
|
cook("p3", 5.7, 2.7, seat=3),
|
||||||
|
],
|
||||||
|
rows=FIELD,
|
||||||
|
)
|
||||||
|
current = state
|
||||||
|
for tick in range(6):
|
||||||
|
current, _events = movement.step_movement(
|
||||||
|
current,
|
||||||
|
STEP_DT,
|
||||||
|
{
|
||||||
|
player.id: press(player.id, 1, 1, seq=tick)
|
||||||
|
for player in current.players
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert_no_body_in_terrain(current)
|
||||||
|
|
||||||
|
positions = [player.pos for player in current.players]
|
||||||
|
assert positions != [player.pos for player in state.players]
|
||||||
|
for index, first in enumerate(current.players):
|
||||||
|
for other in current.players[index + 1 :]:
|
||||||
|
overlapped_x = TWO_TILES - abs(other.pos.x - first.pos.x)
|
||||||
|
overlapped_y = TWO_TILES - abs(other.pos.y - first.pos.y)
|
||||||
|
if overlapped_x > 0.0 and overlapped_y > 0.0:
|
||||||
|
assert min(overlapped_x, overlapped_y) < TWO_TILES
|
||||||
|
|
||||||
|
|
||||||
|
# --- stun -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stunned_cook_cannot_move_and_reports_the_hit_once() -> None:
|
||||||
|
state = kitchen([cook("p0", 5.5, 2.5, stun=movement.STUN_S)], rows=ROOM)
|
||||||
|
current = state
|
||||||
|
seen: list[str] = []
|
||||||
|
for tick in range(11):
|
||||||
|
current, events = movement.step_movement(
|
||||||
|
current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)}
|
||||||
|
)
|
||||||
|
seen.extend(event.kind for event in events)
|
||||||
|
pinned = current.players[0]
|
||||||
|
assert pinned.pos == Vec2(5.5, 2.5)
|
||||||
|
assert pinned.vel == Vec2(0.0, 0.0)
|
||||||
|
assert pinned.state is PlayerState.STUNNED
|
||||||
|
assert pinned.facing is Facing.DOWN # a hit never spins you either
|
||||||
|
assert pinned.stun == pytest.approx(movement.STUN_S - 11 * STEP_DT)
|
||||||
|
assert seen == [EventKind.STUNNED] # the entry only, not one per substep
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stun_expires_and_the_cook_walks_again() -> None:
|
||||||
|
state = kitchen([cook("p0", 5.5, 2.5, stun=0.6)], rows=ROOM)
|
||||||
|
frames = {"p0": press("p0", 1, 0)}
|
||||||
|
pinned = walked(state, frames, 11).players[0]
|
||||||
|
assert pinned.state is PlayerState.STUNNED
|
||||||
|
freed = walked(walked(state, frames, 11), frames, 1).players[0]
|
||||||
|
assert freed.stun == 0.0
|
||||||
|
assert freed.state is PlayerState.MOVING
|
||||||
|
assert freed.pos.x > 5.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_stun_pins_velocity_and_clears_a_stale_state() -> None:
|
||||||
|
hurt = cook("p0", 2.5, 2.5, stun=0.3, vel=Vec2(3.0, 0.0), state=PlayerState.MOVING)
|
||||||
|
bystander = cook("p1", 3.5, 2.5, seat=1)
|
||||||
|
players, events = movement.apply_stun([hurt, bystander], STEP_DT)
|
||||||
|
assert players[0].vel == Vec2(0.0, 0.0)
|
||||||
|
assert players[0].state is PlayerState.STUNNED
|
||||||
|
assert players[0].stun == pytest.approx(0.25)
|
||||||
|
assert players[1] is bystander # an unharmed cook is not even copied
|
||||||
|
reported = [(event.kind, event.payload["player_id"]) for event in events]
|
||||||
|
assert reported == [(EventKind.STUNNED, "p0")]
|
||||||
|
|
||||||
|
stuck = cook("p0", 2.5, 2.5, stun=0.0, state=PlayerState.STUNNED)
|
||||||
|
revived, later = movement.apply_stun([stuck], STEP_DT)
|
||||||
|
assert revived[0].state is PlayerState.IDLE # a hazard's half-written state is corrected
|
||||||
|
assert later == ()
|
||||||
|
|
||||||
|
|
||||||
|
# --- facing -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("intent", "expected"),
|
||||||
|
[
|
||||||
|
(Vec2(1, 0), Facing.RIGHT),
|
||||||
|
(Vec2(-1, 0), Facing.LEFT),
|
||||||
|
(Vec2(0, 1), Facing.DOWN),
|
||||||
|
(Vec2(0, -1), Facing.UP),
|
||||||
|
(Vec2(1, 1), Facing.RIGHT), # a 45 degree press always turns the same way
|
||||||
|
(Vec2(-0.4, -0.9), Facing.UP),
|
||||||
|
(Vec2(0.2, -0.1), Facing.RIGHT),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_facing_follows_the_intent(intent: Vec2, expected: Facing) -> None:
|
||||||
|
assert movement.update_facing(cook("p0", 1, 1), intent) is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", [False, True])
|
||||||
|
def test_standing_still_keeps_the_facing(action: bool) -> None:
|
||||||
|
facing_left = cook("p0", 1, 1, facing=Facing.LEFT)
|
||||||
|
assert movement.update_facing(facing_left, Vec2(), action) is Facing.LEFT
|
||||||
|
assert movement.update_facing(facing_left, Vec2(0, 0), action, None) is Facing.LEFT
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_explicit_turn_beats_the_vector() -> None:
|
||||||
|
assert movement.update_facing(cook("p0", 1, 1), Vec2(1, 0), face=Facing.UP) is Facing.UP
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_action_press_with_no_intent_does_not_spin_the_cook() -> None:
|
||||||
|
"""Walk right, stop, press: the cook still faces right (US-04, one action button)."""
|
||||||
|
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
|
||||||
|
walking = walked(state, {"p0": press("p0", 1, 0)}, 10)
|
||||||
|
assert walking.players[0].facing is Facing.RIGHT
|
||||||
|
holding = walking
|
||||||
|
for seq in range(5):
|
||||||
|
holding, _events = movement.step_movement(
|
||||||
|
holding,
|
||||||
|
STEP_DT,
|
||||||
|
{"p0": PlayerInput(
|
||||||
|
player_id="p0", seq=seq, move=Vec2(), action=True, received_at_tick=9
|
||||||
|
)},
|
||||||
|
)
|
||||||
|
assert holding.players[0].facing is Facing.RIGHT
|
||||||
|
assert holding.players[0].state is PlayerState.IDLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_player_state_tracks_the_velocity() -> None:
|
||||||
|
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
|
||||||
|
walking = walked(state, {"p0": press("p0", 0, -1)}, 3).players[0]
|
||||||
|
assert walking.state is PlayerState.MOVING
|
||||||
|
stopped = walked(replace(state, players=(walking,)), {}, 4).players[0]
|
||||||
|
assert stopped.state is PlayerState.IDLE
|
||||||
|
assert stopped.vel == Vec2(0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the kernel seam --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_moves_without_a_frame_brakes_to_a_stop() -> None:
|
||||||
|
state = kitchen([cook("p0", 5.5, 2.5, vel=Vec2(movement.MAX_SPEED, 0.0))], rows=ROOM)
|
||||||
|
players, events = movement.resolve_moves(state, STEP_DT)
|
||||||
|
assert players[0].vel.x == pytest.approx(movement.MAX_SPEED - movement.DECEL * STEP_DT)
|
||||||
|
assert players[0].pos.x > 5.5
|
||||||
|
assert players[0].state is PlayerState.MOVING
|
||||||
|
assert events == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_movement_is_a_pure_stage_of_the_state() -> None:
|
||||||
|
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
|
||||||
|
before = state.state_hash()
|
||||||
|
moved, events = movement.resolve_moves(state, STEP_DT, {"p0": press("p0", 1, 0)})
|
||||||
|
out, stage_events = movement.step_movement(state, STEP_DT, {"p0": press("p0", 1, 0)})
|
||||||
|
assert state.state_hash() == before # the caller's state is untouched
|
||||||
|
assert out.players == moved
|
||||||
|
assert stage_events == events
|
||||||
|
assert out.grid is state.grid
|
||||||
|
assert out.tick == state.tick
|
||||||
|
assert [player.id for player in out.players] == ["p0"]
|
||||||
|
|
||||||
|
|
||||||
|
def patch(patch_id: str, x: float, y: float) -> Hazard:
|
||||||
|
"""A spill at (x, y): inert data until phase 07 gives hazards behaviour.
|
||||||
|
|
||||||
|
It is here for the subsystem order, not for its effect — the determinism check below
|
||||||
|
must cover a tick that runs hazards *and* movement, because that is the tick a replay
|
||||||
|
has to reproduce exactly.
|
||||||
|
"""
|
||||||
|
return Hazard(id=patch_id, kind=HazardKind.SPILL, pos=Vec2(x, y))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tick_is_deterministic_through_the_movement_stage() -> None:
|
||||||
|
"""Same seed, same input script, same hash — the replay contract, at the new stage."""
|
||||||
|
script = [
|
||||||
|
press("p0", tick % 4 - 1, tick % 3 - 1, seq=tick)
|
||||||
|
for tick in range(1, 41)
|
||||||
|
]
|
||||||
|
|
||||||
|
def run(seed: int) -> KitchenState:
|
||||||
|
current = replace(
|
||||||
|
kitchen([cook("p0", 5.5, 3.5), cook("p1", 6.5, 3.5, seat=1)], rows=FIELD),
|
||||||
|
seed=seed,
|
||||||
|
hazards=(patch("spill_0", 5.5, 3.5),),
|
||||||
|
)
|
||||||
|
for tick, frame in enumerate(script, start=1):
|
||||||
|
current, _events = step(current, STEP_DT, [frame])
|
||||||
|
assert current.tick == tick
|
||||||
|
return current
|
||||||
|
|
||||||
|
first, second = run(0xC0FFEE), run(0xC0FFEE)
|
||||||
|
assert first.state_hash() == second.state_hash()
|
||||||
|
assert first.players == second.players
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_kernel_runs_the_movement_stage() -> None:
|
||||||
|
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
|
||||||
|
current = state
|
||||||
|
for tick in range(10):
|
||||||
|
current, _events = step(current, STEP_DT, [press("p0", 1, 0, seq=tick)])
|
||||||
|
assert current.tick == 10
|
||||||
|
assert current.players[0].pos.x > 5.5
|
||||||
|
assert_no_body_in_terrain(current)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""The movement stage through the real tick, on the levels the game actually ships with.
|
||||||
|
|
||||||
|
`test_movement.py` proves the geometry and `test_movement_stress.py` proves the model under
|
||||||
|
a random walk on a synthetic maze. This is the seam between them and the content pipeline:
|
||||||
|
`load_content` reads `levels/*.yaml`, `build_state` turns that into a kitchen, and `step()`
|
||||||
|
runs the whole tick — timers, orders, hazards, movement, in that fixed order — with four
|
||||||
|
cooks walking around inside the authored geometry.
|
||||||
|
|
||||||
|
It earns its place by being the only test that would notice any of these:
|
||||||
|
|
||||||
|
* a level whose tiles make a cook contain-able (a spawn the body footprint does not fit
|
||||||
|
in, a station placed where the body cannot clear it),
|
||||||
|
* the movement stage drifting out of the tick's subsystem order, or a whole substep of
|
||||||
|
movement going missing because a hook signature changed under the caller,
|
||||||
|
* authored content making the kernel non-deterministic — the replay contract is per-level,
|
||||||
|
not per-fixture,
|
||||||
|
* the round timer freezing movement: a walk that runs past `time_limit` stops, and the
|
||||||
|
progress assertions below would then be measuring a paused kitchen instead of a stuck
|
||||||
|
cook. TICKS therefore stays well inside the shorter level's clock.
|
||||||
|
|
||||||
|
The kitchen here comes from YAML, so nothing in this file names a tile, a station or a
|
||||||
|
wall: it says what must be true of any level that passes the validator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim import movement
|
||||||
|
from undercooked.sim.collision import body_blocker, body_cells
|
||||||
|
from undercooked.sim.content import load_content
|
||||||
|
from undercooked.sim.level import Level
|
||||||
|
from undercooked.sim.model import KitchenState, TileKind, Vec2
|
||||||
|
from undercooked.sim.rng import Rng
|
||||||
|
from undercooked.sim.state_build import SeatSpec, build_state
|
||||||
|
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SHIPPED_LEVELS = REPO_ROOT / "levels"
|
||||||
|
RECIPES = "recipes.yaml"
|
||||||
|
|
||||||
|
TICKS = 1200
|
||||||
|
SEED = 0x0D1C
|
||||||
|
COOK_COUNT = 4
|
||||||
|
|
||||||
|
#: How much of the level each cook must have stood on by the end of the walk. Measured on
|
||||||
|
#: the shipped campaign at a quarter of TICKS: 16-41 cells per cook. Well clear of the
|
||||||
|
#: 2x2 the pair starts stacked in, and well short of anything that would flap on a redesign.
|
||||||
|
MIN_CELLS_VISITED = 10
|
||||||
|
DIRECTIONS: tuple[Vec2, ...] = (
|
||||||
|
Vec2(1, 0),
|
||||||
|
Vec2(1, 1),
|
||||||
|
Vec2(0, 1),
|
||||||
|
Vec2(-1, 1),
|
||||||
|
Vec2(-1, 0),
|
||||||
|
Vec2(-1, -1),
|
||||||
|
Vec2(0, -1),
|
||||||
|
Vec2(1, -1),
|
||||||
|
Vec2(0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: The shipped campaign, read once at import. Content that will not load should stop
|
||||||
|
#: collection rather than fail one test with a confusing AttributeError.
|
||||||
|
CAMPAIGN = load_content(SHIPPED_LEVELS, SHIPPED_LEVELS / RECIPES)
|
||||||
|
|
||||||
|
|
||||||
|
def shipped_levels() -> list[tuple[str, Level]]:
|
||||||
|
"""Every level in `levels/`, as (id, level) pairs for parametrization."""
|
||||||
|
return [(level.id, level) for level in CAMPAIGN.levels]
|
||||||
|
|
||||||
|
|
||||||
|
def seats(count: int) -> tuple[SeatSpec, ...]:
|
||||||
|
"""Four anonymous seats, in seat order, for a full lobby on any level."""
|
||||||
|
return tuple(
|
||||||
|
SeatSpec(player_id=f"p{index}", seat=index, display_name=f"Cook {index}")
|
||||||
|
for index in range(count)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tick_budget(cooks: int) -> float:
|
||||||
|
"""The most one cook may move in a substep: one tick of walking, plus every push."""
|
||||||
|
body = 2 * movement.RADIUS
|
||||||
|
return movement.MAX_SPEED * STEP_DT + movement.PUSH_ITERATIONS * (cooks - 1) * body * 2
|
||||||
|
|
||||||
|
|
||||||
|
def walk_level(
|
||||||
|
level: Level,
|
||||||
|
ticks: int = TICKS,
|
||||||
|
visited: dict[str, set[tuple[int, int]]] | None = None,
|
||||||
|
) -> KitchenState:
|
||||||
|
"""Run `ticks` real substeps of a wandering four-cook kitchen on `level`."""
|
||||||
|
state = build_state(level, seats(COOK_COUNT), SEED, CAMPAIGN.recipe_book)
|
||||||
|
blocked = body_blocker(state.grid, state.stations)
|
||||||
|
assert state.players, "a level with no cooks cannot test movement"
|
||||||
|
budget = tick_budget(len(state.players))
|
||||||
|
rng = Rng(SEED)
|
||||||
|
heading = {player.id: Vec2(0, 0) for player in state.players}
|
||||||
|
|
||||||
|
for tick in range(1, ticks + 1):
|
||||||
|
frames: list[PlayerInput] = []
|
||||||
|
for player in state.players:
|
||||||
|
if rng.next_float() < 0.2:
|
||||||
|
heading[player.id] = rng.choice(DIRECTIONS)
|
||||||
|
frames.append(PlayerInput(
|
||||||
|
player_id=player.id,
|
||||||
|
seq=tick,
|
||||||
|
move=heading[player.id],
|
||||||
|
received_at_tick=tick,
|
||||||
|
))
|
||||||
|
|
||||||
|
before = {player.id: player.pos for player in state.players}
|
||||||
|
state, _events = step(state, STEP_DT, frames)
|
||||||
|
|
||||||
|
assert state.tick == tick, "the movement stage must not own the tick counter"
|
||||||
|
assert state.result is None, "the walk outran the round clock: raise time_limit"
|
||||||
|
for player in state.players:
|
||||||
|
assert math.isfinite(player.pos.x) and math.isfinite(player.pos.y)
|
||||||
|
assert math.isfinite(player.vel.x) and math.isfinite(player.vel.y)
|
||||||
|
assert not any(
|
||||||
|
blocked(x, y) for x, y in body_cells(player.pos, movement.RADIUS)
|
||||||
|
), f"{player.id} is inside {level.id} at tick {tick}: {player.pos}"
|
||||||
|
assert player.vel.length <= movement.MAX_SPEED + 1e-9
|
||||||
|
assert (player.pos - before[player.id]).length <= budget
|
||||||
|
if visited is not None:
|
||||||
|
visited.setdefault(player.id, set()).update(
|
||||||
|
body_cells(player.pos, movement.RADIUS)
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("level_id", "level"), shipped_levels())
|
||||||
|
def test_a_walk_on_a_shipped_level_stays_inside_it(level_id: str, level: Level) -> None:
|
||||||
|
"""Containment, bounded velocity and tick accounting, after every single substep."""
|
||||||
|
del level_id
|
||||||
|
final = walk_level(level)
|
||||||
|
assert final.tick == TICKS
|
||||||
|
assert all(player.vel.length <= movement.MAX_SPEED + 1e-9 for player in final.players)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("level_id", "level"), shipped_levels())
|
||||||
|
def test_a_walk_on_a_shipped_level_goes_somewhere(level_id: str, level: Level) -> None:
|
||||||
|
"""Each cook explores authored geometry instead of wearing out its spawn tile."""
|
||||||
|
del level_id
|
||||||
|
visited: dict[str, set[tuple[int, int]]] = {}
|
||||||
|
walk_level(level, visited=visited)
|
||||||
|
assert len(visited) == 4
|
||||||
|
for player_id, cells in sorted(visited.items()):
|
||||||
|
assert len(cells) >= MIN_CELLS_VISITED, f"{player_id} only ever saw {sorted(cells)}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("level_id", "level"), shipped_levels())
|
||||||
|
def test_a_replay_of_a_shipped_level_reproduces_itself(level_id: str, level: Level) -> None:
|
||||||
|
"""Determinism is per level: same seed and script, identical hash, on every map."""
|
||||||
|
del level_id
|
||||||
|
assert walk_level(level).state_hash() == walk_level(level).state_hash()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_kitchen_has_something_to_walk_on() -> None:
|
||||||
|
"""Guard for the two tests above: the floor is floor, and it is not one cell."""
|
||||||
|
assert len(CAMPAIGN.levels) >= 2
|
||||||
|
for level in CAMPAIGN.levels:
|
||||||
|
floor = sum(1 for tile in level.grid.tiles if tile is TileKind.FLOOR)
|
||||||
|
assert floor > 4 * MIN_CELLS_VISITED, f"{level.id} is too small to walk"
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""`movement` under a scripted random walk: 4 cooks, 2000 ticks, invariants only.
|
||||||
|
|
||||||
|
The table-driven tests in `test_movement.py` prove the geometry for the cases someone
|
||||||
|
thought of. This file is for the cases nobody did: four cooks wandering a pillar maze for
|
||||||
|
2000 substeps, checked after *every* substep — the simulation must never be observed in an
|
||||||
|
impossible state, not even for one tick (PLAN A21).
|
||||||
|
|
||||||
|
The invariants, and why each is the load-bearing kind:
|
||||||
|
|
||||||
|
* **containment** — no body footprint ever overlaps a blocked cell. The maze's walls are
|
||||||
|
one tile thick, so this one check *is* the no-tunnelling proof: a cook that crossed a
|
||||||
|
wall would have to spend a substep inside it. It also covers containment in the void,
|
||||||
|
because out-of-bounds is blocked by definition.
|
||||||
|
* **no pass-through** — no pair ever overlaps by more than `2 * RADIUS`, the brief's
|
||||||
|
bound, which is exactly what a head-on collision at the coarsest legal dt produces.
|
||||||
|
* **no explosions** — a cook's per-tick displacement stays inside one tick of walking
|
||||||
|
plus every push correction that could legally have been applied to it. A sign error in
|
||||||
|
the soft push shows up here, and nowhere in a screenshot.
|
||||||
|
* **progress** — every cook must visit a good many distinct cells. A walk that freezes in
|
||||||
|
a corner passes every safety invariant above while testing nothing.
|
||||||
|
* **determinism** — same seed, same script, same state hash, at `STEP_DT` and at `MAX_DT`.
|
||||||
|
|
||||||
|
The RNG is the sim's own splitmix64: nothing here imports `random`, so a failing run is
|
||||||
|
reproducible to the tick and this file doubles as the movement stage's first golden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim import movement
|
||||||
|
from undercooked.sim.collision import body_blocker, body_cells
|
||||||
|
from undercooked.sim.grid import Grid
|
||||||
|
from undercooked.sim.model import KitchenState, Player, Station, StationKind, TileKind, Vec2
|
||||||
|
from undercooked.sim.rng import Rng
|
||||||
|
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
LEGEND: dict[str, TileKind] = {
|
||||||
|
".": TileKind.FLOOR,
|
||||||
|
"#": TileKind.WALL,
|
||||||
|
"=": TileKind.COUNTER,
|
||||||
|
"o": TileKind.VOID,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: A pillar maze with three doorways: one-tile corridors in every direction, so the walk
|
||||||
|
#: is mostly corners, doorframes and dead ends. Two tiles of terrain are punched into it
|
||||||
|
#: that a cook must treat as a wall but cannot fall through (`o` void, `=` counter), and
|
||||||
|
#: `maze_state` adds two fixtures — a stove, which must block, and a serve window, which
|
||||||
|
#: must not — because "the invariant held on empty floor" is the weakest version of this
|
||||||
|
#: test. `test_the_maze_is_one_room` keeps it a single connected kitchen.
|
||||||
|
MAZE = (
|
||||||
|
"#################",
|
||||||
|
"#...#..o#...#...#",
|
||||||
|
"#.#...#...#...#.#",
|
||||||
|
"#...#...#...#=..#",
|
||||||
|
"#################",
|
||||||
|
)
|
||||||
|
|
||||||
|
TICKS = 2000
|
||||||
|
SEED = 0x5EED
|
||||||
|
COOK_COUNT = 4
|
||||||
|
|
||||||
|
#: How much of the 32-cell kitchen each cook must have set foot in by the end of the
|
||||||
|
#: scripted walk. The maze connects its pockets through three one-tile doorways, so a
|
||||||
|
#: random heading takes a while to thread one: measured on this seed, every cook covers
|
||||||
|
#: 16-18 cells over TICKS and none covers fewer than half that in under a tenth of it.
|
||||||
|
MIN_CELLS_VISITED = 12
|
||||||
|
|
||||||
|
#: The eight compass directions plus standing still, as a fixed tuple so `Rng.choice`
|
||||||
|
#: never depends on set ordering.
|
||||||
|
DIRECTIONS: tuple[Vec2, ...] = (
|
||||||
|
Vec2(1, 0),
|
||||||
|
Vec2(1, 1),
|
||||||
|
Vec2(0, 1),
|
||||||
|
Vec2(-1, 1),
|
||||||
|
Vec2(-1, 0),
|
||||||
|
Vec2(-1, -1),
|
||||||
|
Vec2(0, -1),
|
||||||
|
Vec2(1, -1),
|
||||||
|
Vec2(0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
STARTS: tuple[tuple[float, float], ...] = ((1.5, 1.5), (5.5, 1.5), (9.5, 1.5), (13.5, 1.5))
|
||||||
|
|
||||||
|
|
||||||
|
def maze_state() -> KitchenState:
|
||||||
|
stations = (
|
||||||
|
Station(id="stove_0", kind=StationKind.STOVE, x=6, y=1),
|
||||||
|
Station(id="serve_0", kind=StationKind.SERVE, x=10, y=1),
|
||||||
|
)
|
||||||
|
players = tuple(
|
||||||
|
Player(id=f"p{seat}", seat=seat, pos=Vec2(x, y))
|
||||||
|
for seat, (x, y) in enumerate(STARTS[:COOK_COUNT])
|
||||||
|
)
|
||||||
|
return KitchenState(
|
||||||
|
level_id="stress",
|
||||||
|
seed=SEED,
|
||||||
|
grid=Grid.from_rows(MAZE, LEGEND),
|
||||||
|
stations=stations,
|
||||||
|
players=players,
|
||||||
|
time_left=float(TICKS * STEP_DT),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tick_budget(dt: float) -> float:
|
||||||
|
"""The most a cook may legitimately move in one call.
|
||||||
|
|
||||||
|
One tick of walking at the ceiling, plus `PUSH_ITERATIONS` passes over up to
|
||||||
|
`COOK_COUNT - 1` contacts, each of which can move this cook a full body (and the
|
||||||
|
remainder a neighbour could not take). Anything above this is a runaway correction.
|
||||||
|
"""
|
||||||
|
body = 2 * movement.RADIUS
|
||||||
|
return movement.MAX_SPEED * dt + movement.PUSH_ITERATIONS * (COOK_COUNT - 1) * body * 2
|
||||||
|
|
||||||
|
|
||||||
|
def assert_invariants(state: KitchenState, before: Sequence[Vec2], dt: float) -> None:
|
||||||
|
blocked = body_blocker(state.grid, state.stations)
|
||||||
|
budget = tick_budget(dt)
|
||||||
|
for player, was in zip(state.players, before, strict=True):
|
||||||
|
for value in (player.pos.x, player.pos.y, player.vel.x, player.vel.y, player.stun):
|
||||||
|
assert math.isfinite(value), f"{player.id} left the number line at tick {state.tick}"
|
||||||
|
footprint = body_cells(player.pos, movement.RADIUS)
|
||||||
|
assert not any(blocked(x, y) for x, y in footprint), (
|
||||||
|
f"{player.id} is inside terrain at tick {state.tick}: {player.pos} -> {footprint}"
|
||||||
|
)
|
||||||
|
assert player.vel.length <= movement.MAX_SPEED + 1e-9
|
||||||
|
jumped = (player.pos - was).length
|
||||||
|
assert jumped <= budget, f"{player.id} jumped {jumped:.3f} tiles at tick {state.tick}"
|
||||||
|
|
||||||
|
diameter = 2 * movement.RADIUS
|
||||||
|
for i, first in enumerate(state.players):
|
||||||
|
for second in state.players[i + 1 :]:
|
||||||
|
overlap_x = diameter - abs(second.pos.x - first.pos.x)
|
||||||
|
overlap_y = diameter - abs(second.pos.y - first.pos.y)
|
||||||
|
if overlap_x > 0.0 and overlap_y > 0.0: # the two footprints do intersect
|
||||||
|
assert min(overlap_x, overlap_y) <= diameter + 1e-9, (
|
||||||
|
f"{first.id} and {second.id} passed through each other at tick {state.tick}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_walk(
|
||||||
|
ticks: int = TICKS,
|
||||||
|
dt: float = STEP_DT,
|
||||||
|
visited: dict[str, set[tuple[int, int]]] | None = None,
|
||||||
|
) -> KitchenState:
|
||||||
|
"""The scripted walk: `ticks` substeps of a wandering four-cook kitchen."""
|
||||||
|
current = maze_state()
|
||||||
|
rng = Rng(SEED)
|
||||||
|
heading = [Vec2(0, 0) for _ in current.players]
|
||||||
|
for tick in range(1, ticks + 1):
|
||||||
|
frames: list[PlayerInput] = []
|
||||||
|
for player in current.players:
|
||||||
|
if rng.next_float() < 0.2:
|
||||||
|
heading[player.seat] = rng.choice(DIRECTIONS)
|
||||||
|
frames.append(PlayerInput(
|
||||||
|
player_id=player.id,
|
||||||
|
seq=tick,
|
||||||
|
move=heading[player.seat],
|
||||||
|
received_at_tick=tick,
|
||||||
|
))
|
||||||
|
before = [player.pos for player in current.players]
|
||||||
|
current, _events = movement.step_movement(current, dt, frames)
|
||||||
|
assert_invariants(current, before, dt)
|
||||||
|
if visited is not None:
|
||||||
|
for player in current.players:
|
||||||
|
visited.setdefault(player.id, set()).update(
|
||||||
|
body_cells(player.pos, movement.RADIUS)
|
||||||
|
)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_maze_is_one_room() -> None:
|
||||||
|
"""The progress check below only means something if the kitchen has no sealed pockets."""
|
||||||
|
state = maze_state()
|
||||||
|
blocked = body_blocker(state.grid, state.stations)
|
||||||
|
free = {
|
||||||
|
(x, y)
|
||||||
|
for y in range(state.grid.height)
|
||||||
|
for x in range(state.grid.width)
|
||||||
|
if not blocked(x, y)
|
||||||
|
}
|
||||||
|
seen = {min(free)}
|
||||||
|
stack = [min(free)]
|
||||||
|
while stack:
|
||||||
|
x, y = stack.pop()
|
||||||
|
for step in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||||
|
cell = (x + step[0], y + step[1])
|
||||||
|
if cell in free and cell not in seen:
|
||||||
|
seen.add(cell)
|
||||||
|
stack.append(cell)
|
||||||
|
assert seen == free
|
||||||
|
assert len(free) >= MIN_CELLS_VISITED
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_walk_never_breaks_the_kitchen() -> None:
|
||||||
|
"""Containment, no pass-through and no explosions, checked after every substep."""
|
||||||
|
final = run_walk()
|
||||||
|
assert len(final.players) == COOK_COUNT
|
||||||
|
assert [player.id for player in final.players] == [f"p{seat}" for seat in range(COOK_COUNT)]
|
||||||
|
assert all(player.connected for player in final.players)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_walk_actually_walks() -> None:
|
||||||
|
"""Every cook covers real ground: a frozen cook would satisfy every invariant above."""
|
||||||
|
visited: dict[str, set[tuple[int, int]]] = {}
|
||||||
|
run_walk(TICKS, visited=visited)
|
||||||
|
for player_id, cells in sorted(visited.items()):
|
||||||
|
assert len(cells) >= MIN_CELLS_VISITED, f"{player_id} only ever saw {sorted(cells)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_script_always_produces_the_same_kitchen() -> None:
|
||||||
|
"""Same seed, same script, same hash — the replay contract, at the movement stage."""
|
||||||
|
first = run_walk(TICKS // 2)
|
||||||
|
second = run_walk(TICKS // 2)
|
||||||
|
assert first.state_hash() == second.state_hash()
|
||||||
|
assert first.state_hash() != run_walk(TICKS // 2, dt=MAX_DT).state_hash()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_walk_survives_the_coarsest_legal_tick() -> None:
|
||||||
|
"""`MAX_DT` is 4x the substep: displacement per call quadruples, containment may not."""
|
||||||
|
visited: dict[str, set[tuple[int, int]]] = {}
|
||||||
|
final = run_walk(TICKS // 2, dt=MAX_DT, visited=visited)
|
||||||
|
for player in final.players:
|
||||||
|
assert player.vel.length <= movement.MAX_SPEED + 1e-9
|
||||||
|
assert all(len(cells) >= MIN_CELLS_VISITED for cells in visited.values())
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""The headless perf smoke (PLAN §10, marked `slow` so the quick loop skips it).
|
||||||
|
|
||||||
|
Four cooks, 2 000 ticks on the shipped level 1, one direction per cook per tick
|
||||||
|
drawn from the sim's own splitmix64 (a fixed seed — the walk is a property of
|
||||||
|
the script, not of the machine). The budget is the task's: mean step
|
||||||
|
**< 0.2 ms** and total **< 400 ms** — 5 000+ ticks/s, two and a half orders
|
||||||
|
of magnitude more than the 20 Hz the rooms run at — and the measured numbers
|
||||||
|
are printed in the report so a slow machine shows up in the gate, not as a
|
||||||
|
silent failure.
|
||||||
|
|
||||||
|
The loop times `step()` directly (no hash recording), so the number is the
|
||||||
|
kernel's tick cost, exactly what the §10 budget (mean ≤ 2 ms/room) is about;
|
||||||
|
`run_script`'s injected `clock` seam is what a full-harness measurement goes
|
||||||
|
through instead, and `test_replay.py` pins that seam.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim.content import load_content
|
||||||
|
from undercooked.sim.model import Vec2
|
||||||
|
from undercooked.sim.rng import Rng
|
||||||
|
from undercooked.sim.state_build import SeatSpec, build_state
|
||||||
|
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.slow]
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SHIPPED_LEVELS = REPO_ROOT / "levels"
|
||||||
|
|
||||||
|
#: The campaign, read once at import (the same seam `test_movement_kernel` uses).
|
||||||
|
CAMPAIGN = load_content(SHIPPED_LEVELS, SHIPPED_LEVELS / "recipes.yaml")
|
||||||
|
|
||||||
|
TICKS = 2000
|
||||||
|
SEED = 0x0D1C
|
||||||
|
COOKS = 4
|
||||||
|
|
||||||
|
#: The task's budget: mean step < 0.2 ms, total < 400 ms on the dev machine.
|
||||||
|
BUDGET_MEAN_S = 0.0002
|
||||||
|
BUDGET_TOTAL_S = 0.4
|
||||||
|
|
||||||
|
#: The eight compass directions plus standing still, as a fixed tuple so
|
||||||
|
#: `Rng.choice` (the sim's own generator) is the only source of variation.
|
||||||
|
DIRECTIONS: tuple[Vec2, ...] = (
|
||||||
|
Vec2(1, 0),
|
||||||
|
Vec2(1, 1),
|
||||||
|
Vec2(0, 1),
|
||||||
|
Vec2(-1, 1),
|
||||||
|
Vec2(-1, 0),
|
||||||
|
Vec2(-1, -1),
|
||||||
|
Vec2(0, -1),
|
||||||
|
Vec2(1, -1),
|
||||||
|
Vec2(0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def seats(cooks: int) -> tuple[SeatSpec, ...]:
|
||||||
|
"""Four anonymous seats in seat order; spawns wrap past the second (the
|
||||||
|
level has two), with the stack offset `build_state` applies."""
|
||||||
|
return tuple(
|
||||||
|
SeatSpec(player_id=f"p{index}", seat=index, display_name=f"p{index}")
|
||||||
|
for index in range(cooks)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_four_cook_kitchen_stays_within_the_perf_budget() -> None:
|
||||||
|
level = CAMPAIGN.by_id["01_first_kitchen"]
|
||||||
|
state = build_state(level, seats(COOKS), SEED, CAMPAIGN.recipe_book)
|
||||||
|
rng = Rng(SEED)
|
||||||
|
start = time.perf_counter()
|
||||||
|
for tick in range(TICKS):
|
||||||
|
inputs = [
|
||||||
|
PlayerInput(
|
||||||
|
player_id=f"p{seat}",
|
||||||
|
seq=tick,
|
||||||
|
move=rng.choice(DIRECTIONS),
|
||||||
|
received_at_tick=tick,
|
||||||
|
)
|
||||||
|
for seat in range(COOKS)
|
||||||
|
]
|
||||||
|
state, _ = step(state, STEP_DT, inputs)
|
||||||
|
total = time.perf_counter() - start
|
||||||
|
mean = total / TICKS
|
||||||
|
print(
|
||||||
|
f"perf smoke (level 1, {COOKS} cooks, {TICKS} ticks): "
|
||||||
|
f"total {total * 1000:.1f} ms, mean {mean * 1000:.4f} ms/tick, "
|
||||||
|
f"{1 / mean:,.0f} ticks/s"
|
||||||
|
)
|
||||||
|
assert mean < BUDGET_MEAN_S, (
|
||||||
|
f"mean step {mean * 1000:.4f} ms is over the {BUDGET_MEAN_S * 1000:.1f} ms budget"
|
||||||
|
)
|
||||||
|
assert total < BUDGET_TOTAL_S, (
|
||||||
|
f"{TICKS} ticks took {total * 1000:.1f} ms, over the {BUDGET_TOTAL_S * 1000:.0f} ms budget"
|
||||||
|
)
|
||||||
|
# cheap invariants: the walk stayed inside the kitchen, the tick ran to the end
|
||||||
|
assert state.tick == TICKS
|
||||||
|
for player in state.players:
|
||||||
|
assert 0.35 < player.pos.x < level.grid.width - 0.35
|
||||||
|
assert 0.35 < player.pos.y < level.grid.height - 0.35
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
"""The replay harness against its committed scripts and goldens (A21, PLAN §9 layer 3).
|
||||||
|
|
||||||
|
`tests/sim/test_replay_carry.py` pinned the first golden by hand; this file is the
|
||||||
|
formalised contract: the two committed replay scripts (`walk_solo`, the 600-tick
|
||||||
|
movement-only tour of level 1; `pick_drop_pass`, two cooks running the whole
|
||||||
|
pick → set-down → hand-off → long-press-trash chain on the fixture kitchen) each
|
||||||
|
match their committed `tests/goldens/<name>.golden.json`, a deliberately mutated
|
||||||
|
script diverges and `compare()` names the first bad tick and the changed field,
|
||||||
|
the record cadence is stable, a replay is bit-identical within one process, and
|
||||||
|
the goldens are never rewritten by the test run.
|
||||||
|
|
||||||
|
The levels are loaded explicitly (level 1 from the shipped `levels/`, the fixture
|
||||||
|
kitchen from `tests/fixtures/levels/_replay/`), so the session fixture's
|
||||||
|
level-free directory cannot leak in — and the CLI tests point
|
||||||
|
`UNDERCOOKED_LEVELS_DIR` at `levels/` for the same reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
|
from itertools import pairwise
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim.content import load_level_file
|
||||||
|
from undercooked.sim.recipe import load_recipe_book
|
||||||
|
from undercooked.sim.replay import (
|
||||||
|
EXIT_MISMATCH,
|
||||||
|
EXIT_OK,
|
||||||
|
EXIT_UNUSABLE,
|
||||||
|
Frame,
|
||||||
|
InputScript,
|
||||||
|
ReplayError,
|
||||||
|
ScriptItem,
|
||||||
|
_field_diff, # private, exercised directly below
|
||||||
|
compare,
|
||||||
|
main,
|
||||||
|
run_script,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SHIPPED_LEVELS = REPO_ROOT / "levels"
|
||||||
|
SHIPPED_RECIPES = SHIPPED_LEVELS / "recipes.yaml"
|
||||||
|
|
||||||
|
WALK_SOLO_SCRIPT = REPO_ROOT / "tests" / "fixtures" / "replays" / "walk_solo.json"
|
||||||
|
PICK_DROP_PASS_SCRIPT = (
|
||||||
|
REPO_ROOT / "tests" / "fixtures" / "replays" / "pick_drop_pass.json"
|
||||||
|
)
|
||||||
|
PICK_DROP_PASS_LEVEL = (
|
||||||
|
REPO_ROOT / "tests" / "fixtures" / "levels" / "_replay" / "pick_drop_pass.yaml"
|
||||||
|
)
|
||||||
|
WALK_SOLO_GOLDEN = REPO_ROOT / "tests" / "goldens" / "walk_solo.golden.json"
|
||||||
|
PICK_DROP_PASS_GOLDEN = REPO_ROOT / "tests" / "goldens" / "pick_drop_pass.golden.json"
|
||||||
|
|
||||||
|
|
||||||
|
def golden_map(path: Path) -> dict[int, str]:
|
||||||
|
"""A golden file's `hashes` as the `tick -> hash` mapping `compare` takes."""
|
||||||
|
golden = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return {int(tick): digest for tick, digest in golden["hashes"].items()}
|
||||||
|
|
||||||
|
|
||||||
|
def golden_final(path: Path) -> str:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))["hash"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_walk_solo() -> tuple[Any, Any, InputScript]:
|
||||||
|
"""Level 1, the recipe book and the committed walk_solo script."""
|
||||||
|
level = load_level_file(SHIPPED_LEVELS / "01_first_kitchen.yaml")
|
||||||
|
book = load_recipe_book(SHIPPED_RECIPES)
|
||||||
|
return level, book, InputScript.load(WALK_SOLO_SCRIPT)
|
||||||
|
|
||||||
|
|
||||||
|
def load_pick_drop_pass() -> tuple[Any, Any, InputScript]:
|
||||||
|
"""The fixture kitchen, the recipe book and the committed pick_drop_pass script."""
|
||||||
|
level = load_level_file(PICK_DROP_PASS_LEVEL)
|
||||||
|
book = load_recipe_book(SHIPPED_RECIPES)
|
||||||
|
return level, book, InputScript.load(PICK_DROP_PASS_SCRIPT)
|
||||||
|
|
||||||
|
|
||||||
|
# -- the committed goldens ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_walk_solo_matches_its_committed_golden() -> None:
|
||||||
|
"""600 ticks of movement-only walking on level 1, every tick recorded."""
|
||||||
|
level, book, script = load_walk_solo()
|
||||||
|
result = run_script(level, book, script)
|
||||||
|
assert result.state.tick == 600
|
||||||
|
assert len(result.events) == 0 # movement only: no press, no event
|
||||||
|
assert result.state.players[0].vel.length < 1e-6 # the tour ends settled
|
||||||
|
assert [tick for tick, _ in result.hashes] == list(range(601))
|
||||||
|
assert result.state.state_hash() == golden_final(WALK_SOLO_GOLDEN)
|
||||||
|
assert compare(result, golden_map(WALK_SOLO_GOLDEN)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_drop_pass_matches_its_committed_golden_and_event_sequence() -> None:
|
||||||
|
"""The whole hand chain, pinned both by hash and by the events it must cause."""
|
||||||
|
level, book, script = load_pick_drop_pass()
|
||||||
|
result = run_script(level, book, script)
|
||||||
|
assert result.state.tick == 139
|
||||||
|
expected: tuple[tuple[str, dict[str, object]], ...] = (
|
||||||
|
(
|
||||||
|
"picked",
|
||||||
|
{"player_id": "p0", "station": "spawn_shelf_0", "item": "tomato", "form": "raw"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dropped",
|
||||||
|
{"player_id": "p0", "station": "prep_0", "item": "tomato", "form": "raw"},
|
||||||
|
),
|
||||||
|
("handed_off", {"from": "p0", "to": "p1", "item": "tomato", "form": "raw"}),
|
||||||
|
("trashed", {"player_id": "p1", "item": "tomato", "score_delta": -2}),
|
||||||
|
)
|
||||||
|
assert len(result.events) == len(expected)
|
||||||
|
for event, (want_kind, want_payload) in zip(result.events, expected, strict=True):
|
||||||
|
assert event.kind == want_kind
|
||||||
|
for key, value in want_payload.items():
|
||||||
|
assert event.payload.get(key) == value, (event.kind, event.payload)
|
||||||
|
assert result.state.counters["trashed"] == 1
|
||||||
|
for player in result.state.players: # the round ends settled, hands empty
|
||||||
|
assert player.carrying is None
|
||||||
|
assert player.vel.length < 1e-6
|
||||||
|
assert result.state.state_hash() == golden_final(PICK_DROP_PASS_GOLDEN)
|
||||||
|
assert compare(result, golden_map(PICK_DROP_PASS_GOLDEN)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mutated_coordinate_diverges_and_compare_names_the_bad_tick_and_field() -> None:
|
||||||
|
"""One coordinate edited in the fixture: the first bad tick and the moved field."""
|
||||||
|
level, book, script = load_walk_solo()
|
||||||
|
frames = list(script.frames)
|
||||||
|
frames[0] = replace(frames[0], move=(0.0, -1.0)) # up, not down
|
||||||
|
mutated = replace(script, frames=tuple(frames))
|
||||||
|
result = run_script(level, book, mutated)
|
||||||
|
mismatches = compare(result, golden_map(WALK_SOLO_GOLDEN))
|
||||||
|
assert mismatches, "a mutated script must not match its golden"
|
||||||
|
first = mismatches[0]
|
||||||
|
assert first.kind == "hash"
|
||||||
|
assert first.tick == 1, "the frame at tick 0 is consumed by substep 0 -> tick 1"
|
||||||
|
assert first.actual is not None and first.actual != first.expected
|
||||||
|
assert "players[0].pos.y" in first.changed, (
|
||||||
|
"the diff must name the field the edit moved, " + str(first.changed)
|
||||||
|
)
|
||||||
|
assert "players[0].vel.y" in first.changed
|
||||||
|
assert all(m.tick >= first.tick for m in mismatches)
|
||||||
|
# the tour re-converges on its walls, so the *final* state may agree with the
|
||||||
|
# golden again — what is pinned is the divergence itself:
|
||||||
|
assert len(mismatches) > 1
|
||||||
|
divergent = [m for m in mismatches if m.kind == "hash"]
|
||||||
|
assert divergent and all(m.actual != m.expected for m in divergent)
|
||||||
|
|
||||||
|
|
||||||
|
# -- determinism and cadence --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_replaying_the_same_script_twice_in_one_process_is_identical() -> None:
|
||||||
|
"""No hidden global state: two runs of each script in one process agree exactly."""
|
||||||
|
for loader in (load_walk_solo, load_pick_drop_pass):
|
||||||
|
level, book, script = loader()
|
||||||
|
first = run_script(level, book, script)
|
||||||
|
second = run_script(level, book, script)
|
||||||
|
assert first.hashes == second.hashes
|
||||||
|
assert first.events == second.events
|
||||||
|
assert first.state.state_hash() == second.state.state_hash()
|
||||||
|
assert first.wall_seconds == second.wall_seconds == 0.0 # no clock injected
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_record_cadence_is_stable_and_the_final_tick_is_always_recorded() -> None:
|
||||||
|
level, book, script = load_walk_solo()
|
||||||
|
sparse = replace(script, record_every=5)
|
||||||
|
first = run_script(level, book, sparse)
|
||||||
|
second = run_script(level, book, sparse)
|
||||||
|
assert first.hashes == second.hashes
|
||||||
|
ticks = [tick for tick, _ in first.hashes]
|
||||||
|
assert ticks[0] == 0
|
||||||
|
assert ticks[-1] == 600 # the final tick is always recorded, cadence or not
|
||||||
|
assert set(ticks) == {tick for tick in range(0, 601) if tick % 5 == 0} | {600}
|
||||||
|
silent = replace(script, record_every=0)
|
||||||
|
unrecorded = run_script(level, book, silent)
|
||||||
|
assert unrecorded.hashes == ()
|
||||||
|
assert dict(unrecorded.states) == {}
|
||||||
|
assert unrecorded.state.tick == 600 # the run still runs; only the recording stops
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_run_with_a_clock_measures_wall_seconds_and_a_pinned_length_clips() -> None:
|
||||||
|
"""`clock` is the seam the perf smoke times through; `max_ticks` pins the run."""
|
||||||
|
level, book, script = load_walk_solo()
|
||||||
|
measured = run_script(level, book, script, clock=time.perf_counter)
|
||||||
|
assert measured.wall_seconds > 0.0
|
||||||
|
clipped = run_script(level, book, script, max_ticks=10)
|
||||||
|
assert clipped.state.tick == 10
|
||||||
|
assert [tick for tick, _ in clipped.hashes] == list(range(11)) # cadence 1 + final
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_reports_a_cadence_mismatch_as_missing_and_extra() -> None:
|
||||||
|
"""A golden that records ticks the run did not (and vice versa) is visible."""
|
||||||
|
level, book, script = load_walk_solo()
|
||||||
|
sparse = run_script(level, book, replace(script, record_every=5))
|
||||||
|
by_tick = dict(sparse.hashes)
|
||||||
|
golden = {0: by_tick[0], 7: by_tick[5], 10: by_tick[10]}
|
||||||
|
mismatches = compare(sparse, golden)
|
||||||
|
kinds = {mismatch.tick: mismatch.kind for mismatch in mismatches}
|
||||||
|
assert kinds[7] == "missing" # the golden records a tick the run did not
|
||||||
|
assert kinds[5] == "extra" # the run records a tick the golden does not have
|
||||||
|
assert 0 not in kinds and 10 not in kinds # the matching ticks produce nothing
|
||||||
|
assert all(mismatch.changed == () for mismatch in mismatches)
|
||||||
|
|
||||||
|
|
||||||
|
# -- the goldens are read-only for the tests ----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_field_diff_walks_mappings_and_missing_keys() -> None:
|
||||||
|
"""`counters['trashed']` appears mid-run: the diff must cross the mapping.
|
||||||
|
|
||||||
|
The `walk_solo` states all share one empty `counters` object (so the early
|
||||||
|
`a is b` return short-circuits it); the `pick_drop_pass` run is what makes
|
||||||
|
the mapping branch and a missing key on one side actually run.
|
||||||
|
"""
|
||||||
|
level, book, script = load_pick_drop_pass()
|
||||||
|
result = run_script(level, book, script)
|
||||||
|
ticks = sorted(result.states)
|
||||||
|
pair: tuple[Any, Any] | None = None
|
||||||
|
for a, b in pairwise(ticks):
|
||||||
|
if bool(result.states[a].counters) != bool(result.states[b].counters):
|
||||||
|
pair = (result.states[a], result.states[b])
|
||||||
|
break
|
||||||
|
assert pair is not None, "the trashed tick must flip counters from empty to non-empty"
|
||||||
|
changed = _field_diff(*pair)
|
||||||
|
assert "counters['trashed']" in changed, changed
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_golden_files_are_never_rewritten_by_the_tests(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""The whole check flow — harness and CLI — must not touch the committed goldens."""
|
||||||
|
before = (
|
||||||
|
WALK_SOLO_GOLDEN.read_bytes(),
|
||||||
|
PICK_DROP_PASS_GOLDEN.read_bytes(),
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||||
|
level, book, script = load_pick_drop_pass() # the harness flow
|
||||||
|
assert compare(run_script(level, book, script), golden_map(PICK_DROP_PASS_GOLDEN)) == []
|
||||||
|
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_OK # the CLI flow
|
||||||
|
capsys.readouterr()
|
||||||
|
after = (
|
||||||
|
WALK_SOLO_GOLDEN.read_bytes(),
|
||||||
|
PICK_DROP_PASS_GOLDEN.read_bytes(),
|
||||||
|
)
|
||||||
|
assert before == after
|
||||||
|
|
||||||
|
|
||||||
|
# -- the CLI -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_cli_exits_zero_and_reports_the_match(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||||
|
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_OK
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "ok: 601 hashes match walk_solo.golden.json" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_cli_exits_one_and_names_the_bad_tick_when_the_fixture_is_mutated(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""The task's acceptance test: edit one coordinate, `check` fails and diagnoses."""
|
||||||
|
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||||
|
original = WALK_SOLO_SCRIPT.read_bytes()
|
||||||
|
data = json.loads(original)
|
||||||
|
data["frames"][0]["move"] = [0.0, -1.0]
|
||||||
|
WALK_SOLO_SCRIPT.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_MISMATCH
|
||||||
|
finally:
|
||||||
|
WALK_SOLO_SCRIPT.write_bytes(original)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "first divergent tick 1" in out
|
||||||
|
assert "players[0].pos.y" in out
|
||||||
|
assert WALK_SOLO_SCRIPT.read_bytes() == original # the fixture is left untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_cli_exits_two_without_a_golden(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||||
|
script = InputScript(
|
||||||
|
name="no_golden_here",
|
||||||
|
seed=1,
|
||||||
|
level_id="01_first_kitchen",
|
||||||
|
tick_hz=20,
|
||||||
|
frames=(Frame(tick=0, player_id="p0", move=(1.0, 0.0)),),
|
||||||
|
)
|
||||||
|
path = tmp_path / "no_golden_here.json"
|
||||||
|
script.dump(path)
|
||||||
|
code = main(["check", str(path)])
|
||||||
|
assert code == EXIT_UNUSABLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_cli_exits_two_when_the_level_is_not_in_the_content_dir(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""The pick_drop_pass kitchen is a test fixture, not campaign content."""
|
||||||
|
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||||
|
assert main(["check", str(PICK_DROP_PASS_SCRIPT)]) == EXIT_UNUSABLE
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "99_replay_pick_drop_pass" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_cli_writes_the_golden_and_check_reads_it_back(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""`record` is the manual re-bless; into a temp goldens dir, the real one is untouched."""
|
||||||
|
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||||
|
committed_before = WALK_SOLO_GOLDEN.read_bytes()
|
||||||
|
script = InputScript.load(WALK_SOLO_SCRIPT)
|
||||||
|
path = tmp_path / "record_smoke.json"
|
||||||
|
replace(script, name="record_smoke").dump(path)
|
||||||
|
assert main(["record", str(path)], goldens_dir=tmp_path) == EXIT_OK
|
||||||
|
golden = tmp_path / "record_smoke.golden.json"
|
||||||
|
assert golden.exists()
|
||||||
|
assert main(["check", str(path)], goldens_dir=tmp_path) == EXIT_OK
|
||||||
|
capsys.readouterr()
|
||||||
|
assert main(["record", str(path)], goldens_dir=tmp_path) == EXIT_OK # the re-bless note
|
||||||
|
assert "overwriting" in capsys.readouterr().out
|
||||||
|
# and the committed golden never moves:
|
||||||
|
assert WALK_SOLO_GOLDEN.read_bytes() == committed_before
|
||||||
|
# while a fresh record reproduces the committed hash bit for bit:
|
||||||
|
fresh = json.loads(golden.read_text(encoding="utf-8"))
|
||||||
|
assert fresh["hash"] == json.loads(committed_before.decode("utf-8"))["hash"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_cli_exits_two_on_a_broken_script_file(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
bad = tmp_path / "broken_script.json"
|
||||||
|
bad.write_text(json.dumps({"seed": 1, "level_id": "x", "bogus": 1}), encoding="utf-8")
|
||||||
|
assert main(["check", str(bad)]) == EXIT_UNUSABLE
|
||||||
|
assert "unknown script key(s): bogus" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_cli_exits_two_when_the_content_would_not_load(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(tmp_path / "no_such_dir"))
|
||||||
|
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_UNUSABLE
|
||||||
|
assert "would not load" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_cli_reports_cadence_notes_and_the_missing_and_empty_diff_paths(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""The three diagnostic branches of a failed check, each named in the report."""
|
||||||
|
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
|
||||||
|
level, book, script = load_walk_solo()
|
||||||
|
sparse = replace(script, name="cadence_check", record_every=5)
|
||||||
|
path = tmp_path / "cadence_check.json"
|
||||||
|
sparse.dump(path)
|
||||||
|
assert main(["record", str(path)], goldens_dir=tmp_path) == EXIT_OK
|
||||||
|
|
||||||
|
# 1. the cadence note: the golden was recorded at 5, the script now says 1
|
||||||
|
replace(sparse, name="cadence_check", record_every=1).dump(path)
|
||||||
|
assert main(["check", str(path)], goldens_dir=tmp_path) == EXIT_MISMATCH
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "recorded at record_every=5" in out
|
||||||
|
|
||||||
|
# 2. a `missing` first mismatch: the golden records a tick the run did not
|
||||||
|
missing_path = tmp_path / "missing_first.json"
|
||||||
|
replace(sparse, name="missing_first").dump(missing_path)
|
||||||
|
first_zero = run_script(level, book, sparse).hashes[0][1]
|
||||||
|
(tmp_path / "missing_first.golden.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"name": "missing_first", "tick": 600, "record_every": 5,
|
||||||
|
"hashes": {"0": first_zero, "3": "x" * 64}}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert main(["check", str(missing_path)], goldens_dir=tmp_path) == EXIT_MISMATCH
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "a golden tick this run did not record" in out
|
||||||
|
|
||||||
|
# 3. a hash mismatch at tick 0 carries no field diff (the baseline is itself)
|
||||||
|
empty_path = tmp_path / "empty_diff.json"
|
||||||
|
replace(sparse, name="empty_diff").dump(empty_path)
|
||||||
|
(tmp_path / "empty_diff.golden.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"name": "empty_diff", "tick": 1, "record_every": 5,
|
||||||
|
"hashes": {"0": "y" * 64}}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert main(["check", str(empty_path)], goldens_dir=tmp_path) == EXIT_MISMATCH
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "no field diff" in out
|
||||||
|
|
||||||
|
|
||||||
|
# -- the script format ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_refuses_a_broken_script_naming_every_problem() -> None:
|
||||||
|
script = InputScript(
|
||||||
|
seed=-1, # < 0
|
||||||
|
level_id="", # empty
|
||||||
|
tick_hz=10.0, # not 20
|
||||||
|
record_every=-3, # negative
|
||||||
|
frames=(Frame(tick=-5, player_id="", move=(float("nan"), 1.0), action=0, face="sideways"),), # type: ignore[arg-type] — the guard under test
|
||||||
|
items=(ScriptItem(station="", item="kale", form="soggy"),),
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError) as excinfo:
|
||||||
|
script.validate()
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "seed must be an integer in [0, 2**64)" in message
|
||||||
|
assert "level_id must be a non-empty string" in message
|
||||||
|
assert "tick_hz must be 20" in message
|
||||||
|
assert "record_every must be >= 0" in message
|
||||||
|
assert "tick must be a non-negative integer" in message
|
||||||
|
assert "move must be two finite numbers" in message
|
||||||
|
assert "action must be a bool" in message
|
||||||
|
assert "face must be one of" in message
|
||||||
|
assert "item must be an ItemKind value" in message
|
||||||
|
assert "form must be an ItemForm value" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_refuses_a_non_integer_record_every() -> None:
|
||||||
|
script = InputScript(
|
||||||
|
seed=1,
|
||||||
|
level_id="x",
|
||||||
|
tick_hz=20,
|
||||||
|
record_every=1.5, # type: ignore[arg-type] — the guard exists for exactly this
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError, match=r"record_every must be an integer"):
|
||||||
|
script.validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_accepts_the_committed_scripts() -> None:
|
||||||
|
for path in (WALK_SOLO_SCRIPT, PICK_DROP_PASS_SCRIPT):
|
||||||
|
InputScript.load(path).validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_refuses_unknown_keys_at_every_level(tmp_path: Path) -> None:
|
||||||
|
bad = tmp_path / "bad.json"
|
||||||
|
bad.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"seed": 1,
|
||||||
|
"level_id": "x",
|
||||||
|
"tick_hz": 20,
|
||||||
|
"bogus": 1,
|
||||||
|
"frames": [{"tick": 0, "player_id": "p", "move": [1, 0], "extra": 1}],
|
||||||
|
"items": [{"station": "s", "item": "tomato", "form": "raw", "mystery": 2}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError, match=r"unknown script key\(s\): bogus"):
|
||||||
|
InputScript.load(bad)
|
||||||
|
bad_frame = tmp_path / "bad_frame.json"
|
||||||
|
bad_frame.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"seed": 1,
|
||||||
|
"level_id": "x",
|
||||||
|
"tick_hz": 20,
|
||||||
|
"frames": [{"tick": 0, "player_id": "p", "move": [1, 0], "extra": 1}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError, match=r"frame 0 has unknown key\(s\): extra"):
|
||||||
|
InputScript.load(bad_frame)
|
||||||
|
bad_item = tmp_path / "bad_item.json"
|
||||||
|
bad_item.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"seed": 1,
|
||||||
|
"level_id": "x",
|
||||||
|
"tick_hz": 20,
|
||||||
|
"items": [
|
||||||
|
{"station": "s", "item": "tomato", "form": "raw", "mystery": 2}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError, match=r"item 0 has unknown key\(s\): mystery"):
|
||||||
|
InputScript.load(bad_item)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_refuses_every_malformed_shape(tmp_path: Path) -> None:
|
||||||
|
def writes(name: str, text: str) -> Path:
|
||||||
|
path = tmp_path / name
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
with pytest.raises(ReplayError, match=r"must be a JSON object, got list"):
|
||||||
|
InputScript.load(writes("top_list.json", "[1, 2, 3]"))
|
||||||
|
with pytest.raises(ReplayError, match=r"missing required key 'seed'"):
|
||||||
|
InputScript.load(writes("no_seed.json", json.dumps({"level_id": "x", "tick_hz": 20})))
|
||||||
|
with pytest.raises(ReplayError, match=r"frame 0 must be a JSON object"):
|
||||||
|
InputScript.load(
|
||||||
|
writes("frame_scalar.json", json.dumps(
|
||||||
|
{"seed": 1, "level_id": "x", "tick_hz": 20, "frames": [42]}))
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError, match=r"frame 0 is missing required key 'move'"):
|
||||||
|
InputScript.load(
|
||||||
|
writes("frame_no_move.json", json.dumps(
|
||||||
|
{"seed": 1, "level_id": "x", "tick_hz": 20,
|
||||||
|
"frames": [{"tick": 0, "player_id": "p"}]}))
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError, match=r"move must be a two-element array"):
|
||||||
|
InputScript.load(
|
||||||
|
writes("move_not_list.json", json.dumps(
|
||||||
|
{"seed": 1, "level_id": "x", "tick_hz": 20,
|
||||||
|
"frames": [{"tick": 0, "player_id": "p", "move": 1}]}))
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError, match=r"item 0 must be a JSON object"):
|
||||||
|
InputScript.load(
|
||||||
|
writes("item_scalar.json", json.dumps(
|
||||||
|
{"seed": 1, "level_id": "x", "tick_hz": 20, "items": [42]}))
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError, match=r"item 0 is missing required key 'form'"):
|
||||||
|
InputScript.load(
|
||||||
|
writes("item_no_form.json", json.dumps(
|
||||||
|
{"seed": 1, "level_id": "x", "tick_hz": 20,
|
||||||
|
"items": [{"station": "s", "item": "tomato"}]}))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dump_load_roundtrip_is_lossless(tmp_path: Path) -> None:
|
||||||
|
_, _, script = load_pick_drop_pass()
|
||||||
|
path = tmp_path / "roundtrip.json"
|
||||||
|
script.dump(path)
|
||||||
|
assert InputScript.load(path) == script
|
||||||
|
# an unnamed script dumps without a `name` key; `load` then takes the stem
|
||||||
|
unnamed = replace(script, name="")
|
||||||
|
unnamed_path = tmp_path / "unnamed.json"
|
||||||
|
unnamed.dump(unnamed_path)
|
||||||
|
data = json.loads(unnamed_path.read_text(encoding="utf-8"))
|
||||||
|
assert "name" not in data
|
||||||
|
assert InputScript.load(unnamed_path) == replace(unnamed, name=unnamed_path.stem)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_script_refuses_a_level_id_mismatch() -> None:
|
||||||
|
level, book, script = load_walk_solo()
|
||||||
|
other = replace(script, level_id="99_replay_pick_drop_pass")
|
||||||
|
with pytest.raises(ReplayError, match="names level"):
|
||||||
|
run_script(level, book, other)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_script_refuses_an_item_on_an_unknown_station() -> None:
|
||||||
|
level, book, script = load_walk_solo()
|
||||||
|
broken = replace(
|
||||||
|
script,
|
||||||
|
items=(ScriptItem(station="chop_board_9", item="tomato", form="raw"),),
|
||||||
|
)
|
||||||
|
with pytest.raises(ReplayError, match="does not have"):
|
||||||
|
run_script(level, book, broken)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_script_refuses_a_negative_max_ticks() -> None:
|
||||||
|
level, book, script = load_pick_drop_pass()
|
||||||
|
with pytest.raises(ReplayError, match="max_ticks"):
|
||||||
|
run_script(level, book, script, max_ticks=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frameless_script_runs_zero_ticks_and_records_the_built_state() -> None:
|
||||||
|
level, book, _ = load_pick_drop_pass()
|
||||||
|
frameless = InputScript(
|
||||||
|
name="idle", seed=0x5EED, level_id=level.id, tick_hz=20, frames=()
|
||||||
|
)
|
||||||
|
result = run_script(level, book, frameless)
|
||||||
|
assert result.state.tick == 0
|
||||||
|
assert [tick for tick, _ in result.hashes] == [0]
|
||||||
|
assert result.events == ()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""The two-player carry script against its committed golden hash.
|
||||||
|
|
||||||
|
`tests/goldens/carry_two_player.golden.json` pins the state hash of a fixed 12-tick
|
||||||
|
script: one pass (a set-down picked up inside the hand-off window, twice — out and
|
||||||
|
back) and one contested pick (the full tie broken by seat, the loser told
|
||||||
|
`contested_lost`). The event sequence is asserted in code, so a golden drift is
|
||||||
|
diagnosable in one read: if the events match but the hash does not, a constant or
|
||||||
|
a subsystem order moved; if the events differ, a rule changed shape.
|
||||||
|
|
||||||
|
The golden is committed, not computed: a mismatch fails, and this test prints the
|
||||||
|
diff hint. Regeneration is deliberate — an intentional sim-contract change recorded
|
||||||
|
in the phase task, never a side effect of a failing run (task 05's replay harness
|
||||||
|
formalises the re-bless path and the input-script format this script foreshadows).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim.grid import Grid
|
||||||
|
from undercooked.sim.model import (
|
||||||
|
Facing,
|
||||||
|
Item,
|
||||||
|
ItemForm,
|
||||||
|
ItemKind,
|
||||||
|
KitchenState,
|
||||||
|
Player,
|
||||||
|
Station,
|
||||||
|
StationKind,
|
||||||
|
TileKind,
|
||||||
|
Vec2,
|
||||||
|
)
|
||||||
|
from undercooked.sim.step import STEP_DT, PlayerInput, step
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
GOLDEN_PATH: Path = (
|
||||||
|
Path(__file__).resolve().parent.parent / "goldens" / "carry_two_player.golden.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 13, 9
|
||||||
|
|
||||||
|
|
||||||
|
def build_state() -> KitchenState:
|
||||||
|
"""Tick 0: A at the prep holding a lettuce, B at its left diagonal, both facing it."""
|
||||||
|
grid = Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT))
|
||||||
|
prep = Station(id="prep-9-4", kind=StationKind.PREP, x=9, y=4)
|
||||||
|
a = Player(
|
||||||
|
id="cook-a",
|
||||||
|
pos=Vec2(9.5, 5.5),
|
||||||
|
facing=Facing.UP,
|
||||||
|
carrying=Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW),
|
||||||
|
seat=0,
|
||||||
|
)
|
||||||
|
b = Player(id="cook-b", pos=Vec2(8.5, 5.5), facing=Facing.UP, seat=1)
|
||||||
|
return KitchenState(
|
||||||
|
level_id="03_replay_carry",
|
||||||
|
seed=99,
|
||||||
|
grid=grid,
|
||||||
|
stations=(prep,),
|
||||||
|
players=(a, b),
|
||||||
|
target_score=40,
|
||||||
|
time_left=90.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def frame(player_id: str, seq: int, action: bool, tick: int) -> PlayerInput:
|
||||||
|
return PlayerInput(player_id=player_id, seq=seq, action=action, received_at_tick=tick)
|
||||||
|
|
||||||
|
|
||||||
|
# The 12-tick script: (tick, one or two frames). The pass is t0 -> t2 (a sets down,
|
||||||
|
# b picks: HANDED_OFF), the contest is t6 (both press the prep's item: a, seat 0,
|
||||||
|
# wins; b is told contested_lost), and the return pass is t8 -> t10.
|
||||||
|
SCRIPT: tuple[tuple[int, tuple[PlayerInput, ...]], ...] = (
|
||||||
|
(0, (frame("cook-a", 0, True, 0),)),
|
||||||
|
(1, (frame("cook-a", 1, False, 1),)),
|
||||||
|
(2, (frame("cook-b", 2, True, 2),)),
|
||||||
|
(3, (frame("cook-b", 3, False, 3),)),
|
||||||
|
(4, (frame("cook-b", 4, True, 4),)),
|
||||||
|
(5, (frame("cook-b", 5, False, 5),)),
|
||||||
|
(6, (frame("cook-a", 6, True, 6), frame("cook-b", 6, True, 6))),
|
||||||
|
(7, (frame("cook-a", 7, False, 7),)),
|
||||||
|
(8, (frame("cook-a", 8, True, 8),)),
|
||||||
|
(9, (frame("cook-a", 9, False, 9),)),
|
||||||
|
(10, (frame("cook-b", 10, True, 10),)),
|
||||||
|
(11, (frame("cook-b", 11, False, 11),)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The event the script must produce, tick by tick (kinds and the load-bearing payload).
|
||||||
|
EXPECTED_EVENTS: tuple[tuple[str, dict[str, str]], ...] = (
|
||||||
|
("dropped", {"player_id": "cook-a", "station": "prep-9-4"}),
|
||||||
|
("handed_off", {"from": "cook-a", "to": "cook-b"}),
|
||||||
|
("dropped", {"player_id": "cook-b", "station": "prep-9-4"}),
|
||||||
|
("handed_off", {"from": "cook-b", "to": "cook-a"}),
|
||||||
|
("denied", {"player_id": "cook-b", "reason": "contested_lost"}),
|
||||||
|
("dropped", {"player_id": "cook-a", "station": "prep-9-4"}),
|
||||||
|
("handed_off", {"from": "cook-a", "to": "cook-b"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_script() -> tuple[KitchenState, tuple[tuple[str, dict[str, object]], ...]]:
|
||||||
|
"""Run the 12-tick script; return the final state and the events, keyed by kind."""
|
||||||
|
state = build_state()
|
||||||
|
events: list[tuple[str, dict[str, object]]] = []
|
||||||
|
for _tick, frames in SCRIPT:
|
||||||
|
state, produced = step(state, STEP_DT, frames)
|
||||||
|
events.extend((event.kind, dict(event.payload)) for event in produced)
|
||||||
|
return state, tuple(events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_script_produces_the_expected_event_sequence() -> None:
|
||||||
|
_state, events = run_script()
|
||||||
|
assert len(events) == len(EXPECTED_EVENTS)
|
||||||
|
for (got_kind, got_payload), (want_kind, want_payload) in zip(
|
||||||
|
events, EXPECTED_EVENTS, strict=True
|
||||||
|
):
|
||||||
|
assert got_kind == want_kind
|
||||||
|
for key, value in want_payload.items():
|
||||||
|
assert got_payload.get(key) == value, (got_kind, got_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_script_reaches_the_committed_golden_hash() -> None:
|
||||||
|
golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||||||
|
state, _events = run_script()
|
||||||
|
assert state.tick == golden["tick"]
|
||||||
|
if state.state_hash() != golden["hash"]:
|
||||||
|
# The diff hint the phase promises: name both hashes so the commit shows the drift.
|
||||||
|
print(
|
||||||
|
f"golden mismatch for {GOLDEN_PATH.name}:\n"
|
||||||
|
f" expected {golden['hash']}\n"
|
||||||
|
f" actual {state.state_hash()}\n"
|
||||||
|
"Regenerate only on a deliberate sim-contract change recorded in the phase "
|
||||||
|
"task (docs/SIMULATION.md, 'Goldens'); never to silence a failure."
|
||||||
|
)
|
||||||
|
assert state.state_hash() == golden["hash"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_golden_file_is_committed_and_well_formed() -> None:
|
||||||
|
golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||||||
|
assert golden["name"] == "carry_two_player"
|
||||||
|
assert golden["tick"] == 12
|
||||||
|
assert len(golden["hash"]) == 64 # a sha256 hex digest, nothing else
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerunning_the_script_is_bit_identical() -> None:
|
||||||
|
"""No hidden global state: the same script twice in one process hashes the same."""
|
||||||
|
first, first_events = run_script()
|
||||||
|
second, second_events = run_script()
|
||||||
|
assert first.state_hash() == second.state_hash()
|
||||||
|
assert first_events == second_events
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""`sim/rng.py` is golden-vector locked — the replay contract starts here (A21).
|
||||||
|
|
||||||
|
The numbers below are not "whatever the code returns today": they are splitmix64
|
||||||
|
as published (Steele/Lea/Venc 2014, Algorithm 5), written down once and then
|
||||||
|
never moved. If a future change to the generator breaks them, a committed replay
|
||||||
|
golden would silently describe a different game, and that is exactly what a
|
||||||
|
replay is supposed to catch.
|
||||||
|
|
||||||
|
Beyond the vectors, the tests pin the properties the kernel relies on: the
|
||||||
|
counter wraps at 2**64, `next_float` is a half-open [0,1) draw that is actually
|
||||||
|
spread out, `range_int` hits both of its ends, `fork` is reproducible and never
|
||||||
|
disturbs its parent, and nothing in the module is shared global state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim.rng import GOLDEN_GAMMA, MASK64, Rng, mix64
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
# --- published splitmix64 vectors ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_zero_u64_vector() -> None:
|
||||||
|
rng = Rng(0)
|
||||||
|
assert [rng.next_u64() for _ in range(4)] == [
|
||||||
|
16294208416658607535,
|
||||||
|
7960286522194355700,
|
||||||
|
487617019471545679,
|
||||||
|
17909611376780542444,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_seed_vector() -> None:
|
||||||
|
# 0x5EED0401 is the `random_seed` fixture value: what a real round draws first.
|
||||||
|
rng = Rng(0x5EED_0401)
|
||||||
|
assert [rng.next_u64() for _ in range(2)] == [
|
||||||
|
966088850652082199,
|
||||||
|
13654240339074911480,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_float_vector() -> None:
|
||||||
|
rng = Rng(0)
|
||||||
|
assert rng.next_float() == pytest.approx(0.8833108082136426, abs=0.0)
|
||||||
|
assert rng.next_float() == pytest.approx(0.43152799704850997, abs=0.0)
|
||||||
|
assert rng.next_float() == pytest.approx(0.026433771592597743, abs=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_int_vector() -> None:
|
||||||
|
rng = Rng(0)
|
||||||
|
assert [rng.range_int(0, 5) for _ in range(8)] == [1, 0, 1, 4, 1, 0, 5, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_choice_vector() -> None:
|
||||||
|
rng = Rng(1)
|
||||||
|
assert [rng.choice(["a", "b", "c", "d"]) for _ in range(6)] == [
|
||||||
|
"b",
|
||||||
|
"d",
|
||||||
|
"c",
|
||||||
|
"d",
|
||||||
|
"b",
|
||||||
|
"a",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_vector() -> None:
|
||||||
|
assert Rng(0).fork(0).state == 5197578548964807871
|
||||||
|
assert Rng(0).fork(7).state == 8409816773569330625
|
||||||
|
assert Rng(0).fork(8).state == 15655543297350242377
|
||||||
|
assert Rng(0).fork(0).next_u64() == 6235967106033911276
|
||||||
|
|
||||||
|
|
||||||
|
def test_mix64_is_the_published_finalizer() -> None:
|
||||||
|
assert mix64(0) == 0 # the fixed point `fork` steps around
|
||||||
|
assert mix64(GOLDEN_GAMMA) == 16294208416658607535 # == Rng(0).next_u64()
|
||||||
|
|
||||||
|
|
||||||
|
# --- the counter --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("seed", [0, -1, -2**64, 2**64, 2**65 + 7, MASK64])
|
||||||
|
def test_any_seed_is_reduced_to_64_bits(seed: int) -> None:
|
||||||
|
rng = Rng(seed)
|
||||||
|
assert 0 <= rng.state <= MASK64
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_that_agree_mod_2p64_agree() -> None:
|
||||||
|
assert Rng(-1).state == MASK64
|
||||||
|
assert Rng(-1).next_u64() == Rng(MASK64).next_u64()
|
||||||
|
|
||||||
|
|
||||||
|
def test_counter_wraps_and_never_leaves_64_bits() -> None:
|
||||||
|
rng = Rng(MASK64 - 2)
|
||||||
|
for _ in range(6):
|
||||||
|
rng.next_u64()
|
||||||
|
assert 0 <= rng.state <= MASK64
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_seed_is_not_a_fixed_point() -> None:
|
||||||
|
rng = Rng(0)
|
||||||
|
first = rng.next_u64()
|
||||||
|
assert rng.state != 0
|
||||||
|
assert first != rng.next_u64()
|
||||||
|
|
||||||
|
|
||||||
|
# --- draw distributions ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_float_is_half_open_over_many_draws() -> None:
|
||||||
|
rng = Rng(0xC0FFEE)
|
||||||
|
draws = [rng.next_float() for _ in range(10_000)]
|
||||||
|
assert all(0.0 <= draw < 1.0 for draw in draws)
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_float_is_spread_out() -> None:
|
||||||
|
rng = Rng(0xC0FFEE)
|
||||||
|
buckets = [0] * 10
|
||||||
|
for _ in range(10_000):
|
||||||
|
buckets[int(rng.next_float() * 10)] += 1
|
||||||
|
assert all(850 <= count <= 1150 for count in buckets), buckets
|
||||||
|
mean_bucket = sum(count * (index + 0.5) for index, count in enumerate(buckets)) / 10_000
|
||||||
|
assert 4.5 <= mean_bucket <= 5.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_float_uses_the_whole_range() -> None:
|
||||||
|
rng = Rng(424_242)
|
||||||
|
draws = [rng.next_float() for _ in range(10_000)]
|
||||||
|
assert min(draws) < 0.01
|
||||||
|
assert max(draws) > 0.99
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_int_stays_in_bounds_and_reaches_both_ends() -> None:
|
||||||
|
rng = Rng(2024)
|
||||||
|
draws = [rng.range_int(3, 9) for _ in range(5_000)]
|
||||||
|
assert set(draws) == {3, 4, 5, 6, 7, 8, 9}
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_int_of_a_single_value_never_moves() -> None:
|
||||||
|
rng = Rng(9)
|
||||||
|
assert [rng.range_int(-2, -2) for _ in range(4)] == [-2, -2, -2, -2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_int_rejects_an_empty_range() -> None:
|
||||||
|
with pytest.raises(ValueError, match=r"empty integer range 5\.\.2"):
|
||||||
|
Rng(0).range_int(5, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_choice_only_returns_members() -> None:
|
||||||
|
rng = Rng(7)
|
||||||
|
flavors = ["tomato", "onion", "lettuce"]
|
||||||
|
assert {rng.choice(flavors) for _ in range(500)} == set(flavors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_choice_rejects_an_empty_sequence() -> None:
|
||||||
|
with pytest.raises(ValueError, match="empty sequence"):
|
||||||
|
Rng(0).choice([])
|
||||||
|
|
||||||
|
|
||||||
|
# --- streams are independent, and fork does not disturb the parent ----------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_generators_from_one_seed_agree() -> None:
|
||||||
|
left, right = Rng(1234), Rng(1234)
|
||||||
|
assert [left.next_u64() for _ in range(32)] == [right.next_u64() for _ in range(32)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_generator_is_not_shared_state() -> None:
|
||||||
|
first, second = Rng(5), Rng(5)
|
||||||
|
for _ in range(4):
|
||||||
|
first.next_u64()
|
||||||
|
assert second.state == 5
|
||||||
|
assert second.next_u64() == Rng(5).next_u64()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_is_reproducible_and_independent() -> None:
|
||||||
|
parent = Rng(0xC1)
|
||||||
|
child_a, child_b = parent.fork(3), parent.fork(3)
|
||||||
|
assert child_a == child_b
|
||||||
|
assert [child_a.next_u64() for _ in range(8)] == [child_b.next_u64() for _ in range(8)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_leaves_the_parent_stream_untouched() -> None:
|
||||||
|
parent = Rng(0xC1)
|
||||||
|
before = parent.state
|
||||||
|
for salt in range(6):
|
||||||
|
parent.fork(salt)
|
||||||
|
assert parent.state == before
|
||||||
|
assert parent.next_u64() == Rng(0xC1).next_u64()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("salt", "differs_from_zero"),
|
||||||
|
[(0, False), (1, True), (2, True), (7, True), (-1, True), (MASK64, True)],
|
||||||
|
)
|
||||||
|
def test_the_salt_selects_the_stream(salt: int, differs_from_zero: bool) -> None:
|
||||||
|
parent = Rng(0)
|
||||||
|
other = parent.fork(salt)
|
||||||
|
assert (other.state != parent.fork(0).state) is differs_from_zero
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_zero_is_not_the_parent_stream() -> None:
|
||||||
|
# mix64(0) == 0, so a naive `state ^ mix64(salt)` would clone the parent here.
|
||||||
|
assert Rng(0).fork(0).state != 0
|
||||||
|
assert Rng(0).fork(0).next_u64() != Rng(0).next_u64()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_forks_are_reproducible() -> None:
|
||||||
|
outer = Rng(77)
|
||||||
|
deep_a = outer.fork(1).fork(2).next_u64()
|
||||||
|
deep_b = Rng(77).fork(1).fork(2).next_u64()
|
||||||
|
assert deep_a == deep_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_accepts_any_integer_salt() -> None:
|
||||||
|
assert Rng(3).fork(-42).state == Rng(3).fork(-42 + (1 << 64)).state
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
"""`build_state` + `step` — the kernel's contract: order, clamping, purity, determinism.
|
||||||
|
|
||||||
|
Phase 03 task 01 promises four things, and each has its own test below rather
|
||||||
|
than being inferred from a golden hash (that arrives with task 05):
|
||||||
|
|
||||||
|
* **Clamping** — `dt` outside `[0, MAX_DT]` buys nothing, so a stalled process
|
||||||
|
is never repaid with a burst of substeps;
|
||||||
|
* **Accumulation** — the sim advances in whole `STEP_DT` substeps and carries
|
||||||
|
the remainder, so 17 x 0.05 s is exactly 17 ticks and 0.03 s twice is one tick;
|
||||||
|
* **Rejection, not crash** — input from a player this kitchen does not have
|
||||||
|
becomes an `err` event;
|
||||||
|
* **Purity + determinism** — `step()` never touches the state it was handed,
|
||||||
|
and two states built from one seed stay bit-identical for hundreds of ticks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import math
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim.level import Level, LevelDef
|
||||||
|
from undercooked.sim.model import (
|
||||||
|
EventKind,
|
||||||
|
Facing,
|
||||||
|
KitchenState,
|
||||||
|
PlayerState,
|
||||||
|
RoundResult,
|
||||||
|
Vec2,
|
||||||
|
)
|
||||||
|
from undercooked.sim.purity import assert_pure_imports
|
||||||
|
from undercooked.sim.recipe import RecipeBook
|
||||||
|
from undercooked.sim.state_build import (
|
||||||
|
SEAT_COLORS,
|
||||||
|
SeatSpec,
|
||||||
|
StateBuildError,
|
||||||
|
build_state,
|
||||||
|
color_for_seat,
|
||||||
|
)
|
||||||
|
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, resolve_inputs, step
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
# --- fixture content ------------------------------------------------------------------
|
||||||
|
|
||||||
|
TILES: dict[str, dict[str, str]] = {
|
||||||
|
".": {"kind": "floor"},
|
||||||
|
"#": {"kind": "wall"},
|
||||||
|
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
|
||||||
|
"C": {"kind": "floor", "station": "chop_board"},
|
||||||
|
"V": {"kind": "floor", "station": "serve"},
|
||||||
|
}
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
"#######",
|
||||||
|
"#S.C..#",
|
||||||
|
"#.....#",
|
||||||
|
"#....V#",
|
||||||
|
"#######",
|
||||||
|
]
|
||||||
|
|
||||||
|
RECIPES: dict[str, Any] = {
|
||||||
|
"soup": {
|
||||||
|
"id": "soup",
|
||||||
|
"name": "Tomato Soup",
|
||||||
|
"base_score": 40,
|
||||||
|
"ingredients": [{"kind": "tomato", "form": "raw"}],
|
||||||
|
"steps": [{"kind": "serve", "station": "serve"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_level(**overrides: Any) -> Level:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"id": "01_kernel",
|
||||||
|
"name": "Kernel Kitchen",
|
||||||
|
"order": 1,
|
||||||
|
"time_limit": 12,
|
||||||
|
"target_score": 40,
|
||||||
|
"difficulty": 1,
|
||||||
|
"grid": copy.deepcopy(GRID),
|
||||||
|
"tiles": copy.deepcopy(TILES),
|
||||||
|
"spawns": [[1, 1]],
|
||||||
|
"orders": {"pool": ["soup"], "concurrent": 1, "spawn_interval": [4, 6], "deadline": 30},
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return Level.build(LevelDef.model_validate(data))
|
||||||
|
|
||||||
|
|
||||||
|
def make_book() -> RecipeBook:
|
||||||
|
return RecipeBook.model_validate({"recipes": copy.deepcopy(RECIPES)})
|
||||||
|
|
||||||
|
|
||||||
|
def seats(count: int, start: int = 0) -> tuple[SeatSpec, ...]:
|
||||||
|
return tuple(
|
||||||
|
SeatSpec(player_id=f"p{index}", seat=index, display_name=f"Cook {index}")
|
||||||
|
for index in range(start, start + count)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def level() -> Level:
|
||||||
|
return make_level()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def state(level: Level) -> KitchenState:
|
||||||
|
"""A three-cook round on the fixture level, seed 0x5EED0401."""
|
||||||
|
return build_state(level, seats(3), 0x5EED_0401, make_book())
|
||||||
|
|
||||||
|
|
||||||
|
# --- SeatSpec -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seat_colours_are_a_fixed_palette_of_four() -> None:
|
||||||
|
assert len(SEAT_COLORS) == 4
|
||||||
|
assert len(set(SEAT_COLORS)) == 4
|
||||||
|
assert all(color.startswith("#") and len(color) == 7 for color in SEAT_COLORS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("seat", "expected"),
|
||||||
|
[(0, SEAT_COLORS[0]), (1, SEAT_COLORS[1]), (2, SEAT_COLORS[2]), (3, SEAT_COLORS[3])],
|
||||||
|
)
|
||||||
|
def test_colour_for_seat_follows_the_palette(seat: int, expected: str) -> None:
|
||||||
|
assert color_for_seat(seat) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_colour_for_seat_wraps_past_the_fourth_cook() -> None:
|
||||||
|
assert color_for_seat(4) == SEAT_COLORS[0]
|
||||||
|
assert color_for_seat(11) == SEAT_COLORS[3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_seat_spec_defaults_to_the_palette_colour() -> None:
|
||||||
|
seat = SeatSpec(player_id="abc", seat=2, display_name="Reese")
|
||||||
|
assert seat.resolved_color == SEAT_COLORS[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_seat_spec_keeps_an_explicit_colour() -> None:
|
||||||
|
seat = SeatSpec(player_id="abc", seat=0, display_name="Reese", color="#123456")
|
||||||
|
assert seat.resolved_color == "#123456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seat_spec_is_hashable_and_a_value() -> None:
|
||||||
|
a = SeatSpec("abc", 1, "Ann", "")
|
||||||
|
assert a == SeatSpec("abc", 1, "Ann", "")
|
||||||
|
assert len({a, SeatSpec("abc", 1, "Ann", "")}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_seat_is_refused() -> None:
|
||||||
|
with pytest.raises(StateBuildError, match="non-negative"):
|
||||||
|
SeatSpec(player_id="abc", seat=-1, display_name="Ann")
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_state ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_starts_at_tick_zero_with_the_level_clock(
|
||||||
|
level: Level, state: KitchenState
|
||||||
|
) -> None:
|
||||||
|
assert state.tick == 0
|
||||||
|
assert state.accumulator == 0.0
|
||||||
|
assert state.time_left == level.time_limit == 12
|
||||||
|
assert state.target_score == level.target_score == 40
|
||||||
|
assert state.level_id == "01_kernel"
|
||||||
|
assert state.seed == 0x5EED_0401
|
||||||
|
assert state.rng_state == 0x5EED_0401
|
||||||
|
assert state.score == 0
|
||||||
|
assert state.streak == 0
|
||||||
|
assert state.result is None
|
||||||
|
assert dict(state.counters) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_takes_the_grid_and_stations_from_the_level(
|
||||||
|
level: Level, state: KitchenState
|
||||||
|
) -> None:
|
||||||
|
assert state.grid is level.grid
|
||||||
|
assert state.stations == level.stations
|
||||||
|
assert {station.kind.value for station in state.stations} >= {
|
||||||
|
"spawn_shelf",
|
||||||
|
"chop_board",
|
||||||
|
"serve",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_orders_and_hazards_start_empty_for_later_phases(state: KitchenState) -> None:
|
||||||
|
assert state.orders == ()
|
||||||
|
assert state.hazards == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_players_are_built_in_seat_order(state: KitchenState) -> None:
|
||||||
|
assert [player.id for player in state.players] == ["p0", "p1", "p2"]
|
||||||
|
assert [player.seat for player in state.players] == [0, 1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_players_start_at_the_spawn_centre_facing_down(level: Level, state: KitchenState) -> None:
|
||||||
|
assert level.spawns == ((1, 1),)
|
||||||
|
first = state.players[0]
|
||||||
|
assert first.pos == Vec2(1.5, 1.5)
|
||||||
|
assert first.facing is Facing.DOWN
|
||||||
|
assert first.state is PlayerState.IDLE
|
||||||
|
assert first.connected is True
|
||||||
|
assert first.carrying is None
|
||||||
|
assert first.vel == Vec2(0.0, 0.0)
|
||||||
|
assert first.stun == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cooks_sharing_a_spawn_are_offset_by_a_quarter_tile(state: KitchenState) -> None:
|
||||||
|
_, second, third = state.players
|
||||||
|
assert second.pos == Vec2(1.75, 1.5)
|
||||||
|
assert third.pos == Vec2(1.5, 1.75)
|
||||||
|
positions = {player.pos for player in state.players}
|
||||||
|
assert len(positions) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_full_kitchen_stays_inside_its_spawn_tile(state: KitchenState) -> None:
|
||||||
|
four = build_state(make_level(), seats(4), 7, make_book())
|
||||||
|
assert [player.pos for player in four.players] == [
|
||||||
|
Vec2(1.5, 1.5),
|
||||||
|
Vec2(1.75, 1.5),
|
||||||
|
Vec2(1.5, 1.75),
|
||||||
|
Vec2(1.75, 1.75),
|
||||||
|
]
|
||||||
|
assert all(
|
||||||
|
four.grid.is_walkable(int(player.pos.x), int(player.pos.y)) for player in four.players
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seats_wrap_over_the_available_spawns() -> None:
|
||||||
|
level = make_level(spawns=[[1, 1], [5, 3]])
|
||||||
|
built = build_state(level, seats(4), 1, make_book())
|
||||||
|
assert [(player.pos.x, player.pos.y) for player in built.players] == [
|
||||||
|
(1.5, 1.5), # seat 0 -> spawn 0
|
||||||
|
(5.5, 3.5), # seat 1 -> spawn 1
|
||||||
|
(1.75, 1.5), # seat 2 -> spawn 0 again, offset
|
||||||
|
(5.75, 3.5), # seat 3 -> spawn 1 again, offset
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_seat_spec_order_does_not_matter() -> None:
|
||||||
|
scrambled = (
|
||||||
|
SeatSpec("p2", 2, "C"),
|
||||||
|
SeatSpec("p0", 0, "A"),
|
||||||
|
SeatSpec("p1", 1, "B"),
|
||||||
|
)
|
||||||
|
built = build_state(make_level(), scrambled, 5, make_book())
|
||||||
|
assert [player.id for player in built.players] == ["p0", "p1", "p2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_seats_builds_an_empty_kitchen(level: Level) -> None:
|
||||||
|
built = build_state(level, (), 3, make_book())
|
||||||
|
assert built.players == ()
|
||||||
|
assert built.state_hash() # still a perfectly hashable round
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_level_without_spawns_cannot_be_built(level: Level) -> None:
|
||||||
|
barren = replace(level, spawns=())
|
||||||
|
with pytest.raises(StateBuildError, match="defines no spawns"):
|
||||||
|
build_state(barren, seats(1), 1, make_book())
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_state_is_reproducible(level: Level) -> None:
|
||||||
|
first = build_state(level, seats(3), 99, make_book())
|
||||||
|
second = build_state(level, seats(3), 99, make_book())
|
||||||
|
assert first.state_hash() == second.state_hash()
|
||||||
|
|
||||||
|
|
||||||
|
# --- step: clamping and accumulation ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def tick(state: KitchenState, dt: float) -> int:
|
||||||
|
"""Ticks a single `step()` call buys from a fresh accumulator."""
|
||||||
|
advanced, _ = step(state, dt)
|
||||||
|
return advanced.tick
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_substep_is_one_tick(state: KitchenState) -> None:
|
||||||
|
assert tick(state, STEP_DT) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("dt", [0.0, -0.0, -1.0, -STEP_DT, float("-inf")])
|
||||||
|
def test_a_non_positive_clock_advance_buys_nothing(state: KitchenState, dt: float) -> None:
|
||||||
|
assert tick(state, dt) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_nan_clock_advance_is_treated_as_no_time(state: KitchenState) -> None:
|
||||||
|
dt: Any = float("nan")
|
||||||
|
advanced, events = step(state, dt)
|
||||||
|
assert advanced.tick == 0
|
||||||
|
assert events == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_call_never_runs_more_than_max_dt(state: KitchenState) -> None:
|
||||||
|
assert tick(state, MAX_DT) == 2
|
||||||
|
assert tick(state, 60.0) == 2 # a minute of wall time is still two substeps
|
||||||
|
assert tick(state, float("inf")) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_ceiling_is_exactly_max_dt_over_step_dt() -> None:
|
||||||
|
assert MAX_DT / STEP_DT == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_seventeen_slices_of_step_dt_are_seventeen_ticks(state: KitchenState) -> None:
|
||||||
|
current = state
|
||||||
|
for _ in range(17):
|
||||||
|
current, _events = step(current, STEP_DT)
|
||||||
|
assert current.tick == 17
|
||||||
|
assert current.accumulator == pytest.approx(0.0, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_below_one_substep_is_carried_not_dropped(state: KitchenState) -> None:
|
||||||
|
current = state
|
||||||
|
ticks_seen: list[int] = []
|
||||||
|
for _ in range(10): # 10 x 0.03 s = 0.3 s -> six substeps, remainder 0.0
|
||||||
|
current, _events = step(current, 0.03)
|
||||||
|
ticks_seen.append(current.tick)
|
||||||
|
assert ticks_seen == [0, 1, 1, 2, 3, 3, 4, 4, 5, 6]
|
||||||
|
assert current.accumulator == pytest.approx(0.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_accumulator_holds_back_a_partial_substep(state: KitchenState) -> None:
|
||||||
|
current, _events = step(state, 0.04)
|
||||||
|
assert current.tick == 0
|
||||||
|
assert current.accumulator == pytest.approx(0.04)
|
||||||
|
current, _events = step(current, 0.02)
|
||||||
|
assert current.tick == 1
|
||||||
|
assert current.accumulator == pytest.approx(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_accumulator_never_goes_negative(state: KitchenState) -> None:
|
||||||
|
current = state
|
||||||
|
for dt in (0.05, 0.07, 0.01, 0.001, 0.049):
|
||||||
|
current, _events = step(current, dt)
|
||||||
|
assert current.accumulator >= 0.0
|
||||||
|
assert current.accumulator < STEP_DT
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_clock_counts_down_with_the_ticks(state: KitchenState) -> None:
|
||||||
|
current = state
|
||||||
|
for _ in range(10):
|
||||||
|
current, _events = step(current, STEP_DT)
|
||||||
|
assert current.time_left == pytest.approx(12 - 10 * STEP_DT)
|
||||||
|
|
||||||
|
|
||||||
|
# --- step: inputs ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def inputs(*specs: tuple[str, int, int]) -> tuple[PlayerInput, ...]:
|
||||||
|
"""`PlayerInput` from (player_id, seq, received_at_tick) triples."""
|
||||||
|
return tuple(
|
||||||
|
PlayerInput(player_id=player_id, seq=seq, received_at_tick=received)
|
||||||
|
for player_id, seq, received in specs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inputs_for_real_players_are_accepted_without_events(state: KitchenState) -> None:
|
||||||
|
advanced, events = step(state, STEP_DT, inputs(("p0", 1, 0)))
|
||||||
|
assert events == ()
|
||||||
|
assert advanced.tick == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_player_is_an_error_event_not_a_crash(state: KitchenState) -> None:
|
||||||
|
advanced, events = step(state, STEP_DT, inputs(("ghost", 4, 0)))
|
||||||
|
assert advanced.tick == 1 # the round keeps running
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].kind is EventKind.ERROR
|
||||||
|
assert dict(events[0].payload) == {
|
||||||
|
"player_id": "ghost",
|
||||||
|
"reason": "unknown_player",
|
||||||
|
"seq": 4,
|
||||||
|
}
|
||||||
|
# Dropping the ghost must cost the real cooks nothing: compared against the same tick
|
||||||
|
# with no input at all, not against `state`, because movement now separates the two
|
||||||
|
# cooks who share a spawn tile and that is the kernel's doing, not the ghost's.
|
||||||
|
frameless, _events = step(state, STEP_DT)
|
||||||
|
assert advanced.players == frameless.players
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mixed_batch_reports_only_the_ghost(state: KitchenState) -> None:
|
||||||
|
_advanced, events = step(
|
||||||
|
state, STEP_DT, inputs(("p1", 1, 0), ("nobody", 2, 0), ("p2", 1, 0))
|
||||||
|
)
|
||||||
|
assert [event.kind for event in events] == [EventKind.ERROR]
|
||||||
|
assert events[0].payload["player_id"] == "nobody"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_inputs_keeps_the_earliest_frame(state: KitchenState) -> None:
|
||||||
|
resolved, events = resolve_inputs(
|
||||||
|
state, inputs(("p0", 9, 5), ("p0", 10, 2), ("p0", 11, 7))
|
||||||
|
)
|
||||||
|
assert events == ()
|
||||||
|
assert resolved["p0"].seq == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_inputs_breaks_an_arrival_tie_by_seq(state: KitchenState) -> None:
|
||||||
|
resolved, _events = resolve_inputs(state, inputs(("p0", 12, 3), ("p0", 11, 3)))
|
||||||
|
assert resolved["p0"].seq == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_inputs_is_stable_on_a_full_tie(state: KitchenState) -> None:
|
||||||
|
resolved, _events = resolve_inputs(state, inputs(("p0", 8, 3), ("p0", 8, 3)))
|
||||||
|
assert resolved["p0"].seq == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_inputs_iterates_in_seat_order(state: KitchenState) -> None:
|
||||||
|
resolved, _events = resolve_inputs(state, inputs(("p2", 1, 9), ("p0", 1, 4), ("p1", 1, 6)))
|
||||||
|
assert list(resolved) == ["p0", "p1", "p2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_inputs_drops_everything_for_players_that_are_not_here(
|
||||||
|
state: KitchenState,
|
||||||
|
) -> None:
|
||||||
|
resolved, events = resolve_inputs(state, inputs(("ghost", 1, 0), ("p0", 1, 0)))
|
||||||
|
assert list(resolved) == ["p0"]
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_inputs_of_nothing_is_empty(state: KitchenState) -> None:
|
||||||
|
assert resolve_inputs(state, ()) == ({}, ())
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_kitchen_rejects_every_input() -> None:
|
||||||
|
barren = build_state(make_level(), (), 2, make_book())
|
||||||
|
resolved, events = resolve_inputs(barren, inputs(("p0", 1, 0)))
|
||||||
|
assert resolved == {}
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_fields_survive_untouched(state: KitchenState) -> None:
|
||||||
|
frame = PlayerInput(
|
||||||
|
player_id="p0",
|
||||||
|
seq=3,
|
||||||
|
move=Vec2(0.6, -0.8),
|
||||||
|
action=True,
|
||||||
|
face=Facing.LEFT,
|
||||||
|
received_at_tick=11,
|
||||||
|
)
|
||||||
|
resolved, _events = resolve_inputs(state, (frame,))
|
||||||
|
kept = resolved["p0"]
|
||||||
|
assert kept is frame
|
||||||
|
assert (kept.move, kept.action, kept.face, kept.received_at_tick) == (
|
||||||
|
Vec2(0.6, -0.8),
|
||||||
|
True,
|
||||||
|
Facing.LEFT,
|
||||||
|
11,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_input_defaults() -> None:
|
||||||
|
frame = PlayerInput(player_id="p0")
|
||||||
|
assert frame.seq == 0
|
||||||
|
assert frame.move == Vec2(0.0, 0.0)
|
||||||
|
assert frame.action is False
|
||||||
|
assert frame.face is None
|
||||||
|
assert frame.received_at_tick == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_input_is_frozen(state: KitchenState) -> None:
|
||||||
|
frame = PlayerInput(player_id="p0")
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
frame.seq = 1 # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
# --- step: purity ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_does_not_mutate_the_state_it_was_given(state: KitchenState) -> None:
|
||||||
|
before_hash = state.state_hash()
|
||||||
|
snapshot = replace(state)
|
||||||
|
advanced, _events = step(state, 0.2, inputs(("p0", 1, 0), ("ghost", 1, 0)))
|
||||||
|
assert state == snapshot
|
||||||
|
assert state.state_hash() == before_hash
|
||||||
|
assert advanced != state
|
||||||
|
assert advanced.tick == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_events_are_a_tuple_in_order(state: KitchenState) -> None:
|
||||||
|
_advanced, events = step(state, STEP_DT, inputs(("ghost-a", 1, 0), ("ghost-b", 1, 0)))
|
||||||
|
assert isinstance(events, tuple)
|
||||||
|
assert [event.payload["player_id"] for event in events] == ["ghost-a", "ghost-b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_kernel_modules_stay_free_of_forbidden_imports() -> None:
|
||||||
|
assert_pure_imports()
|
||||||
|
|
||||||
|
|
||||||
|
# --- step: determinism --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
state: KitchenState,
|
||||||
|
script: Sequence[tuple[float, tuple[PlayerInput, ...]]],
|
||||||
|
) -> KitchenState:
|
||||||
|
current = state
|
||||||
|
for dt, frames in script:
|
||||||
|
current, _events = step(current, dt, frames)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_rounds_from_one_seed_stay_identical_over_200_steps(level: Level) -> None:
|
||||||
|
left = build_state(level, seats(3), 0x5EED_0401, make_book())
|
||||||
|
right = build_state(level, seats(3), 0x5EED_0401, make_book())
|
||||||
|
script = [
|
||||||
|
(STEP_DT, inputs(("p0", index, index), ("p1", index, index)))
|
||||||
|
for index in range(200)
|
||||||
|
]
|
||||||
|
assert run(left, script).state_hash() == run(right, script).state_hash()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_seed_reaches_the_state_so_two_seeds_differ(level: Level) -> None:
|
||||||
|
one = build_state(level, seats(2), 1, make_book())
|
||||||
|
other = build_state(level, seats(2), 2, make_book())
|
||||||
|
assert one.state_hash() != other.state_hash()
|
||||||
|
assert one.rng_state != other.rng_state
|
||||||
|
|
||||||
|
|
||||||
|
def test_tick_is_monotonic_across_200_steps(state: KitchenState) -> None:
|
||||||
|
current = state
|
||||||
|
previous = current.tick
|
||||||
|
for _ in range(200):
|
||||||
|
current, _events = step(current, STEP_DT)
|
||||||
|
assert current.tick == previous + 1
|
||||||
|
assert current.time_left <= 12.0
|
||||||
|
previous = current.tick
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_step_that_runs_no_substeps_changes_nothing_but_the_accumulator(
|
||||||
|
state: KitchenState,
|
||||||
|
) -> None:
|
||||||
|
current, events = step(state, 0.01)
|
||||||
|
assert events == ()
|
||||||
|
assert current.tick == 0
|
||||||
|
assert current.time_left == state.time_left
|
||||||
|
assert current.players == state.players
|
||||||
|
|
||||||
|
|
||||||
|
def test_batched_and_sliced_time_agree(state: KitchenState) -> None:
|
||||||
|
batched, _ = step(state, MAX_DT)
|
||||||
|
sliced = step(state, 0.025)[0]
|
||||||
|
sliced = step(sliced, 0.025)[0]
|
||||||
|
sliced = step(sliced, 0.025)[0]
|
||||||
|
sliced, _ = step(sliced, 0.025)
|
||||||
|
assert batched.tick == sliced.tick == 2
|
||||||
|
assert batched.state_hash() == sliced.state_hash()
|
||||||
|
|
||||||
|
|
||||||
|
# --- step: the round timer (stage 7) --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_round_ends_lost_when_the_clock_beats_the_score(state: KitchenState) -> None:
|
||||||
|
current = state
|
||||||
|
for _ in range(240): # 240 x 0.05 s = the whole 12 s clock
|
||||||
|
current, _events = step(current, STEP_DT)
|
||||||
|
assert current.time_left == 0.0
|
||||||
|
assert current.result is RoundResult.LOST
|
||||||
|
assert current.tick == 240
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_round_is_won_on_the_buzzer_when_the_target_was_reached(
|
||||||
|
state: KitchenState,
|
||||||
|
) -> None:
|
||||||
|
winning = replace(state, score=state.target_score)
|
||||||
|
current = winning
|
||||||
|
for _ in range(240):
|
||||||
|
current, _events = step(current, STEP_DT)
|
||||||
|
assert current.result is RoundResult.WON
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_clock_never_goes_negative_after_the_buzzer(state: KitchenState) -> None:
|
||||||
|
current = replace(state, time_left=STEP_DT)
|
||||||
|
current, _events = step(current, STEP_DT)
|
||||||
|
assert current.time_left == 0.0
|
||||||
|
assert current.result is RoundResult.LOST
|
||||||
|
current, _events = step(current, STEP_DT)
|
||||||
|
assert current.time_left == 0.0
|
||||||
|
assert not math.isclose(current.time_left, -1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_already_decided_round_keeps_its_result(state: KitchenState) -> None:
|
||||||
|
decided = replace(state, time_left=STEP_DT, result=RoundResult.WON, score=99)
|
||||||
|
current, _events = step(decided, STEP_DT)
|
||||||
|
assert current.result is RoundResult.WON
|
||||||
|
assert current.time_left == 0.0
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
"""`sim/interact.py` targeting — what the reach zone contains (phase 03 task 03).
|
||||||
|
|
||||||
|
The reach zone is discrete first and continuous second: `candidate_cells()` names
|
||||||
|
the tiles the hand walks (front, the two diagonal-front, underfoot) and
|
||||||
|
`REACH + FORGIVE` decides whether the cook is actually standing close enough to
|
||||||
|
the tile it found. This file pins both halves with hand-computed geometry:
|
||||||
|
|
||||||
|
* **candidate cells** — the per-facing order, and the fact that the four
|
||||||
|
facings are 90° rotations of one row rather than four ad-hoc tie-breaks;
|
||||||
|
* **reach** — the forgiveness boundary (0.99 ok, 1.6 itself inclusive, 1.61
|
||||||
|
rejected), including the case the diagonal tier exists for: the same diagonal
|
||||||
|
station is out of reach from the far corner of the cook's tile and in reach
|
||||||
|
from the near corner;
|
||||||
|
* **priority** — item outranks the fixture it sits on, a full hand skips the
|
||||||
|
item, a non-ready fixture holds its contents, the serve window is its own
|
||||||
|
target kind, nothing at all is `None`;
|
||||||
|
* **the wall** — a fixture two tiles ahead (wall in between) is never a
|
||||||
|
candidate: the discrete walk has no "through the wall" mode, and the
|
||||||
|
out-of-reach skip keeps the walk going so a nearer tile still gets its turn.
|
||||||
|
|
||||||
|
The press-edge behaviour — one press, one named refusal, however long the button
|
||||||
|
stays held — is at the bottom, through the full `step()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim.grid import Grid
|
||||||
|
from undercooked.sim.interact import (
|
||||||
|
FORGIVE,
|
||||||
|
REACH,
|
||||||
|
DeniedReason,
|
||||||
|
ItemTarget,
|
||||||
|
ServeTarget,
|
||||||
|
StationTarget,
|
||||||
|
Verb,
|
||||||
|
candidate_cells,
|
||||||
|
faces_target,
|
||||||
|
select_target,
|
||||||
|
select_verb,
|
||||||
|
station_cells,
|
||||||
|
target_center,
|
||||||
|
within_reach,
|
||||||
|
)
|
||||||
|
from undercooked.sim.model import (
|
||||||
|
EventKind,
|
||||||
|
Facing,
|
||||||
|
Item,
|
||||||
|
ItemForm,
|
||||||
|
ItemKind,
|
||||||
|
KitchenState,
|
||||||
|
Player,
|
||||||
|
PlayerState,
|
||||||
|
Station,
|
||||||
|
StationKind,
|
||||||
|
StationState,
|
||||||
|
TileKind,
|
||||||
|
Vec2,
|
||||||
|
)
|
||||||
|
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, step
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 13, 9
|
||||||
|
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
|
||||||
|
|
||||||
|
|
||||||
|
# --- fixtures ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(
|
||||||
|
stations: tuple[Station, ...] = (),
|
||||||
|
players: tuple[Player, ...] = (),
|
||||||
|
tile_overrides: dict[tuple[int, int], TileKind] | None = None,
|
||||||
|
) -> KitchenState:
|
||||||
|
tiles = [TileKind.FLOOR] * (WIDTH * HEIGHT)
|
||||||
|
for (x, y), kind in (tile_overrides or {}).items():
|
||||||
|
tiles[y * WIDTH + x] = kind
|
||||||
|
return KitchenState(
|
||||||
|
level_id="03_targeting",
|
||||||
|
seed=1,
|
||||||
|
grid=Grid(width=WIDTH, height=HEIGHT, tiles=tuple(tiles)),
|
||||||
|
stations=stations,
|
||||||
|
players=players,
|
||||||
|
target_score=40,
|
||||||
|
time_left=90.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cook(
|
||||||
|
player_id: str = "p0",
|
||||||
|
*,
|
||||||
|
pos: Vec2 = DEFAULT_POS,
|
||||||
|
facing: Facing = Facing.UP,
|
||||||
|
carrying: Item | None = None,
|
||||||
|
stun: float = 0.0,
|
||||||
|
state: PlayerState = PlayerState.IDLE,
|
||||||
|
seat: int = 0,
|
||||||
|
held_ticks: int = 0,
|
||||||
|
pressed_tick: int = 0,
|
||||||
|
) -> Player:
|
||||||
|
return Player(
|
||||||
|
id=player_id,
|
||||||
|
pos=pos,
|
||||||
|
facing=facing,
|
||||||
|
carrying=carrying,
|
||||||
|
state=state,
|
||||||
|
stun=stun,
|
||||||
|
seat=seat,
|
||||||
|
action_held_ticks=held_ticks,
|
||||||
|
action_pressed_tick=pressed_tick,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fixture(
|
||||||
|
kind: StationKind = StationKind.STOVE,
|
||||||
|
*,
|
||||||
|
x: int = 5,
|
||||||
|
y: int = 4,
|
||||||
|
state: StationState = StationState.IDLE,
|
||||||
|
contents: tuple[Item, ...] = (),
|
||||||
|
worker_id: str | None = None,
|
||||||
|
dirty: bool = False,
|
||||||
|
cooldown: float = 0.0,
|
||||||
|
) -> Station:
|
||||||
|
return Station(
|
||||||
|
id=f"{kind.value}-{x}-{y}",
|
||||||
|
kind=kind,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
state=state,
|
||||||
|
contents=contents,
|
||||||
|
worker_id=worker_id,
|
||||||
|
dirty=dirty,
|
||||||
|
cooldown=cooldown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
|
||||||
|
return Item(kind=ItemKind.TOMATO, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
# --- candidate cells --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("facing", "expected"),
|
||||||
|
[
|
||||||
|
(Facing.UP, ((2, 2), (1, 2), (3, 2), (2, 3))),
|
||||||
|
(Facing.RIGHT, ((3, 3), (3, 2), (3, 4), (2, 3))),
|
||||||
|
(Facing.DOWN, ((2, 4), (3, 4), (1, 4), (2, 3))),
|
||||||
|
(Facing.LEFT, ((1, 3), (1, 4), (1, 2), (2, 3))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_candidate_cells_walk_front_then_both_diagonals_then_underfoot(
|
||||||
|
facing: Facing, expected: tuple[tuple[int, int], ...]
|
||||||
|
) -> None:
|
||||||
|
# The cook stands at the centre of cell (2, 3); every facing reports the front
|
||||||
|
# tile first, its two diagonal neighbours second (left, then right, in the
|
||||||
|
# rotated row), and the tile underfoot last.
|
||||||
|
player = cook("p0", pos=Vec2(2.5, 3.5), facing=facing)
|
||||||
|
assert player.pos == Vec2(2.5, 3.5)
|
||||||
|
assert candidate_cells(player) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_cells_do_not_depend_on_where_in_the_tile_the_cook_stands() -> None:
|
||||||
|
positions = [Vec2(2.51, 3.99), Vec2(2.02, 3.01), Vec2(2.99, 3.51)]
|
||||||
|
for facing in Facing:
|
||||||
|
baseline = candidate_cells(cook("p0", pos=positions[0], facing=facing))
|
||||||
|
for position in positions[1:]:
|
||||||
|
assert candidate_cells(cook("p0", pos=position, facing=facing)) == baseline
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_cells_use_the_floor_of_the_position() -> None:
|
||||||
|
# (3.0, 3.0) is in cell (3, 3), not (2, 2): the body centre decides.
|
||||||
|
assert candidate_cells(cook("p0", pos=Vec2(3.0, 3.0), facing=Facing.UP)) == (
|
||||||
|
(3, 2),
|
||||||
|
(2, 2),
|
||||||
|
(4, 2),
|
||||||
|
(3, 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_cells_are_ordered_and_deduplicated() -> None:
|
||||||
|
cells = candidate_cells(cook("p0", pos=Vec2(2.5, 3.5), facing=Facing.RIGHT))
|
||||||
|
assert len(cells) == 4
|
||||||
|
assert len(set(cells)) == 4
|
||||||
|
assert cells[3] == (2, 3) # underfoot is always last, whatever the facing
|
||||||
|
|
||||||
|
|
||||||
|
# --- the forgiveness boundary -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reach_plus_forgive_is_the_number_the_boundary_tests_use() -> None:
|
||||||
|
assert abs((REACH + FORGIVE) - 1.6) < 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("distance", "in_reach"),
|
||||||
|
[
|
||||||
|
(0.99, True), # the task boundary: 0.99 is ok
|
||||||
|
(REACH, True), # the front tile itself
|
||||||
|
(1.6, True), # the boundary is inclusive (with REACH_EPS of slack)
|
||||||
|
(1.61, False), # the task boundary: 1.61 is rejected
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_within_reach_forgives_up_to_the_boundary(
|
||||||
|
distance: float, in_reach: bool
|
||||||
|
) -> None:
|
||||||
|
state = make_state(stations=(fixture(),))
|
||||||
|
# The player sits on the vertical line through the station centre (5.5, 4.5),
|
||||||
|
# so the distance is exactly `distance` and no corner math gets in.
|
||||||
|
player = cook("p0", pos=Vec2(5.5, 4.5 - distance), facing=Facing.UP)
|
||||||
|
assert within_reach(state, player, StationTarget(state.stations[0])) is in_reach
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_center_is_the_cell_centre_for_a_fixture() -> None:
|
||||||
|
state = make_state(stations=(fixture(kind=StationKind.SINK, x=3, y=2),))
|
||||||
|
assert target_center(state, StationTarget(state.stations[0])) == Vec2(3.5, 2.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_center_falls_back_to_the_stamped_position_when_the_station_is_gone() -> None:
|
||||||
|
state = make_state()
|
||||||
|
stale = ItemTarget(item=tomato(), on_station_id="gone", at=Vec2(5.5, 4.5))
|
||||||
|
assert target_center(state, stale) == Vec2(5.5, 4.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_item_already_in_the_hand_needs_no_reach() -> None:
|
||||||
|
state = make_state()
|
||||||
|
player = cook("p0")
|
||||||
|
in_hand = ItemTarget(item=tomato()) # no station, no world position
|
||||||
|
assert target_center(state, in_hand) is None
|
||||||
|
assert within_reach(state, player, in_hand) is True
|
||||||
|
assert faces_target(state, player, in_hand) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- select_target: reach through the cell walk ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_station_at_0p99_in_front_is_targeted() -> None:
|
||||||
|
state = make_state(
|
||||||
|
stations=(fixture(),),
|
||||||
|
players=(cook("p0", pos=Vec2(5.5, 4.51), facing=Facing.DOWN),), # 0.99 below the centre
|
||||||
|
)
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, StationTarget)
|
||||||
|
assert target.station.id == "stove-5-4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_diagonal_station_at_1p61_is_skipped_not_refused() -> None:
|
||||||
|
# From the far corner of the cook's own tile the diagonal centre is
|
||||||
|
# 1.15 * sqrt(2) = 1.618 away, outside REACH + FORGIVE, so the tile is
|
||||||
|
# skipped rather than becoming the answer.
|
||||||
|
state = make_state(
|
||||||
|
stations=(fixture(kind=StationKind.STOVE, x=6, y=6),),
|
||||||
|
players=(cook("p0", pos=Vec2(5.35, 5.35), facing=Facing.RIGHT),),
|
||||||
|
)
|
||||||
|
assert select_target(state, state.players[0]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_diagonal_station_is_reachable_from_the_near_corner() -> None:
|
||||||
|
state = make_state(
|
||||||
|
stations=(fixture(kind=StationKind.STOVE, x=6, y=6),),
|
||||||
|
players=(cook("p0", pos=Vec2(5.65, 5.65), facing=Facing.RIGHT),),
|
||||||
|
)
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, StationTarget)
|
||||||
|
assert target.station.id == "stove-6-6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_out_of_reach_diagonal_is_skipped_so_the_tile_underfoot_still_wins() -> None:
|
||||||
|
state = make_state(
|
||||||
|
stations=(
|
||||||
|
fixture(kind=StationKind.STOVE, x=6, y=6), # candidate 3, out of reach here
|
||||||
|
fixture(kind=StationKind.SINK, x=5, y=5), # candidate 4, underfoot, in reach
|
||||||
|
),
|
||||||
|
players=(cook("p0", pos=Vec2(5.35, 5.35), facing=Facing.RIGHT),),
|
||||||
|
)
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, StationTarget)
|
||||||
|
assert target.station.kind is StationKind.SINK
|
||||||
|
|
||||||
|
|
||||||
|
# --- select_target: priority ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_ready_fixture_with_contents_yields_the_item_to_a_free_hand() -> None:
|
||||||
|
ready = fixture(state=StationState.READY, contents=(tomato(ItemForm.CHOPPED),))
|
||||||
|
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, ItemTarget)
|
||||||
|
assert target.item.form is ItemForm.CHOPPED
|
||||||
|
assert target.on_station_id == ready.id
|
||||||
|
assert target.at == Vec2(5.5, 4.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_full_hand_skips_the_item_and_gets_the_fixture_instead() -> None:
|
||||||
|
ready = fixture(state=StationState.READY, contents=(tomato(ItemForm.CHOPPED),))
|
||||||
|
state = make_state(
|
||||||
|
stations=(ready,),
|
||||||
|
players=(
|
||||||
|
cook(
|
||||||
|
"p0",
|
||||||
|
carrying=Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, StationTarget)
|
||||||
|
assert target.station is ready
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_spawn_shelf_hands_stock_even_while_idle() -> None:
|
||||||
|
shelf = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(),))
|
||||||
|
state = make_state(stations=(shelf,), players=(cook("p0"),))
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, ItemTarget)
|
||||||
|
assert target.item.kind is ItemKind.TOMATO
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_spawn_shelf_with_no_stock_is_a_plain_fixture() -> None:
|
||||||
|
shelf = fixture(kind=StationKind.SPAWN_SHELF)
|
||||||
|
state = make_state(stations=(shelf,), players=(cook("p0"),))
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, StationTarget)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_ready_fixture_holds_its_contents() -> None:
|
||||||
|
working = fixture(
|
||||||
|
kind=StationKind.CHOP_BOARD,
|
||||||
|
state=StationState.WORKING,
|
||||||
|
contents=(tomato(),),
|
||||||
|
worker_id="p0",
|
||||||
|
)
|
||||||
|
state = make_state(stations=(working,), players=(cook("p0"),))
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, StationTarget) # the onion is *to be chopped*, not grabbable
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_ready_fixture_with_empty_contents_is_a_plain_fixture() -> None:
|
||||||
|
ready = fixture(state=StationState.READY)
|
||||||
|
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, StationTarget)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_serve_window_is_a_serve_target_not_a_station_target() -> None:
|
||||||
|
window = fixture(kind=StationKind.SERVE)
|
||||||
|
state = make_state(stations=(window,), players=(cook("p0"),))
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, ServeTarget)
|
||||||
|
assert target.station is window
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_in_the_zone_is_none() -> None:
|
||||||
|
state = make_state(players=(cook("p0"),))
|
||||||
|
assert select_target(state, state.players[0]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_station_two_tiles_away_is_never_a_candidate_even_beside_a_wall() -> None:
|
||||||
|
# The wall fills the front tile; the stove is beyond it. The cell walk has
|
||||||
|
# no "through the wall" mode, so the stove is simply not a candidate and
|
||||||
|
# the press cannot land on it.
|
||||||
|
state = make_state(
|
||||||
|
stations=(fixture(kind=StationKind.STOVE, x=5, y=3),),
|
||||||
|
players=(cook("p0"),),
|
||||||
|
tile_overrides={(5, 4): TileKind.WALL},
|
||||||
|
)
|
||||||
|
assert select_target(state, state.players[0]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_front_tile_outranks_a_diagonal_one() -> None:
|
||||||
|
state = make_state(
|
||||||
|
stations=(
|
||||||
|
fixture(kind=StationKind.STOVE, x=6, y=4), # diagonal (candidate 3 for UP)
|
||||||
|
fixture(kind=StationKind.CHOP_BOARD, x=5, y=4), # front (candidate 1)
|
||||||
|
),
|
||||||
|
players=(cook("p0"),),
|
||||||
|
)
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, StationTarget)
|
||||||
|
assert target.station.kind is StationKind.CHOP_BOARD
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_cells_keys_fixtures_by_their_authored_tile() -> None:
|
||||||
|
state = make_state(
|
||||||
|
stations=(
|
||||||
|
fixture(kind=StationKind.STOVE, x=3, y=2),
|
||||||
|
fixture(kind=StationKind.SINK, x=7, y=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cells = station_cells(state)
|
||||||
|
assert set(cells) == {(3, 2), (7, 1)}
|
||||||
|
assert cells[(3, 2)].kind is StationKind.STOVE
|
||||||
|
|
||||||
|
|
||||||
|
# --- facing: the half-plane, not an angle ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_faces_target_refuses_a_target_behind_the_cook() -> None:
|
||||||
|
state = make_state(stations=(fixture(kind=StationKind.SERVE, x=5, y=6),))
|
||||||
|
player = cook("p0", pos=Vec2(5.5, 5.5), facing=Facing.UP)
|
||||||
|
assert faces_target(state, player, StationTarget(state.stations[0])) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_faces_target_accepts_a_target_level_with_the_cook() -> None:
|
||||||
|
state = make_state(stations=(fixture(kind=StationKind.SERVE, x=4, y=5),))
|
||||||
|
player = cook("p0", pos=Vec2(5.5, 5.5), facing=Facing.UP)
|
||||||
|
# `along` is 0 for a target on the cook's own row, so a serve window the
|
||||||
|
# cook is standing beside is not refused.
|
||||||
|
assert faces_target(state, player, StationTarget(state.stations[0])) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- the STUNNED denial ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stunned_cook_is_refused_before_the_target_is_even_read() -> None:
|
||||||
|
state = make_state(
|
||||||
|
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
|
||||||
|
players=(cook("p0", stun=0.3),),
|
||||||
|
)
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
verb, denial = select_verb(state, state.players[0], target)
|
||||||
|
assert (verb, denial) == (Verb.NONE, DeniedReason.STUNNED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_stunned_state_flag_alone_is_enough() -> None:
|
||||||
|
state = make_state(
|
||||||
|
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
|
||||||
|
players=(cook("p0", state=PlayerState.STUNNED, stun=0.0),),
|
||||||
|
)
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
verb, denial = select_verb(state, state.players[0], target)
|
||||||
|
assert (verb, denial) == (Verb.NONE, DeniedReason.STUNNED)
|
||||||
|
|
||||||
|
|
||||||
|
# --- one press, one event (through the full kernel) -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_do_nothing_press_emits_exactly_one_denied_not_one_per_tick() -> None:
|
||||||
|
state = make_state(players=(cook("p0"),))
|
||||||
|
frame = PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0)
|
||||||
|
first_before_hash = state.state_hash()
|
||||||
|
|
||||||
|
first, events = step(state, STEP_DT, (frame,))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].kind is EventKind.DENIED
|
||||||
|
assert dict(events[0].payload) == {
|
||||||
|
"player_id": "p0",
|
||||||
|
"reason": "nothing_here",
|
||||||
|
"verb": "none",
|
||||||
|
}
|
||||||
|
assert (events[0].x, events[0].y) == (5.5, 5.5)
|
||||||
|
assert first.players[0].action_held_ticks == 1
|
||||||
|
assert first.players[0].action_pressed_tick == 0
|
||||||
|
|
||||||
|
# The same frame on the next tick continues the press: no new edge, no new
|
||||||
|
# event, the hold just gets longer.
|
||||||
|
second, held_events = step(first, STEP_DT, (frame,))
|
||||||
|
assert held_events == ()
|
||||||
|
assert second.players[0].action_held_ticks == 2
|
||||||
|
assert second.players[0].action_pressed_tick == 0
|
||||||
|
|
||||||
|
# One call that runs two substeps: still exactly one press.
|
||||||
|
batched, batched_events = step(state, MAX_DT, (frame,))
|
||||||
|
assert sum(event.kind is EventKind.DENIED for event in batched_events) == 1
|
||||||
|
assert batched.players[0].action_held_ticks == 2
|
||||||
|
|
||||||
|
# Releasing the button clears the counters and the next press is a new one.
|
||||||
|
released, released_events = step(
|
||||||
|
second, STEP_DT, (PlayerInput(player_id="p0", seq=1, action=False),)
|
||||||
|
)
|
||||||
|
assert released_events == ()
|
||||||
|
assert released.players[0].action_held_ticks == 0
|
||||||
|
assert released.players[0].action_pressed_tick == 0
|
||||||
|
# Purity: the state the first press was handed is still exactly what it was.
|
||||||
|
assert state.state_hash() == first_before_hash
|
||||||
|
assert state.players[0].action_held_ticks == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_press_at_a_station_out_of_reach_is_one_named_refusal() -> None:
|
||||||
|
state = make_state(
|
||||||
|
stations=(fixture(kind=StationKind.STOVE, x=5, y=3),),
|
||||||
|
players=(cook("p0"),),
|
||||||
|
tile_overrides={(5, 4): TileKind.WALL},
|
||||||
|
)
|
||||||
|
frame = PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0)
|
||||||
|
first, events = step(state, STEP_DT, (frame,))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].kind is EventKind.DENIED
|
||||||
|
# The discrete zone has no "through the wall" mode, so the press surfaces as
|
||||||
|
# nothing_here: the one effect, named, at the cook.
|
||||||
|
assert dict(events[0].payload)["reason"] == "nothing_here"
|
||||||
|
assert first.players[0].action_held_ticks == 1
|
||||||
@@ -0,0 +1,804 @@
|
|||||||
|
"""`sim/interact.py` verb ladder — what a press means, or why it means nothing.
|
||||||
|
|
||||||
|
PLAN §5 gives the cook one button and a priority order; this file pins it
|
||||||
|
table-driven as `(carrying, target) -> (verb, denial)`, covering **every**
|
||||||
|
`Verb` and **every** `DeniedReason` at least once (the coverage tests at
|
||||||
|
the bottom assert that the table and the contested tests together cannot quietly
|
||||||
|
lose a row — `CONTESTED_LOST` is produced by the gate, not by `select_verb`).
|
||||||
|
The order as
|
||||||
|
implemented:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stunned -> STUNNED
|
||||||
|
nothing in the zone -> NOTHING_HERE
|
||||||
|
beyond REACH+FORGIVE -> OUT_OF_REACH
|
||||||
|
behind the cook -> NOT_FACING
|
||||||
|
item in reach: full hand -> HANDS_FULL, no holder -> INVALID_STATE,
|
||||||
|
otherwise PICK_ITEM
|
||||||
|
fixture in reach: state first (dirty -> STATION_DIRTY, working ->
|
||||||
|
HOLD_WORK/STATION_BUSY, blocked -> INVALID_STATE,
|
||||||
|
cooling -> STATION_BUSY, ready -> PICK_FROM_STATION),
|
||||||
|
then kind: serve / wash / trash / shelf / work surface
|
||||||
|
```
|
||||||
|
|
||||||
|
After the matrix: what `resolve_action` implements in *this* phase (pick,
|
||||||
|
set-down, named refusals, and the deliberate no-ops phase 04 will spend), and
|
||||||
|
the contested arbitration `action_gate` runs when two cooks press for one thing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from undercooked.sim.grid import Grid
|
||||||
|
from undercooked.sim.interact import (
|
||||||
|
DeniedReason,
|
||||||
|
ItemTarget,
|
||||||
|
ServeTarget,
|
||||||
|
StationTarget,
|
||||||
|
Target,
|
||||||
|
Verb,
|
||||||
|
action_gate,
|
||||||
|
claim_rank,
|
||||||
|
claims_entity,
|
||||||
|
resolve_action,
|
||||||
|
select_target,
|
||||||
|
select_verb,
|
||||||
|
target_key,
|
||||||
|
)
|
||||||
|
from undercooked.sim.model import (
|
||||||
|
EventKind,
|
||||||
|
Facing,
|
||||||
|
Item,
|
||||||
|
ItemForm,
|
||||||
|
ItemKind,
|
||||||
|
KitchenState,
|
||||||
|
Player,
|
||||||
|
PlayerState,
|
||||||
|
Station,
|
||||||
|
StationKind,
|
||||||
|
StationState,
|
||||||
|
TileKind,
|
||||||
|
Vec2,
|
||||||
|
)
|
||||||
|
from undercooked.sim.step import STEP_DT, PlayerInput
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 13, 9
|
||||||
|
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
|
||||||
|
|
||||||
|
|
||||||
|
# --- fixtures ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(
|
||||||
|
stations: tuple[Station, ...] = (),
|
||||||
|
players: tuple[Player, ...] = (),
|
||||||
|
) -> KitchenState:
|
||||||
|
return KitchenState(
|
||||||
|
level_id="03_verbs",
|
||||||
|
seed=2,
|
||||||
|
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
|
||||||
|
stations=stations,
|
||||||
|
players=players,
|
||||||
|
target_score=40,
|
||||||
|
time_left=90.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cook(
|
||||||
|
player_id: str = "p0",
|
||||||
|
*,
|
||||||
|
pos: Vec2 = DEFAULT_POS,
|
||||||
|
facing: Facing = Facing.UP,
|
||||||
|
carrying: Item | None = None,
|
||||||
|
stun: float = 0.0,
|
||||||
|
state: PlayerState = PlayerState.IDLE,
|
||||||
|
seat: int = 0,
|
||||||
|
held_ticks: int = 0,
|
||||||
|
pressed_tick: int = 0,
|
||||||
|
) -> Player:
|
||||||
|
return Player(
|
||||||
|
id=player_id,
|
||||||
|
pos=pos,
|
||||||
|
facing=facing,
|
||||||
|
carrying=carrying,
|
||||||
|
state=state,
|
||||||
|
stun=stun,
|
||||||
|
seat=seat,
|
||||||
|
action_held_ticks=held_ticks,
|
||||||
|
action_pressed_tick=pressed_tick,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fixture(
|
||||||
|
kind: StationKind = StationKind.STOVE,
|
||||||
|
*,
|
||||||
|
x: int = 5,
|
||||||
|
y: int = 4,
|
||||||
|
state: StationState = StationState.IDLE,
|
||||||
|
contents: tuple[Item, ...] = (),
|
||||||
|
worker_id: str | None = None,
|
||||||
|
dirty: bool = False,
|
||||||
|
cooldown: float = 0.0,
|
||||||
|
) -> Station:
|
||||||
|
return Station(
|
||||||
|
id=f"{kind.value}-{x}-{y}",
|
||||||
|
kind=kind,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
state=state,
|
||||||
|
contents=contents,
|
||||||
|
worker_id=worker_id,
|
||||||
|
dirty=dirty,
|
||||||
|
cooldown=cooldown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
|
||||||
|
return Item(kind=ItemKind.TOMATO, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
def lettuce() -> Item:
|
||||||
|
return Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW)
|
||||||
|
|
||||||
|
|
||||||
|
def plated_dish() -> Item:
|
||||||
|
return Item(kind=ItemKind.PLATE, form=ItemForm.PLATED, dish_id=1)
|
||||||
|
|
||||||
|
|
||||||
|
def dirty_plate() -> Item:
|
||||||
|
return Item(kind=ItemKind.PLATE, form=ItemForm.DIRTY)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the verb ladder, table-driven --------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The cook defaults to cell (5, 5) facing UP; the fixture defaults to the front
|
||||||
|
# cell (5, 4). Rows override either. `expect` is the (Verb, DeniedReason) pair.
|
||||||
|
|
||||||
|
MATRIX: list[dict[str, Any]] = [
|
||||||
|
# the cook's own condition outranks everything
|
||||||
|
{
|
||||||
|
"name": "stun_outranks_a_valid_target",
|
||||||
|
"player": {"stun": 0.3},
|
||||||
|
"station": {"kind": StationKind.SPAWN_SHELF},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STUNNED),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "the_stunned_state_flag_alone_refuses",
|
||||||
|
"player": {"state": PlayerState.STUNNED},
|
||||||
|
"station": {"kind": StationKind.SPAWN_SHELF},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STUNNED),
|
||||||
|
},
|
||||||
|
# nothing to act on
|
||||||
|
{
|
||||||
|
"name": "nothing_at_all_is_a_named_refusal",
|
||||||
|
"target": "none",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.NOTHING_HERE),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_target_beyond_reach_plus_forgive_is_out_of_reach",
|
||||||
|
"player": {"pos": Vec2(8.5, 5.5)},
|
||||||
|
"station": {"kind": StationKind.STOVE},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.OUT_OF_REACH),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_target_behind_the_cook_is_not_facing",
|
||||||
|
"station": {"y": 6},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.NOT_FACING),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_lateral_target_passes_the_facing_test_into_the_ladder",
|
||||||
|
"station": {"x": 4, "y": 5},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||||
|
},
|
||||||
|
# items: one hand per cook, and a holder the state can actually empty
|
||||||
|
{
|
||||||
|
"name": "a_free_hand_picks_an_item",
|
||||||
|
"station": {"state": StationState.READY, "contents": (tomato(),)},
|
||||||
|
"target": "item",
|
||||||
|
"expect": (Verb.PICK_ITEM, None),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_full_hand_refuses_the_item",
|
||||||
|
"player": {"carrying": "lettuce"},
|
||||||
|
"target": "item",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.HANDS_FULL),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "an_item_with_no_holder_is_invalid_state",
|
||||||
|
"target": "item_loose",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "an_item_whose_fixture_left_the_state_is_invalid_state",
|
||||||
|
"target": "item_ghost",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||||
|
},
|
||||||
|
# station state outranks station kind
|
||||||
|
{
|
||||||
|
"name": "a_dirty_fixture_refuses_anything",
|
||||||
|
"station": {"dirty": True},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "the_dirty_state_refuses_anything",
|
||||||
|
"station": {"state": StationState.DIRTY},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_dirty_blocked_fixture_reports_the_dirt_first",
|
||||||
|
"station": {"dirty": True, "state": StationState.BLOCKED},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "another_cooks_working_fixture_is_busy",
|
||||||
|
"station": {"state": StationState.WORKING, "worker_id": "p9"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "your_own_working_fixture_is_held",
|
||||||
|
"station": {"state": StationState.WORKING, "worker_id": "p0"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.HOLD_WORK, None),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "an_unattributed_working_fixture_is_held_not_busy",
|
||||||
|
"station": {"state": StationState.WORKING},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.HOLD_WORK, None),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_blocked_fixture_admits_no_verb",
|
||||||
|
"station": {"state": StationState.BLOCKED},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_busy_fixture_is_busy",
|
||||||
|
"station": {"state": StationState.BUSY},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_cooling_fixture_is_busy",
|
||||||
|
"station": {"cooldown": 0.5},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_ready_fixture_hands_its_output_to_a_free_hand",
|
||||||
|
"station": {"state": StationState.READY},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.PICK_FROM_STATION, None),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_ready_fixture_refuses_a_full_hand",
|
||||||
|
"station": {"state": StationState.READY},
|
||||||
|
"player": {"carrying": "lettuce"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.HANDS_FULL),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_ready_fixture_with_stock_yields_the_item",
|
||||||
|
"station": {"state": StationState.READY, "contents": (tomato(ItemForm.CHOPPED),)},
|
||||||
|
"target": "item",
|
||||||
|
"expect": (Verb.PICK_ITEM, None),
|
||||||
|
},
|
||||||
|
# kind: the serve window
|
||||||
|
{
|
||||||
|
"name": "serving_needs_something_in_the_hand",
|
||||||
|
"station": {"kind": StationKind.SERVE},
|
||||||
|
"target": "serve",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.HANDS_EMPTY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "serving_refuses_the_wrong_item",
|
||||||
|
"station": {"kind": StationKind.SERVE},
|
||||||
|
"player": {"carrying": "tomato"},
|
||||||
|
"target": "serve",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.WRONG_ITEM),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_plated_dish_is_served",
|
||||||
|
"station": {"kind": StationKind.SERVE},
|
||||||
|
"player": {"carrying": "plated"},
|
||||||
|
"target": "serve",
|
||||||
|
"expect": (Verb.SERVE, None),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_dirty_serve_window_refuses_a_dish",
|
||||||
|
"station": {"kind": StationKind.SERVE, "dirty": True},
|
||||||
|
"player": {"carrying": "plated"},
|
||||||
|
"target": "serve",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_blocked_serve_window_refuses_a_dish",
|
||||||
|
"station": {"kind": StationKind.SERVE, "state": StationState.BLOCKED},
|
||||||
|
"player": {"carrying": "plated"},
|
||||||
|
"target": "serve",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||||
|
},
|
||||||
|
# kind: the sink
|
||||||
|
{
|
||||||
|
"name": "washing_needs_something_in_the_hand",
|
||||||
|
"station": {"kind": StationKind.SINK},
|
||||||
|
"target": "serve",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.HANDS_EMPTY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "washing_refuses_the_clean",
|
||||||
|
"station": {"kind": StationKind.SINK},
|
||||||
|
"player": {"carrying": "tomato"},
|
||||||
|
"target": "serve",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.WRONG_ITEM),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_dirty_plate_is_washed",
|
||||||
|
"station": {"kind": StationKind.SINK},
|
||||||
|
"player": {"carrying": "dirty_plate"},
|
||||||
|
"target": "serve",
|
||||||
|
"expect": (Verb.WASH, None),
|
||||||
|
},
|
||||||
|
# kind: the trash
|
||||||
|
{
|
||||||
|
"name": "trashing_needs_something_in_the_hand",
|
||||||
|
"station": {"kind": StationKind.TRASH},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.HANDS_EMPTY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "anything_carries_to_the_trash",
|
||||||
|
"station": {"kind": StationKind.TRASH},
|
||||||
|
"player": {"carrying": "tomato"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.TRASH, None),
|
||||||
|
},
|
||||||
|
# kind: the spawn shelf is stock, and a set-down escape hatch
|
||||||
|
{
|
||||||
|
"name": "a_shelf_hands_stock_to_a_free_hand",
|
||||||
|
"station": {"kind": StationKind.SPAWN_SHELF},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.PICK_FROM_STATION, None),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_shelf_takes_a_set_down_from_a_full_hand",
|
||||||
|
"station": {"kind": StationKind.SPAWN_SHELF},
|
||||||
|
"player": {"carrying": "lettuce"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.RELEASE, None),
|
||||||
|
},
|
||||||
|
# kind: the work surfaces act on what is on them
|
||||||
|
{
|
||||||
|
"name": "a_loaded_surface_starts_work",
|
||||||
|
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.START_WORK, None),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_loaded_surface_refuses_a_full_hand",
|
||||||
|
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
|
||||||
|
"player": {"carrying": "lettuce"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "an_empty_surface_and_an_empty_hand_is_invalid_state",
|
||||||
|
"station": {"kind": StationKind.CHOP_BOARD},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "an_empty_surface_takes_a_placed_item",
|
||||||
|
"station": {"kind": StationKind.CHOP_BOARD},
|
||||||
|
"player": {"carrying": "lettuce"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.PLACE_ON_STATION, None),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "an_empty_prep_counter_plates",
|
||||||
|
"station": {"kind": StationKind.PREP},
|
||||||
|
"player": {"carrying": "lettuce"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": (Verb.PLATE_DISH, None),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
CARRYING: dict[str, Item] = {
|
||||||
|
"tomato": tomato(),
|
||||||
|
"lettuce": lettuce(),
|
||||||
|
"plated": plated_dish(),
|
||||||
|
"dirty_plate": dirty_plate(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build(row: dict[str, Any]) -> tuple[KitchenState, Player, Target | None]:
|
||||||
|
"""Assemble one matrix row: the kitchen, the cook, and what they point at."""
|
||||||
|
station = fixture(**row["station"]) if row.get("station") else None
|
||||||
|
state = make_state(stations=(station,) if station is not None else ())
|
||||||
|
player_overrides = dict(row.get("player", {}))
|
||||||
|
if "carrying" in player_overrides:
|
||||||
|
player_overrides["carrying"] = CARRYING[player_overrides["carrying"]]
|
||||||
|
player = cook(**player_overrides)
|
||||||
|
kind = row["target"]
|
||||||
|
item = CARRYING.get("tomato", tomato())
|
||||||
|
if kind == "none":
|
||||||
|
target: Target | None = None
|
||||||
|
elif kind == "station":
|
||||||
|
target = StationTarget(station) # type: ignore[arg-type]
|
||||||
|
elif kind == "serve":
|
||||||
|
target = ServeTarget(station) # type: ignore[arg-type]
|
||||||
|
elif kind == "item":
|
||||||
|
target = ItemTarget(item=item, on_station_id=station.id if station else "nope")
|
||||||
|
elif kind == "item_loose":
|
||||||
|
target = ItemTarget(item=item)
|
||||||
|
elif kind == "item_ghost":
|
||||||
|
target = ItemTarget(item=item, on_station_id="ghost", at=Vec2(5.5, 4.5))
|
||||||
|
else:
|
||||||
|
msg = f"unknown target kind {kind!r} in row {row['name']!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return state, player, target
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("row", MATRIX, ids=lambda row: row["name"])
|
||||||
|
def test_verb_ladder(row: dict[str, Any]) -> None:
|
||||||
|
state, player, target = build(row)
|
||||||
|
verb, denial = select_verb(state, player, target)
|
||||||
|
assert (verb, denial) == row["expect"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_matrix_covers_every_verb() -> None:
|
||||||
|
covered = {row["expect"][0] for row in MATRIX}
|
||||||
|
assert covered == set(Verb)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_matrix_covers_every_denied_reason() -> None:
|
||||||
|
# `CONTESTED_LOST` is the one reason the ladder cannot produce: it comes
|
||||||
|
# from the action gate's arbitration of two cooks, so the matrix covers the
|
||||||
|
# other ten and the contested tests below pin this one.
|
||||||
|
covered = {row["expect"][1] for row in MATRIX if row["expect"][1] is not None}
|
||||||
|
assert covered == set(DeniedReason) - {DeniedReason.CONTESTED_LOST}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_denied_reason_appears_in_a_test() -> None:
|
||||||
|
# The set-coverage the phase demands: the matrix's reasons plus the reason a
|
||||||
|
# real contested press produces together are the whole enum.
|
||||||
|
from_matrix = {row["expect"][1] for row in MATRIX if row["expect"][1] is not None}
|
||||||
|
state, frames = _contested_kitchen()
|
||||||
|
_new_state, events = action_gate(state, frames, STEP_DT)
|
||||||
|
from_gate = {
|
||||||
|
DeniedReason(dict(event.payload)["reason"])
|
||||||
|
for event in events
|
||||||
|
if event.kind is EventKind.DENIED
|
||||||
|
}
|
||||||
|
assert from_matrix | from_gate == set(DeniedReason)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_row_holds_a_verb_and_a_denial_at_once() -> None:
|
||||||
|
for row in MATRIX:
|
||||||
|
verb, denial = row["expect"]
|
||||||
|
if verb is not Verb.NONE:
|
||||||
|
assert denial is None, row["name"]
|
||||||
|
else:
|
||||||
|
assert denial is not None, row["name"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- resolve_action: what this phase implements ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_action_without_a_target_is_one_named_refusal() -> None:
|
||||||
|
state = make_state(players=(cook("p0"),))
|
||||||
|
new_state, events = resolve_action(state, state.players[0])
|
||||||
|
assert new_state is state # a refusal moves nothing
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].kind is EventKind.DENIED
|
||||||
|
assert dict(events[0].payload) == {
|
||||||
|
"player_id": "p0",
|
||||||
|
"reason": "nothing_here",
|
||||||
|
"verb": "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_action_for_a_stunned_press_names_the_reason() -> None:
|
||||||
|
state = make_state(
|
||||||
|
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
|
||||||
|
players=(cook("p0", stun=0.3),),
|
||||||
|
)
|
||||||
|
new_state, events = resolve_action(state, state.players[0])
|
||||||
|
assert new_state is state
|
||||||
|
assert len(events) == 1
|
||||||
|
assert dict(events[0].payload)["reason"] == "stunned"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("held_for", [STEP_DT, 0.4])
|
||||||
|
def test_held_for_above_zero_is_the_documented_no_op(held_for: float) -> None:
|
||||||
|
state = make_state(players=(cook("p0"),))
|
||||||
|
new_state, events = resolve_action(state, state.players[0], held_for=held_for)
|
||||||
|
assert new_state is state
|
||||||
|
assert events == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_takes_the_item_off_the_fixture_and_into_the_hand() -> None:
|
||||||
|
chopped = tomato(ItemForm.CHOPPED)
|
||||||
|
ready = fixture(state=StationState.READY, contents=(chopped,))
|
||||||
|
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||||
|
before = state.state_hash()
|
||||||
|
new_state, events = resolve_action(state, state.players[0])
|
||||||
|
assert new_state.players[0].carrying is chopped
|
||||||
|
assert new_state.stations[0].contents == ()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].kind is EventKind.PICKED
|
||||||
|
assert dict(events[0].payload) == {
|
||||||
|
"player_id": "p0",
|
||||||
|
"station": ready.id,
|
||||||
|
"item": "tomato",
|
||||||
|
"form": "chopped",
|
||||||
|
}
|
||||||
|
# purity: the input state and the cook object it held are untouched
|
||||||
|
assert state.state_hash() == before
|
||||||
|
assert state.players[0].carrying is None
|
||||||
|
assert state.stations[0].contents == (chopped,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_removes_only_the_first_of_duplicates() -> None:
|
||||||
|
second = Item(kind=ItemKind.TOMATO, form=ItemForm.RAW, quality=0.5)
|
||||||
|
ready = fixture(state=StationState.READY, contents=(tomato(), second))
|
||||||
|
state = make_state(stations=(ready,), players=(cook("p0"),))
|
||||||
|
new_state, _events = resolve_action(state, state.players[0])
|
||||||
|
assert len(new_state.stations[0].contents) == 1
|
||||||
|
assert new_state.stations[0].contents[0].quality == 0.5
|
||||||
|
assert new_state.players[0].carrying is ready.contents[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_sets_the_item_down_on_the_fixture() -> None:
|
||||||
|
shelf = fixture(kind=StationKind.SPAWN_SHELF)
|
||||||
|
held = lettuce()
|
||||||
|
state = make_state(stations=(shelf,), players=(cook("p0", carrying=held),))
|
||||||
|
before = state.state_hash()
|
||||||
|
new_state, events = resolve_action(state, state.players[0])
|
||||||
|
assert new_state.players[0].carrying is None
|
||||||
|
assert new_state.stations[0].contents == (held,)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].kind is EventKind.DROPPED
|
||||||
|
assert dict(events[0].payload) == {
|
||||||
|
"player_id": "p0",
|
||||||
|
"station": shelf.id,
|
||||||
|
"item": "lettuce",
|
||||||
|
"form": "raw",
|
||||||
|
}
|
||||||
|
assert state.state_hash() == before
|
||||||
|
assert state.players[0].carrying is held
|
||||||
|
assert state.stations[0].contents == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_serve_window_is_not_a_set_down_target() -> None:
|
||||||
|
# A full hand at the window: `select_target` skips the item tier and the
|
||||||
|
# SERVE kind is a `ServeTarget`; the serve ladder then demands a plated dish,
|
||||||
|
# so a raw item is refused — the window is the score path, not a shelf to
|
||||||
|
# drop on (`RELEASE` exists only at a fixture, and the shelf is that hatch).
|
||||||
|
window = fixture(kind=StationKind.SERVE)
|
||||||
|
state = make_state(stations=(window,), players=(cook("p0", carrying=lettuce()),))
|
||||||
|
target = select_target(state, state.players[0])
|
||||||
|
assert isinstance(target, ServeTarget)
|
||||||
|
verb, denial = select_verb(state, state.players[0], target)
|
||||||
|
assert (verb, denial) == (Verb.NONE, DeniedReason.WRONG_ITEM)
|
||||||
|
new_state, events = resolve_action(state, state.players[0])
|
||||||
|
assert new_state is state
|
||||||
|
assert len(events) == 1
|
||||||
|
assert dict(events[0].payload)["reason"] == "wrong_item"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the phase 04 verbs: named today, deliberately unspent -----------------------------
|
||||||
|
#
|
||||||
|
# `PLACE_ON_STATION` and `PLATE_DISH` left this table with task 04: a set-down now
|
||||||
|
# actually sets the item down (the hand-off's marked place, `sim/carry.py` — pinned
|
||||||
|
# by `tests/sim/test_carry.py`). What remains unspent is the work itself.
|
||||||
|
|
||||||
|
NO_OP_ROWS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"name": "start_work",
|
||||||
|
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
|
||||||
|
"target": "station",
|
||||||
|
"expect": Verb.START_WORK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hold_work",
|
||||||
|
"station": {
|
||||||
|
"kind": StationKind.CHOP_BOARD,
|
||||||
|
"state": StationState.WORKING,
|
||||||
|
"contents": (tomato(),),
|
||||||
|
"worker_id": "p0",
|
||||||
|
},
|
||||||
|
"target": "station",
|
||||||
|
"expect": Verb.HOLD_WORK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "serve",
|
||||||
|
"station": {"kind": StationKind.SERVE},
|
||||||
|
"player": {"carrying": "plated"},
|
||||||
|
"target": "serve",
|
||||||
|
"expect": Verb.SERVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wash",
|
||||||
|
"station": {"kind": StationKind.SINK},
|
||||||
|
"player": {"carrying": "dirty_plate"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": Verb.WASH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trash",
|
||||||
|
"station": {"kind": StationKind.TRASH},
|
||||||
|
"player": {"carrying": "tomato"},
|
||||||
|
"target": "station",
|
||||||
|
"expect": Verb.TRASH,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("row", NO_OP_ROWS, ids=lambda row: row["name"])
|
||||||
|
def test_the_phase_04_verbs_are_named_but_deliberate_no_ops(row: dict[str, Any]) -> None:
|
||||||
|
# `select_verb` names the verb today (the ladder is pinned by the matrix);
|
||||||
|
# `resolve_action` spends nothing for the work verbs until phase 04's pipeline
|
||||||
|
# exists: no state change, and no `DENIED` — a no-op is not a refusal, the
|
||||||
|
# player did nothing wrong, and the client plays nothing rather than a nudge.
|
||||||
|
# `TRASH` is the same shape at the press: the press starts the hold, and the
|
||||||
|
# action gate spends it at the 8-tick threshold (`tests/sim/test_carry.py`).
|
||||||
|
state, player, target = build(row)
|
||||||
|
verb, denial = select_verb(state, player, target)
|
||||||
|
assert (verb, denial) == (row["expect"], None)
|
||||||
|
before = state.state_hash()
|
||||||
|
new_state, events = resolve_action(state, player)
|
||||||
|
assert new_state is state
|
||||||
|
assert events == ()
|
||||||
|
assert state.state_hash() == before
|
||||||
|
|
||||||
|
|
||||||
|
# --- contested arbitration (the action gate) --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _contested_kitchen() -> tuple[KitchenState, dict[str, PlayerInput]]:
|
||||||
|
"""Two cooks, one ready item: seat 0 in the front cell, seat 1 on the diagonal.
|
||||||
|
|
||||||
|
Both face the station's cell (5, 4) — seat 0 through its front tier, seat 1
|
||||||
|
through the diagonal tier — and both have a free hand, so both would pick
|
||||||
|
the same item out of the same fixture were there no arbitration.
|
||||||
|
"""
|
||||||
|
ready = fixture(state=StationState.READY, contents=(tomato(),))
|
||||||
|
state = make_state(
|
||||||
|
stations=(ready,),
|
||||||
|
players=(
|
||||||
|
cook("p0", seat=0), # (5, 5) facing UP: front cell (5, 4)
|
||||||
|
cook("p1", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1), # (5, 4) is its diagonal
|
||||||
|
),
|
||||||
|
)
|
||||||
|
frames = {
|
||||||
|
"p0": PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0),
|
||||||
|
"p1": PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=0),
|
||||||
|
}
|
||||||
|
return state, frames
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_earliest_received_frame_wins_a_contested_item() -> None:
|
||||||
|
state, frames = _contested_kitchen()
|
||||||
|
frames["p1"] = PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=1)
|
||||||
|
new_state, events = action_gate(state, frames, STEP_DT)
|
||||||
|
picked = [event for event in events if event.kind is EventKind.PICKED]
|
||||||
|
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||||
|
assert len(picked) == 1 and len(denied) == 1 # one effect per cook, never per tick
|
||||||
|
assert dict(picked[0].payload)["player_id"] == "p0"
|
||||||
|
assert dict(denied[0].payload) == {
|
||||||
|
"player_id": "p1",
|
||||||
|
"reason": "contested_lost",
|
||||||
|
"verb": "none",
|
||||||
|
}
|
||||||
|
assert new_state.players[0].carrying is not None
|
||||||
|
assert new_state.players[1].carrying is None
|
||||||
|
assert new_state.stations[0].contents == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_full_tie_is_broken_by_the_seat_index() -> None:
|
||||||
|
state, frames = _contested_kitchen() # both tick 0, seq 0: the seat decides
|
||||||
|
new_state, events = action_gate(state, frames, STEP_DT)
|
||||||
|
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||||
|
assert len(denied) == 1
|
||||||
|
assert dict(denied[0].payload)["player_id"] == "p1"
|
||||||
|
assert dict(denied[0].payload)["reason"] == "contested_lost"
|
||||||
|
assert new_state.players[0].carrying is not None
|
||||||
|
assert new_state.players[1].carrying is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_lower_seq_wins_a_same_tick_contest() -> None:
|
||||||
|
state, frames = _contested_kitchen()
|
||||||
|
frames["p0"] = PlayerInput(player_id="p0", seq=5, action=True, received_at_tick=0)
|
||||||
|
frames["p1"] = PlayerInput(player_id="p1", seq=2, action=True, received_at_tick=0)
|
||||||
|
new_state, _events = action_gate(state, frames, STEP_DT)
|
||||||
|
assert new_state.players[1].carrying is not None # seq 2 < seq 5, same tick
|
||||||
|
assert new_state.players[0].carrying is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stunned_cook_keeps_their_own_reason_and_is_not_a_contest_loser() -> None:
|
||||||
|
state, frames = _contested_kitchen()
|
||||||
|
state = state.with_players(
|
||||||
|
tuple(
|
||||||
|
replace(player, stun=0.3) if player.id == "p1" else player
|
||||||
|
for player in state.players
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_new_state, events = action_gate(state, frames, STEP_DT)
|
||||||
|
denied = [event for event in events if event.kind is EventKind.DENIED]
|
||||||
|
# Blaming the other cook for a refusal the stun caused would be a second lie;
|
||||||
|
# the stunned cook gets the stunned reason and never enters the claim scan.
|
||||||
|
assert len(denied) == 1
|
||||||
|
assert dict(denied[0].payload) == {"player_id": "p1", "reason": "stunned", "verb": "none"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_cooks_at_a_shelf_with_no_stock_do_not_contend() -> None:
|
||||||
|
# A shelf pick with nothing in `contents` is stock from the level's legend
|
||||||
|
# (phase 04 replenishes it), so two simultaneous taps are both legal and
|
||||||
|
# neither is in the other's way — no `CONTESTED_LOST`, no lie about a fight
|
||||||
|
# there was no fight in.
|
||||||
|
shelf = fixture(kind=StationKind.SPAWN_SHELF)
|
||||||
|
state = make_state(
|
||||||
|
stations=(shelf,),
|
||||||
|
players=(
|
||||||
|
cook("p0", seat=0),
|
||||||
|
cook("p1", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
frames = {
|
||||||
|
"p0": PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0),
|
||||||
|
"p1": PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=0),
|
||||||
|
}
|
||||||
|
new_state, events = action_gate(state, frames, STEP_DT)
|
||||||
|
assert events == () # both are phase 04 no-ops, and neither was refused
|
||||||
|
assert new_state.players[0].action_held_ticks == 1
|
||||||
|
assert new_state.players[1].action_held_ticks == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- the claim helpers ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_claims_entity_distinguishes_a_stock_pick_from_an_empty_shelf() -> None:
|
||||||
|
shelf = fixture(kind=StationKind.SPAWN_SHELF)
|
||||||
|
stocked = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(),))
|
||||||
|
assert claims_entity(Verb.PICK_FROM_STATION, StationTarget(shelf)) is False
|
||||||
|
assert claims_entity(Verb.PICK_FROM_STATION, StationTarget(stocked)) is True
|
||||||
|
assert claims_entity(Verb.PICK_ITEM, ItemTarget(item=tomato(), on_station_id=shelf.id)) is True
|
||||||
|
assert claims_entity(Verb.START_WORK, StationTarget(stocked)) is True
|
||||||
|
assert claims_entity(Verb.RELEASE, StationTarget(shelf)) is False
|
||||||
|
assert claims_entity(Verb.SERVE, ServeTarget(shelf)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_key_claims_shelf_stock_as_a_pile_and_floor_items_by_position() -> None:
|
||||||
|
first = ItemTarget(item=tomato(), on_station_id="shelf-1")
|
||||||
|
second = ItemTarget(item=tomato(), on_station_id="shelf-1")
|
||||||
|
assert target_key(first) == target_key(second)
|
||||||
|
assert target_key(first) == ("item", "shelf-1")
|
||||||
|
floor = ItemTarget(item=tomato(), at=Vec2(5.5, 4.5))
|
||||||
|
assert target_key(floor) == ("floor", (5.5 * 64, 4.5 * 64)) # Vec2.KEY, 1/64 tile
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_rank_orders_by_arrival_then_seq_then_seat() -> None:
|
||||||
|
p0 = cook("p0", seat=0)
|
||||||
|
p1 = cook("p1", seat=1)
|
||||||
|
high_seq = PlayerInput(player_id="p0", seq=9, received_at_tick=0)
|
||||||
|
low_seq = PlayerInput(player_id="p1", seq=1, received_at_tick=0)
|
||||||
|
later_tick = PlayerInput(player_id="p1", seq=0, received_at_tick=1)
|
||||||
|
assert claim_rank(p1, low_seq) < claim_rank(p0, high_seq) # seq before seat
|
||||||
|
assert claim_rank(p0, high_seq) < claim_rank(p1, later_tick) # arrival before seq
|
||||||
|
assert claim_rank(p0, low_seq) < claim_rank(p1, low_seq) # the seat breaks a full tie
|
||||||
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
|
|||||||
"docs/DESIGN.md",
|
"docs/DESIGN.md",
|
||||||
"docs/DEPLOYMENT.md",
|
"docs/DEPLOYMENT.md",
|
||||||
"docs/LEVELS.md",
|
"docs/LEVELS.md",
|
||||||
|
"docs/SIMULATION.md",
|
||||||
"docs/TESTING.md",
|
"docs/TESTING.md",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user