**Final report — phase 07 final verification pass** - **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found. - **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase). - **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔. - **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry. - **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`. - **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate. - **Next pending phase:** `08_client_prediction_and_touch`.
751 lines
31 KiB
Python
751 lines
31 KiB
Python
"""Phase 07 task 03 — the snapshot buffer, the interpolation, the delta
|
|
decoder and the event queue, driven through the page's own modules.
|
|
|
|
The pure modules (`net/snapshots.js`, `net/decode.js`, `net/events.js`,
|
|
`render/loop.js`) are tested *in the browser* — one `page.evaluate`
|
|
assertion per module path (the task's completion criterion) — with an
|
|
injected clock, so the synthetic streams are exact: no real network, no
|
|
wall clock, no waiting. The server (the `round_server` campaign) is
|
|
only the page's host; the wire half — `applyDelta` against a frame
|
|
encoded by the *real* `encode_snapshot` — is the one place the Python
|
|
and the JS meet (deep equality, field by field).
|
|
|
|
The stream contract under test (docs/CLIENT.md "The snapshot buffer"):
|
|
frames keyed by `t`, in order (older/duplicate dropped, a `t` jump a
|
|
recorded gap), a gap *longer than* `MAX_GAP_TICKS` (6) fires the
|
|
resync — debounced to one request per `RESYNC_DEBOUNCE_MS` (500 ms) —
|
|
and the stream's full cadence clears the latch; no frame for
|
|
`maxAgeMs` and the sample **freezes** (it never extrapolates).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from undercooked.server.encoding import encode_snapshot
|
|
from undercooked.sim.grid import Grid
|
|
from undercooked.sim.model import (
|
|
Facing,
|
|
Hazard,
|
|
HazardKind,
|
|
Item,
|
|
ItemForm,
|
|
ItemKind,
|
|
KitchenState,
|
|
Order,
|
|
Player,
|
|
PlayerState,
|
|
Station,
|
|
StationKind,
|
|
StationState,
|
|
TileKind,
|
|
Vec2,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from playwright.sync_api import Page
|
|
|
|
pytestmark = pytest.mark.e2e
|
|
|
|
#: The per-step bound the smoothness suite asserts (the task's number):
|
|
#: `MAX_SPEED * (1 / SNAPSHOT_HZ) * 1.35` = 3.5 * 0.05 * 1.35.
|
|
STEP_BOUND = 3.5 * 0.05 * 1.35
|
|
|
|
|
|
def _load(page: Page, url: str) -> None:
|
|
page.goto(url, wait_until="networkidle")
|
|
|
|
|
|
# ---------------------------------------------------------------- the buffer
|
|
|
|
|
|
def test_buffer_drops_older_frames_and_records_gaps(page: Page, round_server) -> None:
|
|
"""In-order delivery: older and duplicate `t` are dropped (counted),
|
|
a `t` jump of 3 (under the 6) is a recorded gap, not a resync."""
|
|
_load(page, f"{round_server.url}/")
|
|
result = page.evaluate(
|
|
"""
|
|
async () => {
|
|
const { createSnapshotBuffer } = await import('/static/js/net/snapshots.js');
|
|
let fake = 0;
|
|
const buf = createSnapshotBuffer({ now: () => fake });
|
|
const fr = (t, x, full = true) => {
|
|
const p = [{ i: 'c', x, y: 5, f: 1, h: null, s: 'moving', pi: 0 }];
|
|
if (full) {
|
|
return { full: true, t, s: 0, tgt: 20, tl: 1, p, st: [], o: [], hz: [], e: [] };
|
|
}
|
|
return { full: false, t, s: 0, tgt: 20, tl: 1, d: { p, st: [], o: [], hz: [] }, e: [] };
|
|
};
|
|
fake = 0; const a0 = buf.push(fr(0, 0));
|
|
fake = 50; const a1 = buf.push(fr(1, 0.175));
|
|
fake = 100; const older = buf.push(fr(0, 0)); // older: dropped
|
|
fake = 150; const dup = buf.push(fr(1, 0.175)); // duplicate: dropped
|
|
fake = 200; const jump = buf.push(fr(4, 0.7)); // gap of 3: recorded
|
|
return { a0, a1, older, dup, jump, stats: buf.stats(), pending: buf.needsFullResync() };
|
|
}
|
|
"""
|
|
)
|
|
assert result["a0"] is True and result["a1"] is True
|
|
assert result["older"] is False and result["dup"] is False
|
|
assert result["jump"] is True
|
|
assert result["stats"]["pushed"] == 3
|
|
assert result["stats"]["dropped"] == 2
|
|
assert result["stats"]["gaps"] == 1
|
|
assert result["stats"]["maxGapTicks"] == 3
|
|
assert result["pending"] is False # a gap of 3 is not a resync
|
|
|
|
|
|
def test_buffer_gap_over_six_ticks_triggers_a_debounced_resync(page: Page, round_server) -> None:
|
|
"""A gap *longer than* 6 ticks fires the resync request — once —
|
|
holds delta frames out until a full frame arrives (the stream's
|
|
cadence is the resync), debounces a second gap inside the 500 ms
|
|
window, and fires again after it."""
|
|
_load(page, f"{round_server.url}/")
|
|
result = page.evaluate(
|
|
"""
|
|
async () => {
|
|
const { createSnapshotBuffer } = await import('/static/js/net/snapshots.js');
|
|
let fake = 0;
|
|
let resyncs = 0;
|
|
const buf = createSnapshotBuffer({ now: () => fake, onResync: () => { resyncs += 1; } });
|
|
const fr = (t, x, full = true) => {
|
|
const p = [{ i: 'c', x, y: 5, f: 1, h: null, s: 'moving', pi: 0 }];
|
|
if (full) {
|
|
return { full: true, t, s: 0, tgt: 20, tl: 1, p, st: [], o: [], hz: [], e: [] };
|
|
}
|
|
return { full: false, t, s: 0, tgt: 20, tl: 1, d: { p, st: [], o: [], hz: [] }, e: [] };
|
|
};
|
|
fake = 0; buf.push(fr(0, 0));
|
|
fake = 50; buf.push(fr(1, 0.175));
|
|
// The gap of 8 (> 6), on a *delta* frame: the request fires.
|
|
const snap = () => {
|
|
const s = buf.stats();
|
|
return { pending: buf.needsFullResync(), resyncs, skipped: s.skippedUntilFull };
|
|
};
|
|
fake = 100; const gapped = buf.push(fr(9, 1.6, false));
|
|
const afterGap = snap();
|
|
// A further delta while pending: skipped, no second request.
|
|
fake = 150; const skipped = buf.push(fr(10, 1.8, false));
|
|
const afterSkip = snap();
|
|
// A second big gap inside the 500 ms debounce window: no fire.
|
|
fake = 200; buf.push(fr(20, 3.5, false));
|
|
const debounced = { resyncs, pending: buf.needsFullResync() };
|
|
// The stream's full cadence (a full frame) clears the latch.
|
|
fake = 300; const cleared = buf.push(fr(21, 3.7));
|
|
const afterFull = { pending: buf.needsFullResync(), resyncs, cleared };
|
|
// And after the 500 ms window, a big gap fires again.
|
|
fake = 900; buf.push(fr(30, 5.3, false));
|
|
const refired = { resyncs, pending: buf.needsFullResync() };
|
|
fake = 1000; buf.push(fr(31, 5.45, false));
|
|
return {
|
|
gapped, afterGap, skipped, afterSkip, debounced, afterFull, refired,
|
|
latest: buf.latest().t,
|
|
};
|
|
}
|
|
"""
|
|
)
|
|
assert result["gapped"] is False, "the gapped delta is held back, not recorded"
|
|
assert result["afterGap"]["pending"] is True, "the gap over six must set the latch"
|
|
assert result["afterGap"]["resyncs"] == 1, "the resync request fires exactly once"
|
|
assert result["skipped"] is False, "a delta while pending is skipped"
|
|
assert result["afterSkip"]["resyncs"] == 1, "the debounce holds the second request"
|
|
assert result["afterSkip"]["skipped"] == 2, (
|
|
"both the gapped delta and the pending one are held back"
|
|
)
|
|
assert result["debounced"]["resyncs"] == 1, "a gap inside 500 ms is debounced"
|
|
assert result["debounced"]["pending"] is True
|
|
assert result["afterFull"]["cleared"] is True
|
|
assert result["afterFull"]["pending"] is False, "a full frame clears the latch"
|
|
assert result["refired"]["resyncs"] == 2, "after 500 ms the request fires again"
|
|
assert result["refired"]["pending"] is True, (
|
|
"the gapped delta re-set the latch; the next full frame will clear it"
|
|
)
|
|
assert result["latest"] == 21, "the view holds the last good (full) frame"
|
|
|
|
|
|
def test_buffer_freezes_when_the_stream_goes_quiet(page: Page, round_server) -> None:
|
|
"""No frame for `maxAgeMs`: the sample holds the last frame
|
|
(`a === b`, `alpha 0`) — the buffer never extrapolates, a frozen
|
|
cook stays exactly where the last frame put it."""
|
|
_load(page, f"{round_server.url}/")
|
|
result = page.evaluate(
|
|
"""
|
|
async () => {
|
|
const { createSnapshotBuffer, lerpState } = await import('/static/js/net/snapshots.js');
|
|
let fake = 0;
|
|
const buf = createSnapshotBuffer({ interpDelayMs: 100, maxAgeMs: 1000, now: () => fake });
|
|
const fr = (t, x) => ({
|
|
full: true, t, s: 0, tgt: 20, tl: 1,
|
|
p: [{ i: 'c', x, y: 5, f: 1, h: null, s: 'moving', pi: 0 }],
|
|
st: [], o: [], hz: [], e: [],
|
|
});
|
|
fake = 0; buf.push(fr(0, 0));
|
|
fake = 50; buf.push(fr(1, 0.175));
|
|
fake = 100; buf.push(fr(2, 0.35));
|
|
// The stream goes quiet for well over `maxAgeMs` (1 s).
|
|
fake = 5000;
|
|
const sample = buf.sample(5000);
|
|
const state = lerpState(sample.a, sample.b, sample.alpha);
|
|
return {
|
|
sameFrame: sample.a === sample.b,
|
|
alpha: sample.alpha,
|
|
frozen: sample.frozen,
|
|
x: state.p[0].x,
|
|
latestT: buf.latest().t,
|
|
};
|
|
}
|
|
"""
|
|
)
|
|
assert result["sameFrame"] is True
|
|
assert result["alpha"] == 0
|
|
assert result["frozen"] is True
|
|
assert result["x"] == 0.35, "the frozen hold is the last frame's position, verbatim"
|
|
assert result["latestT"] == 2
|
|
|
|
|
|
def test_synthetic_thirty_frame_stream_is_monotonic_and_bounded(page: Page, round_server) -> None:
|
|
"""The 30-frame synthetic stream: a cook walking at `MAX_SPEED`
|
|
(3.5 tiles/s, 0.175 per 50 ms tick). The sampled positions are
|
|
monotonic, the first sample is the walk's start (0), the last
|
|
in-stream sample is the frame it brackets, no sample-to-sample
|
|
step exceeds `MAX_SPEED * (1/snapshot_hz) * 1.35` (0.23625), and
|
|
past the final frame the position holds (no extrapolation).
|
|
28 samples: render time 100..1450 (50 ms steps) over a 100 ms
|
|
interp delay is 0..1350."""
|
|
_load(page, f"{round_server.url}/")
|
|
result = page.evaluate(
|
|
"""
|
|
async () => {
|
|
const { createSnapshotBuffer, lerpState } = await import('/static/js/net/snapshots.js');
|
|
let fake = 0;
|
|
// `maxAgeMs` wide enough to keep all 30 frames in the window.
|
|
const buf = createSnapshotBuffer({
|
|
interpDelayMs: 100, maxAgeMs: 100000, now: () => fake,
|
|
});
|
|
const fr = (t) => ({
|
|
full: true, t, s: 0, tgt: 20, tl: 1,
|
|
p: [{ i: 'c', x: t * 0.175, y: 5, f: 1, h: null, s: 'moving', pi: 0 }],
|
|
st: [], o: [], hz: [], e: [],
|
|
});
|
|
for (let t = 0; t < 30; t += 1) {
|
|
fake = t * 50;
|
|
buf.push(fr(t));
|
|
}
|
|
const xs = [];
|
|
for (let now = 100; now <= 1450; now += 50) {
|
|
const sample = buf.sample(now);
|
|
const state = lerpState(sample.a, sample.b, sample.alpha);
|
|
xs.push(state.p[0].x);
|
|
}
|
|
// The tail: past the final frame the sample holds.
|
|
const tailSample = buf.sample(2000);
|
|
const tailState = lerpState(tailSample.a, tailSample.b, tailSample.alpha);
|
|
let monotonic = true;
|
|
let maxStep = 0;
|
|
for (let i = 1; i < xs.length; i += 1) {
|
|
const step = xs[i] - xs[i - 1];
|
|
if (step < 0) monotonic = false;
|
|
if (step > maxStep) maxStep = step;
|
|
}
|
|
return {
|
|
xs,
|
|
monotonic,
|
|
maxStep,
|
|
first: xs[0],
|
|
last: xs[xs.length - 1],
|
|
tailX: tailState.p[0].x,
|
|
tailFrozen: tailSample.frozen,
|
|
};
|
|
}
|
|
"""
|
|
)
|
|
xs = result["xs"]
|
|
assert len(xs) == 28, "28 samples: render time 100..1450 in 50 ms steps"
|
|
assert result["monotonic"] is True, f"the sampled walk went backwards: {xs}"
|
|
assert result["first"] == 0, f"the first sample is the walk's start: {xs[0]}"
|
|
assert result["last"] == pytest.approx(27 * 0.175, abs=1e-9), (
|
|
f"the last in-stream sample is the frame it brackets: {xs[-1]}"
|
|
)
|
|
assert result["maxStep"] <= STEP_BOUND, (
|
|
f"a sample-to-sample step of {result['maxStep']} exceeds {STEP_BOUND}"
|
|
)
|
|
assert result["tailX"] == pytest.approx(29 * 0.175, abs=1e-9), (
|
|
"past the final frame the position holds (no extrapolation)"
|
|
)
|
|
assert result["tailFrozen"] is True
|
|
|
|
|
|
# ------------------------------------------------------------- lerpState
|
|
|
|
|
|
def test_lerp_state_interpolates_and_snaps(page: Page, round_server) -> None:
|
|
"""`lerpState`: positions lerp along the chord, the facing (and the
|
|
carried item and state) snap at the `alpha >= 0.5` boundary, the
|
|
station progress and the order time-left lerp, an entity added at
|
|
`b` appears for the whole interval (at `b`'s position), and one
|
|
removed vanishes exactly at the boundary (held at `a`'s values
|
|
until `alpha >= 0.5`, then gone)."""
|
|
_load(page, f"{round_server.url}/")
|
|
result = page.evaluate(
|
|
"""
|
|
async () => {
|
|
const { lerpState } = await import('/static/js/net/snapshots.js');
|
|
const a = {
|
|
t: 10, s: 0, tgt: 20, tl: 59.5,
|
|
p: [
|
|
{ i: 'stay', x: 1, y: 1, f: 0, h: 'tomato_raw', s: 'moving', pi: 0 },
|
|
{ i: 'vanish', x: 4, y: 4, f: 2, h: null, s: 'idle', pi: 1 },
|
|
],
|
|
st: [{ k: 'stove', x: 3, y: 2, g: 'onion_raw', q: 0.9, p: 0.2, s: 'working' }],
|
|
o: [{ id: 7, r: 'soup', l: 0.8, pts: 40 }],
|
|
hz: [],
|
|
e: [{ k: 'chopped', x: 4, y: 1 }],
|
|
};
|
|
const b = {
|
|
t: 11, s: 5, tgt: 20, tl: 59.45,
|
|
p: [
|
|
{ i: 'stay', x: 2, y: 1, f: 1, h: 'tomato_chopped', s: 'moving', pi: 0 },
|
|
{ i: 'appear', x: 8, y: 1, f: 3, h: null, s: 'idle', pi: 2 },
|
|
],
|
|
st: [{ k: 'stove', x: 3, y: 2, g: 'onion_raw', q: 0.9, p: 0.4, s: 'working' }],
|
|
o: [{ id: 7, r: 'soup', l: 0.75, pts: 40 }],
|
|
hz: [],
|
|
e: [{ k: 'chopped', x: 4, y: 1 }],
|
|
};
|
|
const at = (alpha) => lerpState(a, b, alpha);
|
|
const at0 = at(0);
|
|
const at4 = at(0.4);
|
|
const at5 = at(0.5);
|
|
const at1 = at(1);
|
|
const pick = (s, i) => s.p.find((p) => p.i === i);
|
|
return {
|
|
at0: {
|
|
x: at0.p[0].x, f: at0.p[0].f, h: at0.p[0].h,
|
|
vanish: pick(at0, 'vanish'), appear: pick(at0, 'appear'),
|
|
sp: at0.st[0].p, tl: at0.tl,
|
|
},
|
|
at4: {
|
|
x: at4.p[0].x, f: at4.p[0].f, h: at4.p[0].h,
|
|
vanish: pick(at4, 'vanish'), sp: at4.st[0].p,
|
|
},
|
|
at5: {
|
|
x: at5.p[0].x, f: at5.p[0].f, h: at5.p[0].h, s: at5.p[0].s,
|
|
vanish: pick(at5, 'vanish'), appear: pick(at5, 'appear'),
|
|
sp: at5.st[0].p,
|
|
},
|
|
at1: {
|
|
x: at1.p[0].x, f: at1.p[0].f,
|
|
appear: pick(at1, 'appear'), vanish: pick(at1, 'vanish'),
|
|
sp: at1.st[0].p, l: at1.o[0].l, e: at1.e,
|
|
},
|
|
};
|
|
}
|
|
"""
|
|
)
|
|
# `alpha 0`: the chord's start, `a`'s discrete values; the vanishing
|
|
# entity is still visible, the new one is here too (it appears for
|
|
# the whole interval, at `b`'s position).
|
|
assert result["at0"]["x"] == 1
|
|
assert result["at0"]["f"] == 0, "below the boundary the facing holds `a`"
|
|
assert result["at0"]["h"] == "tomato_raw", "the carried item holds `a`"
|
|
assert result["at0"]["vanish"]["x"] == 4, "the vanishing entity is visible below 0.5"
|
|
assert result["at0"]["appear"] is not None, "the added entity appears for the whole interval"
|
|
assert result["at0"]["appear"]["x"] == 8
|
|
assert result["at0"]["sp"] == 0.2
|
|
assert result["at0"]["tl"] == pytest.approx(59.5)
|
|
# `alpha 0.4`: mid-chord lerp, the discrete fields still `a`.
|
|
assert result["at4"]["x"] == pytest.approx(1.4)
|
|
assert result["at4"]["f"] == 0
|
|
assert result["at4"]["h"] == "tomato_raw"
|
|
assert result["at4"]["vanish"]["x"] == 4
|
|
assert result["at4"]["sp"] == pytest.approx(0.28)
|
|
# `alpha 0.5`: the boundary — the discrete fields snap to `b`, the
|
|
# vanishing entity is gone, the new one is here.
|
|
assert result["at5"]["x"] == pytest.approx(1.5)
|
|
assert result["at5"]["f"] == 1, "at the boundary the facing snaps to `b`"
|
|
assert result["at5"]["h"] == "tomato_chopped"
|
|
assert result["at5"]["s"] == "moving"
|
|
assert result["at5"]["vanish"] is None, "at the boundary the vanishing entity is gone"
|
|
assert result["at5"]["appear"] is not None
|
|
assert result["at5"]["sp"] == pytest.approx(0.3)
|
|
# `alpha 1`: `b` verbatim.
|
|
assert result["at1"]["x"] == 2
|
|
assert result["at1"]["f"] == 1
|
|
assert result["at1"]["appear"]["x"] == 8
|
|
assert result["at1"]["vanish"] is None
|
|
assert result["at1"]["sp"] == pytest.approx(0.4)
|
|
assert result["at1"]["l"] == pytest.approx(0.75)
|
|
assert result["at1"]["e"] == [{"k": "chopped", "x": 4, "y": 1}]
|
|
|
|
|
|
# ------------------------------------------------------------ applyDelta
|
|
|
|
|
|
def _make_states() -> tuple[KitchenState, KitchenState]:
|
|
"""Two hand-built states (the `test_encoding_no_leak.py` machine,
|
|
extended): state A is the baseline (two cooks, a stove with
|
|
content, an empty board, an order, a fire); state B is one tick
|
|
on — the first cook moved and turned, the second *left the round*,
|
|
the stove advanced to ready, the board took content and turned
|
|
busy, a second order appeared, and the fire was put out. Every
|
|
delta path (an in-place change, an append, a tombstone) is
|
|
exercised at least once."""
|
|
grid = Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64)
|
|
a = KitchenState(
|
|
level_id="01_first_kitchen",
|
|
seed=0x1234,
|
|
grid=grid,
|
|
tick=100,
|
|
stations=(
|
|
Station(
|
|
id="stove_1",
|
|
kind=StationKind.STOVE,
|
|
x=3.234,
|
|
y=2.0,
|
|
contents=(Item(ItemKind.ONION, ItemForm.RAW, quality=0.98),),
|
|
state=StationState.WORKING,
|
|
progress=0.42,
|
|
),
|
|
Station(
|
|
id="chop_board_1",
|
|
kind=StationKind.CHOP_BOARD,
|
|
x=5.0,
|
|
y=7.0,
|
|
state=StationState.IDLE,
|
|
),
|
|
),
|
|
players=(
|
|
Player(
|
|
id="tok-a",
|
|
pos=Vec2(1.234, 2.345),
|
|
facing=Facing.RIGHT,
|
|
carrying=Item(ItemKind.TOMATO, ItemForm.RAW, quality=0.9),
|
|
state=PlayerState.MOVING,
|
|
seat=0,
|
|
),
|
|
Player(
|
|
id="tok-b",
|
|
pos=Vec2(6.0, 6.0),
|
|
facing=Facing.DOWN,
|
|
state=PlayerState.IDLE,
|
|
seat=1,
|
|
),
|
|
),
|
|
orders=(Order(id=7, recipe_id="soup", time_left=30.0, deadline=60, value=40),),
|
|
order_deadline=60,
|
|
hazards=(Hazard(id="hz-1", kind=HazardKind.FIRE, pos=Vec2(7.65, 5.2), life=12.34),),
|
|
score=0,
|
|
target_score=20,
|
|
time_left=59.95,
|
|
rng_state=0xCAFEBABE,
|
|
)
|
|
b = a.with_players(
|
|
(replace(a.players[0], pos=Vec2(1.409, 2.345), facing=Facing.DOWN),)
|
|
)
|
|
b = b.with_orders(
|
|
(
|
|
Order(id=7, recipe_id="soup", time_left=29.95, deadline=60, value=40),
|
|
Order(id=8, recipe_id="salad", time_left=60.0, deadline=60, value=30),
|
|
)
|
|
)
|
|
b = b.with_hazards(())
|
|
b = b.with_stations(
|
|
(
|
|
replace(b.stations[0], progress=0.47, state=StationState.READY),
|
|
replace(
|
|
b.stations[1],
|
|
contents=(Item(ItemKind.TOMATO, ItemForm.CHOPPED, quality=0.95),),
|
|
state=StationState.BUSY,
|
|
),
|
|
)
|
|
)
|
|
b = b.with_tick(101).with_score(5)
|
|
return a, b
|
|
|
|
|
|
def test_apply_delta_decodes_the_server_frame_to_the_full_frame(
|
|
page: Page, round_server
|
|
) -> None:
|
|
"""`applyDelta` (the client's decoder) applied to a delta frame
|
|
encoded by the *real* `encode_snapshot` lands, field by field, on
|
|
the full frame the server would have sent — positions, the carried
|
|
short, the tombstones (the cook who left, the put-out fire), the
|
|
appended order, the scalars, the events."""
|
|
_load(page, f"{round_server.url}/")
|
|
a, b = _make_states()
|
|
full_a = encode_snapshot(a)
|
|
delta = encode_snapshot(b, full_a)
|
|
full_b = encode_snapshot(b)
|
|
assert delta["full"] is False # the encoder really did make a delta
|
|
# The JS decoder, in the page's own module graph.
|
|
result = page.evaluate(
|
|
"""
|
|
async ([fullA, delta]) => {
|
|
const { applyDelta } = await import('/static/js/net/decode.js');
|
|
return applyDelta(delta, fullA);
|
|
}
|
|
""",
|
|
[full_a, delta],
|
|
)
|
|
assert result["full"] is True
|
|
# The scalars and events ride the delta: taken from the frame.
|
|
assert result["t"] == full_b["t"]
|
|
assert result["s"] == full_b["s"]
|
|
assert result["tgt"] == full_b["tgt"]
|
|
assert result["tl"] == full_b["tl"]
|
|
# Field by field, collection by collection (a deep equality).
|
|
assert result["p"] == full_b["p"], "the players (a move, a turn, a tombstone)"
|
|
assert result["st"] == full_b["st"], "the stations (a progress change, a content change)"
|
|
assert result["o"] == full_b["o"], "the orders (an in-place update, an append)"
|
|
assert result["hz"] == full_b["hz"], "the hazards (a tombstone)"
|
|
assert result == full_b, "the whole decoded frame equals the full frame"
|
|
|
|
|
|
def test_apply_delta_chains_delta_over_delta(page: Page, round_server) -> None:
|
|
"""The encoder diffs against *the previous frame* — full or delta:
|
|
`applyDelta` mirrors that (a delta frame's entities live under `d`,
|
|
its `gone` tombstones are not state). A full → delta1 → delta2
|
|
chain decodes to the final full frame."""
|
|
_load(page, f"{round_server.url}/")
|
|
a, b = _make_states()
|
|
full_a = encode_snapshot(a)
|
|
delta1 = encode_snapshot(b, full_a)
|
|
c = b.with_players(tuple(replace(p, pos=Vec2(p.pos.x + 0.175, p.pos.y)) for p in b.players))
|
|
delta2 = encode_snapshot(c, delta1)
|
|
full_c = encode_snapshot(c)
|
|
result = page.evaluate(
|
|
"""
|
|
async ([fullA, d1, d2]) => {
|
|
const { applyDelta } = await import('/static/js/net/decode.js');
|
|
const one = applyDelta(d1, fullA);
|
|
return applyDelta(d2, one);
|
|
}
|
|
""",
|
|
[full_a, delta1, delta2],
|
|
)
|
|
assert result == full_c, "the chained delta decodes to the final full frame"
|
|
|
|
|
|
# ---------------------------------------------------------- the event queue
|
|
|
|
|
|
def test_event_queue_ttl_dedup_and_tick_reset(page: Page, round_server) -> None:
|
|
"""The event queue: a per-kind `ttlMs` (a `served` outlives a
|
|
`chopped`), the dedup window (the same event inside 100 ms is
|
|
counted, not re-queued), `drift` prunes the expired, and a `push`
|
|
whose tick goes *backwards* is a resync — the queue clears, so a
|
|
`served` from the previous round cannot replay over the new one.
|
|
The standalone `ev` message's shape (`kind`) is admitted too."""
|
|
_load(page, f"{round_server.url}/")
|
|
result = page.evaluate(
|
|
"""
|
|
async () => {
|
|
const { createEventQueue } = await import('/static/js/net/events.js');
|
|
let fake = 0;
|
|
const q = createEventQueue({ now: () => fake });
|
|
fake = 0;
|
|
const first = q.push(1, [{ k: 'chopped', x: 1, y: 2, i: 'tok-a' }]);
|
|
const echo = q.push(2, [{ k: 'chopped', x: 1, y: 2, i: 'tok-a' }]);
|
|
fake = 200;
|
|
const again = q.push(3, [{ k: 'chopped', x: 1, y: 2, i: 'tok-a' }]);
|
|
const served = q.push(3, [{ k: 'served', x: 9, y: 1, oi: 7, d: 44 }]);
|
|
const unknown = q.push(3, [{ k: 'no_such_kind', x: 0, y: 0 }]);
|
|
const viaKind = q.push(3, [{ kind: 'score', x: 0, y: 0, pts: 44 }]);
|
|
const at1000 = { due: q.due(1000).map((e) => e.k), size: q.stats().size };
|
|
const pruned = q.drift(1500);
|
|
const at1500 = { due: q.due(1500).map((e) => e.k), size: q.stats().size };
|
|
// The tick goes backwards (a resync / a new round): the queue clears.
|
|
const resetPush = q.push(2, [{ k: 'score', x: 1, y: 1, pts: 5 }]);
|
|
const afterReset = {
|
|
due: q.due().map((e) => e.k),
|
|
size: q.stats().size,
|
|
resyncs: q.stats().resyncs,
|
|
};
|
|
return {
|
|
first, echo, again, served, unknown, viaKind, at1000, pruned,
|
|
at1500, resetPush, afterReset, stats: q.stats(),
|
|
};
|
|
}
|
|
"""
|
|
)
|
|
assert result["first"] == 1
|
|
assert result["echo"] == 0, "the dedup window holds the echo"
|
|
assert result["stats"]["deduped"] == 1
|
|
assert result["again"] == 1, "after the window the event queues again"
|
|
assert result["served"] == 1
|
|
assert result["unknown"] == 0, "an unknown kind is no FX, not an error"
|
|
assert result["viaKind"] == 1, "the standalone `ev` shape (`kind`) is admitted"
|
|
# At 1000: the second `chopped` (ttl 800 from 200), the `served`
|
|
# (ttl 1200 from 200) and the `score` (ttl 800 from 200) are live;
|
|
# the first `chopped` (ttl 800 from 0) is expired (still unpruned).
|
|
assert sorted(result["at1000"]["due"]) == ["chopped", "score", "served"]
|
|
assert result["at1000"]["size"] == 4 # all four entries, one expired
|
|
# At 1500: everything is expired and pruned (the entries and the
|
|
# dedup keys past their window).
|
|
assert result["at1500"]["due"] == []
|
|
assert result["at1500"]["size"] == 0
|
|
assert result["pruned"] >= 4
|
|
# The reset: only the post-reset event is live.
|
|
assert result["resetPush"] == 1
|
|
assert result["afterReset"]["due"] == ["score"]
|
|
assert result["afterReset"]["size"] == 1
|
|
assert result["afterReset"]["resyncs"] == 1
|
|
|
|
|
|
# ------------------------------------------------------------ the loop
|
|
|
|
|
|
def test_render_loop_pauses_on_hidden_and_measures(page: Page, round_server) -> None:
|
|
"""The single render loop, driven with an injected clock and a
|
|
captured rAF (no real animation): frames render while active, the
|
|
`visibilitychange` to hidden stops the chain (no frame renders
|
|
while hidden), the resume restarts it — and the delta spanning the
|
|
5 s pause is *dropped*, not measured, so the `frameMs` census
|
|
(`p50`/`p95`/`max`) stays honest (what `window.__uc.frameMs`
|
|
reports)."""
|
|
_load(page, f"{round_server.url}/")
|
|
result = page.evaluate(
|
|
"""
|
|
async () => {
|
|
const { createRenderLoop } = await import('/static/js/render/loop.js');
|
|
let t = 0;
|
|
let pending = null;
|
|
const renders = [];
|
|
let active = true;
|
|
const stub = {
|
|
hidden: false,
|
|
listeners: {},
|
|
addEventListener(ev, fn) { (this.listeners[ev] ??= []).push(fn); },
|
|
removeEventListener() {},
|
|
};
|
|
const loop = createRenderLoop({
|
|
now: () => t,
|
|
owner: stub,
|
|
active: () => active,
|
|
render: (now) => { renders.push(now); },
|
|
raf: (fn) => { pending = fn; return 1; },
|
|
unraf: () => { pending = null; return true; },
|
|
});
|
|
const drive = (n) => {
|
|
for (let i = 0; i < n; i += 1) {
|
|
t += 16.7;
|
|
const fn = pending;
|
|
pending = null;
|
|
if (fn) fn();
|
|
}
|
|
};
|
|
loop.start();
|
|
drive(10);
|
|
const before = loop.frameStats();
|
|
// Hide the tab: the pending frame finds `hidden` and stops.
|
|
stub.hidden = true;
|
|
stub.listeners.visibilitychange[0]();
|
|
const framesAtPause = renders.length;
|
|
if (pending) { const fn = pending; pending = null; fn(); }
|
|
t += 5000; // the pause: a long wall-clock gap
|
|
const hiddenRenders = renders.length - framesAtPause;
|
|
// Resume: the chain restarts; the delta spanning the pause drops.
|
|
stub.hidden = false;
|
|
stub.listeners.visibilitychange[0]();
|
|
drive(10);
|
|
const after = loop.frameStats();
|
|
return { before, hiddenRenders, after, rendersCount: renders.length };
|
|
}
|
|
"""
|
|
)
|
|
assert result["before"]["frames"] == 10
|
|
assert result["before"]["p50"] > 0, "the self-measured delta window is populated"
|
|
assert result["hiddenRenders"] == 0, "no frame renders while hidden"
|
|
assert result["after"]["frames"] == 20
|
|
assert result["after"]["hidden"] is False
|
|
assert result["after"]["max"] < 1000, (
|
|
f"the delta spanning the 5 s pause was measured (max {result['after']['max']})"
|
|
)
|
|
assert result["after"]["p95"] < 100
|
|
|
|
|
|
def test_the_idle_loop_keeps_the_chain_alive_for_play(page: Page, round_server) -> None:
|
|
"""A non-active frame renders nothing but keeps the rAF chain alive:
|
|
when the screen turns `PLAYING`, the loop picks it up on the very
|
|
next frame — no event, no restart needed."""
|
|
_load(page, f"{round_server.url}/")
|
|
result = page.evaluate(
|
|
"""
|
|
async () => {
|
|
const { createRenderLoop } = await import('/static/js/render/loop.js');
|
|
let t = 0;
|
|
let pending = null;
|
|
const renders = [];
|
|
let active = false; // the screen is not PLAYING yet
|
|
const stub = {
|
|
hidden: false,
|
|
listeners: {},
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
};
|
|
const loop = createRenderLoop({
|
|
now: () => t,
|
|
owner: stub,
|
|
active: () => active,
|
|
render: (now) => { renders.push(now); },
|
|
raf: (fn) => { pending = fn; return 1; },
|
|
unraf: () => { pending = null; return true; },
|
|
});
|
|
const drive = (n) => {
|
|
for (let i = 0; i < n; i += 1) {
|
|
t += 16.7;
|
|
const fn = pending;
|
|
pending = null;
|
|
if (fn) fn();
|
|
}
|
|
};
|
|
loop.start();
|
|
drive(5); // idle: no renders, the chain stays alive
|
|
const idleRenders = renders.length;
|
|
active = true; // the screen turns PLAYING
|
|
drive(5); // picked up on the next frames
|
|
return { idleRenders, afterRenders: renders.length, stats: loop.frameStats() };
|
|
}
|
|
"""
|
|
)
|
|
assert result["idleRenders"] == 0, "an idle screen renders nothing"
|
|
assert result["afterRenders"] == 5, "the chain was alive: PLAYING is picked up"
|
|
assert result["stats"]["frames"] == 5
|
|
|
|
|
|
# ---------------------------------------------------------- the seams
|
|
|
|
|
|
def test_url_params_and_seams_are_wired(page: Page, round_server) -> None:
|
|
"""The composition root's seams: `window.__undercooked` (the machine,
|
|
plus the buffer/queue/params the boot attaches) and `window.__uc`
|
|
(the census and the pure modules) are set by module evaluation,
|
|
and `?freeze=1` parses (the sample-time freeze is the composition's
|
|
business; the buffer's freeze half is the synthetic stream above)."""
|
|
_load(page, f"{round_server.url}/?freeze=1")
|
|
result = page.evaluate(
|
|
"""
|
|
() => ({
|
|
app: typeof window.__undercooked,
|
|
screen: window.__undercooked.screen,
|
|
params: window.__undercooked.params,
|
|
buffer: typeof window.__undercooked.snapshotBuffer,
|
|
events: typeof window.__undercooked.eventQueue,
|
|
uc: typeof window.__uc,
|
|
ucParams: window.__uc.params,
|
|
hasFrameMs: typeof window.__uc.frameMs,
|
|
})
|
|
"""
|
|
)
|
|
assert result["app"] == "object"
|
|
assert result["screen"] == "landing"
|
|
assert result["buffer"] == "object"
|
|
assert result["events"] == "object"
|
|
assert result["uc"] == "object"
|
|
assert result["ucParams"]["freeze"] is True, "the `?freeze=1` param is parsed"
|
|
assert result["ucParams"]["controls"] == "keyboard" # the default
|
|
assert result["hasFrameMs"] == "object" # the live census (a getter object)
|