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