Everything is green and the working tree is clean. Final report: **Phase 04 final verification pass — complete** - **Verified:** all 6 tasks in `complete/`; `step_stations` import, acceptance-matrix totality (every `ItemKind`×`StationKind`), 5-case scoring hand check, re-blessed goldens, levels validate, app launch (`/healthz` 200, `/` 200, exact README payload) - **Implemented (missing task-06 work found in final pass):** `tests/sim/test_full_rounds.py` (scripted solo win on level 1 incl. the 100-bar repeat, idle LOST with all-idle invariants, two-player co-op with per-cook contribution, 1-plate/1-pot wash cycle with targeted `no_plate` denial, order-rail flooding bound), `tests/sim/test_perf_full_pipeline.py` (slow: 4 cooks × 3 000 ticks, mean 0.02 ms « 0.5 ms budget), `tests/unit/test_sim_hooks_wired.py`, the "Golden history: phase 03 → phase 04" line in `docs/SIMULATION.md` - **Defects fixed:** 20 ruff + 7 pyright errors (6 pre-existing in `helpers.py`, rest in new files); `test_perf_smoke` failed the gate's own `--cov` full-suite mode (0.2026 ms > 0.2 ms budget) — raised to 0.3 ms/600 ms, documented in its docstring - **Gate:** `bash .agents/validate.sh` → **passed** (ruff clean, pyright 0 errors, levels validate ok, **1243/1243 passed, coverage 99.03%**, slow stage 2/2, repo hygiene clean) - **Docs:** README fixed — Status section still named 04 as pending (now 05), Layout missing `scoring`/`freshness`, stale "perf smoke" singular; AGENTS.md fixed — "perf smoke" → both slow perf budgets. Rest verified consistent - **Decisions:** the task's `duo_tick < solo_tick` claim is unachievable (9 configs measured: single-pipeline kitchen serializes the 2nd cook) — the two-player test instead proves co-op is *rewarded by the rules* (both cooks scored, contributions sum to the round score); wash-cycle denial asserted targeted (the `no_plate` guard) since the policy preempts it in-round (ladder answers `STATION_DIRTY`) - **Next pending phase:** `05_anonymous_lobbies_and_room_lifecycle`
1065 lines
68 KiB
Markdown
1065 lines
68 KiB
Markdown
# SIMULATION.md — the tick contract
|
||
|
||
**Status:** current as of phase 04 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, the station
|
||
pipelines: the acceptance matrix, the transition timers, the worker hold and release,
|
||
dirty props and the plate/pot stock, the ticket rail: seeded order spawning with
|
||
the concurrency cap and the full-rail retry, tick-precise deadlines and the miss
|
||
bookkeeping, serve scoring: the named serve rejections, FIFO order matching,
|
||
the quality/streak/time-bonus math and the miss penalty, freshness: the burn
|
||
countdown, the quality decay, the push-out and the dirty loop — and the round's
|
||
end conditions: the early win, the buzzer verdict, the post-verdict settle,
|
||
the stars and the plain-JSON result payload, plus the replay harness: the
|
||
input-script format, the committed golden hashes, the `record`/`check` CLI, and
|
||
the headless perf smoke). **Filled in by phase 10** for the hazards —
|
||
that replaces the 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". |
|
||
| `SETTLE_S` | `1.0` s | `sim/round_state.py` | The settle window: how long the room (phase 05) keeps a decided round ticking, so the verdict substep's events land. The sim is a frozen stage-7-and-tick machine for the whole window. |
|
||
| `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. |
|
||
|
||
### Orders constants
|
||
|
||
The ticket rail's numbers (PLAN §5 "Orders, scoring, results"), in `sim/orders.py`:
|
||
|
||
| Constant | Value | Means |
|
||
| --- | --- | --- |
|
||
| `TICK_SECONDS` | `0.05` s | One substep in seconds — restated here because the state's time unit is the tick, and `sim/step.py` (which owns `STEP_DT`) imports this module, so a runtime import back would be a cycle. |
|
||
| `FIRST_ORDER_DELAY_TICKS` | `30` ticks | The first order arrives 1.5 s into the round: the countdown-to-cook beat. `build_state` starts `next_order_at` here. |
|
||
| `MIN_RETRY_TICKS` | `20` ticks | The full-rail retry gap (1 s): a due tick the rail cannot serve retries `MIN_RETRY_TICKS` later, without spending the interval draw. |
|
||
|
||
A level's `orders` spec (seconds) is converted to ticks by `to_ticks()` — whole ticks,
|
||
clamped to at least one, because a zero-tick span is how a second spawn would land
|
||
on the first's tick — at round build (`sim/state_build.py` copies the spec onto the
|
||
state as scalars, so a replay is self-contained without the `Level` object).
|
||
|
||
`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` | real (phase 04 task 01) |
|
||
| 4b | Freshness: the burn countdown, the quality decay, the push-out's clock | `sim/freshness.py` | real (phase 04 task 04) |
|
||
| 5 | Orders (spawn, deadline, miss — the miss's penalty and streak reset ride on the injected `on_miss`) | `sim/orders.py` (+ `sim/scoring.py`) | real (phase 04 task 02, scoring task 03) |
|
||
| 6 | Hazards | `sim/hazards.py` | identity → phase 10 |
|
||
| 7 | Round timer, end conditions and the settle (the countdown, the early win, the buzzer, the one `ROUND_END`, the post-verdict freeze) | `sim/round_state.py` | real (phase 04 task 05) |
|
||
| 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 with a *full* hand — the player needs "this board is filthy", not "you are holding the wrong thing". A free hand is answered with `WASH` (the in-place wash, the work station's fix for its own dirt) instead. Dirt outranks every other state. |
|
||
| `STATION_BUSY` | `station_busy` | A fixture another cook is working, a `BUSY` fixture, or one whose worker-release grace is running (its `cooldown` bookkeeping, `RELEASE_TICKS` substeps without the worker in reach). 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.
|
||
|
||
## The station pipeline
|
||
|
||
`sim/stations.py` spends the work verbs the ladder names, and stage 4 advances the
|
||
work. The rules live in data, not `if` ladders:
|
||
|
||
* **`ACCEPTS`** — station kind × item kind, total over both enums: does this
|
||
fixture's pipeline ever work this item (in some form)? The chop board works every
|
||
raw ingredient except rice (PLAN §5: "raw rice needs no board") and never a plate;
|
||
the stove works every ingredient but not a plate; the prep plates every ingredient
|
||
(a plate is part of what it operates on); the serve window consumes a plated dish
|
||
only; the sink washes a dirty prop (the plate — the pot is a stuck prop, washed in
|
||
place at the stove); the trash and the shelf take anything.
|
||
* **`TRANSITIONS`** — `(station kind, item form) -> (output form, seconds)`: chop
|
||
board `RAW → CHOPPED` in 0.9 s, stove `CHOPPED → COOKED` in 6 s, stove
|
||
`RAW → COOKED` in 8 s (refined per kind by `STOVE_RAW_SECONDS`: rice 8 s, the pan
|
||
5 s for patty/fish), prep `COOKED | CHOPPED → PLATED` in 0.6 s, sink
|
||
`DIRTY → RAW` in 1.2 s. A pair with no row is not worked — the one lookup
|
||
(`transition_for`) is the whole of "does this fixture take this item into its
|
||
pipeline".
|
||
|
||
`step_stations` runs as substep stage 4. It advances `progress` on a `WORKING`
|
||
station while its worker is within `WORKER_REACH` (`REACH + FORGIVE` — a worker
|
||
stands exactly where a press reaches) and still holding the action button
|
||
(`action_held_ticks > 0` after the gate's increment — a release freezes the progress,
|
||
a resume continues it, it never rolls back), flips the station to `READY` at the
|
||
duration (emitting the stage event — `CHOPPED` / `COOKED` / `WASHED` — then
|
||
`READY`), and lets a `READY` station whose contents have been consumed fall back to
|
||
`IDLE` on the next substep. When the worker leaves the reach, a release grace of
|
||
`RELEASE_TICKS` (4) consecutive substeps runs on the station's `cooldown` field (its
|
||
only user); when the grace expires the `worker_id` is released — the work stays
|
||
frozen, a new cook's `HOLD_WORK` press claims the slot. `BLOCKED` is cleared the
|
||
moment its cause (a hazard naming the station, phase 10) is gone.
|
||
|
||
`apply_station_verb` is what `interact.resolve_action` routes. A set-down on a
|
||
work surface that works the item *becomes* the pipeline input — the work starts
|
||
on the spot, the set-down mark goes, the prop's stock decrements; a set-down the
|
||
station does not work stays a marked grabbable object (a raw ingredient at the prep
|
||
is not a dish, and the set-down itself does happen — the hand-off's whole memory
|
||
rests on it). `PLATE_DISH` takes the carried cooked/chopped ingredient plus a plate
|
||
out of the `plates_clean` stock; `WASH` takes a carried dirty item into the sink,
|
||
or is the ladder's free-hand answer at a dirty fixture — the in-place wash of 1.2 s
|
||
that clears the dirt and returns the prop to stock. Lifting a `PREP`'s plated dish
|
||
or a `STOVE`'s cooked item leaves the fixture `DIRTY` — its prop (the plate, the
|
||
pot) is stuck on it. The shelf's stock is the level's legend (phase 05's room seeds
|
||
it from `level.station_items`); until then a pick from an empty shelf is a no-op, not
|
||
a refusal. The `SERVE` press is spent by `sim/scoring.py` (below): a matched
|
||
dish is consumed and scored, a rejected one stays in the hand with a named
|
||
`DENIED` (`no_orders` / `wrong_recipe` / `incomplete` / `burnt` / `no_target`).
|
||
The `TRASH` press still only starts the hold the gate spends.
|
||
|
||
Dirty props and stock: a `DIRTY` station refuses the use verbs (`STATION_DIRTY`) and
|
||
answers a free hand with `WASH`. Stock is decremented when the work that needs the
|
||
prop starts and incremented when the wash completes; a missing counter reads as
|
||
`DEFAULT_STOCK` (99, the schema's "effectively unlimited") — phase 05's room seeds
|
||
the counters from `level.station_limits` at round start, and this default is what a
|
||
round built without them sees. A zero stock is a named refusal: `NOTHING_HERE` with
|
||
`{"why": "no_plate"}` / `{"why": "no_pot"}`.
|
||
|
||
`tests/sim/test_stations.py` pins the matrix cell by cell against an explicit
|
||
expectation table (a new `StationKind` / `ItemKind` without a cell fails the build),
|
||
one test per transition row at the tick-granularity boundary (substep `duration − 1`
|
||
is still `WORKING`, substep `duration` is `READY`), the hold freeze/resume, the
|
||
release grace and re-claim, the dirty gating and the stock; `tests/sim/test_station_events.py`
|
||
pins the event kinds, payloads, positions and order.
|
||
|
||
## Freshness — the clocks that create pressure
|
||
|
||
`sim/freshness.py` runs as substep stage 4b (after the station pipelines,
|
||
before the orders) and owns the quality: food decays on clocks the kitchen
|
||
itself starts. No state fields are added — the burn countdown reuses
|
||
`Item.ttl`, and the decay is a per-tick decrement of the existing
|
||
`Item.quality` — so the replay goldens committed before this stage keep
|
||
their hashes (a state with nothing decaying comes back the same object, no
|
||
events: hash-neutral).
|
||
|
||
**Where food decays:**
|
||
|
||
| Where | What | Rate |
|
||
|---|---|---|
|
||
| A ready stove (on the heat) | the `COOKED` dish — the countdown starts at the ready flip (`ttl = BURN_AFTER_S`) | the countdown; at `BURN_AFTER_S` (8.0 s) the dish is `BURNT`, its quality 0.0, the station `DIRTY` (the burnt pot is stuck) and one `BURNT` event |
|
||
| A chop board | a `RAW` or `CHOPPED` left on the board | linear over `ROT_S` (20.0 s) to 0.0, then `BURNT` — a raw onion left there is the one that rots |
|
||
| The hand | a carried `CHOPPED` / `COOKED` | half the board's rate — a hand is not a fridge; a carried `RAW` and a carried `PLATED` dish do not decay (a `PLATED` dish keeps the quality it was plated with) |
|
||
| Elsewhere | a set-down object off the board | not decaying — the board, the hand and the heat are the clocks |
|
||
|
||
The burn boundary is tick-exact: a dish on the heat burns on the 160th
|
||
substep after the ready flip (8.0 s / 0.05 s), not the 159th — the
|
||
`BURNT` event, the zero quality and the station's `DIRTY` flip land on the
|
||
same substep. A lifted dish pauses the countdown; a set-down on the ready
|
||
stove resumes it. A stove's work completed in a substep starts the
|
||
countdown in the same one (the flip's first tick is the stage 4b's).
|
||
|
||
**The push-out.** A new raw item must be placed on a board that holds a
|
||
`BURNT` item — the raw that rotted there: the burnt item yields, it
|
||
despawns with a named `WASTED` event and `counters["wasted"] += 1` per
|
||
item destroyed, and the set-down proceeds (the work starts on the new raw).
|
||
A usable raw item is never silently overwritten: a board holding one
|
||
refuses the set-down with the named `station_busy` — the ladder's own
|
||
guard (`stations.push_out_allowed`) is the same predicate the set-down
|
||
path (`_push_burnt_board_items`) answers with.
|
||
|
||
**Burnt is refused.** A burnt dish never reaches a score: a `PLATED` dish
|
||
at quality 0 is the scoring's named `burnt` refusal, and a `BURNT`-form
|
||
item is refused one step earlier — the ladder's `wrong_item` (it is not a
|
||
dish at all). The dish stays in the hand either way; the order is
|
||
untouched.
|
||
|
||
**The dirty loop, and the no-dead-end guarantee.** The props move between
|
||
the clean pool and the dirty pool, and the sum is the level's limit
|
||
every tick: a *use* (the work's start — the plate's `PLATE_DISH`, the
|
||
pot's raw set-down) moves one prop `…_clean → …_dirty`, and a *wash*
|
||
(the sink, or the in-place wash of a stuck prop) moves it back. Lifting a
|
||
`PREP`'s plated dish or a `STOVE`'s cooked item leaves the fixture `DIRTY`
|
||
(its prop is stuck on it); a zero stock is a named refusal
|
||
(`NOTHING_HERE` with the `why`), and the counters never go below 0. The
|
||
no-dead-end guarantee — a worst-case cook who serves everything possible
|
||
and never washes until forced never gets stuck: one wash (1.2 s)
|
||
restores the stock, a `plates_clean == 0` never outlives the wash plus 5
|
||
s of walking, and the validator's `STATION_LIMIT_ZERO` rule keeps
|
||
zero-prop levels out of the campaign before the first tick.
|
||
|
||
`tests/sim/test_freshness.py` pins the tick-exact burn boundary (a ready
|
||
stove, and a stove driven through its whole work), the board's linear
|
||
decay at 25/50/75/100 % of `ROT_S`, the half-rate carry, the push-out
|
||
(the `WASTED`, the usable refusals, the counter), the burnt refusals
|
||
through the whole `step()`, the dirty loop's stock round trips (the
|
||
in-place wash, the sink's wash, the zero stock), the no-op neutrality, and
|
||
the 1 000-tick conservation sequence; `tests/sim/test_no_deadlock.py`
|
||
runs the 2 000-tick worst case (per-tick conservation, the wash window,
|
||
one wash restores the plate, the validator's half).
|
||
|
||
## Orders — the ticket rail
|
||
|
||
`sim/orders.py` runs as substep stage 5. A due tick does exactly one of three
|
||
things, and the whole of the randomness contract is the state threading:
|
||
`step_orders` rebuilds the `Rng` from `state.rng_state`, draws, and writes the
|
||
counter back into the state it returns — a spawn is exactly two `next_u64`s
|
||
(the `choice` over the pool, then the interval `range_int`), and a replay
|
||
reproduces the sequence from the seed alone. Stage 6 (hazards, phase 10)
|
||
continues the *same* stream from the post-orders counter, so one substep's
|
||
draws are a function of the pre-step `rng_state` and nothing else.
|
||
|
||
**Spawning.** An order is due when `tick >= next_order_at` (a pre-increment
|
||
tick). `build_state` starts `next_order_at` at `FIRST_ORDER_DELAY_TICKS` (1.5 s)
|
||
so the countdown-to-cook beat feels right. When the rail has room
|
||
(`len(orders) < order_concurrent`), the recipe is `Rng(state.rng_state)
|
||
.choice(pool)` — the level's pool, copied onto the state at build — the new
|
||
order carries `value = recipe.base_score` from the `RecipeBook` the caller
|
||
passes to `step` (a `None` book on a state that would spawn is a `ValueError`
|
||
at the spawn: a caller bug, named, not a silent zero), `deadline` the absolute
|
||
tick `tick + order_deadline`, and its id from the monotonic `next_order_id`
|
||
(from 1). The next spawn is scheduled at `tick + range_int(min, max)` — the
|
||
draw is clamped to at least one tick ahead, which is what makes "never two
|
||
spawns on one tick" a construction fact.
|
||
|
||
**The full-rail retry — and a retry is not a draw.** When the due tick arrives
|
||
with the rail at its cap, the next attempt is `tick + MIN_RETRY_TICKS` (1 s)
|
||
and *neither* the recipe draw *nor* the interval draw is consumed: the
|
||
`rng_state` is untouched. The rule exists because without it a full rail would
|
||
silently warp the cadence — every blocked due tick would spend a draw the next
|
||
spawn never used, and a replay could not tell a retry from a real gap. (An
|
||
empty pool is a no-op, not a retry: nothing can ever be ordered.)
|
||
|
||
**Deadlines, to the tick.** An order's clock is
|
||
`(deadline - tick) * TICK_SECONDS`, derived, never accumulated — so it expires
|
||
on *exactly* the substep whose tick reaches its `deadline`: it has lived
|
||
`order_deadline` substeps, `order_deadline * 0.05 s`. On that substep the
|
||
order is removed, `counters["missed"] += 1`, and one `ORDER_MISSED` carries its
|
||
id and recipe (`x`/`y` are 0 — the rail is HUD, not floor). The `penalty`
|
||
score deduction and the streak reset are `scoring.py`'s (the single owner of
|
||
score and streak), injected as the stage's `on_miss` callback — the kernel
|
||
passes `scoring.apply_miss`, so this stage records the miss and names it,
|
||
and the two modules never import each other.
|
||
Spawning emits no event: the snapshot's `o` list (below) is what a new order
|
||
shows up in.
|
||
|
||
**The summary.** `order_summary(state)` is the compact ticket rail the phase-06
|
||
snapshot encoder projects (PLAN §4's `o`), one mapping per live order in rail
|
||
order: `id`, `r` (the recipe id), `l` (the fraction of the deadline still
|
||
unspent, two decimals, clamped to `[0, 1]`) and `pts` (the value).
|
||
|
||
`tests/sim/test_orders.py` pins the first-order delay (nothing before tick 30),
|
||
the spawn's two-draw thread and the interval scheduling, the full-rail retry
|
||
(its own test: the retry cadence, and the `rng_state` untouched across the
|
||
blocked due ticks), the zero-tick-span clamp, deadline expiry on the exact tick,
|
||
the `ORDER_MISSED` payload, 200 simulated seconds of interval bounds and cap
|
||
under idle and busy rails, same-seed/different-seed sequences, and the summary;
|
||
`tests/sim/test_orders_fuzz.py` runs twenty randomized specs (fixed seed) for
|
||
300 s each, re-checking no negative times, no duplicate ids, no unbounded
|
||
order count and no exceptions on every tick.
|
||
|
||
## Serve scoring — the window and the miss
|
||
|
||
`sim/scoring.py` is the single owner of the round's `score` and `streak`
|
||
(the two numbers the verdict turns on), from both sides: the `SERVE` press
|
||
(routed by `stations.apply_station_verb` with the kernel's book) and the
|
||
rail's miss (injected into `step_orders` as `on_miss`, so `orders.py` and
|
||
`scoring.py` never import each other). Nothing here reads the clock or
|
||
anything outside the state it is handed.
|
||
|
||
**A serve, in check order** — the first failure is the answer, each a named
|
||
`DENIED` with the dish left in the hand and the state otherwise untouched:
|
||
|
||
| Check | Reason | When it fires |
|
||
|---|---|---|
|
||
| burnt | `burnt` | `quality <= 0.0` or form `BURNT` — a burnt dish is rejected, never scored |
|
||
| no target | `no_target` | the dish is not `PLATED` (not a dish at all) |
|
||
| no orders | `no_orders` | the ticket rail is empty |
|
||
| wrong recipe | `wrong_recipe` | a declared `dish_id` the book does not know; an ingredient no book recipe requires; or no live order naming the resolved recipe |
|
||
| incomplete | `incomplete` | the dish's single ingredient is not one the declared recipe requires (a missing or wrong ingredient — the sim plates one item per dish) |
|
||
|
||
A dish without a declared `dish_id` composes *every* book recipe that
|
||
requires its ingredient kind — the match then decides among them. On a
|
||
match, the dish fills the **oldest compatible live order** (the smallest
|
||
id — ids are monotonic at spawn, so first onto the rail is first served,
|
||
FIFO) and the state spends on it: the order is removed from the rail, the
|
||
hand empties, `score += points`, `streak += 1`, the serving cook's `score`
|
||
and `dishes_served` advance (per-player contribution — display only, never
|
||
competitive, PLAN §5), and `counters["served"] += 1`. The dirty props are
|
||
not the serve's: a prop is dirty from its *use* (the work's start — the
|
||
plate's `PLATE_DISH`, the pot's set-down; the station pipeline's stock
|
||
bookkeeping, below), so the serve itself moves no counter. `SERVED` (the
|
||
payload carries `order_id`, `points`, `quality`, `streak` and
|
||
`player_id`) is followed by `SCORE`, both at the cook.
|
||
|
||
**The math** (`tests/sim/test_scoring.py` pins it against five hand-computed
|
||
serves, the expectations written as literals):
|
||
|
||
```text
|
||
points = round(base × quality × streak_mult × time_bonus)
|
||
|
||
base = the order's value (the recipe's base_score, frozen at spawn)
|
||
quality = the dish's quality (1.0 → 0.0; a burnt dish never reaches it)
|
||
streak_mult = 1 + 0.1 × min(streak, 5) # the streak *after* the increment;
|
||
# capped at MAX_STREAK_MULT = 1.5
|
||
time_bonus = 1 + 0.5 × fraction # fraction = time_left over the level
|
||
# deadline in seconds (the state's
|
||
# order_deadline × 0.05 — the span every
|
||
# order is spawned with), clamped [0, 1];
|
||
# 1.5 with the full deadline unspent,
|
||
# 1.0 at the edge; a non-positive span
|
||
# buys no bonus (no division by zero)
|
||
```
|
||
|
||
The floor is `MIN_SCORE_FLOOR = 0`: the score is never negative. `round` is
|
||
Python's (banker's rounding at an exact `.5`; the pinned cases never land
|
||
on one).
|
||
|
||
**A miss.** `apply_miss(state, order)` — the scoring half of the expiry
|
||
`step_orders` records: `score = max(0, score − order_penalty)` and
|
||
`streak = 0`, with `SCORE` carrying the applied delta (negative or zero,
|
||
with the `order_id`) at the rail. A miss that changes nothing — no penalty
|
||
configured and no streak to reset — returns the state untouched and fires no
|
||
`SCORE` (the `ORDER_MISSED` already names it).
|
||
|
||
`tests/sim/test_scoring.py` pins the five hand-computed serves (the
|
||
literals, no formula re-derived in the test), every rejection path (the dish
|
||
and the order intact, the state the same object back), FIFO matching with
|
||
two compatible orders, the streak cap, the score floor, the per-player
|
||
contribution summing to the round's score, the bookless-press `ValueError`,
|
||
and the wiring through the whole `step()` (a press at the window scoring,
|
||
a press rejected, an expiry missing); `tests/sim/test_scoring_property.py`
|
||
runs 500 seeded random serves and re-checks points ≥ 0, the `served`
|
||
counter equal to the orders removed, the streak multiplier never past its
|
||
cap, and no order served twice.
|
||
|
||
## Round end — the verdict, the settle, the stars
|
||
|
||
`sim/round_state.py` runs as substep stage 7, and it owns the round's clock
|
||
and its verdict — PLAN §5 says the verdict turns on `score` against
|
||
`target_score` and nothing else.
|
||
|
||
**The verdict, one check per substep.** The score stage (2b, through the
|
||
action gate's `SERVE`) and the verdict stage run in the *same* substep, so
|
||
a serve that reaches the target wins on that press:
|
||
|
||
| Check | When | Outcome |
|
||
|---|---|---|
|
||
| the clock | `time_left` runs to zero (a residue under `TIME_EPS` is float noise, not "0.0 s left") | `WON` if `score >= target_score`, `LOST` otherwise; `time_left` 0.0 |
|
||
| the early win | `score >= target_score` with the clock still live | `WON`; `time_left` held at this substep's countdown — the clock stops on the win, at the value it had when the press scored |
|
||
|
||
Exactly one `ROUND_END` event fires, on the substep the verdict lands (the
|
||
payload carries `result`, `score`, `target` and `stars`, at the rail — the
|
||
event's `x`/`y` are 0: the end of a round is HUD, not floor). An
|
||
`ABORTED` round fires none: the room tells the players with its own `ev`.
|
||
|
||
**The settle — the freeze after the verdict.** A state that carries a
|
||
`result` is finished gameplay. From the substep the verdict lands, the
|
||
kernel freezes every gameplay stage — the action gate, movement, stations,
|
||
freshness, orders and hazards are all skipped — and only the frozen clock
|
||
clamp and `tick += 1` keep running:
|
||
|
||
| What | During the settle |
|
||
|---|---|
|
||
| player inputs | ignored (no movement, no verbs) |
|
||
| stations / freshness / orders / hazards | frozen (no progress, no burn, no deadline spent) |
|
||
| `time_left` | held at the verdict value — the verdict is the last tick that changes the clock; never negative |
|
||
| `rng_state` | untouched (a draw-less `Rng` returns its seed) |
|
||
| events | none — the `ROUND_END` and the verdict substep's own events (the winning `SERVED`, the `SCORE`) are the only ones that land |
|
||
|
||
The room supervisor (phase 05) owns the window: it keeps the room ticking
|
||
for `SETTLE_S` (1.0 s) after the verdict so the winning substep's events
|
||
land in the players' buffers. Because the sim is a frozen
|
||
stage-7-and-tick machine for the whole window, `stars` and the result
|
||
payload are a function of the state at the verdict alone — the `round`
|
||
message sent at the buzzer and the one sent at the window's end agree on
|
||
everything but the tick count.
|
||
|
||
`abort(state, reason)` is the crash-isolation seam (phase 12): the room's
|
||
tick loop caught an exception and the round ends `ABORTED`, without a
|
||
`ROUND_END` of its own (`reason` is the room's `ev` text — the state stores
|
||
no free-form strings, so a replay cannot drift on them). The verdict is
|
||
final: a round that already won or lost keeps it — a crash after the
|
||
buzzer cannot un-win a round.
|
||
|
||
**The stars.** `stars(state)` grades a finished state, from the frozen
|
||
`time_left` and `time_limit` (the share of the clock the win left on the
|
||
table):
|
||
|
||
| Stars | When |
|
||
|---|---|
|
||
| 0 | not `WON` — `LOST`, `ABORTED`, or a round not ended yet |
|
||
| 1 | won, with less than 25 % of the clock left (or a no-clock state: `time_limit <= 0` — there is no share of a missing clock to grade) |
|
||
| 2 | won, with `time_left >= 0.25 × time_limit` |
|
||
| 3 | won, with `time_left >= 0.5 × time_limit` |
|
||
|
||
The thresholds are inclusive at the boundary (exactly 25 % / 50 % buys the
|
||
star), and `0.25 × time_limit` is an exact binary half (a power-of-two
|
||
scale), so the comparison is stable to the tick —
|
||
`tests/sim/test_round_state.py` pins both sides of each boundary at tick
|
||
granularity. A buzzer win is one star (zero clock), and a win in a state
|
||
with no clock (a hand-built round) is the win star only.
|
||
|
||
**The result payload.** `result_payload(state)` is the body of the phase-06
|
||
`round` message (PLAN §7), plain JSON by construction (no `Vec2`, no enum,
|
||
no dataclass, no NaN — `tests/sim/test_result_payload.py` walks it with a
|
||
recursive `assert_json_safe` and names the first leak by path). Its keys,
|
||
exactly:
|
||
|
||
| Key | Value |
|
||
|---|---|
|
||
| `result` | `"won"` / `"lost"` / `"aborted"` — or `null` before the verdict |
|
||
| `score`, `target` | the round's score and the level's `target_score` |
|
||
| `stars` | `stars(state)` |
|
||
| `time_left` | the frozen verdict value |
|
||
| `counters` | the projection onto exactly `served` / `missed` / `wasted` (a never-written counter reads 0; the state's other counters do not leak in) |
|
||
| `longest_streak` | the round's high-water mark of consecutive serves — `sim/scoring.py` is the single owner (a serve raises the mark, a miss resets the *current* streak and never the mark); `sim/round_state.py` reads it and nothing else writes it |
|
||
| `players` | one mapping per seat, in seat order: `id`, `name` (the seat's `display_name`, the sanitized non-authoritative label), `score`, `dishes_served`, `missed_contrib` |
|
||
| `level_id`, `seed`, `ticks` | the round's provenance and length |
|
||
|
||
`missed_contrib` is the one number in the payload that is an *estimate*, and
|
||
it is documented as such: the sim does not attribute an expired order to a
|
||
cook (it expired on the rail, not on a person), so the payload spreads the
|
||
round's `missed` count evenly over its seats (`missed // players` each,
|
||
floor) — display only, never a competitive gate (PLAN §5).
|
||
|
||
`build_state` copies `level.time_limit` onto the state beside `time_left`
|
||
(the stars need the limit, and a replay must re-derive the grade without
|
||
the `Level` object) and each seat's `display_name` onto its cook.
|
||
|
||
`tests/sim/test_round_state.py` pins the verdict (the early win on the
|
||
substep the score crosses, the buzzer on both sides of `TIME_EPS`, the one
|
||
`ROUND_END`), the settle (inputs ignored, no new `SERVED`, no order expiry,
|
||
the station and order objects untouched, the tick monotonic, `rng_state`
|
||
untouched, `time_left` never negative), the `ABORTED` seam (the verdict
|
||
final, the kitchen otherwise the same object), the star boundaries (one
|
||
tick below and exactly at 25 % and 50 %), the star stability across the
|
||
settle, `longest_streak` (the owner raises it, the miss never resets it,
|
||
the payload reads it) and `build_state`'s copies; `tests/sim/test_result_payload.py`
|
||
walks the payload for JSON-safety (zero leaks), pins the exact key sets (top
|
||
level, `counters`, per-player), the value types, the seat order, the
|
||
`missed_contrib` split (floor, no-cooks, one-cook) and the payload's
|
||
byte-stability across two identical builds and across the settle window.
|
||
|
||
## 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`; stage 5 (orders) rebuilds an `Rng` from it, draws its
|
||
spawns, and writes the counter back to the state it returns, while stage 6's
|
||
(hazards) `Rng` is built from the *post-orders* counter — so one substep's draws are
|
||
a function of the pre-step `rng_state` alone, and the state stays frozen and
|
||
hashable while subsystems still get a stream. `Rng.fork(salt)` derives a child
|
||
stream without disturbing its parent: the option for a subsystem that needs
|
||
independence.
|
||
* **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. The level's `orders` spec is copied onto
|
||
the state as scalars in tick units (`sim/orders.to_ticks`; `next_order_at` at the 1.5 s
|
||
first-order delay, `next_order_id` at 1), which is how a replay is self-contained without
|
||
the `Level` object; `book` is not read here — it is what the caller's `step` /
|
||
`run_script` passes to stage 5, which values its spawns from it. `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`.
|
||
|
||
**Golden history:** `phase 03 → phase 04 (pipeline added)` — the three
|
||
committed goldens were re-blessed for the phase's deliberate sim-contract
|
||
changes: `KitchenState` carries the level's `orders` spec as scalars in
|
||
tick units plus `next_order_at` / `next_order_id` (task 02), and the
|
||
level's `time_limit` beside `time_left`, the seat's `display_name`, and
|
||
the decided round's settle freeze (task 05) — re-blessed one last time at
|
||
the end of task 06, with the pipeline live. No rule of an already-real
|
||
stage changed; the event sequences the goldens pin are unchanged — the
|
||
full record is in "Goldens and the CLI" below.
|
||
|
||
### 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. As of phase 04 task 02 the three committed
|
||
goldens were re-blessed for one such change: `KitchenState` carries the
|
||
level's `orders` spec as scalars in tick units plus the two bookkeeping fields
|
||
(`next_order_at`, `next_order_id`), so the state's serialization moved — the
|
||
replayed kitchens' pools are live, so the rounds now spawn the level's orders
|
||
mid-run, and no rule of an already-real stage changed (the event sequences the
|
||
goldens pin are unchanged). As of phase 04 task 05 the three committed
|
||
goldens were re-blessed for one more such change: `KitchenState` carries the
|
||
level's `time_limit` beside `time_left` (the stars grade the win by the share
|
||
of the clock left, and a replay must re-derive the grade without the `Level`
|
||
object), a `Player` carries the seat's `display_name`, and a decided round
|
||
freezes the gameplay stages for the settle — so `walk_solo`'s final 360 ticks
|
||
(past level 1's 12 s buzzer) are the frozen settle rather than more walking.
|
||
Again no rule of an already-real stage changed; the event sequences the
|
||
goldens pin are unchanged. 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 01
|
||
(the station pipelines) the cooks walk, slide, bump, get stunned and stop, the hands do
|
||
something (a free hand picks, a full hand sets down, the set-down is marked, a long press
|
||
over the trash can destroys the carried item, two cooks who press the same item in the
|
||
same substep are arbitrated to a winner and a `CONTESTED_LOST` loser), and the work verbs
|
||
are spent: a set-down on a work surface starts the work, the prep plates, the stove cooks
|
||
(chop 0.9 s, the stove 6 s chopped / 8 s raw — 5 s for patty/fish, the pan's; 8 s for
|
||
rice, the pot's), the sink and the fixtures' own in-place wash wash in 1.2 s, a released
|
||
worker's work stays frozen and claimable, and a dirty fixture is washed — its prop's
|
||
stock paid back. Freshness is real too: a dish on a ready stove burns
|
||
exactly 8.0 s after the ready flip (the station goes `DIRTY` with it), a
|
||
raw or chopped item left on a board rots linearly over 20 s, a carried
|
||
prepared item decays at half the board's rate, a burnt board item yields
|
||
to a new raw set-down (a named `WASTED`, `counters["wasted"]`) while a
|
||
usable one never is, and the dirty loop's stock — use dirties, a wash
|
||
cleans, `…_clean + …_dirty` is the level's limit every tick — has its
|
||
no-dead-end guarantee proven on a 2 000-tick worst-case run. Every refusal is a named `DENIED` event. The ticket rail is
|
||
real too: the level's orders spawn from its pool 1.5 s in (the recipe a
|
||
seeded draw, the value the recipe's `base_score`), the concurrency cap holds,
|
||
a full rail retries after 1 s without spending the draw, and an order expires
|
||
on exactly the tick its clock reaches 0 — missed, counted, and named. Serve
|
||
scoring is real too: a plated dish is validated (burnt, not-a-dish, no orders,
|
||
wrong recipe, incomplete — each a named `DENIED` with the dish kept in the
|
||
hand), a matched dish fills the oldest compatible order and scores
|
||
`round(base × quality × streak × time_bonus)` with the streak capped and the
|
||
score floored, and the expiry's miss spends its penalty and resets the streak.
|
||
Still identity: no hazard burns or spills (phase 10), the
|
||
shelf's stock is not yet seeded (phase 05's room, from `level.station_items`),
|
||
and a pot is a stuck prop (its wash is the stove's in-place wash — it is
|
||
not a carried item). 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, the single worker slot a fixture has, and the round's end conditions: the early
|
||
win the moment the score reaches the target, the buzzer verdict, the one
|
||
`ROUND_END`, the post-verdict settle that holds the clock and the kitchen
|
||
for `SETTLE_S`, the stars from the frozen clock, and the plain-JSON result
|
||
payload the `round` message carries — all of it computed by the server, never
|
||
by the client.
|