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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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": {}}
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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