phase: 06_realtime_websocket_protocol

All green. Final verification complete — writing the report.

**Final pass report — phase 06 `realtime_websocket_protocol`**

- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched

Next pending phase: `07_canvas_client_and_hud`.
This commit is contained in:
2026-10-09 11:13:25 -04:00
parent f51ba919af
commit f26849c1f3
44 changed files with 11525 additions and 72 deletions
+1
View File
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
"docs/PROTOCOL.md",
"docs/ROOMS.md",
"docs/SIMULATION.md",
"docs/TESTING.md",
+434
View File
@@ -0,0 +1,434 @@
"""Unit: the snapshot encoder keeps the sim's internals off the wire
(phase 06, task 04) — the recursive no-leak guard, the 2-decimal
quantization, the explicit event-payload map, the delta tombstones, and
`snapshot_size`'s UTF-8 accounting.
The no-leak rule (task 04): a frame contains no `token`, no
`display_name`, no `seed`, no station internal `id`, no `rng_state` —
checked recursively, on *values* as well as *keys* (a station's
internal id, `stove_1`, is a value, not a key). The sim's internal
identity is projected out by design: a station's wire identity is its
kind + coordinates (`k` + `x`/`y`), and an event's payload goes through
the explicit `EVENT_PAYLOAD_SHORT` map — a key that is not listed there
never leaves the server.
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from dataclasses import replace
from typing import Any
import pytest
from undercooked.server.encoding import (
EVENT_PAYLOAD_SHORT,
encode_events,
encode_snapshot,
event_wire_dict,
snapshot_size,
)
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
Event,
EventKind,
Facing,
Hazard,
HazardKind,
Item,
ItemForm,
ItemKind,
KitchenState,
Order,
Player,
PlayerState,
Station,
StationKind,
StationState,
TileKind,
Vec2,
)
pytestmark = pytest.mark.unit
#: The forbidden *key names* — the sim's internal fields, never on the
#: wire (task 04's no-leak list; `station` is the internal-id carrier
#: the explicit payload map exists to drop).
FORBIDDEN_KEYS: frozenset[str] = frozenset(
{"token", "display_name", "seed", "rng_state", "station"}
)
#: The station/hazard internal ids (values — they must not appear
#: anywhere in a frame).
STOVE_ID = "stove_1"
CHOP_ID = "chop_board_1"
HARD_ID = "hz-fire-1"
A_DISPLAY_NAME = "A very unique cook name"
A_SEED = 0x0BADF00D
A_RNG_STATE = 0xCAFEBABE
A_PLAYER_ID = "tok-abcdef0123456789" # the token value (allowed as `i`)
def make_state() -> KitchenState:
"""A hand-built state with every internal field set to a unique
value: two stations (internal ids, contents, progress), one player
(token id, display name, unquantized position, a carried item), one
order, one hazard, a big `seed` and `rng_state`."""
return KitchenState(
level_id="01_first_kitchen",
seed=A_SEED,
grid=Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64),
tick=4821,
stations=(
Station(
id=STOVE_ID,
kind=StationKind.STOVE,
x=3.23456,
y=2.0,
contents=(Item(ItemKind.ONION, ItemForm.RAW, quality=0.98765),),
state=StationState.WORKING,
progress=0.4242,
),
Station(
id=CHOP_ID,
kind=StationKind.CHOP_BOARD,
x=5.0,
y=7.0,
state=StationState.DIRTY,
dirty=True,
),
),
players=(
Player(
id=A_PLAYER_ID,
pos=Vec2(1.23456, -2.34567),
vel=Vec2(0.5, -0.5),
facing=Facing.RIGHT,
carrying=Item(ItemKind.TOMATO, ItemForm.CHOPPED, quality=0.98765),
state=PlayerState.MOVING,
seat=1,
display_name=A_DISPLAY_NAME,
),
),
orders=(
Order(id=7, recipe_id="soup", time_left=30.0, deadline=1200, value=40),
),
order_deadline=1200,
hazards=(
Hazard(
id=HARD_ID,
kind=HazardKind.FIRE,
pos=Vec2(7.654321, 5.2),
life=12.346,
),
),
score=120,
target_score=140,
time_left=83.654,
rng_state=A_RNG_STATE,
)
def walk(node: Any) -> Iterator[tuple[Any, Any]]:
"""Every `(key, value)` pair in the JSON tree (keys only on objects)."""
if isinstance(node, dict):
for key, value in node.items():
yield key, value
yield from walk(value)
elif isinstance(node, list):
for item in node:
yield from walk(item)
def assert_no_leak(frame: dict[str, Any]) -> None:
"""The recursive no-leak check: no forbidden key, and no internal
value (a station/hazard id, the display name, the seed, the
rng_state) anywhere in the tree."""
values: list[Any] = []
for key, value in walk(frame):
assert key not in FORBIDDEN_KEYS, f"forbidden key on the wire: {key!r}"
values.append(value)
for forbidden in (STOVE_ID, CHOP_ID, HARD_ID, A_DISPLAY_NAME):
assert forbidden not in values, f"internal value on the wire: {forbidden!r}"
assert A_SEED not in values, "the round seed leaked onto the wire"
assert A_RNG_STATE not in values, "the rng_state leaked onto the wire"
# ------------------------------------------------------------- the no-leak guard
def test_no_forbidden_key_or_internal_value_reaches_the_wire() -> None:
assert_no_leak(encode_snapshot(make_state()))
def test_the_delta_frame_is_no_leakier_than_the_full() -> None:
state = make_state()
full = encode_snapshot(state)
moved = state.with_players(
(replace(state.players[0], pos=Vec2(4.321, 2.765), state=PlayerState.IDLE),)
)
assert_no_leak(encode_snapshot(moved, full))
def test_the_event_entries_carry_only_the_mapped_keys() -> None:
"""An event whose payload carries the internal keys (`station`,
`tick`, an unknown one) is projected through the explicit map — the
unlisted keys (and the internal id they carry) never reach the
wire."""
events = [
Event(
kind=EventKind.RESYNC,
payload={"player_id": A_PLAYER_ID, "seq": 41},
),
Event(
kind=EventKind.DENIED,
x=3.23456,
y=2.0,
payload={
"player_id": A_PLAYER_ID,
"reason": "contested",
"station": STOVE_ID, # the internal id: must not survive
"tick": 4821, # the frame's `t` is the tick: dropped
"mystery": "no-such-mapping",
},
),
]
encoded = encode_events(events)
assert len(encoded) == 2
allowed = {"k", "x", "y", *EVENT_PAYLOAD_SHORT.values()}
for entry in encoded:
for key, _ in walk(entry):
assert key in allowed, f"an unlisted event key reached the wire: {key!r}"
(denied,) = [entry for entry in encoded if entry["k"] == "denied"]
assert denied == {"k": "denied", "x": 3.23, "y": 2.0, "i": A_PLAYER_ID, "r": "contested"}
assert STOVE_ID not in [value for _, value in walk(encoded)]
def test_the_event_wire_dict_drops_unlisted_keys_the_same_way() -> None:
event = Event(
kind=EventKind.BURNT,
x=3.23456,
y=2.0,
payload={
"station": STOVE_ID,
"item": "onion",
"form": "burnt",
"tick": 4821,
"mystery": 1,
},
)
wire = event_wire_dict(event)
assert wire["kind"] == "burnt"
assert wire["x"] == 3.23
assert wire["payload"] == {"it": "onion", "fm": "burnt"}
# ----------------------------------------------------------- quantization
def test_quantization_is_to_two_decimals() -> None:
frame = encode_snapshot(make_state())
(player,) = frame["p"]
(station,) = (
entry for entry in frame["st"] if entry["k"] == "chop_board"
)
(stove,) = (entry for entry in frame["st"] if entry["k"] == "stove")
(order,) = frame["o"]
(hazard,) = frame["hz"]
# The player's position (Vec2.quantized(2)).
assert (player["x"], player["y"]) == (1.23, -2.35)
# The station's position, progress and the content's quality.
assert (stove["x"], stove["y"]) == (3.23, 2.0)
assert stove["p"] == 0.42
assert stove["q"] == 0.99 # round(0.98765, 2)
# The order's deadline share (30 s of a 60 s window) and the clock.
assert order["l"] == 0.5
assert frame["tl"] == 83.65 # round(83.654, 2)
# The hazard's position and life (seconds, 2 decimals).
assert (hazard["x"], hazard["y"]) == (7.65, 5.2)
assert hazard["l"] == 12.35
# The carried item's short form (the `h` field) and the station's
# first content (`g`).
assert player["h"] == "tomato_chopped"
assert stove["g"] == "onion_raw"
assert station["g"] is None
assert station["q"] == 1.0 # an empty station's quality
def test_facing_is_an_index_of_zero_to_three() -> None:
frame = encode_snapshot(make_state())
(player,) = frame["p"]
assert player["f"] == 1 # Facing.RIGHT
for entry in frame["p"]:
assert entry["f"] in (0, 1, 2, 3)
def test_snapshot_size_is_the_utf8_length_of_the_frame_json() -> None:
frame = encode_snapshot(make_state())
assert snapshot_size(frame) == len(json.dumps(frame).encode("utf-8"))
# ---------------------------------------------------------------- the delta
def test_removed_orders_and_hazards_are_tombstoned() -> None:
state = make_state()
full = encode_snapshot(state)
without = state.with_orders(()).with_hazards(())
delta = encode_snapshot(without, full)
assert delta["full"] is False
(order_tombstone,) = [
entry for entry in delta["d"]["o"] if entry.get("gone") is True
]
assert order_tombstone == {"id": 7, "gone": True}
(hazard_tombstone,) = [
entry for entry in delta["d"]["hz"] if entry.get("gone") is True
]
assert hazard_tombstone == {"t": "fire", "x": 7.65, "y": 5.2, "gone": True}
def test_removed_players_and_stations_are_tombstoned_too() -> None:
"""Every collection tombstones a vanished entity: a player (a cook
that left the round) and a station (a fixture that is gone) carry
their identity keys under `gone` so the client can drop them."""
state = make_state()
full = encode_snapshot(state)
without = state.with_players(()).with_stations(())
delta = encode_snapshot(without, full)
(player_tombstone,) = [
entry for entry in delta["d"]["p"] if entry.get("gone") is True
]
assert player_tombstone == {"i": A_PLAYER_ID, "gone": True}
station_tombstones = [
entry for entry in delta["d"]["st"] if entry.get("gone") is True
]
assert len(station_tombstones) == 2
assert {
(entry["k"], entry["x"], entry["y"])
for entry in station_tombstones
} == {("stove", 3.23, 2.0), ("chop_board", 5.0, 7.0)}
def test_a_malformed_prev_degrades_to_an_empty_prev() -> None:
"""A `prev` whose entity collection is not a list (a client bug in
the stream, not a sim state) degrades to an empty previous: every
current entity is in, and no tombstone is invented."""
state = make_state()
delta = encode_snapshot(state, prev={"p": {}, "st": "nope"})
expected_player = {
"i": A_PLAYER_ID,
"x": 1.23,
"y": -2.35,
"f": 1,
"h": "tomato_chopped",
"s": "moving",
"pi": 1,
}
assert delta["d"]["p"] == [expected_player]
assert len(delta["d"]["st"]) == 2
assert not any(entry.get("gone") for entry in delta["d"]["st"])
def test_a_non_finite_or_null_payload_value_never_reaches_the_wire() -> None:
"""`_short_value`'s guard: a `None` (and a `NaN`/`inf`) payload
value is dropped — a null or a `NaN` in a JSON frame is noise, not
information; a finite float is quantized to 2 decimals."""
event = Event(
kind=EventKind.SERVED,
payload={
"order_id": 7,
"points": float("nan"),
"quality": None,
"streak": float("inf"),
"delta": 12.346,
},
)
(entry,) = encode_events([event])
assert entry == {"k": "served", "x": 0.0, "y": 0.0, "oi": 7, "d": 12.35}
(wire,) = [event_wire_dict(event)]
assert wire["payload"] == {"oi": 7, "d": 12.35}
def test_an_unchanged_entity_is_out_of_the_delta() -> None:
"""The diff is against the *previous* frame (not the last full): an
entity unchanged since `prev` is out, and one that moved *back* to
its original values is in (the client holds the previous values —
the move back is a change it must hear about). Decoding the chain
(full → delta1) must land on `moved`'s full frame."""
state = make_state()
full = encode_snapshot(state)
moved = state.with_players(
(replace(state.players[0], pos=Vec2(4.321, 2.765)),)
)
delta1 = encode_snapshot(moved, full)
assert delta1["d"]["p"][0]["x"] == 4.32 # the move is in
# Unchanged since the previous frame: out.
delta2 = encode_snapshot(moved, delta1)
assert delta2["d"]["p"] == []
# Back at the original values: in (a change since the previous frame).
delta3 = encode_snapshot(state, delta2)
(player,) = delta3["d"]["p"]
assert player["x"] == 1.23 and player["y"] == -2.35
# And the chain decodes: applying delta1 to the full lands on
# `moved`'s full frame.
assert _apply_delta(full, delta1) == encode_snapshot(moved)
def _apply_delta(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]:
"""The client's merge: upsert by identity key (preserving the base's
order, new entities appended — the encoder's own semantics), drop
the `gone` tombstones, take the scalars and `e` from the delta."""
# The scalars (`t`, `s`, `tgt`, `tl`) and `e` ride the delta; the
# entity arrays merge onto the base.
decoded = {
key: value
for key, value in delta.items()
if key not in ("d", "full")
}
identity: dict[str, Any] = {
"p": lambda e: (e["i"],),
"st": lambda e: (e["k"], e["x"], e["y"]),
"o": lambda e: (e["id"],),
"hz": lambda e: (e["t"], e["x"], e["y"]),
}
for key in ("p", "st", "o", "hz"):
base_entries: list[dict[str, Any]] = base.get(key, [])
delta_entries: list[dict[str, Any]] = delta["d"][key]
by_identity = {identity[key](entry): dict(entry) for entry in delta_entries}
merged: list[dict[str, Any]] = []
seen: set[tuple[Any, ...]] = set()
for entry in base_entries:
key_ = identity[key](entry)
if by_identity.get(key_, {}).get("gone") is True:
continue # tombstoned
merged.append(by_identity.get(key_, dict(entry)))
seen.add(key_)
for entry in delta_entries:
key_ = identity[key](entry)
if key_ not in seen and entry.get("gone") is not True:
merged.append(dict(entry))
decoded[key] = merged
decoded["full"] = True # a decoded chain is a full state
return decoded
def test_the_delta_against_a_delta_prev_reads_the_prev_from_d() -> None:
"""A delta frame's entities live under `d` — the next delta must
diff against *that* (a prev that is itself a delta)."""
state = make_state()
full = encode_snapshot(state)
moved = state.with_players(
(replace(state.players[0], pos=Vec2(4.321, 2.765)),)
)
delta1 = encode_snapshot(moved, full)
moved_again = state.with_players(
(replace(state.players[0], pos=Vec2(4.5, 2.765)),)
)
delta2 = encode_snapshot(moved_again, delta1)
(player,) = delta2["d"]["p"]
assert player["x"] == 4.5 # the change since delta1, not since the full
# `y` is unchanged since delta1, but the whole entry is in (it is a
# whole-entry diff): the entry carries both values.
assert player["y"] == 2.77
+303
View File
@@ -0,0 +1,303 @@
"""`sim/ghost.py` — the ghost's state and hands (phase 06 task 05, US-09).
The wall-clock grace is the room's (measured off `seat.last_seen`); this file
pins the sim's half, pure and deterministic:
* **`mark_ghost`** — the sim-side half of a drop: `connected=False`, the cook
in `DISCONNECTED`; idempotent (a second call changes nothing);
* **`restore_player`** — the sim-side half of the rejoin inside the grace:
`connected=True`, the cook in `IDLE`; a still-carried item is kept;
idempotent;
* **`release_ghost_carry`** — the park's release: the carried item goes onto
the station on the cook's tile (a marked set-down, one `DROPPED`) or into
`counters["lost_items"]` (one `GHOST_PARK`) on a bare tile; an item is never
deleted silently; idempotent (a double call cannot double-release); a
not-yet-marked (connected) cook is switched first — the forced release on
a `SeatAbandoned` when the grace ran out before the park deadline;
* **the ghost parks in the movement stage** — a disconnected cook's (stale)
input does not move it, and a stun that runs out on a disconnected cook
revives it in `DISCONNECTED`, not `IDLE`;
* **the events encode** — `ghost_on`/`ghost_off`/`ghost_park` ride the `k`
names with the short payload keys (`i`/`it`/`fm`), and the unlisted keys
never leave the server.
"""
from __future__ import annotations
import pytest
from undercooked.server.encoding import encode_events
from undercooked.sim.carry import first_loose_entry
from undercooked.sim.ghost import (
PARK_AFTER_S,
mark_ghost,
release_ghost_carry,
restore_player,
station_at,
)
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
Event,
EventKind,
Facing,
Item,
ItemForm,
ItemKind,
KitchenState,
Player,
PlayerState,
Station,
StationKind,
TileKind,
Vec2,
)
from undercooked.sim.movement import apply_stun, resolve_moves
from undercooked.sim.step import PlayerInput
pytestmark = pytest.mark.unit
WIDTH, HEIGHT = 7, 5
#: The cook's cell — the station test's fixture sits on it.
CELL_X, CELL_Y = 3, 2
def make_state(
stations: tuple[Station, ...] = (),
players: tuple[Player, ...] = (),
) -> KitchenState:
return KitchenState(
level_id="03_ghost",
seed=7,
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
stations=stations,
players=players,
target_score=40,
time_left=90.0,
)
def cook(
player_id: str = "p0",
*,
pos: Vec2 | None = None,
carrying: Item | None = None,
connected: bool = True,
state: PlayerState = PlayerState.IDLE,
stun: float = 0.0,
seat: int = 0,
) -> Player:
return Player(
id=player_id,
pos=pos if pos is not None else Vec2(CELL_X + 0.5, CELL_Y + 0.5),
facing=Facing.DOWN,
carrying=carrying,
connected=connected,
state=state,
stun=stun,
seat=seat,
)
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
return Item(kind=ItemKind.TOMATO, form=form)
def fixture(station_id: str, kind: StationKind = StationKind.PREP) -> Station:
return Station(id=station_id, kind=kind, x=float(CELL_X), y=float(CELL_Y))
# --- the constant ---------------------------------------------------------------
def test_the_park_deadline_is_three_seconds() -> None:
assert PARK_AFTER_S == 3.0
# --- mark / restore -------------------------------------------------------------
def test_mark_ghost_switches_the_cook_and_is_idempotent() -> None:
state = make_state(players=(cook(),))
once = mark_ghost(state, state.players[0])
assert once.players[0].connected is False
assert once.players[0].state is PlayerState.DISCONNECTED
# Idempotent: a second call changes nothing (the event is the room's,
# so the sim must not double-apply the switch either).
twice = mark_ghost(once, once.players[0])
assert twice is once
assert twice.players[0].connected is False
def test_mark_ghost_keeps_the_carry() -> None:
state = make_state(players=(cook(carrying=tomato()),))
marked = mark_ghost(state, state.players[0])
assert marked.players[0].carrying is not None # the item waits for the park
assert marked.players[0].state is PlayerState.DISCONNECTED
def test_restore_player_brings_the_cook_back_and_keeps_the_carry() -> None:
ghost = mark_ghost(make_state(players=(cook(carrying=tomato()),)), cook(carrying=tomato()))
player = ghost.players[0]
restored = restore_player(ghost, player)
assert restored.players[0].connected is True
assert restored.players[0].state is PlayerState.IDLE
assert restored.players[0].carrying is not None # the rejoin keeps the carry
# Idempotent: a second restore changes nothing.
again = restore_player(restored, restored.players[0])
assert again is restored
# --- the release (the park / the forced release) --------------------------------
def test_station_at_finds_the_station_on_the_tile_and_none_on_bare_floor() -> None:
shelf = fixture("prep_0", StationKind.PREP)
state = make_state(stations=(shelf,), players=(cook(),))
assert station_at(state, state.players[0].pos) is shelf
bare = make_state(players=(cook(pos=Vec2(1.5, 1.5)),))
assert station_at(bare, Vec2(1.5, 1.5)) is None
def test_release_parks_onto_the_station_with_a_marked_set_down() -> None:
shelf = fixture("prep_0", StationKind.PREP)
state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),))
state = mark_ghost(state, state.players[0])
released, events = release_ghost_carry(state, state.players[0])
assert released.players[0].carrying is None
station = released.stations[0]
assert station.contents == (tomato(),)
# A marked set-down: grabbable at once (the hand-off window applies — a
# released item can be passed).
entry = first_loose_entry(released, station)
assert entry is not None and entry[0] == tomato()
assert [e.kind for e in events] == [EventKind.DROPPED]
def test_release_parks_into_lost_items_on_a_bare_tile() -> None:
state = make_state(players=(cook(pos=Vec2(1.5, 1.5), carrying=tomato(ItemForm.CHOPPED)),))
state = mark_ghost(state, state.players[0])
released, events = release_ghost_carry(state, state.players[0])
assert released.players[0].carrying is None
assert released.counters["lost_items"] == 1
assert len(events) == 1
event = events[0]
assert event.kind is EventKind.GHOST_PARK
assert event.payload == {
"player_id": "p0",
"item": "tomato",
"form": "chopped",
}
def test_release_is_idempotent_a_double_call_cannot_double_release() -> None:
shelf = fixture("prep_0", StationKind.PREP)
state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),))
state = mark_ghost(state, state.players[0])
released, first = release_ghost_carry(state, state.players[0])
assert len(first) == 1
# Second call: the hand is empty, nothing to release — no events, no
# counter bump, the set-down untouched.
again, second = release_ghost_carry(released, released.players[0])
assert second == ()
assert released.counters.get("lost_items", 0) == 0
assert again.stations[0].contents == (tomato(),)
# Same for the counter path: the tombstone is counted exactly once.
bare = make_state(players=(cook(pos=Vec2(1.5, 1.5), carrying=tomato()),))
bare = mark_ghost(bare, bare.players[0])
parked, _ = release_ghost_carry(bare, bare.players[0])
again_bare, second_bare = release_ghost_carry(parked, parked.players[0])
assert second_bare == ()
assert again_bare.counters["lost_items"] == 1
def test_release_of_a_connected_cook_switches_and_releases() -> None:
"""The forced release on a `SeatAbandoned`: the seat is emptied before
the 3 s park deadline, and the item must release at once (the sim
switches the ghost first, then releases)."""
shelf = fixture("prep_0", StationKind.PREP)
state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),))
released, events = release_ghost_carry(state, state.players[0])
assert released.players[0].connected is False
assert released.players[0].state is PlayerState.DISCONNECTED
assert released.players[0].carrying is None
assert released.stations[0].contents == (tomato(),)
assert [e.kind for e in events] == [EventKind.DROPPED]
def test_release_of_an_empty_hand_is_a_silent_noop() -> None:
state = make_state(players=(cook(pos=Vec2(1.5, 1.5)),))
state = mark_ghost(state, state.players[0])
released, events = release_ghost_carry(state, state.players[0])
assert events == ()
assert "lost_items" not in released.counters
# --- the ghost parks in the movement stage ---------------------------------------
def test_a_stun_that_runs_out_on_a_disconnected_cook_revives_it_disconnected() -> None:
players, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.05),), 0.05)
assert players[0].state is PlayerState.DISCONNECTED
assert players[0].connected is False
# A stun still running keeps the cook pinned (and stunned).
running, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.3),), 0.05)
assert running[0].state is PlayerState.STUNNED
assert running[0].stun == pytest.approx(0.25)
# A stun-zero STUNNED cook revives to `DISCONNECTED` as well, not `IDLE`.
zero, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.0),), 0.05)
assert zero[0].state is PlayerState.DISCONNECTED
# A connected cook still revives to `IDLE`.
alive, _ = apply_stun((cook(state=PlayerState.STUNNED, stun=0.05),), 0.05)
assert alive[0].state is PlayerState.IDLE
def test_a_disconnected_cook_parks_stale_input_does_not_move_it() -> None:
state = make_state(players=(cook(connected=False, state=PlayerState.DISCONNECTED),))
before = state.players[0].pos
after, events = resolve_moves(
state, 0.05, {"p0": PlayerInput(player_id="p0", move=Vec2(1.0, 0.0))}
)
assert after[0].pos == before # the ghost does not walk
assert after[0].vel.length == 0.0
assert after[0].state is PlayerState.DISCONNECTED
assert events == ()
# A connected cook on the same frame still moves.
state2 = make_state(players=(cook(state=PlayerState.IDLE),))
after2, _ = resolve_moves(
state2, 0.05, {"p0": PlayerInput(player_id="p0", move=Vec2(1.0, 0.0))}
)
assert after2[0].pos.x > state2.players[0].pos.x
assert after2[0].state is PlayerState.MOVING
# --- the events encode -----------------------------------------------------------
def test_the_ghost_events_encode_with_their_k_names_and_short_payloads() -> None:
encoded = encode_events(
(
Event(
kind=EventKind.GHOST_ON,
x=3.5,
y=2.5,
payload={"player_id": "p0"},
),
Event(kind=EventKind.GHOST_OFF, x=3.5, y=2.5, payload={"player_id": "p0"}),
Event(
kind=EventKind.GHOST_PARK,
x=1.5,
y=1.5,
payload={"player_id": "p0", "item": "tomato", "form": "chopped"},
),
)
)
assert [entry["k"] for entry in encoded] == ["ghost_on", "ghost_off", "ghost_park"]
assert encoded[0] == {"k": "ghost_on", "x": 3.5, "y": 2.5, "i": "p0"}
assert encoded[2] == {
"k": "ghost_park",
"x": 1.5,
"y": 1.5,
"i": "p0",
"it": "tomato",
"fm": "chopped",
}
+247
View File
@@ -0,0 +1,247 @@
"""Unit: `docs/PROTOCOL.md` and `protocol.py` cannot drift (phase 06, task 01).
The phase's completion criteria demand that "every field in code appears in
the doc" — and, in the other direction, that the doc never names a field
that does not exist. This test is the guard: for **every** message model
in both directions (the nine client→server, the eight server→client, and
the six wire objects they nest) it extracts the field names from the doc's
``### ModelName`` table and compares them with the model's ``model_fields``
— set-equal, in both directions. It also keeps the doc's error-code and
close-code tables equal to the `ErrorCode` / `CloseCode` constants.
Doc convention (what this test relies on): every ``###`` heading in
``docs/PROTOCOL.md`` is exactly one model section (no other ``###``
headings — use ``##`` for prose sections), and each section holds one
markdown table whose first column is the field name. So: an added field
that is not documented fails here, a documented field that is not in the
model fails here, a renamed model fails here, and a new error code without
a doc row fails here.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from pydantic import BaseModel
from undercooked.server.protocol import (
CloseCode,
CountdownMsg,
EmoteMsg,
ErrMsg,
ErrorCode,
EventWire,
EvMsg,
HelloMsg,
InputMsg,
LobbyMsg,
NextLevelMsg,
PingMsg,
PlayerScoreWire,
PongMsg,
ReadyMsg,
RoundMsg,
SeatWire,
SetLevelMsg,
SnapMsg,
SpectatorWire,
StartMsg,
ToLobbyMsg,
Vec2Msg,
WelcomeMsg,
YouWire,
)
pytestmark = pytest.mark.unit
DOCS = Path(__file__).resolve().parents[2] / "docs" / "PROTOCOL.md"
#: Every message model in both directions, plus the wire objects they nest.
C_TO_S_MODELS: dict[str, type[BaseModel]] = {
"HelloMsg": HelloMsg,
"ReadyMsg": ReadyMsg,
"SetLevelMsg": SetLevelMsg,
"StartMsg": StartMsg,
"InputMsg": InputMsg,
"EmoteMsg": EmoteMsg,
"ToLobbyMsg": ToLobbyMsg,
"NextLevelMsg": NextLevelMsg,
"PingMsg": PingMsg,
}
S_TO_C_MODELS: dict[str, type[BaseModel]] = {
"WelcomeMsg": WelcomeMsg,
"LobbyMsg": LobbyMsg,
"CountdownMsg": CountdownMsg,
"SnapMsg": SnapMsg,
"RoundMsg": RoundMsg,
"EvMsg": EvMsg,
"ErrMsg": ErrMsg,
"PongMsg": PongMsg,
}
WIRE_MODELS: dict[str, type[BaseModel]] = {
"Vec2Msg": Vec2Msg,
"YouWire": YouWire,
"SeatWire": SeatWire,
"SpectatorWire": SpectatorWire,
"EventWire": EventWire,
"PlayerScoreWire": PlayerScoreWire,
}
ALL_MODELS: dict[str, type[BaseModel]] = {
**C_TO_S_MODELS,
**S_TO_C_MODELS,
**WIRE_MODELS,
}
SECTION_HEADING = re.compile(r"^###\s+(\S+)\s*$")
def _clean(cell: str) -> str:
return cell.strip().strip("`").strip()
def extract_model_sections(text: str) -> dict[str, list[str]]:
"""Field names (first table column) per ``###`` section.
A section runs from its ``###`` heading to the next heading of any
level; inside it, only table rows count, and the header row (first cell
``Field``) and the separator row (dashes/colons) are skipped.
"""
sections: dict[str, list[str]] = {}
current: str | None = None
for line in text.splitlines():
heading = SECTION_HEADING.match(line)
if heading is not None:
name = heading.group(1)
sections.setdefault(name, [])
current = name
continue
if line.lstrip().startswith("#"):
current = None # any other heading ends the section
continue
if current is None:
continue
stripped = line.strip()
if not stripped.startswith("|"):
continue
cells = [_clean(cell) for cell in stripped.strip("|").split("|")]
if not cells or cells[0] in {"Field", ""}:
continue
if set(cells[0]) <= {"-", ":"}:
continue
sections[current].append(cells[0])
return sections
def extract_section_first_cells(text: str, heading: str) -> list[str]:
"""First table column of one ``##`` section (the code tables)."""
lines = text.splitlines()
try:
start = next(i for i, line in enumerate(lines) if line.strip() == heading)
except StopIteration:
pytest.fail(f"{heading!r} section not found in docs/PROTOCOL.md")
cells: list[str] = []
for line in lines[start + 1 :]:
if line.lstrip().startswith("#"):
break
stripped = line.strip()
if not stripped.startswith("|"):
continue
first = _clean(stripped.strip("|").split("|")[0])
if first in {"Code", ""} or set(first) <= {"-", ":"}:
continue
cells.append(first)
return cells
def test_the_doc_exists() -> None:
assert DOCS.is_file(), "docs/PROTOCOL.md is the phase deliverable — it must exist"
def test_documented_sections_are_exactly_the_model_registry() -> None:
sections = extract_model_sections(DOCS.read_text(encoding="utf-8"))
missing = set(ALL_MODELS) - set(sections)
extra = set(sections) - set(ALL_MODELS)
assert not missing, f"models with no ### section in the doc: {sorted(missing)}"
assert not extra, (
f"### sections that are not model sections (doc convention): {sorted(extra)}"
)
@pytest.mark.parametrize("name", sorted(ALL_MODELS))
def test_the_doc_fields_match_the_model_fields_in_both_directions(name: str) -> None:
model = ALL_MODELS[name]
sections = extract_model_sections(DOCS.read_text(encoding="utf-8"))
assert name in sections, f"no ### {name} section in docs/PROTOCOL.md"
documented = set(sections[name])
implemented = set(model.model_fields)
undocumented = implemented - documented
ghost = documented - implemented
assert not undocumented, (
f"{name}: fields in the model but missing from the doc: {sorted(undocumented)}"
)
assert not ghost, (
f"{name}: fields documented but not in the model: {sorted(ghost)}"
)
def test_both_directions_are_covered_by_the_registry() -> None:
assert len(C_TO_S_MODELS) == 9
assert len(S_TO_C_MODELS) == 8
assert len(WIRE_MODELS) == 6
assert set(ALL_MODELS) == set(C_TO_S_MODELS) | set(S_TO_C_MODELS) | set(WIRE_MODELS)
def test_the_error_code_table_matches_the_constants() -> None:
cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Error codes")
assert len(cells) == len(set(cells)), "duplicate rows in the error-code table"
documented = set(cells)
implemented = {
ErrorCode.UNKNOWN_TYPE,
ErrorCode.BAD_JSON,
ErrorCode.BAD_FIELD,
ErrorCode.TOO_BIG,
ErrorCode.RATE_LIMITED,
ErrorCode.NOT_SEATED,
ErrorCode.NOT_HOST,
ErrorCode.LEVEL_LOCKED,
ErrorCode.WRONG_STATE,
ErrorCode.ROOM_FULL,
ErrorCode.ROOM_GONE,
ErrorCode.PROTOCOL_MISMATCH,
ErrorCode.EVICTED,
ErrorCode.INTERNAL,
}
assert documented == implemented, (
f"error-code table drift: missing {sorted(implemented - documented)}, "
f"ghosts {sorted(documented - implemented)}"
)
def test_the_close_code_table_matches_the_constants() -> None:
cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Close codes")
documented = {int(cell) for cell in cells}
implemented = {
CloseCode.NORMAL,
CloseCode.PROTOCOL_MISMATCH,
CloseCode.ROOM_GONE,
CloseCode.ROOM_FULL,
CloseCode.EVICTED,
CloseCode.ABUSE,
}
assert documented == implemented, (
f"close-code table drift: missing {sorted(implemented - documented)}, "
f"ghosts {sorted(documented - implemented)}"
)
def test_the_whole_contract_round_trips_through_the_models() -> None:
"""Every model here is a real Pydantic model with a `type` discriminator
(C→S) or a defaulted one (S→C) — a smoke over the registry itself."""
for name, model in ALL_MODELS.items():
assert isinstance(model, type)
assert issubclass(model, BaseModel)
fields = set(model.model_fields)
if name in C_TO_S_MODELS:
assert "type" in fields, f"{name} has no discriminator"
+380
View File
@@ -0,0 +1,380 @@
"""Unit: the wire contract's accept/reject surface (phase 06, task 01).
`parse_client_message` is the whole *shape* check — JSON decode,
discriminator dispatch, per-model field validation, the `hello` `p` gate —
and it must never raise: every bad frame comes back as a `ProtocolError`
with a stable `code`. This table pins the whole surface: the nine message
types parse (including the boundary `move` magnitudes), and every documented
bad shape yields exactly its documented `code` (`bad_json`, `unknown_type`,
`bad_field`, `protocol_mismatch`).
Also pinned here: the `ErrorCode` / `CloseCode` constant sets (a new code
must be documented before it exists), `ProtocolError.to_err`, and the
server→client models' wire shape (every model encodes with its exact
`model_fields` and the right `type` / `p`).
"""
from __future__ import annotations
import pytest
from pydantic import BaseModel
from undercooked.server.protocol import (
MIN_SUPPORTED_VERSION,
PROTOCOL_VERSION,
ClientMessage,
CloseCode,
CountdownMsg,
EmoteMsg,
ErrMsg,
ErrorCode,
EventWire,
EvMsg,
HelloMsg,
InputMsg,
LobbyMsg,
NextLevelMsg,
PingMsg,
PlayerScoreWire,
PongMsg,
ProtocolError,
ReadyMsg,
RoundMsg,
SeatWire,
SetLevelMsg,
SnapMsg,
SpectatorWire,
StartMsg,
ToLobbyMsg,
Vec2Msg,
WelcomeMsg,
YouWire,
parse_client_message,
wire_dict,
)
pytestmark = pytest.mark.unit
#: A fixture session id (named to keep `S105`/`S106` quiet).
SESSION_ID = "tok-1"
def _error_of(result: ClientMessage | ProtocolError) -> ProtocolError | None:
return result if isinstance(result, ProtocolError) else None
# ------------------------------------------------------------------- accept
ACCEPT_CASES: list[tuple[bytes, type[BaseModel]]] = [
(b'{"type":"hello","p":1}', HelloMsg),
(b'{"type":"hello","p":1,"token":"tok-1234","name":"Ada"}', HelloMsg),
(b'{"type":"ready"}', ReadyMsg),
(b'{"type":"set_level","level":"01_first_kitchen"}', SetLevelMsg),
(b'{"type":"start"}', StartMsg),
(b'{"type":"input","seq":0,"move":{"x":0.0,"y":0.0},"act":false,"face":null}', InputMsg),
(b'{"type":"input","seq":7,"move":{"x":0.6,"y":0.8},"act":true,"face":null}', InputMsg),
(b'{"type":"input","seq":8,"move":{"x":-1.0,"y":0.0},"act":false,"face":"up"}', InputMsg),
(
b'{"type":"input","seq":9,"move":{"x":0.7,"y":0.7},"act":false,'
b'"face":"right"}',
InputMsg,
),
(b'{"type":"emote","id":"wave"}', EmoteMsg),
(b'{"type":"to_lobby"}', ToLobbyMsg),
(b'{"type":"next_level"}', NextLevelMsg),
(b'{"type":"ping","t":0}', PingMsg),
(b'{"type":"ping","t":1234567}', PingMsg),
]
@pytest.mark.parametrize(("raw", "expected"), ACCEPT_CASES)
def test_every_message_type_parses(raw: bytes, expected: type[BaseModel]) -> None:
result = parse_client_message(raw)
assert not isinstance(result, ProtocolError)
assert type(result) is expected
def test_anonymous_hello_parses_and_carries_no_token() -> None:
result = parse_client_message(b'{"type":"hello","p":1}')
assert isinstance(result, HelloMsg)
assert result.token is None
assert result.name is None
def test_a_boundary_move_magnitude_is_accepted() -> None:
# hypot(0.6, 0.8) is 1.0 exactly: the boundary is inclusive.
result = parse_client_message(
b'{"type":"input","seq":1,"move":{"x":0.6,"y":0.8},"act":false,"face":null}'
)
assert isinstance(result, InputMsg)
assert isinstance(result.move, Vec2Msg)
# ------------------------------------------------------------------- reject
REJECT_CASES: list[tuple[bytes, str]] = [
# bad_json — not JSON at all.
(b"{", "bad_json"),
(b"", "bad_json"),
(b"[1, 2]", "bad_field"),
(b'"just a string"', "bad_field"),
(b"123.5", "bad_field"),
# bad_field — valid JSON, wrong shape.
(b"null", "bad_field"),
(b"{}", "bad_field"),
(b'{"type":5}', "bad_field"),
(b'{"type":"hello"}', "bad_field"), # p missing
(b'{"type":"hello","p":"1"}', "bad_field"), # p is a string
(b'{"type":"hello","p":3.5}', "bad_field"), # p is not an int
(b'{"type":"hello","p":1,"token":""}', "bad_field"), # token too short
(b'{"type":"hello","p":1,"token":"' + b"a" * 129 + b'"}', "bad_field"),
(b'{"type":"hello","p":1,"name":"' + b"x" * 65 + b'"}', "bad_field"),
(b'{"type":"input","seq":-1,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3.5,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":"3","move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":"true","face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":1,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":50,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":1.01,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":1,"y":1},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":"1","y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":NaN,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":Infinity},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,"face":"north"}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false}', "bad_field"), # face missing
(
b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,'
b'"face":null,"extra":1}',
"bad_field",
),
(
b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,'
b'"face":null,"pos":{"x":9}}',
"bad_field",
),
(b'{"type":"ping","t":-1}', "bad_field"),
(b'{"type":"ping","t":"1"}', "bad_field"),
(b'{"type":"emote","id":""}', "bad_field"),
(b'{"type":"set_level","level":""}', "bad_field"),
(b'{"type":"set_level"}', "bad_field"),
# unknown_type — a `type` the server does not know.
(b'{"type":"bogus"}', "unknown_type"),
(b'{"type":"HELLO"}', "unknown_type"), # discriminators are case-sensitive
(b'{"type":"position","x":1}', "unknown_type"),
# protocol_mismatch — `p` outside [MIN_SUPPORTED_VERSION, PROTOCOL_VERSION].
(b'{"type":"hello","p":0}', "protocol_mismatch"),
(b'{"type":"hello","p":2}', "protocol_mismatch"),
(b'{"type":"hello","p":99}', "protocol_mismatch"),
]
@pytest.mark.parametrize(("raw", "expected_code"), REJECT_CASES)
def test_bad_frames_yield_their_documented_code(raw: bytes, expected_code: str) -> None:
result = parse_client_message(raw)
error = _error_of(result)
assert error is not None, f"{raw!r} parsed into a message, expected {expected_code}"
assert error.code == expected_code
def test_parse_never_raises_over_the_whole_table() -> None:
for raw, _ in ACCEPT_CASES + REJECT_CASES:
try:
parse_client_message(raw)
except Exception as exc:
pytest.fail(f"parse_client_message raised {exc!r} for {raw!r}")
def test_parse_of_a_valid_ping_is_not_an_error() -> None:
result = parse_client_message(b'{"type":"ping","t":1}')
assert not isinstance(result, ProtocolError)
def test_the_task_criterion_truncated_frame_is_bad_json() -> None:
error = _error_of(parse_client_message(b"{"))
assert error is not None
assert error.code == "bad_json"
def test_unknown_type_error_names_the_offending_type() -> None:
error = _error_of(parse_client_message(b'{"type":"teleport"}'))
assert error is not None
assert "teleport" in error.detail
# ------------------------------------------------------------- error model
def test_protocol_error_to_err_builds_the_wire_frame() -> None:
error = ProtocolError(code="bad_field", detail="x: input should be a valid number")
err = error.to_err()
assert err.type == "err"
assert err.code == "bad_field"
assert err.msg == "x: input should be a valid number"
assert err.seq is None
with_seq = error.to_err(seq=42)
assert with_seq.seq == 42
def test_protocol_error_without_detail_falls_back_to_the_code() -> None:
err = ProtocolError(code="wrong_state").to_err()
assert err.msg == "wrong_state"
def test_the_error_code_set_is_pinned() -> None:
expected = {
"unknown_type",
"bad_json",
"bad_field",
"too_big",
"rate_limited",
"not_seated",
"not_host",
"level_locked",
"wrong_state",
"room_full",
"room_gone",
"protocol_mismatch",
"evicted",
"internal",
}
codes = {
ErrorCode.UNKNOWN_TYPE,
ErrorCode.BAD_JSON,
ErrorCode.BAD_FIELD,
ErrorCode.TOO_BIG,
ErrorCode.RATE_LIMITED,
ErrorCode.NOT_SEATED,
ErrorCode.NOT_HOST,
ErrorCode.LEVEL_LOCKED,
ErrorCode.WRONG_STATE,
ErrorCode.ROOM_FULL,
ErrorCode.ROOM_GONE,
ErrorCode.PROTOCOL_MISMATCH,
ErrorCode.EVICTED,
ErrorCode.INTERNAL,
}
assert codes == expected
def test_the_close_code_set_is_pinned() -> None:
assert CloseCode.NORMAL == 1000
assert CloseCode.PROTOCOL_MISMATCH == 4400
assert CloseCode.ROOM_GONE == 4401
assert CloseCode.ROOM_FULL == 4402
assert CloseCode.EVICTED == 4403
assert CloseCode.ABUSE == 4404
# ------------------------------------------------------------ server → client
def test_the_protocol_version_constants() -> None:
assert PROTOCOL_VERSION == 1
assert MIN_SUPPORTED_VERSION == 1
def test_welcome_encodes_its_exact_fields() -> None:
seats = [
SeatWire(index=0, name="Ada", color="#e5484d", ready=True, host=True, connected=True)
]
msg = WelcomeMsg(
you=YouWire(token=SESSION_ID, seat=0, host=True),
room="ABCDE",
state="lobby",
level="01_first_kitchen",
seats=seats,
)
dumped = wire_dict(msg)
assert set(dumped) == {
"type",
"p",
"you",
"room",
"state",
"level",
"seats",
"rejoined",
}
assert dumped["type"] == "welcome"
assert dumped["p"] == PROTOCOL_VERSION
assert dumped["you"] == {"token": SESSION_ID, "seat": 0, "host": True}
# A fresh join (the default) is not a rejoin (task 05: `rejoined`
# is `true` only when a `hello` reclaims a disconnected seat).
assert dumped["rejoined"] is False
def test_lobby_encodes_its_exact_fields() -> None:
msg = LobbyMsg(
code="ABCDE",
state="lobby",
level=None,
host_seat=0,
can_start=False,
seats=[],
spectators=[SpectatorWire(name="Mallory", color="#3b9eff")],
unlocked=1,
round_no=0,
)
dumped = wire_dict(msg)
assert set(dumped) == {
"type", "p", "code", "state", "level", "host_seat", "can_start",
"seats", "spectators", "unlocked", "round_no",
}
assert dumped["type"] == "lobby"
def test_countdown_snap_round_ev_pong_encode_their_fields() -> None:
countdown = wire_dict(CountdownMsg(n=3))
assert set(countdown) == {"type", "p", "n"} and countdown["n"] == 3
snap = wire_dict(SnapMsg(raw={"t": 4821, "p": []}))
assert set(snap) == {"type", "p", "raw"}
raw_body = snap["raw"]
assert isinstance(raw_body, dict) and raw_body["t"] == 4821
round_ = wire_dict(
RoundMsg(
result="won",
score=140,
target=140,
stars=3,
time_left=60.0,
counters={"served": 5, "missed": 0, "wasted": 0},
longest_streak=5,
players=[
PlayerScoreWire(
id="tok-1", name="Ada", score=140, dishes_served=5, missed_contrib=0
)
],
level_id="01_first_kitchen",
seed=1234,
ticks=1200,
unlocked="02_double_shift",
)
)
assert set(round_) == {
"type", "p", "result", "score", "target", "stars", "time_left", "counters",
"longest_streak", "players", "level_id", "seed", "ticks", "unlocked",
}
ev = wire_dict(EvMsg(events=[EventWire(kind="served", x=9.0, y=1.0, payload={"i": 17})]))
assert set(ev) == {"type", "p", "events"}
assert ev["events"] == [{"kind": "served", "x": 9.0, "y": 1.0, "payload": {"i": 17}}]
pong = wire_dict(PongMsg(t=42, now=12.5))
assert set(pong) == {"type", "p", "t", "now"} and pong["t"] == 42
def test_err_msg_encodes_with_an_optional_seq() -> None:
dumped = wire_dict(ErrMsg(code="bad_field", msg="no", seq=7))
assert set(dumped) == {"type", "p", "code", "msg", "seq"} and dumped["seq"] == 7
anonymous = wire_dict(ErrMsg(code="wrong_state", msg="no"))
assert anonymous["seq"] is None
def test_event_wire_payload_defaults_to_empty() -> None:
event = EventWire(kind="chopped", x=3.0, y=2.0)
assert event.payload == {}
assert wire_dict(event) == {"kind": "chopped", "x": 3.0, "y": 2.0, "payload": {}}
+94
View File
@@ -0,0 +1,94 @@
"""Unit: `MESSAGE_WHITELIST` — totality and the pinned matrix (phase 06, task 01).
The whitelist is the single source of "what is legal when" (PLAN §7
"Accepted when"), imported by the endpoint and never duplicated. These tests
pin the whole matrix (a silent edit changes the wire contract) and assert
its *totality*: every room state is classified, every classification is a
known client type, and every client type is legal in at least one state —
so a new `ClientMsgType` that is never whitelisted (or a state that is
never classified) fails loudly.
"""
from __future__ import annotations
import pytest
from undercooked.server.protocol import CLIENT_MSG_TYPES, MESSAGE_WHITELIST
from undercooked.server.room import RoomState
pytestmark = pytest.mark.unit
#: The pinned matrix — PLAN §7 "Accepted when", one row per room state.
EXPECTED_MATRIX: dict[RoomState, frozenset[str]] = {
RoomState.LOBBY: frozenset({"hello", "ready", "set_level", "start", "emote", "ping"}),
RoomState.COUNTDOWN: frozenset({"hello", "ping"}),
RoomState.PLAYING: frozenset({"hello", "input", "emote", "ping"}),
RoomState.ROUND_END: frozenset({"hello", "to_lobby", "next_level", "ping"}),
RoomState.CLOSED: frozenset({"hello", "ping"}),
}
def test_the_matrix_is_pinned() -> None:
actual = {state: frozenset(types) for state, types in MESSAGE_WHITELIST.items()}
assert actual == EXPECTED_MATRIX
def test_totality_every_room_state_is_classified() -> None:
assert set(MESSAGE_WHITELIST) == set(RoomState)
def test_totality_every_whitelisted_type_is_known() -> None:
for state, allowed in MESSAGE_WHITELIST.items():
unknown = allowed - CLIENT_MSG_TYPES
assert not unknown, f"{state.value}: unknown types {sorted(unknown)}"
def test_totality_every_client_type_is_legal_somewhere() -> None:
union: set[str] = set()
for allowed in MESSAGE_WHITELIST.values():
union |= set(allowed)
never = CLIENT_MSG_TYPES - union
assert not never, f"client types legal in no state at all: {sorted(never)}"
def test_hello_and_ping_are_legal_in_every_state() -> None:
for state, allowed in MESSAGE_WHITELIST.items():
assert {"hello", "ping"} <= allowed, (
f"{state.value}: hello (the rejoin handshake) and ping (the "
f"keep-alive) must be legal in every state"
)
def test_input_is_playing_only() -> None:
assert "input" in MESSAGE_WHITELIST[RoomState.PLAYING]
for state in (RoomState.LOBBY, RoomState.COUNTDOWN, RoomState.ROUND_END, RoomState.CLOSED):
assert "input" not in MESSAGE_WHITELIST[state]
def test_the_lobby_verbs_are_lobby_only() -> None:
for verb in ("ready", "set_level", "start"):
assert verb in MESSAGE_WHITELIST[RoomState.LOBBY]
for state in (RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.ROUND_END, RoomState.CLOSED):
for verb in ("ready", "set_level", "start"):
assert verb not in MESSAGE_WHITELIST[state]
def test_emote_is_lobby_and_playing_only() -> None:
assert "emote" in MESSAGE_WHITELIST[RoomState.LOBBY]
assert "emote" in MESSAGE_WHITELIST[RoomState.PLAYING]
for state in (RoomState.COUNTDOWN, RoomState.ROUND_END, RoomState.CLOSED):
assert "emote" not in MESSAGE_WHITELIST[state]
def test_the_round_end_verbs_are_round_end_only() -> None:
for verb in ("to_lobby", "next_level"):
assert verb in MESSAGE_WHITELIST[RoomState.ROUND_END]
for state in (RoomState.LOBBY, RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.CLOSED):
for verb in ("to_lobby", "next_level"):
assert verb not in MESSAGE_WHITELIST[state]
def test_countdown_accepts_no_gameplay_verb() -> None:
# The 3 s countdown is a wall: only the handshake and the keep-alive get
# through (a late `ready` or `input` is `wrong_state`).
assert MESSAGE_WHITELIST[RoomState.COUNTDOWN] == {"hello", "ping"}
+344
View File
@@ -0,0 +1,344 @@
"""Unit: the send-queue policy and the per-connection guardrail arithmetic
(phase 06 task 02) — the `Connection`'s queue is the only buffer between
the game loop and a slow reader, so it must be bounded and honest:
* **snapshot droppability under a slow reader** — once the queue is at
`MAX_SEND_QUEUE` a new frame may only enter after the *oldest snapshot*
is dropped; `lobby` / `round` / `err` frames are never dropped and keep
their order,
* **the one-`rate_limited`-err bound** — a flood queues at most one
`rate_limited` `err` (the client is told once; the queue cannot grow
with the flood),
* **no unbounded growth** — the queue caps at `MAX_SEND_QUEUE` for the
dominant (snapshot) traffic; only the never-dropped frames can push it
past that, and only by their own count (game-bounded: one
`lobby` / `round` per state change, one coalesced `rate_limited`),
* **the token bucket** — `rate` tokens per second, a burst of `burst`,
refilled strictly off the injected `Clock` (never a wall clock),
* **the abuse tracker** — 3 *consecutive* rate/oversize violations inside
the 10 s window close `4404` (a clean frame resets the run); 5 shape
violations inside the window close it (a violation older than the
window does not count).
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from typing import Any
import pytest
from undercooked.server.clock import FakeClock
from undercooked.server.protocol import (
ErrMsg,
ErrorCode,
LobbyMsg,
RoundMsg,
SnapMsg,
)
from undercooked.server.ws import (
ABUSE_WINDOW_S,
MAX_SEND_QUEUE,
AbuseTracker,
Connection,
TokenBucket,
pump_out,
)
pytestmark = pytest.mark.unit
class _FakeWs:
"""A `WsLike` that records what the pump wrote (no socket)."""
def __init__(self) -> None:
self.sent: list[dict[str, Any]] = []
async def send_text(self, data: str) -> None:
self.sent.append(json.loads(data))
def _conn(clock: FakeClock) -> tuple[Connection, _FakeWs]:
ws = _FakeWs()
return (
Connection(
ws=ws,
token="tok-unit", # noqa: S106 - a test identity, not a credential
player_id="tok-unit",
seat_index=0,
role="player",
rate=TokenBucket(10.0, 10, clock),
),
ws,
)
def _snap(index: int) -> SnapMsg:
return SnapMsg(type="snap", raw={"t": index, "p": []})
def _lobby(index: int) -> LobbyMsg:
return LobbyMsg(
type="lobby",
code=f"C{index}",
state="lobby",
can_start=False,
seats=[],
spectators=[],
)
def _round() -> RoundMsg:
return RoundMsg(
type="round",
result="won",
score=1,
target=1,
stars=0,
time_left=0.0,
counters={},
longest_streak=0,
players=[],
level_id="x",
seed=1,
ticks=0,
)
# ------------------------------------------------------------------ drop policy
async def test_slow_reader_drops_oldest_snapshots() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
# A slow reader: nothing is pumped yet, so the queue fills.
for index in range(3):
conn.enqueue(_lobby(index)) # non-droppable
for index in range(20):
conn.enqueue(_snap(index)) # droppable
assert conn.send_queue.qsize() <= MAX_SEND_QUEUE
# Drain and check: the lobbies are all there, in order; the survivors
# are the NEWEST snapshots.
got: list[Any] = []
while not conn.send_queue.empty():
got.append(conn.send_queue.get_nowait())
types = [item.type for item in got]
assert types == ["lobby"] * 3 + ["snap"] * (MAX_SEND_QUEUE - 3)
survivors = [item.raw["t"] for item in got if item.type == "snap"]
assert survivors == list(
range(20 - (MAX_SEND_QUEUE - 3), 20)
), "the oldest snapshots must be the ones dropped"
async def test_non_droppable_frames_preserve_order() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
frames = [
_lobby(0),
_snap(0),
_round(),
_snap(1),
_lobby(1),
_snap(2),
_round(),
_snap(3),
_lobby(2),
_snap(4),
_snap(5),
_snap(6),
_snap(7),
_snap(8),
]
for frame in frames:
conn.enqueue(frame)
got: list[Any] = []
while not conn.send_queue.empty():
got.append(conn.send_queue.get_nowait())
# Nothing non-droppable was dropped; the snaps that survived are the
# newest, and every frame keeps its relative order.
non_droppable = [item for item in got if item.type in ("lobby", "round")]
assert list(non_droppable) == [
_lobby(0),
_round(),
_lobby(1),
_round(),
_lobby(2),
]
snaps = [item for item in got if item.type == "snap"]
assert [s.raw["t"] for s in snaps] == sorted(s.raw["t"] for s in snaps)
async def test_queue_cannot_grow_with_a_rate_limited_flood() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
for index in range(50): # a flood of refusals (e.g. 50 rate_limited errs)
conn.enqueue(
ErrMsg(
type="err",
code=ErrorCode.RATE_LIMITED,
msg=f"refusal {index}",
)
)
# At most ONE rate_limited err is queued (coalesced); the queue is
# bounded regardless of the flood's size.
held: list[Any] = []
while True:
try:
held.append(conn.send_queue.get_nowait())
except asyncio.QueueEmpty:
break
rate_limited = [item for item in held if item.code == ErrorCode.RATE_LIMITED]
assert len(rate_limited) == 1
assert len(held) <= MAX_SEND_QUEUE + 1
async def test_queue_caps_at_max_under_snapshot_flood() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
for index in range(100): # the dominant traffic, a slow reader: the
conn.enqueue(_snap(index)) # queue holds the newest, caps at the max
assert conn.send_queue.qsize() == MAX_SEND_QUEUE
held: list[Any] = []
while True:
try:
held.append(conn.send_queue.get_nowait())
except asyncio.QueueEmpty:
break
assert [item.raw["t"] for item in held] == list(range(92, 100))
async def test_all_non_droppable_frames_are_delivered_never_dropped() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
for index in range(30):
conn.enqueue(_lobby(index))
# Nothing is ever dropped: once the queue holds no snapshot there is
# nothing to drop, and the queue grows with the non-droppable frames
# the server actually sent (bounded by the game's own event count —
# a lobby/round/err per state change, a coalesced `rate_limited`).
assert conn.send_queue.qsize() == 30
async def test_pump_delivers_frames_in_order_to_a_slow_reader() -> None:
clock = FakeClock()
conn, ws = _conn(clock)
for index in range(15):
conn.enqueue(_snap(index))
assert conn.send_queue.qsize() == MAX_SEND_QUEUE
pump = asyncio.create_task(pump_out(ws, conn))
await asyncio.sleep(0) # let the pump run its passes
conn.alive = False
pump.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pump
sent_snaps = [frame["raw"]["t"] for frame in ws.sent if frame["type"] == "snap"]
# Whatever the reader saw is in order and a suffix of the batch (only
# the oldest may have been dropped).
assert sent_snaps == sorted(sent_snaps)
assert sent_snaps == list(range(15 - len(sent_snaps), 15))
# ----------------------------------------------------------------- token bucket
def test_bucket_serves_its_burst_then_empty() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=5.0, burst=5, clock=clock)
assert all(bucket.take() for _ in range(5))
assert not bucket.take() # the sixth, with no time elapsed: empty
def test_bucket_refills_off_the_injected_clock() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=5.0, burst=5, clock=clock)
for _ in range(5):
bucket.take()
clock.advance(0.2) # one token (5/s * 0.2 s)
assert bucket.take()
assert not bucket.take()
clock.advance(1.0) # a full second: back to the burst cap
assert all(bucket.take() for _ in range(5))
assert not bucket.take()
def test_bucket_never_refills_past_the_burst_cap() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=5.0, burst=3, clock=clock)
clock.advance(100.0) # a very long idle
assert bucket.tokens() == 3.0
assert all(bucket.take() for _ in range(3))
assert not bucket.take()
def test_bucket_rejects_impossible_parameters() -> None:
clock = FakeClock()
with pytest.raises(ValueError, match=r"rate must be > 0"):
TokenBucket(rate=0.0, burst=1, clock=clock)
with pytest.raises(ValueError, match=r"burst must be >= 1"):
TokenBucket(rate=1.0, burst=0, clock=clock)
def test_bucket_tokens_reports_the_refilled_count() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=2.0, burst=10, clock=clock)
for _ in range(5):
bucket.take()
assert bucket.tokens() == 5.0
clock.advance(0.5) # +1 (2/s * 0.5 s)
assert bucket.tokens() == 6.0
# ------------------------------------------------------------- the abuse tracker
def test_rate_close_after_three_consecutive_violations() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
assert not tracker.note_rate_violation()
assert not tracker.note_rate_violation()
assert tracker.note_rate_violation() # the third, inside the window
def test_clean_frame_resets_the_consecutive_run() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
assert not tracker.note_rate_violation()
tracker.note_clean()
assert not tracker.note_rate_violation()
assert not tracker.note_rate_violation() # the run only has two now
clock.advance(1.0)
assert tracker.note_rate_violation() # three consecutive again
def test_rate_violations_outside_the_window_do_not_close() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
assert not tracker.note_rate_violation() # t=0
clock.advance(ABUSE_WINDOW_S + 0.001) # the first stamp falls out
assert not tracker.note_rate_violation() # t=10.001: the run is two, one in-window
assert not tracker.note_rate_violation() # t=10.001: the run is three, two in-window
clock.advance(0.1)
assert tracker.note_rate_violation() # now three fit in the window
def test_shape_close_after_five_in_the_window() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
for _ in range(4):
assert not tracker.note_shape_violation()
assert tracker.note_shape_violation() # the fifth
def test_shape_violations_outside_the_window_do_not_close() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
for _ in range(4):
assert not tracker.note_shape_violation() # four at t=0
assert tracker.note_shape_violation() # the fifth closes (all in-window)
# Age the whole batch past the window, then it must not count again.
clock.advance(ABUSE_WINDOW_S + 0.001)
for _ in range(4):
assert not tracker.note_shape_violation() # one..four in-window
assert tracker.note_shape_violation() # the fifth in-window
+561
View File
@@ -0,0 +1,561 @@
"""Unit: the input-ingestion path (phase 06, task 03) — `ws.to_player_input`,
`ws.seq_verdict`, the `_dispatch_input` layers (seat check, seq discipline,
clamp, gap counting) and the room's per-tick queue (capacity `4 x seats`,
drop-oldest-for-that-player, `stats()` census).
The sim-side backstop of the clamp — a `(1000, 1000)` intent driven through
`to_player_input` + `Room.queue_input` + `tick_once` for 20 ticks never moves
a cook more than `MAX_SPEED x 0.05 x 1.01` per tick — is the hostile-speed
proof the live-socket suite in `tests/integration/test_ws_input.py` mirrors
at the wire layer (the wire's own `Vec2Msg` rejection of magnitude > 1 is the
early, explicit half of the rule; `movement.intent_direction()` is the
backstop the sim keeps behind both).
"""
from __future__ import annotations
import math
from typing import Any
import pytest
from undercooked.config import Settings
from undercooked.server.clock import FakeClock
from undercooked.server.protocol import ErrMsg, ErrorCode, InputMsg, Vec2Msg
from undercooked.server.room import Room, RoomState
from undercooked.server.ws import (
TOLERATED_SEQ_GAP,
Connection,
TokenBucket,
WsLike,
_dispatch_input,
seq_verdict,
to_player_input,
)
from undercooked.sim.content import ContentBundle
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import Facing, ItemForm, ItemKind, StationKind, Vec2
from undercooked.sim.movement import MAX_SPEED
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
from undercooked.sim.state_build import SeatSpec, build_state
from undercooked.sim.step import STEP_DT, PlayerInput
pytestmark = pytest.mark.unit
# --------------------------------------------------------------------- helpers
class _FakeWs(WsLike):
"""A socket stand-in: records the text frames it was sent."""
def __init__(self) -> None:
self.sent: list[str] = []
async def send_text(self, data: str) -> None:
self.sent.append(data)
def make_room(**settings: Any) -> tuple[Room, FakeClock]:
"""A room on a `FakeClock`, one seated (host) player, `PLAYING` (the
`queue_input` path under test needs the state, not the countdown)."""
clock = FakeClock()
room = Room(code="INPT1", settings=Settings(**settings), clock=clock)
room.add_seat("tok-a", "Ada", True)
room.state = RoomState.PLAYING
return room, clock
def make_conn(
token: str, clock: FakeClock, *, seat_index: int = 0
) -> tuple[Connection, _FakeWs]:
ws = _FakeWs()
conn = Connection(
ws=ws,
token=token,
player_id=token,
seat_index=seat_index,
role="player",
rate=TokenBucket(40, 40, clock),
)
return conn, ws
def make_input(
seq: int,
x: float = 0.0,
y: float = 0.0,
act: object = False,
face: object = None,
) -> InputMsg:
"""A `InputMsg` via `model_construct` — bypasses the strict validation
on purpose: the clamp layer under test must stand whatever the wire
layer already rejected (the `(1000, 1000)` hostile frame, the non-bool
`act`, the unknown `face` all live here)."""
return InputMsg.model_construct(
type="input",
seq=seq,
move=Vec2Msg.model_construct(x=x, y=y),
act=act,
face=face,
)
# ---------------------------------------------------------------- seq_verdict
@pytest.mark.parametrize(
("last_seq", "seq", "expected"),
[
(0, 1, "accept"),
(5, 6, "accept"),
(5, 5, "duplicate"),
(5, 4, "stale"),
(5, 0, "stale"),
],
)
def test_seq_verdict_is_the_task_03_matrix(
last_seq: int, seq: int, expected: str
) -> None:
assert seq_verdict(last_seq, seq) == expected
def test_the_tolerated_gap_is_three() -> None:
assert TOLERATED_SEQ_GAP == 3
# ------------------------------------------------------- to_player_input (clamp)
def test_a_hostile_move_is_clamped_to_the_unit_disc() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=1000.0, y=1000.0), tick=7)
assert frame.player_id == "tok-a"
assert frame.seq == 1
assert frame.received_at_tick == 7
length = math.hypot(frame.move.x, frame.move.y)
assert length <= 1.0 + 1e-9, f"the clamp let a magnitude of {length} through"
# A unit diagonal (the (1000, 1000) direction, normalised) — not a boost.
assert abs(frame.move.x - frame.move.y) < 1e-9
assert frame.move.x == pytest.approx(0.707, abs=1e-3)
def test_a_unit_move_passes_through_unclamped() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=1.0, y=0.0), tick=0)
assert (frame.move.x, frame.move.y) == (1.0, 0.0)
def test_move_is_quantized_to_three_decimals() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=0.123456, y=-0.654321), tick=0)
assert frame.move.x == round(0.123456, 3)
assert frame.move.y == round(-0.654321, 3)
def test_act_is_coerced_to_bool() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
assert to_player_input(conn, make_input(1, act=1), tick=0).action is True
assert to_player_input(conn, make_input(1, act=0), tick=0).action is False
# ------------------------------------------------- face derivation (task 03)
@pytest.mark.parametrize(
("x", "y", "expected"),
[
(1.0, 0.0, Facing.RIGHT),
(-1.0, 0.0, Facing.LEFT),
(0.0, -1.0, Facing.UP),
(0.0, 1.0, Facing.DOWN),
(0.7, 0.7, Facing.RIGHT), # a 45° press: the dominant (horizontal) axis
(0.7, -0.7, Facing.RIGHT),
(-0.7, 0.7, Facing.LEFT),
(-0.7, -0.7, Facing.LEFT),
],
)
def test_face_is_derived_from_move_when_null(
x: float, y: float, expected: Facing
) -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=x, y=y, face=None), tick=0)
assert frame.face is expected
def test_face_is_derived_from_move_when_unknown() -> None:
"""An unknown `face` is unreachable over the wire (`InputMsg` rejects it
as `bad_field` first) — but `to_player_input` is the clamp layer, and a
directly-constructed one must derive, not raise."""
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(
conn, make_input(1, x=1.0, y=0.0, face="backwards"), tick=0
)
assert frame.face is Facing.RIGHT
def test_a_zero_move_derives_no_facing() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=0.0, y=0.0), tick=0)
assert frame.face is None
def test_an_explicit_known_face_wins_over_move() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=1.0, y=0.0, face="up"), tick=0)
assert frame.face is Facing.UP
# --------------------------------------------------- _dispatch_input: seat check
def test_a_spectator_input_is_refused_not_seated() -> None:
"""A token with no seat (a spectator, or none) is `err not_seated`;
nothing is queued, and `last_seq` is untouched."""
room, clock = make_room()
room.add_spectator("tok-spec", "Spec")
conn, _ = make_conn("tok-spec", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
assert not room._queued_inputs
assert conn.last_seq == 0
(err,) = conn.send_queue._queue # type: ignore[reportAttributeAccessIssue]
assert isinstance(err, ErrMsg)
assert err.code == ErrorCode.NOT_SEATED
def test_a_disconnected_seat_is_silently_ignored() -> None:
room, clock = make_room()
room.mark_seat_disconnected("tok-a")
conn, ws = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
assert not room._queued_inputs
assert conn.last_seq == 0 # not accepted: the stat path never ran
assert room.stats()["inputs_received"] == 0
assert not ws.sent # no err, nothing
def test_the_room_layer_repeats_the_disconnected_guard() -> None:
"""`Room.queue_input` is the sim-side backstop: a `DISCONNECTED` seat's
frame is ignored there too (no event, no queue, no stat)."""
room, _ = make_room()
room.mark_seat_disconnected("tok-a")
assert (
room.queue_input(
"tok-a",
PlayerInput(player_id="tok-a", seq=1, move=Vec2(1.0, 0.0)),
)
is False
)
assert not room._queued_inputs
assert not room.events_since_snapshot
assert room.stats()["inputs_received"] == 0
# ------------------------------------------- _dispatch_input: the seq discipline
def test_an_accepted_input_advances_last_seq_and_queues() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
assert conn.last_seq == 1
assert len(room._queued_inputs) == 1
assert room._queued_inputs[0].received_at_tick == 0 # the sim tick (None state)
assert room.stats()["inputs_received"] == 1
assert room.stats()["input_gaps"] == 0
def test_an_equal_seq_is_silently_dropped() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) # the resend
assert conn.last_seq == 1
assert len(room._queued_inputs) == 1 # the first one only
assert room.stats()["inputs_received"] == 1
assert room.stats()["stale_seqs"] == 0
assert not [
event
for event in room.events_since_snapshot
if event.kind == "resync"
]
def test_a_backwards_seq_is_refused_with_seq_and_a_resync_event() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(5, x=1.0, y=0.0))
_dispatch_input(conn, room, make_input(3, x=1.0, y=0.0)) # behind 5
assert conn.last_seq == 5 # never lowered
assert len(room._queued_inputs) == 1
assert room.stats()["stale_seqs"] == 1
(err,) = conn.send_queue._queue # type: ignore[reportAttributeAccessIssue]
assert isinstance(err, ErrMsg)
assert err.code == ErrorCode.BAD_FIELD
assert err.seq == 3 # the frame's seq: what the client resyncs off
resyncs = [
event
for event in room.events_since_snapshot
if event.kind == "resync"
]
assert len(resyncs) == 1
assert resyncs[0].payload == {"player_id": "tok-a", "seq": 3}
def test_a_gap_of_three_or_less_is_tolerated_silently() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
_dispatch_input(conn, room, make_input(4, x=1.0, y=0.0)) # a jump of 3
assert conn.last_seq == 4
assert len(room._queued_inputs) == 2
assert room.stats()["input_gaps"] == 0
def test_a_larger_gap_is_accepted_and_counted() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
_dispatch_input(conn, room, make_input(50, x=1.0, y=0.0)) # a jump of 49
assert conn.last_seq == 50
assert len(room._queued_inputs) == 2
assert room.stats()["input_gaps"] == 1
def test_a_hostile_move_counts_clamped_moves_and_clamps() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1000.0, y=1000.0))
assert room.stats()["clamped_moves"] == 1
(frame,) = room._queued_inputs
assert math.hypot(frame.move.x, frame.move.y) <= 1.0 + 1e-9
def test_input_in_a_non_playing_room_is_dropped_silently() -> None:
room, clock = make_room()
room.state = RoomState.LOBBY
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
assert not room._queued_inputs
assert room.stats()["inputs_received"] == 0
# --------------------------------------------------- the room's per-tick queue
def _fill(room: Room, token: str, count: int, start_seq: int = 1) -> None:
for offset in range(count):
assert room.queue_input(
token,
PlayerInput(
player_id=token, seq=start_seq + offset, move=Vec2(1.0, 0.0)
),
)
def test_the_queue_capacity_is_four_times_the_seats() -> None:
room, _ = make_room() # one seat: capacity 4
_fill(room, "tok-a", 4)
assert len(room._queued_inputs) == 4
# The 5th (same player): the oldest of *that player's* entries drops.
assert room.queue_input(
"tok-a", PlayerInput(player_id="tok-a", seq=5, move=Vec2(1.0, 0.0))
)
assert len(room._queued_inputs) == 4
assert room._queued_inputs[0].seq == 2 # seq 1 was the drop
assert room.stats()["inputs_dropped"] == 1
assert room.stats()["inputs_received"] == 5
def test_overflow_never_drops_another_players_entries() -> None:
"""The queue is full of player A's frames: player B's first frame is
the drop (a spammer cannot clear another player's tick); A's oldest
still drops for A's own next frame."""
room, _ = make_room()
room.add_seat("tok-b", "Bob", False)
_fill(room, "tok-a", 8) # 2 seats: capacity 8; 8 of A's frames
assert (
room.queue_input(
"tok-b", PlayerInput(player_id="tok-b", seq=1, move=Vec2(1.0, 0.0))
)
is False
)
assert not any(frame.player_id == "tok-b" for frame in room._queued_inputs)
assert room.stats()["inputs_dropped"] == 1
# A's own 9th frame evicts A's oldest, as usual.
assert room.queue_input(
"tok-a", PlayerInput(player_id="tok-a", seq=9, move=Vec2(1.0, 0.0))
)
assert room._queued_inputs[0].seq == 2
assert len(room._queued_inputs) == 8
def test_an_unknown_or_spectator_id_is_a_buffered_err_event() -> None:
room, _ = make_room()
room.add_spectator("tok-spec", "Spec")
assert (
room.queue_input(
"tok-spec", PlayerInput(player_id="tok-spec", seq=1)
)
is False
)
assert (
room.queue_input("tok-none", PlayerInput(player_id="tok-none", seq=1))
is False
)
reasons = [
event.payload.get("reason")
for event in room.events_since_snapshot
if event.kind == "err"
]
assert reasons == ["not_seated", "unknown_player"]
assert room.stats()["inputs_received"] == 0
def test_note_input_stat_rejects_a_misspelling() -> None:
room, _ = make_room()
with pytest.raises(KeyError):
room.note_input_stat("inputs_received_typo")
with pytest.raises(KeyError):
room.note_input_stat("")
def test_stats_is_a_fixed_shape_census_of_zeroes_fresh() -> None:
room, _ = make_room()
assert room.stats() == {
"inputs_received": 0,
"inputs_dropped": 0,
"stale_seqs": 0,
"clamped_moves": 0,
"input_gaps": 0,
"events_dropped": 0,
}
def test_note_stale_seq_counts_and_buffers_the_resync_event() -> None:
room, _ = make_room()
room.note_stale_seq("tok-a", 3)
assert room.stats()["stale_seqs"] == 1
resyncs = [
event for event in room.events_since_snapshot if event.kind == "resync"
]
assert len(resyncs) == 1
assert resyncs[0].payload == {"player_id": "tok-a", "seq": 3}
# --------------------------------- the hostile-speed proof (sim-side, per tick)
def test_a_hostile_intent_moved_through_the_whole_path_never_exceeds_max_speed() -> None:
"""`(1000, 1000)` every tick, through `to_player_input` +
`Room.queue_input` + `tick_once`: the per-tick distance stays within
`MAX_SPEED x dt x 1.01`. This is the clamp chain's proof (the wire
layer's `Vec2Msg` rejection is a separate, earlier line of defence —
the live suite asserts it there)."""
# A bare, open 9x4 kitchen (no walls to lean on): the cook's motion is
# the velocity's, so the distance bound is the velocity bound. The
# `LevelDef`'s own guards (`target_score > 0`, a non-empty `pool`)
# want a recipe, so `soup` rides along (a high `target` keeps the
# 20-tick window from ever ending the round).
level = Level.build(
LevelDef.model_validate(
{
"id": "hostile_1",
"name": "Hostile",
"order": 1,
"time_limit": 30.0,
"target_score": 1000,
"grid": [
"#########",
"#.......#",
"#....SV.#",
"#.......#",
"#########",
],
"tiles": {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"V": {"kind": "floor", "station": "serve"},
},
"spawns": [[4, 2]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [10, 10],
"deadline": 30,
},
}
)
)
bundle = ContentBundle(
recipe_book=RecipeBook(
recipes={
"soup": Recipe(
id="soup",
name="Soup",
base_score=40,
ingredients=[
Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW)
],
steps=[
Step(kind="serve", station=StationKind.SERVE, seconds=0.0)
],
)
},
ingredients=[ItemKind.TOMATO],
),
levels=(level,),
)
clock = FakeClock()
room = Room(code="INPT1", settings=Settings(), clock=clock, content=bundle)
room.add_seat("tok-a", "Ada", True)
room.state = RoomState.PLAYING
state = build_state(
level,
[SeatSpec(player_id="tok-a", seat=0, display_name="Ada", color="")],
7,
book=bundle.recipe_book,
)
room.sim_state = state
conn, _ = make_conn("tok-a", clock)
room.tick_once() # warm-up: sets `_last_at` (a 0-`dt` tick advances
# nothing — with the substep guard it also keeps the queue)
previous: Vec2 | None = None
worst = 0.0
for tick in range(1, 21):
_dispatch_input(
conn,
room,
make_input(tick, x=1000.0, y=1000.0, act=True),
)
assert len(room._queued_inputs) == 1, "one frame per tick, one drop max"
clock.advance(0.05)
room.tick_once()
assert room.sim_state is not None
(player,) = room.sim_state.players
if previous is not None:
distance = (player.pos - previous).length
worst = max(worst, distance)
previous = player.pos
bound = MAX_SPEED * STEP_DT * 1.01
assert worst <= bound, (
f"the cook moved {worst:.4f} tiles in one tick; the bound is {bound:.4f}"
)