Files
undercooked/tests/unit/test_encoding_no_leak.py
ducoterra f26849c1f3 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`.
2026-10-09 11:13:25 -04:00

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