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:
@@ -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}"
|
||||
)
|
||||
Reference in New Issue
Block a user