phase: 03_simulation_movement_and_interaction
**Final verification pass — phase 03 `simulation_movement_and_interaction` (all 5 task files already in `complete/`)** - Verified all phase-03 deliverables exist and work: `sim/` kernel (`step`, `state_build`, `rng`, `movement`, `collision`, `interact`, `carry`, `replay`), replay fixtures + 3 committed goldens, `docs/SIMULATION.md`. - `bash .agents/validate.sh` → **passed**: ruff `All checks passed!`, pyright `0 errors`, `undercooked-levels validate` green, full `pytest` **1033 passed** (coverage **99.71%** ≥ 90% floor), slow sim stage green, repo-hygiene clean. - Perf smoke (4 players, 2 000 ticks): mean **0.049 ms/tick, 20 423 ticks/s** — ≥ 10 000 ticks/s criterion met (test lives in `test_perf_smoke.py`, which the gate's slow stage runs; the overview names it `test_replay.py`). - Completion criteria: import `undercooked.sim.step.step` ✅ · `tests/sim` green incl. determinism + goldens ✅ · perf ✅ · constant-change-fails-by-design ✅ (committed-hash equality + mutation test naming first bad tick/field; goldens never rewritten by tests) · `docs/SIMULATION.md` ✅ (constants, 8-step order, purity/determinism, re-bless) · full suite + coverage ✅ · app launches: `/healthz` 200, `/` 200, `/api/levels` serves content ✅ · replay CLI `check walk_solo.json` exit 0 ✅ · no regression in phases 01/02 (their suites green) ✅. - Documentation: README.md — fixed one stale inconsistency (Status opening listed only 01/02 complete with 03 pending; now lists 01–03 complete, next pending `04_kitchen_pipeline_and_scoring`); all other README claims (CLI flags, config table, shell text, replay CLI exit codes) verified against code. AGENTS.md — verified consistent with the project as it stands, no changes needed. - No deviations. Next pending phase: **`04_kitchen_pipeline_and_scoring`**.
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
# SIMULATION.md — the tick contract
|
||||
|
||||
**Status:** current as of phase 03 task 05 (the kernel of task 01, real movement,
|
||||
collision, facing and stun, the action button: reach-zone targeting, the verb ladder,
|
||||
named denials and contested arbitration, the hands: one-item-per-hand carry, the
|
||||
marked set-down with the counter hand-off, and the long-press trash — and the replay
|
||||
harness: the input-script format, the committed golden hashes, the `record`/`check`
|
||||
CLI, and the headless perf smoke). **Filled in by
|
||||
phase 04** for the station and order pipelines, and by phase 10 for hazards — each of
|
||||
those replaces an identity hook named in the order table below.
|
||||
|
||||
## Purpose
|
||||
|
||||
This document is the contract between the deterministic game rules and everyone who
|
||||
calls them: how a tick is born, how much simulation one call may buy, the order subsystems
|
||||
run in and why that order is gameplay, how player input is turned into authoritative
|
||||
change, what purity and determinism cost in code, and what a golden hash is allowed to
|
||||
mean. It exists because the simulation is the single source of truth (A4) — a rule that
|
||||
reads differently in two places is a bug in the rules, not in the client.
|
||||
|
||||
The rules of the kitchen live in `src/undercooked/sim/` and nowhere else. This document
|
||||
is the contract those modules owe every caller — the room supervisor (phase 05), the
|
||||
protocol layer (phase 06) and the client (phase 07) included. `.agents/PLAN.md` decides
|
||||
*what* the simulation is (A4: server-authoritative fixed tick; A5: purity; A21: replay);
|
||||
this file says how that reads in code.
|
||||
|
||||
Write here, not in a docstring, whenever a later phase changes: the tick contract,
|
||||
movement and collision, the pipeline rules, contested arbitration, or hazard rules.
|
||||
|
||||
## The tick
|
||||
|
||||
```text
|
||||
one call -> step(state, dt, inputs) -> (state', events)
|
||||
│
|
||||
dt clamped to [0, MAX_DT], added to state.accumulator
|
||||
│
|
||||
while accumulator >= STEP_DT: exactly one substep, accumulator -= STEP_DT
|
||||
```
|
||||
|
||||
The simulation is driven in **whole substeps of `STEP_DT` = 0.05 s**, i.e. 20 Hz, the rate
|
||||
A4 locks. `UNDERCOOKED_TICK_HZ` (`config.py`) decides how many substeps the room asks for
|
||||
per wall second; it never changes what one substep contains. A caller may hand `step()`
|
||||
any `dt` it likes — the client's frame time, a batched timer, a test harness — and the
|
||||
simulation still advances in the same 0.05 s increments, so physics never depends on how
|
||||
noisy the host's clock is.
|
||||
|
||||
Leftover time is not lost: `KitchenState.accumulator` carries the fraction that did not
|
||||
add up to a whole substep into the next call. 17 calls of 0.05 s are exactly 17 ticks;
|
||||
two calls of 0.03 s are one tick plus 0.01 s of credit.
|
||||
|
||||
### Constants
|
||||
|
||||
| Constant | Value | Lives in | Means |
|
||||
| --- | --- | --- | --- |
|
||||
| `STEP_DT` | `0.05` s | `sim/step.py` | One substep — one tick. The only dt a subsystem ever sees. |
|
||||
| `MAX_DT` | `0.1` s | `sim/step.py` | Ceiling on one `step()` call: at most 2 substeps, so a stall cannot spiral. |
|
||||
| `TICK_EPS` | `1e-9` s | `sim/step.py` | Tolerance on the substep bound; `0.85 − 17 × 0.05` is `2.2e-16`, not `0`. |
|
||||
| `TIME_EPS` | `1e-9` s | `sim/round_state.py` | Same idea for the round clock: below this the round is over, not "0.0 s left". |
|
||||
| `SEAT_COLORS` | 4 hex colours | `sim/state_build.py` | The fixed cook palette, indexed by `seat % 4`. |
|
||||
|
||||
### Movement constants
|
||||
|
||||
The numbers PLAN §5 fixes for a cook, now that stage 3 is real. They live in
|
||||
`sim/movement.py` (`RADIUS` and `CONTACT_EPS` are the collision geometry, and live in
|
||||
`sim/collision.py` for the second one because both modules need them):
|
||||
|
||||
| Constant | Value | Means |
|
||||
| --- | --- | --- |
|
||||
| `MAX_SPEED` | `3.5` tiles/s | Speed ceiling. No diagonal bonus: `DIAGONAL_NORM` makes a diagonal a direction, not extra throttle. |
|
||||
| `ACCEL` | `30` tiles/s² | How fast intent becomes velocity. |
|
||||
| `DECEL` | `40` tiles/s² | How fast it goes away when the stick centres — deliberately harder than `ACCEL`: overshooting a station is the mistake players make most. |
|
||||
| `RADIUS` | `0.35` tile | Half-width of the cook's body **square** (an AABB, not a circle) in tile units. This is PLAN §5's `PLAYER_RADIUS`, named for what the collision module reads it as. |
|
||||
| `CONTACT_EPS` | `1e-4` tile | The gap a swept body stops short of a blocked cell, and the inset a footprint is measured with. See "One geometry, two questions". |
|
||||
| `DIAGONAL_NORM` | `True` | Set to `False` and a diagonal is √2 faster. Read at call time so the test can prove which rule is in force. |
|
||||
| `STUN_S` | `0.6` s | How long a hit keeps a cook still. Whoever stuns writes it to `Player.stun`; `apply_stun()` ticks it down. |
|
||||
| `STUN_EPS` | `1e-9` s | A stun is over at this remainder: `0.6 − 12 × 0.05` is `1.4e-17`, not `0`. |
|
||||
| `MOVE_EPS` | `1e-6` tiles/s | Below this the cook is `IDLE`, not `MOVING`. |
|
||||
| `PUSH_ITERATIONS` | `2` | Soft-push passes. Two propagates a separation through a chain of four cooks. |
|
||||
| `PUSH_SLOP` | `1e-6` tile | Overlap below this is float noise, not a collision — without it, shoulder-to-shoulder cooks jitter forever. |
|
||||
|
||||
PLAN §5 fixes the numbers the rest of this phase implements. They are recorded here
|
||||
so a constant is written down once, before the code that reads it:
|
||||
|
||||
| Constant | Value | Arrives in | Means |
|
||||
| --- | --- | --- | --- |
|
||||
| `REACH` | `1.0` tile | task 03 (real) | First targeting tier: the tile in front of the cook, centre-to-centre. |
|
||||
| `FORGIVE` | `0.6` tile | task 03 (real) | Forgiveness on top of `REACH`: why the diagonal-front tier (√2 ≈ 1.414 centre-to-centre) is in reach and the diagonal behind the far shoulder (2.12) is not. |
|
||||
| `HANDLING_S` | `0.0` s | task 04 (settled) | Minimum seconds between two accepted actions by one cook: the press edge is the gate — a new press is accepted the substep after a release, so the floor is `0.0` and PLAN's unspecified value is settled here, not in a knob. |
|
||||
|
||||
`MAX_DT` exists because a fixed tick without a ceiling is a denial-of-service against
|
||||
yourself: if the process stalls for ten seconds, "catch up" means 200 substeps in one go,
|
||||
which takes longer than 200 substeps should, which delays the next call, and so on.
|
||||
Clamping means a stall costs the round at most 0.1 s of simulation and the round keeps
|
||||
going — the correct trade for a co-op game where being behind is better than being dead.
|
||||
|
||||
## The substep order
|
||||
|
||||
Order *is* gameplay: two subsystems reading each other's output produce different rounds
|
||||
if they run in a different sequence. Every substep runs these stages, in this order, with
|
||||
no I/O and no reordering:
|
||||
|
||||
| # | Stage | Module | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Clamp `dt`, accumulate, decide how many substeps run | `sim/step.py` | real |
|
||||
| 2 | Resolve inputs to one frame per seat; `err` for unknown ids | `sim/step.py` | real |
|
||||
| 2b | Action gate: hold counters, one press per rising edge, verb ladder, named denials, contested arbitration, the long-press trash | `sim/interact.py` + `sim/carry.py` | real (task 03/04) |
|
||||
| 3 | Movement and collision | `sim/movement.py` | real (task 02) |
|
||||
| 4 | Station pipelines | `sim/stations.py` | identity → phase 04 |
|
||||
| 5 | Orders | `sim/orders.py` | identity → phase 04 |
|
||||
| 6 | Hazards | `sim/hazards.py` | identity → phase 10 |
|
||||
| 7 | Round timer and end conditions | `sim/round_state.py` | countdown + time-up verdict real; early win and stars → phase 04 |
|
||||
| 8 | `tick += 1` | `sim/step.py` | real |
|
||||
|
||||
Stage 2 happens **once per `step()` call**, not once per substep: intent is resolved, then
|
||||
the same resolved intent is offered to every substep that call produces. That is what
|
||||
makes `step(state, 0.1)` and two calls of `step(state, 0.05)` agree, and it matches the
|
||||
client's side — a cook keeps moving in the direction last pressed until a newer frame
|
||||
says otherwise.
|
||||
|
||||
A resolved frame per seat is chosen by **earliest `received_at_tick`, tie-break lowest
|
||||
`seq`, first-seen on a full tie**, and the mapping is returned in **seat order**. Two
|
||||
clients sending inputs for the same seat in the same batch (a retransmit after a
|
||||
reconnect, a hostile tab) therefore resolve identically on every machine, and effects
|
||||
that touch more than one cook apply in an order nobody has to guess. An unknown
|
||||
`player_id` is a `EventKind.ERROR` event with `{"player_id", "reason": "unknown_player",
|
||||
"seq"}` and nothing else: the round does not care who sent it, and a hostile client
|
||||
cannot make a step raise.
|
||||
|
||||
## Movement and collision
|
||||
|
||||
Stage 3 turns the resolved frame into a position, in two halves that never mix: velocity
|
||||
comes from intent, and position comes from velocity.
|
||||
|
||||
```text
|
||||
PlayerInput.move (whatever the client sent, e.g. Vec2(50, 0))
|
||||
→ intent_direction() clamp to length ≤ 1, so a hostile value is a direction, not a
|
||||
teleport, and (1,1) is not √2 faster than (1,0)
|
||||
→ desired_velocity() accelerate toward it, brake when the stick centres, 0 if stunned
|
||||
→ sweep_axis() × 2 x then y, each clamped to the greatest legal delta
|
||||
→ _separate() soft push, twice; a genuine deadlock reverts one input
|
||||
→ Player(pos, vel, facing, state, stun)
|
||||
```
|
||||
|
||||
The clamp is the server refusing to negotiate (A4): `step()` carries `PlayerInput.move`
|
||||
verbatim, and `intent_direction()` is the one place it becomes authoritative. A client that
|
||||
sends `move=(50, 0)` walks exactly as fast as one that sends `(1, 0)` — there is a test for
|
||||
exactly that, through the whole `step()`, not the movement helper.
|
||||
|
||||
A cook with no frame this tick brakes to a stop rather than drifting: `step()` hands every
|
||||
substep of one call the same frame, so releasing a key stops the cook within one tick
|
||||
(`DECEL`) no matter how many substeps that call ran.
|
||||
|
||||
### What blocks
|
||||
|
||||
`sim/collision.py` answers one question — *may a body stand here* — for the whole sim:
|
||||
|
||||
| Tile or fixture | Blocks a body |
|
||||
| --- | --- |
|
||||
| `wall`, `counter`, `void`, out-of-bounds | yes |
|
||||
| `floor` | no |
|
||||
| `spawn_shelf`, `chop_board`, `stove`, `prep` | yes — you walk around a fixture |
|
||||
| `serve`, `sink`, `trash` | no — you lean over these to use them |
|
||||
|
||||
The pass-through set is `collision.PASS_THROUGH_STATIONS`, and it is the collision answer
|
||||
only: the reach rule (task 03) is allowed to want a *different* set, because reaching a
|
||||
serve window from the tile in front of it is a separate question from walking over it. A
|
||||
hazard or a crate adds cells through `cell_blocked(extra_blocked=...)` / `body_blocker(...)`
|
||||
without either of those rules learning about the other.
|
||||
|
||||
A body is a square of side `2 × RADIUS` = 0.7 tiles, so it fits a 1-tile corridor with
|
||||
0.3 tiles to spare and cannot fit a gap it would have to squeeze through.
|
||||
|
||||
### One geometry, two questions
|
||||
|
||||
Two things must agree about which cells a body covers: the footprint (`body_cells()`, what
|
||||
phase 07's reach will ask about) and the sweep (`sweep_axis()`, what decides where a move
|
||||
ends). They agree because both call `_edge_cells()` on a *centre*, never on a pre-added
|
||||
edge: `(lead + delta) − eps` and `(centre + delta) + radius − eps` differ by one float ulp
|
||||
exactly at the moment a body becomes flush with a boundary, and one ulp is enough for the
|
||||
sweep to say "not entered" while the footprint says "occupied" — a cook 2e-16 inside a
|
||||
wall, invisible on screen and fatal for a replay golden. So the sweep clamps the
|
||||
*destination centre* to `cell − radius − CONTACT_EPS`, two epsilons clear of the boundary
|
||||
that would make the cell occupied, and float noise cannot close that gap.
|
||||
|
||||
The sweep tests every cell the body would *newly* overlap, across its whole cross-axis
|
||||
span — not the destination cell only. That buys three things at once. Nothing can be
|
||||
walked through: at `MAX_SPEED` and the coarsest call the kernel allows (`MAX_DT` = 0.1 s) a
|
||||
cook covers 0.35 tiles, which a destination test would catch too, but the sweep does not
|
||||
care what speed or `dt` a caller hands it, so a doubled ceiling, a longer catch-up tick or a
|
||||
future hazard that throws a cook across the room still cannot cross a 1-tile wall (there is
|
||||
a test that doubles `MAX_SPEED` specifically to hold that). A diagonal move cannot clip the
|
||||
corner of a wall, because the last axis moved is checked against the body's *full* span on
|
||||
the other one. And a cell the body already sits in is never re-tested, so a cook that starts
|
||||
a substep somewhere impossible — a spawn a level author got wrong — is not wedged by it.
|
||||
|
||||
### Cooks push cooks
|
||||
|
||||
Bodies do not collide with terrain and each other in one pass; terrain is resolved on the
|
||||
way out and cooks afterwards, because a push must never shove a cook *into* a wall.
|
||||
|
||||
* **Symmetric.** Each cook gives half the separation, and whatever one cannot take (a
|
||||
wall behind it) the other takes. Two cooks meeting head-on both move; one pinned by a
|
||||
wall moves the other the full amount.
|
||||
* **Positional only.** The push edits `pos`, never `vel`. Being bumped does not fling
|
||||
anybody, and a bumped cook keeps walking in the direction they pressed.
|
||||
* **Iterated.** `PUSH_ITERATIONS` passes so a chain of four drains apart instead of leaving
|
||||
the last cook inside the third.
|
||||
* **Reverted only on a real deadlock.** When terrain stops *both* cooks and not a tile of
|
||||
separation is possible — two cooks stacked in a 1×1 alcove is the only layout that can do
|
||||
it — the later-received input is dropped and that cook is put back where the substep
|
||||
started. Same arbitration rule `resolve_inputs` uses (earliest `received_at_tick` wins,
|
||||
tie-break lowest `seq`), and a cook that sent no frame at all is never the loser, because
|
||||
it has no input to discard. A jam that *could* separate — four cooks in a doorway — is
|
||||
not a deadlock, it is a queue, and it drains over the next few ticks.
|
||||
|
||||
The revert restores the post-`apply_stun` player, so a revert can never undo the stun tick
|
||||
that already happened.
|
||||
|
||||
## The action button
|
||||
|
||||
PLAN §5 gives the cook **one button**; `sim/interact.py` decides, purely and
|
||||
deterministically, what it means *right now* — which entity is targeted, which verb
|
||||
applies, or the named reason nothing happens. Stage 2b runs it once per substep, in
|
||||
seat order, on the frames `resolve_inputs` already reduced to one per seat.
|
||||
|
||||
### The reach zone: discrete first, continuous second
|
||||
|
||||
`candidate_cells()` names the tiles the hand walks, in priority order, deduplicated:
|
||||
|
||||
1. the tile directly in front (the `REACH` tier — always in reach from any point of the
|
||||
tile, worst case √(0.5² + 1.5²) ≈ 1.581);
|
||||
2. the two diagonal-front tiles (the `FORGIVE` tier exists for them: √2 ≈ 1.414 from the
|
||||
tile centre, so a purely orthogonal reach would refuse every diagonal press, and a
|
||||
purely discrete walk would hand over the diagonal behind your far shoulder at 2.12);
|
||||
3. the tile under the cook — because a serve window, sink and trash can are walkable
|
||||
(`PASS_THROUGH_STATIONS`), a cook can be standing *on* the thing they want to use with
|
||||
nothing in front.
|
||||
|
||||
`REACH + FORGIVE` from the cook's position to the tile's centre then decides whether the
|
||||
cook is close enough to the tile the walk found. The two halves answer different
|
||||
questions: the cell walk is why a fixture two tiles away is never even a candidate (there
|
||||
is no "through the wall" mode — a wall in the front cell simply means the tile beyond it
|
||||
is not walked), and the distance check is why the same diagonal fixture is reachable from
|
||||
the near corner of the cook's tile and not from the far one. A candidate out of reach is
|
||||
*skipped*, not refused, so a nearer tile still gets its turn. The four facings are 90°
|
||||
rotations of one row, so a diagonal press feels the same whichever way the cook points.
|
||||
|
||||
### The target, then the verb
|
||||
|
||||
`select_target()` returns the first usable cell in that walk:
|
||||
|
||||
1. a grabbable item, but only with a free hand — a cook who is carrying something is here
|
||||
to *empty* their hand, so preferring the item would make the placement ladder
|
||||
unreachable (PLAN §5's "carrying → drop/transfer onto target station" comes first);
|
||||
2. the serve window, as a `ServeTarget`, so the score path is distinguishable from merely
|
||||
standing at a fixture;
|
||||
3. the fixture itself;
|
||||
4. `None`.
|
||||
|
||||
A fixture *owns* what is on it: `station.contents` is not a shelf of grabbable things. A
|
||||
chop board with an onion on it is an onion *to be chopped* — pressing it works the item,
|
||||
which is the only way PLAN §5's "hold to chop" can mean anything. An item becomes
|
||||
grabbable when it is output: on a spawn shelf (stock, even while `contents` is empty —
|
||||
the kind comes from the level's legend), or on a station whose state says the work is
|
||||
done (`READY`). That rule lives in one private helper and nowhere else, so phase 04's
|
||||
pipelines have exactly one place to change their minds. The serve window is never a
|
||||
set-down target: a full hand at it gets the serve ladder's `WRONG_ITEM`, not a `RELEASE`
|
||||
(the set-down escape hatch is the spawn shelf, where any carried item can be put down).
|
||||
|
||||
`select_verb()` is the priority ladder, with the cook's own condition first. There is no
|
||||
`(verb, reason)` combination and no unexplained no-op: a legal press is `(verb, None)`, a
|
||||
refused one is `(Verb.NONE, reason)` — the client shows the reason as a nudge, and the
|
||||
server never answers a press with a silent nothing.
|
||||
|
||||
### The denied reasons
|
||||
|
||||
Every refusal names itself. These are the `DeniedReason` constants, and the path that
|
||||
produces each:
|
||||
|
||||
| Reason | String | Produced when |
|
||||
| --- | --- | --- |
|
||||
| `STUNNED` | `stunned` | The cook's `stun` is down or their state is `STUNNED`, regardless of target — the client's "you got hit" outranks every other nudge. Checked before the target is even read. |
|
||||
| `NOTHING_HERE` | `nothing_here` | No station in the reach zone (the whiff). A press at a fixture beyond a wall surfaces as this: the zone has no "through the wall" mode. |
|
||||
| `OUT_OF_REACH` | `out_of_reach` | The target's centre is more than `REACH + FORGIVE` from the cook's position (re-measured for targets a caller kept from an earlier substep). |
|
||||
| `NOT_FACING` | `not_facing` | The target's centre is behind the plane of the cook's facing (a target level with the cook — a serve window they are standing on — passes). |
|
||||
| `HANDS_FULL` | `hands_full` | An item target with a full hand, a ready fixture's output with a full hand, or a loaded work surface with a full hand (one item at a time on a surface). |
|
||||
| `HANDS_EMPTY` | `hands_empty` | Serving, washing or trashing with nothing in the hand. |
|
||||
| `WRONG_ITEM` | `wrong_item` | A non-plated item at the serve window, a non-dirty item at the sink. The difference between "go fetch a plate" and "that is not a dish" is `HANDS_EMPTY` vs `WRONG_ITEM`. |
|
||||
| `STATION_DIRTY` | `station_dirty` | A dirty fixture, whatever is held — the player needs "this board is filthy", not "you are holding the wrong thing". Dirt outranks every other state. |
|
||||
| `STATION_BUSY` | `station_busy` | A fixture another cook is working, a `BUSY` fixture, or one on cooldown. The cook who started it gets `HOLD_WORK` instead. |
|
||||
| `INVALID_STATE` | `invalid_state` | An idle fixture with nothing on it, a `BLOCKED` fixture, or an item whose holder is not in the state (task 04's floor drops are named this way until they are liftable). |
|
||||
| `CONTESTED_LOST` | `contested_lost` | The cook lost a claim another cook arrived for first — the only reason the *gate* produces; the ladder cannot emit it. |
|
||||
|
||||
A press that would otherwise succeed emits exactly one event however long the button stays
|
||||
down, and a refusal emits exactly one `DENIED` with the reason in the payload — never one
|
||||
per tick.
|
||||
|
||||
### One button, one press, one effect
|
||||
|
||||
`action_gate()` turns each frame's `action` flag into the cook's hold counters
|
||||
(`Player.action_held_ticks`, `Player.action_pressed_tick`) and spends the rising edge:
|
||||
|
||||
* a frame without `action` clears both counters (the button is up; the next `True` is a
|
||||
new press);
|
||||
* the first frame of a hold is the **press**: it is resolved with `held_for = 0.0`;
|
||||
* every later substep of the same hold increments the counter and passes the seconds the
|
||||
button has been down — the seam the long-press `TRASH` spends ("The hand"); the
|
||||
`HOLD_WORK` threshold is phase 04's. Until phase 04, a hold that is not a trash
|
||||
hold changes nothing: one press, one effect, however long the button is held.
|
||||
|
||||
`resolve_action()` spends the pick and the set-down (both of which are
|
||||
`sim/carry.py`'s now): `PICK_ITEM` takes the item off the fixture and into the hand
|
||||
(one `PICKED` — one `HANDED_OFF` when the pick lifts a set-down inside the window, see
|
||||
"The hand"), `RELEASE` / `PLACE_ON_STATION` / `PLATE_DISH` put the carried item on the
|
||||
fixture (one `DROPPED`, and the set-down's mark), and every denial is one `DENIED`
|
||||
each. The work verbs — `START_WORK`, `HOLD_WORK`, `SERVE`, `WASH` — are *named* by the
|
||||
ladder today and deliberately unspent: a press that selects one is a no-op with no
|
||||
`DENIED`, because the player did nothing wrong and phase 04's pipelines will spend the
|
||||
press. The `TRASH` press is the same shape at the press: it starts the hold, and the
|
||||
gate spends it at the 8-tick threshold ("The hand"). The set-down leaves the
|
||||
fixture's `state` untouched on purpose: what a set-down *means* to a pipeline is
|
||||
phase 04's transition, and inventing it here is how a golden ends up encoding a rule
|
||||
nobody chose.
|
||||
|
||||
### The hand
|
||||
|
||||
The button decides; the hand acts. `sim/carry.py` owns what a verb *does*, and it owns
|
||||
three constants with it:
|
||||
|
||||
| Constant | Value | Means |
|
||||
| --- | --- | --- |
|
||||
| `HANDOFF_WINDOW_TICKS` | `20` ticks | The hand-off window, inclusive: a pick of a set-down is a pass while `pick_tick − placed_tick ≤ 20`; one tick past, it is a plain pick. |
|
||||
| `TRASH_HOLD_TICKS` | `8` substeps | The trash threshold: the 8th substep of one hold is the tick that trashes, and never the 7th. |
|
||||
| `TRASH_SCORE_DELTA` | `−2` | The raw score nudge a trashed item is worth; the `TRASHED` payload carries it, and phase 04's scoring spends it (clamped at 0 overall). The state's score is not touched before then. |
|
||||
|
||||
**One item per hand.** A full hand is the ladder's `HANDS_FULL`; a hand is empty, holds
|
||||
an item, or trashes it. Items live in hands and on fixtures — there is no floor drop
|
||||
in v1 (PLAN §5), so a press at open floor with a full hand is the named
|
||||
`NOTHING_HERE`, not a drop.
|
||||
|
||||
**A set-down is a placed object.** `RELEASE` (the shelf), `PLACE_ON_STATION` (a work
|
||||
surface) and `PLATE_DISH` (the prep counter) all do the same thing: the item goes onto
|
||||
the fixture's `contents`, the hand empties, and the set-down is **marked** —
|
||||
`(placer id, placed-at tick)` — in `KitchenState.item_marks`, a tuple parallel to the
|
||||
fixture's `contents` (`None` where no hand put the entry down: level stock, and phase
|
||||
04's pipeline inputs and outputs). The mark is state truth (a replay must reproduce
|
||||
the `HANDED_OFF` it causes) and never a snapshot concern.
|
||||
|
||||
**Grabbability is the mark.** A free hand may take off a fixture:
|
||||
|
||||
* a spawn shelf — whatever it holds, stock or set-down;
|
||||
* anything else — a *marked* entry, the moment it is set down (the hand-off exists
|
||||
for exactly that);
|
||||
* an unmarked entry only when the station's state is `READY` (a pipeline's output —
|
||||
phase 04). Everything else — an onion on an idle board — is `START_WORK`'s, not
|
||||
`PICK_ITEM`'s.
|
||||
|
||||
**The hand-off.** A pick that lifts a marked item placed by *another* cook within the
|
||||
window is a `HANDED_OFF` with both ids (`from`, `to`) instead of a `PICKED` — that is
|
||||
the sanctioned pass, the whole of "place it on the counter and let a teammate grab
|
||||
it". A self-pick is a plain `PICKED`; a pick outside the window is a plain `PICKED`;
|
||||
a same-substep place-then-pick is inside the window (zero ticks old). The mark is
|
||||
removed with the entry, and a place anywhere stamps a fresh mark, so a pass chains:
|
||||
A → B (B's pick) → B places (new mark) → C's pick is a B → C pass.
|
||||
|
||||
**The trash is long-press and always available.** A `TRASH` press starts a hold; the
|
||||
hold is re-derived each substep from the *current* state (the press is not remembered —
|
||||
the counters are the hold), so walking off the can voids it and a release resets it.
|
||||
On the 8th substep of a hold the carried item is destroyed: one `TRASHED` event whose
|
||||
payload carries the raw `TRASH_SCORE_DELTA`, `counters["trashed"] += 1`, and an empty
|
||||
hand. A short press trashes nothing. A threshold met with an empty hand is
|
||||
`TRASHED_ALREADY_EMPTY` — a named event, never a silent no-op.
|
||||
|
||||
### Contested targets
|
||||
|
||||
Two cooks pressing the same entity in the same substep are arbitrated the way the
|
||||
movement deadlock is: `claim_rank` is `received_at_tick`, then `seq`, then seat index —
|
||||
a tie is impossible because two cooks cannot share a seat. Only a press that would
|
||||
otherwise *succeed* enters the scan (a stunned or empty-handed cook keeps their own
|
||||
reason; blaming the other cook for a refusal the stun caused would be a second lie).
|
||||
And only a press that takes something only one cook can have is a claim at all:
|
||||
`PICK_ITEM` lifts a fixture's item, `START_WORK` takes its single worker slot, and
|
||||
`PICK_FROM_STATION` claims the finished thing only while there *is* one in `contents` —
|
||||
two cooks tapping a shelf with nothing in it both take stock from the level's legend
|
||||
and neither was ever in the other's way. The winner picks; every loser is told
|
||||
`CONTESTED_LOST` instead of watching the item vanish for no stated reason. The claim
|
||||
scan runs against the pre-action state, so every cook is judged on the same kitchen, and
|
||||
its bookkeeping — `active_claims`, the `target_key → (rank, player_id)` map — is
|
||||
**substep-local**: built, arbitrated, and discarded at the substep's end, never written
|
||||
into the state or the snapshot, so a replay reproduces the arbitration from the frames
|
||||
alone.
|
||||
|
||||
### What proves it
|
||||
|
||||
`tests/sim/test_targeting.py` pins the reach zone with hand-computed geometry: the
|
||||
candidate walk per facing (and its invariance to where in the tile the cook stands),
|
||||
the forgiveness boundary (0.99 in, 1.6 inclusive, 1.61 out), the same diagonal fixture in
|
||||
and out of reach from the near and far corners, the priority order (item over fixture,
|
||||
full hand skips the item, a work surface holds its contents, the serve window's own
|
||||
target kind), the wall case, and the press edge through the whole `step()` — one press,
|
||||
one named refusal, one hold that changes nothing, one release that clears. `tests/sim/
|
||||
test_verbs.py` is the table-driven ladder: every `(carrying, target) -> (verb, denial)`
|
||||
covers every `Verb` and every `DeniedReason` at least once (a coverage test asserts the
|
||||
table cannot quietly lose a row), the `resolve_action` implementations and no-ops, and
|
||||
the gate's arbitration with two real cooks in one substep. `tests/sim/test_carry.py`
|
||||
pins the hands themselves: the grabbability split (a set-down is grabbable at once, an
|
||||
unmarked pipeline input is not), the one-item-per-hand refusal, the pick/place round
|
||||
trip with its `Item` identity, the hand-off window (inclusive at 20, a same-tick pass,
|
||||
a self-pick and a third party outside the window), the trash (destroyed on the 8th
|
||||
substep and not the 7th, short press, release-resets, walk-away voids, the named
|
||||
`TRASHED_ALREADY_EMPTY`), the contested pick with the item count conserved, and a
|
||||
100-tick fuzz of random simultaneous presses that re-checks the conservation law after
|
||||
every tick. `tests/sim/test_replay_carry.py` runs the scripted two-player pass-and-
|
||||
contest against its committed golden hash (`tests/goldens/carry_two_player.golden.json`).
|
||||
|
||||
### Stun, facing, traction
|
||||
|
||||
`apply_stun()` runs at the top of the stage: it ticks `Player.stun` down by `dt`, pins
|
||||
`vel` to zero while it lasts, holds the position, and forces `PlayerState.STUNNED` — then
|
||||
`IDLE` again from the substep the stun runs out, so a cook is never stuck in the state. It
|
||||
emits one `EventKind.STUNNED` per cook *entering* a stun (at the cook's position, payload
|
||||
`player_id` and the `stun` duration), which is how the client plays the hit once instead of
|
||||
twenty times a second. Whoever causes a stun — phase 10's moving obstacle — sets
|
||||
`Player.stun = STUN_S`; this is the only place it is spent.
|
||||
|
||||
Facing is a rule about *intent*, never about position:
|
||||
|
||||
1. an explicit `face` in the frame (d-pad, touch stick) wins;
|
||||
2. otherwise the last non-zero intent, dominant axis, ties resolve to horizontal;
|
||||
3. standing still keeps the facing, so an `action` press with no direction does not spin
|
||||
the cook around (PLAN §5 "one action button");
|
||||
4. a pushed cook does not turn: being moved is not the same as deciding to go somewhere.
|
||||
|
||||
`MoveModifiers(speed_scale, accel_scale, slide)` is the seam phase 10 plugs into: a spill
|
||||
scales the ceiling (`speed_scale`) and the grip in both directions (`accel_scale`, which is
|
||||
a different feel from merely being slower), and `slide` removes braking but not throttle —
|
||||
so no `if hazard` ever appears in this module. Until hazards are real the kernel passes
|
||||
none, and the traction a cook gets is the floor.
|
||||
|
||||
### What proves it
|
||||
|
||||
`tests/sim/test_movement.py` is the table-driven half: hand-computed geometry per rule —
|
||||
walking into each tile kind, each pass-through and blocking fixture, the double-speed
|
||||
tunnel attempt, the diagonal-parity check, push symmetry, the alcove deadlock, the four-cook
|
||||
chain, stun entry and expiry, the facing table. `tests/sim/test_movement_stress.py` is the
|
||||
half nobody hand-computes: four cooks, 2000 substeps, a scripted random walk from the sim's
|
||||
own splitmix64 over a pillar maze with doorways, a void tile, a counter run and two
|
||||
fixtures, asserting containment, non-overlap, bounded per-tick displacement and progress
|
||||
after *every* substep, plus hash equality between two runs of the same script — at
|
||||
`STEP_DT` and at `MAX_DT`. `tests/sim/test_movement_kernel.py` runs the same walk through
|
||||
the whole `step()` on the levels in `levels/`, so authored content cannot quietly become
|
||||
un-walkable.
|
||||
|
||||
## Purity and determinism
|
||||
|
||||
A5 makes `sim/` import-free of the outside world; `sim/purity.py` parses the package and
|
||||
fails on `asyncio`, `fastapi`, `socket`, `time` or `uvicorn`, and
|
||||
`tests/sim/test_step_kernel.py` runs that guard. On top of it:
|
||||
|
||||
* **No wall clock.** Time enters only as the `dt` argument. `time`/`datetime` do not
|
||||
appear in `sim/` — not even in the replay harness (`replay.py`): it measures wall
|
||||
time only when a caller *injects* a `clock` callable, and reports `0.0` when none
|
||||
is, so the module imports nothing the purity guard would refuse and the kernel
|
||||
stays clock-free by construction.
|
||||
* **No `random`.** `sim/rng.py` is splitmix64 — 20 lines, no stdlib dependency — because
|
||||
`random.Random`'s stream is explicitly not portable across Python versions and a replay
|
||||
must survive an interpreter upgrade. The round's integer lives in
|
||||
`KitchenState.rng_state`; each substep rebuilds an `Rng` from it and writes the counter
|
||||
back, so the state stays frozen and hashable while subsystems still get a stream.
|
||||
`Rng.fork(salt)` derives a child stream without disturbing its parent, which is how
|
||||
orders and hazards get independent streams that both reproduce from the seed.
|
||||
* **Nothing is mutated.** `KitchenState` and every entity are frozen dataclasses; a
|
||||
substep returns `dataclasses.replace(...)` copies. Hand a state to `step()` and keep it:
|
||||
it is still exactly what it was, hash included.
|
||||
* **Same seed, same input script, same state.** Two states built by `build_state(level,
|
||||
seats, seed, book)` and fed the same `(dt, inputs)` script stay bit-identical — which
|
||||
means the same `state_hash()` — for as long as you like. Different seeds diverge.
|
||||
|
||||
```bash
|
||||
uv run pytest tests/sim -q
|
||||
uv run python -c "from undercooked.sim.step import step; from undercooked.sim.state_build import build_state; print('kernel ok')"
|
||||
```
|
||||
|
||||
## Building a round
|
||||
|
||||
`build_state(level, seats, seed, book)` is the only way a round begins, so a round is a
|
||||
function of exactly four things: level content, seats, seed, inputs. Seats are placed at
|
||||
`level.spawns[seat % len(level.spawns)]` — a four-cook round on a two-spawn kitchen seats
|
||||
everyone — at the tile **centre**, with cooks sharing a spawn offset a quarter tile along
|
||||
x then y so nobody starts inside anybody else. `StateBuildError` covers the two cases with
|
||||
no deterministic answer: a level with zero spawns, and a negative seat index.
|
||||
|
||||
## Replaying and re-blessing goldens
|
||||
|
||||
A round is a function of exactly four things — level, seats, seed, inputs — so a
|
||||
committed script plus a golden state hash is a reproducible test of the whole kernel,
|
||||
and `sim/replay.py` is the seam that makes it a one-command contract (A21, PLAN §9
|
||||
layer 3). Three goldens are committed as of task 05: `carry_two_player` (task 04's
|
||||
12-tick pass and contested pick, checked by `tests/sim/test_replay_carry.py`),
|
||||
`walk_solo` (600 ticks of movement-only walking around the level-1 kitchen, every
|
||||
tick recorded) and `pick_drop_pass` (two cooks on the fixture kitchen in
|
||||
`tests/fixtures/levels/_replay/`: a pick from the shelf, a set-down on the prep,
|
||||
a `HANDED_OFF` pass, and a long-press trash), each checked by
|
||||
`tests/sim/test_replay.py`.
|
||||
|
||||
### The input script
|
||||
|
||||
`InputScript` is one JSON file — `{seed, level_id, tick_hz, frames}` plus the
|
||||
optional `name`, `record_every` and `items` — and `InputScript.load(path)` /
|
||||
`.dump(path)` / `.validate()` are its whole surface. `validate()` refuses anything
|
||||
the kernel cannot replay, naming every problem at once: a non-`u64` `seed`, a
|
||||
`tick_hz` that is not `1 / STEP_DT` (the script is replayed in `STEP_DT`
|
||||
substeps, so any other rate is unrunnable), a non-finite `move`, an unknown
|
||||
`face`, an unknown `ItemKind`/`ItemForm`; `load()` is strict about the shape,
|
||||
so a typo cannot quietly become a different script.
|
||||
|
||||
* A frame is `{tick, player_id, move, action, face}`; it is fed to the substep
|
||||
that advances `state.tick` from the frame's `tick` (stamped
|
||||
`received_at_tick = tick`, `seq` = the frame's order for its player), exactly
|
||||
the stamping a live room does. A long press is eight consecutive `True` frames.
|
||||
* The seats are derived from the script alone: the unique `player_id`s in order
|
||||
of first appearance take seats 0, 1, 2, … at the level's spawns (with
|
||||
`build_state`'s stacking offset). A replay needs no room object and no token
|
||||
list — the four things, nothing else.
|
||||
* `items` pre-place `Item(kind, form)` on named stations before tick 0. Phase 04's
|
||||
pipelines do not exist yet, so an item can enter the world only this way or in
|
||||
a hand; the `pick_drop_pass` shelf stock is exactly such an entry.
|
||||
* `record_every` is the golden's cadence: `1` (the default) records every tick,
|
||||
`0` records nothing (what a timed run uses to skip the hash cost). The built
|
||||
state (tick 0) and the final tick are always recorded, whatever the cadence.
|
||||
* The run length is the last frame's tick + 1, or `max_ticks` when the caller
|
||||
pins it — the `check` CLI pins it to the golden's tick, so a script edited to
|
||||
be shorter or longer is still compared on the golden's terms.
|
||||
|
||||
`run_script(level, book, script, max_ticks=None, clock=None)` is the pure driver:
|
||||
`build_state`, the pre-placed items, then `step(state, STEP_DT, frames)` per
|
||||
tick, recording `(tick, state_hash())` at the cadence. The states at the recorded
|
||||
ticks ride along in the `ReplayResult` so `compare(result, golden)` can diff a
|
||||
mismatch without re-running anything: `compare()` checks every golden tick in
|
||||
order, reports the first divergent tick first, and the first hash mismatch
|
||||
carries the field-level diff (which player, station or order moved since the
|
||||
last matching tick — `players[0].pos.y`, `stations[2].contents[0]`, …), so a
|
||||
broken golden is diagnosable in one read. `missing`/`extra` entries flag a
|
||||
`record_every` cadence mismatch on either side.
|
||||
|
||||
### Goldens and the CLI
|
||||
|
||||
```
|
||||
uv run python -m undercooked.sim.replay check tests/fixtures/replays/walk_solo.json
|
||||
uv run python -m undercooked.sim.replay record tests/fixtures/replays/walk_solo.json
|
||||
```
|
||||
|
||||
`check` runs the script — its level must be in the content directory the CLI
|
||||
loads (`UNDERCOOKED_LEVELS_DIR`, default `levels/`; the `pick_drop_pass` kitchen
|
||||
is a test fixture, so its golden is checked through the harness from
|
||||
`test_replay.py`) — and compares every recorded hash to
|
||||
`tests/goldens/<name>.golden.json` (the script's provenance, the final tick and
|
||||
hash, and every recorded hash). Exit `0` when they all match, `1` when any
|
||||
diverge (the first bad tick and its changed fields are printed), `2` when the
|
||||
script, the level or the golden is missing. `record` does the same run and writes
|
||||
the golden: it is the **manual re-bless**, and it says so on screen.
|
||||
|
||||
A golden is re-blessed **only** after a deliberate sim-contract change — a
|
||||
constant moved, a subsystem reordered, a rule intentionally changed — and the
|
||||
change is recorded in the phase task that makes it. The hash diff then appears
|
||||
in the commit, which is the point: a golden that moved *without* that record is
|
||||
the failure the harness exists to catch, and `record` run to silence a failure
|
||||
is how the guard gets defeated. The numbers under a golden that has not moved
|
||||
are locked two levels down, where they cannot drift: `tests/sim/test_rng.py`
|
||||
pins published splitmix64 vectors (the generator itself can never change
|
||||
quietly) and `tests/sim/test_step_kernel.py` pins the tick count, the clamping,
|
||||
the accumulation and cross-run hash equality.
|
||||
|
||||
### The perf smoke
|
||||
|
||||
`tests/sim/test_perf_smoke.py` (marked `slow`, so the quick loop skips it but
|
||||
the gate's slow stage runs it): four cooks, 2 000 ticks on level 1, one
|
||||
direction per cook per tick from the sim's own splitmix64. The budget is
|
||||
asserted and the measured numbers printed in the report: mean step **< 0.2 ms**
|
||||
and total **< 400 ms** — 5 000+ ticks/s, two and a half orders of magnitude
|
||||
more than the 20 Hz the rooms run at.
|
||||
|
||||
### What proves it
|
||||
|
||||
`tests/sim/test_replay.py` pins the whole seam: both committed scripts match
|
||||
their goldens (the `pick_drop_pass` event sequence asserted in code, so a drift
|
||||
shows *which* rule moved), the one-coordinate mutation (first bad tick named,
|
||||
changed field named), the record-cadence stability and its `record_every = 0`
|
||||
escape, the in-process double replay, the clock seam, the CLI's three exit
|
||||
paths (match, mutation, missing level/golden), the `record`-into-a-temp-dir
|
||||
re-bless, and the golden files' never being rewritten by a test run. The
|
||||
`carry_two_player` golden stays with `tests/sim/test_replay_carry.py`, where
|
||||
task 04 pinned it.
|
||||
|
||||
## Not real yet
|
||||
|
||||
A hook returning the state unchanged is not a bug, it is this phase's scaffolding — but it
|
||||
is worth naming out loud so nobody reads a green suite as "the game works". As of task 04
|
||||
the cooks walk, slide, bump, get stunned and stop, and the hands do something: a free
|
||||
hand picks an item out of a ready fixture or off a counter a teammate set down (a
|
||||
`HANDED_OFF` inside the window, a `PICKED` outside it), a full hand sets one back down
|
||||
on a fixture — shelf, work surface or prep counter — and the set-down is marked for
|
||||
exactly that, a long press over the trash can destroys the carried item on its 8th
|
||||
substep (`TRASHED`, the raw score delta in the payload, `counters["trashed"]` up by
|
||||
one), and two cooks who press the same item in the same substep are arbitrated to a
|
||||
winner and a `CONTESTED_LOST` loser, the item in exactly one place. Every refusal is a
|
||||
named `DENIED` event. Still identity: no station progresses (phase 04), no order spawns
|
||||
(phase 04), no hazard burns or spills (phase 10), and the work verbs — chop, cook,
|
||||
plate, serve, wash — are named by the ladder but unspent no-ops until phase 04's
|
||||
pipelines exist to spend them (`HOLD_WORK`'s threshold with them; the long-press
|
||||
`TRASH`, the hand-off and the `HANDLING_S` floor settled at `0.0` arrived with task
|
||||
04's carry rules). What is real is the shape they all plug into: the clock, the
|
||||
accumulator, the ordering, the input contract, the body that cannot enter a wall, the
|
||||
reach zone that cannot see through a wall, the pair of hands that never hold two items
|
||||
at once, and the round timer that ends a round 12 seconds after it started, whether or
|
||||
not anybody cooked.
|
||||
+2
-2
@@ -20,10 +20,10 @@ layer live in `tests/conftest.py`, and layer-specific fixtures live in that laye
|
||||
| Directory | Contains | I/O allowed |
|
||||
|---|---|---|
|
||||
| `tests/unit/` | pure logic with no I/O: config parsing, header values, CLI precedence, protocol/validator helpers, and the doc/content guards that keep README, `.env.example`, `PLAN.md` and the code agreeing | none (in-process `TestClient` requests are fine) |
|
||||
| `tests/sim/` | tests of the pure kernel that need no server: grid geometry, the entity model, the A5 import-purity guard, and the level validator (phase 02) — plus the headless determinism/replay suites with golden state hashes (`tests/sim/test_replay_*.py`) that arrive with the tick in phase 03 | none — the sim must stay callable without a server |
|
||||
| `tests/sim/` | tests of the pure kernel that need no server: grid geometry, the entity model, the A5 import-purity guard, and the level validator (phase 02), plus the tick itself (phase 03) — the seeded RNG's published golden vectors (`test_rng.py`), the kernel's clamping/accumulation/purity/determinism suite (`test_step_kernel.py`), and the movement stage: hand-computed geometry per rule (`test_movement.py`), a scripted 2000-substep four-cook walk on the sim's own RNG that re-checks containment, non-overlap, bounded displacement and hash equality after every substep (`test_movement_stress.py`), and the same walk through the whole `step()` on the shipped levels (`test_movement_kernel.py`), and the action button (phase 03 task 03): the reach-zone geometry and one-press-one-event press edge (`test_targeting.py`), and the table-driven verb ladder that covers every `Verb` and every `DeniedReason` plus the gate's contested arbitration (`test_verbs.py`); the golden-state-hash replay suites — `test_replay_carry.py` (task 04's two-player pass and contest), `test_replay.py` (the committed goldens against `sim/replay.py`'s input-script format, the `record`/`check` CLI and the perf seam) — and the `slow`-marked perf smoke (`test_perf_smoke.py`) | none — the sim must stay callable without a server |
|
||||
| `tests/integration/` | multi-module, in-process tests: app boot through `create_app()`, HTTP routes, static serving, room/protocol behaviour with real WebSocket clients (room + protocol integration is PLAN §9 layer 4, and lives here) | in-process app, loopback sockets |
|
||||
| `tests/e2e/` | one Playwright suite per user story, named `test_usNN_*.py`; multiplayer stories drive **two browser contexts** against one room | real browser, real socket |
|
||||
| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` | none |
|
||||
| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` (`_bad/` holds the deliberately-broken campaign the `_` discovery rule ignores; `_replay/` holds the `pick_drop_pass` fixture kitchen, ignored the same way). `tests/fixtures/replays/` holds the committed input scripts and `tests/goldens/` the committed golden hashes they are gated on (`sim/replay.py`) | none |
|
||||
|
||||
There is **no `tests/server/`**: PLAN §9 counts server/route layers of coverage, not
|
||||
directories. A route asserted as a pure function (header values, status codes, CLI
|
||||
|
||||
Reference in New Issue
Block a user