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`.
562 lines
19 KiB
Python
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}"
|
|
)
|