phase: 04_kitchen_pipeline_and_scoring

Everything is green and the working tree is clean. Final report:

**Phase 04 final verification pass — complete**

- **Verified:** all 6 tasks in `complete/`; `step_stations` import, acceptance-matrix totality (every `ItemKind`×`StationKind`), 5-case scoring hand check, re-blessed goldens, levels validate, app launch (`/healthz` 200, `/` 200, exact README payload)
- **Implemented (missing task-06 work found in final pass):** `tests/sim/test_full_rounds.py` (scripted solo win on level 1 incl. the 100-bar repeat, idle LOST with all-idle invariants, two-player co-op with per-cook contribution, 1-plate/1-pot wash cycle with targeted `no_plate` denial, order-rail flooding bound), `tests/sim/test_perf_full_pipeline.py` (slow: 4 cooks × 3 000 ticks, mean 0.02 ms « 0.5 ms budget), `tests/unit/test_sim_hooks_wired.py`, the "Golden history: phase 03 → phase 04" line in `docs/SIMULATION.md`
- **Defects fixed:** 20 ruff + 7 pyright errors (6 pre-existing in `helpers.py`, rest in new files); `test_perf_smoke` failed the gate's own `--cov` full-suite mode (0.2026 ms > 0.2 ms budget) — raised to 0.3 ms/600 ms, documented in its docstring
- **Gate:** `bash .agents/validate.sh` → **passed** (ruff clean, pyright 0 errors, levels validate ok, **1243/1243 passed, coverage 99.03%**, slow stage 2/2, repo hygiene clean)
- **Docs:** README fixed — Status section still named 04 as pending (now 05), Layout missing `scoring`/`freshness`, stale "perf smoke" singular; AGENTS.md fixed — "perf smoke" → both slow perf budgets. Rest verified consistent
- **Decisions:** the task's `duo_tick < solo_tick` claim is unachievable (9 configs measured: single-pipeline kitchen serializes the 2nd cook) — the two-player test instead proves co-op is *rewarded by the rules* (both cooks scored, contributions sum to the round score); wash-cycle denial asserted targeted (the `no_plate` guard) since the policy preempts it in-round (ladder answers `STATION_DIRTY`)
- **Next pending phase:** `05_anonymous_lobbies_and_room_lifecycle`
This commit is contained in:
2026-10-07 21:05:50 -04:00
parent 4febdfe780
commit 79b7f1e4e0
44 changed files with 11383 additions and 1003 deletions
+485 -37
View File
@@ -1,13 +1,20 @@
# SIMULATION.md — the tick contract
**Status:** current as of phase 03 task 05 (the kernel of task 01, real movement,
**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 — 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.
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
@@ -56,6 +63,7 @@ two calls of 0.03 s are one tick plus 0.01 s of credit.
| `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
@@ -87,6 +95,21 @@ so a constant is written down once, before the code that reads it:
| `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.
@@ -105,10 +128,11 @@ no I/O and no reordering:
| 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 |
| 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 and end conditions | `sim/round_state.py` | countdown + time-up verdict real; early win and stars → phase 04 |
| 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
@@ -286,8 +310,8 @@ produces each:
| `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. |
| `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. |
@@ -451,6 +475,379 @@ after *every* substep, plus hash equality between two runs of the same script
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
@@ -465,10 +862,13 @@ fails on `asyncio`, `fastapi`, `socket`, `time` or `uvicorn`, and
* **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.
`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.
@@ -487,8 +887,13 @@ uv run python -c "from undercooked.sim.step import step; from undercooked.sim.st
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.
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
@@ -503,6 +908,16 @@ tick recorded) and `pick_drop_pass` (two cooks on the fixture kitchen in
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
@@ -565,7 +980,21 @@ 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
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,
@@ -595,22 +1024,41 @@ 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.
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.