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`.
435 lines
15 KiB
Python
435 lines
15 KiB
Python
"""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
|