"""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