Files
undercooked/tests/unit/test_ws_input_path.py
ducoterra f26849c1f3 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`.
2026-10-09 11:13:25 -04:00

562 lines
19 KiB
Python

"""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}"
)