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:
+485
-37
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user