From e32e3fa35fe9213c039cafc190be67ca7826c4b1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 20 Sep 2026 11:05:42 +0300 Subject: [PATCH 1/7] [fix] open-core-m1: the drawer-mount check was asserting the pre-tab drawer The one red every lane in roadmap 29 carried as "pre-existing" is a TEST fault, not a product one. No core code changes here - `git diff -- src/` is empty. - WHEN IT STARTED: the check was written at 4799994 (roadmap 14 PM, 2026-07-24), when ConnectInfoDrawer rendered `{#if $drawerSlot}` inline in its single body, so opening the drawer with the chevron showed the plugin's section straight away. The NEXT DAY 4b7b8cf ("one tabbed drawer (Info/Rooms/Toasts)") moved the cloud mount behind the Rooms tab, and nobody updated the suite. It has been red for the whole life of the tabbed drawer - about two months. - MEASURED with a throwaway probe rather than inferred: plugin loaded, chevron clicked -> {drawerOpen:true, drawerTab:"info", tabs:["Info","Rooms","Toasts"], roomsBtn:true, section:false}; the Rooms shortcut or the Rooms tab clicked -> section:true, and `.cloud-slot` count 1 -> 2. Inert build: no tabs, no Rooms button, no cloud slot. - THE PRODUCT IS RIGHT and stays untouched. The chevron opening on Info is deliberate (toggleInfo keeps the last tab; the drawer's own job is connection and server info), and the plugin's content has a first-class way in: Connect grows `#connect-rooms-button` exactly when `$drawerSlot` is set, and `openRooms` opens the drawer on that tab. - THE CHECK now drives that shortcut - the way the app offers the mount - and asserts the section renders on the Rooms tab. - NEW CHECK `M1d: no Rooms affordance in the inert build`: the Rooms shortcut exists only because a plugin mounted drawer content, which is what stops the corrected check passing vacuously. 17 checks -> 18. Nothing was deleted. - The e2e skill drops open-core-m1 from both dirty-baseline lists, says where the drawer mount now lives, and records the lesson: identical-on-base only rules out YOUR diff, so when a red is pre-existing, spend two minutes on when it started and what changed then. Counterfactuals (product broken, then restored byte-identically): - removed `` from the Rooms tab -> `PM: drawer section renders on the drawer Rooms tab` FAILS. - dropped `$drawerSlot &&` from Connect's Rooms-button gate -> `M1d: no Rooms affordance in the inert build` FAILS. - both breaks together: exactly 2 FAILURES, no others; restored -> 18/18. Gates: open-core-m1 16/1 -> 18/18; battery under the lock ALL PASS (ai-presets, approval-timeout, connect-states, dial-metadata, join-result, net-handshake, open-core-m1 - 7 suites, 203 checks, 399s); svelte-check 336/47 unchanged (identical by construction - no src change); vitest 178; build green server-down. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/e2e-verify/SKILL.md | 21 +++++++++++++++++---- tests/e2e/open-core-m1.test.cjs | 14 +++++++++++--- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.claude/skills/e2e-verify/SKILL.md b/.claude/skills/e2e-verify/SKILL.md index ba77100a..e148ae03 100644 --- a/.claude/skills/e2e-verify/SKILL.md +++ b/.claude/skills/e2e-verify/SKILL.md @@ -969,7 +969,10 @@ drops the P2P session. + `freshReload`, then asserts the seams (`window.__stores.cloudHooks.canApply`, `profileSlot`/`drawerSlot` are functions, mounted DOM). The flowbite avatar Dropdown is flaky to open headlessly — assert profile mounts at the STORE level, not by - clicking `#avatar-menu`. A `transition:slide` element stays in the DOM through the + clicking `#avatar-menu`. The DRAWER mount renders on the connect drawer's ROOMS tab, + not under the chevron (which opens on Info and keeps the last tab): reach it with + `#connect-rooms-button`, the Connect-pill shortcut that exists only while `$drawerSlot` + is set — which is also what keeps that check from passing vacuously. A `transition:slide` element stays in the DOM through the ~200ms out-transition — poll with `eventually`, don't assert `count===0` immediately. - **HMR churn makes runs LIE** (cost ~4 cycles in #16-Q5): a page that loads while vite is still re-transforming just-edited modules gets a half-mounted app — @@ -1109,7 +1112,7 @@ drops the P2P session. - KNOWN failing suites in the localhost env (2026-07-28, proven identical across a full old-deps/new-deps baseline comparison — treat as the dirty baseline, not regressions): the drag-drop-SIMULATION cluster (explorer-drop, explorer, - packs-drop) + user-modules (setup crash), open-core-m1 (1 drawer check), + packs-drop) + user-modules (setup crash), dock-sidebar-inset, layout, panels, script-nodes, and a few two-peer timing suites (module-sdk, scene-music, physics-kinematic, physics-discoverability, roadmap-13-notifications-notes, scene-assets, @@ -1129,10 +1132,20 @@ drops the P2P session. suite in a PRISTINE sibling worktree on its OWN freshly started server and diffing the PASS/FAIL lines — the only A/B that means anything (see the day-lived-server trap): `flow-customnode-io` (1 check — "a stale snapshot cannot resurrect the pruned edge"), - `flow-object-embed` (`locator.dblclick` timeout). `open-core-m1`'s single drawer check - was re-confirmed on that same pair: 18 identical PASS/FAIL lines both sides. Two + `flow-object-embed` (`locator.dblclick` timeout). Two worktrees is what makes this cheap — you never touch the tree under test, so there is no stash to pop and no chance of the "restart fixed it" confound. +- **"PRE-EXISTING" IS A DIAGNOSIS ABOUT THE ENVIRONMENT, NOT A VERDICT ON THE CHECK — + AND `open-core-m1` SPENT TWO MONTHS ON THIS LIST BECAUSE OF THE DIFFERENCE.** Its one + red was A/B'd honestly every time (identical on pristine 1.14.0, 18 identical PASS/FAIL + lines across two worktrees) and every lane correctly moved on — but identical-on-base + only rules out YOUR diff. Here the check had simply been asserting a superseded + contract since the day after it was written: it opened the connect drawer with the + chevron and demanded the plugin's section, and the very next commit (4b7b8cf) made the + drawer TABBED and moved that mount behind the Rooms tab. `git log --follow` on the + suite against `git log` on the component answered it in one look. So when a red is + pre-existing, spend the two minutes asking WHEN it started and WHAT changed then; a + red nobody reads is a suite nobody reads. - Long full-suite runs: the Bash tool caps at 10 min — launch the runner DETACHED (PowerShell `Start-Process node -ArgumentList 'tests\e2e\run.cjs ...'` with output redirects) and poll/Monitor the log. A dev server started via the Bash diff --git a/tests/e2e/open-core-m1.test.cjs b/tests/e2e/open-core-m1.test.cjs index 50c86801..524fbcb0 100644 --- a/tests/e2e/open-core-m1.test.cjs +++ b/tests/e2e/open-core-m1.test.cjs @@ -21,6 +21,9 @@ h.run(async () => { h.check(def.hasProvider === false, 'M1a: no capability provider installed by default'); h.check(def.auth === null, 'M1b: no auth provider installed by default'); h.check((await A.page.locator('.cloud-slot').count()) === 0, 'M1d: no cloud UI mounted by default'); + // the Rooms shortcut on the Connect pill exists ONLY because a plugin mounted + // drawer content — which is what makes the Rooms-tab check further down non-vacuous. + h.check((await A.page.locator('#connect-rooms-button').count()) === 0, 'M1d: no Rooms affordance in the inert build'); // --- load the example plugin ------------------------------------------- await A.page.evaluate(() => localStorage.setItem('cloudPluginUrl', '/cloud-plugin-example.js')); @@ -62,10 +65,15 @@ h.run(async () => { }); h.check(v2.profile, 'PM: plugin installs a profile mount (mountProfile / profileSlot)'); h.check(v2.drawer, 'PM: plugin installs a Connect-drawer mount (mountConnectDrawer / drawerSlot)'); - // drawer mount renders in the DOM when the (i) drawer opens - await A.page.locator('[data-testid="connect-info-button"]').click(); + // The drawer mount renders on the drawer's ROOMS tab: batch CN (4b7b8cf) turned the + // info drawer into Info/Rooms/Toasts and moved the plugin's content behind the Rooms + // tab, which exists only when a plugin mounted some. The chevron deliberately opens + // on Info, so reach the mount the way the app offers it — the Rooms shortcut the + // Connect pill grows for exactly this (the inert check above pins that it is the + // plugin putting it there). + await A.page.locator('#connect-rooms-button').click(); await A.page.waitForTimeout(350); - h.check(await A.page.locator('#cloud-drawer-section').first().isVisible(), 'PM: drawer section renders in the open info drawer'); + h.check(await A.page.locator('#cloud-drawer-section').first().isVisible(), 'PM: drawer section renders on the drawer Rooms tab'); await A.page.mouse.click(10, 500); // close the drawer await A.page.waitForTimeout(350); From f4e96d108e53a11e584ef50d2631cb3912bb60b9 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 20 Sep 2026 12:18:59 +0300 Subject: [PATCH 2/7] [fix] knock: a body under an external physics hold is hit, not carried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib/physics.js: `bodyVelocityOf` reports `held` for a `user` hold only (the grab it was documented for) and adds the raw `hold` kind. `held: !!entry.hold` folded the EXTERNAL hold — the engine yielding to another writer (a module walking a body every frame, a peer's move stream) — into "somebody is carrying it". - src/lib/knock.js: `evaluateProbe` still skips a carried body; on the initiator `fireKnock` treats a body that refused the impulse because it is DRIVEN (`hold === 'external'`) as a real hit — logged and sent like every other, the impulse alone refused (the next write would erase it) — which is the rule the receive side already keeps for a held crate (noteRemoteHit logs, applyHit refuses, independently). A body nobody drives that still refuses (gone, not dynamic) sends and spends nothing, as before. - Found by the waves template (29-F): its enemies are dynamic bodies the waves module walks each frame and its damage source is `hit`. On the one peer stepping the world every sweep read 0 hits with the body at `hold: 'external'` (a solo probe, eight sweeps), while a non-initiator (no body, `held: false`) hit them fine — the host could not damage a walking enemy. Counterfactual (game-waves against the staged scene): `held: !!entry.hold` restored -> 58/25, red from 4.1/4.2 (the initiator's slow knock reads 0 hits) and 4.3/4.4 (nothing for B to log) through the whole round (6.1-6.12); fix restored, physics.js identical. knock-physics 1.15 (a CARRIED body is never knocked) held in the same battery. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/knock.js | 23 +++++++++++++++++------ src/lib/physics.js | 10 +++++++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/lib/knock.js b/src/lib/knock.js index 32d551a7..0775625a 100644 --- a/src/lib/knock.js +++ b/src/lib/knock.js @@ -201,13 +201,13 @@ function bodyVelocity(uuid) { if (exact) { _bodyVel.fromArray(exact.linvel); _bodyAng.fromArray(exact.angvel); - return { linvel: _bodyVel, angvel: _bodyAng, held: exact.held }; + return { linvel: _bodyVel, angvel: _bodyAng, held: exact.held, hold: exact.hold }; } const ring = bodyTracks.get(uuid); if (ring && ring.length >= 2) _bodyVel.copy(velocityFromSamples(ring).linvel); else _bodyVel.set(0, 0, 0); _bodyAng.set(0, 0, 0); - return { linvel: _bodyVel, angvel: _bodyAng, held: false }; + return { linvel: _bodyVel, angvel: _bodyAng, held: false, hold: null }; } /** @param {string} id @param {number} radius */ @@ -262,7 +262,9 @@ function evaluateProbe(probe, now, group, dyn) { if (!dyn.has(uuid) || held.has(uuid)) continue; const bounds = boundsOf(object); const body = bodyVelocity(uuid); - if (body.held) continue; // somebody is carrying it: knocking it would fight their hold + // somebody is carrying it: knocking it would fight their hold. A body under an + // EXTERNAL hold (driven by a module or a peer's stream) is NOT skipped — see fireKnock + if (body.held) continue; const contact = contactOf(pPos, probe.radius, pVel, bounds.centre, bounds.radius, body.linvel); const may = cooldownStep(probe, uuid, contact.overlap, now); if (!contact.overlap) continue; @@ -291,8 +293,10 @@ function evaluateProbe(probe, now, group, dyn) { } /** - * The hit leaves here. Initiator: into the body first, and a refusal (held, gone) - * sends nothing and spends nothing. Otherwise: onto the wire, predicted locally when + * The hit leaves here. Initiator: into the body first, and a refusal (carried, gone) + * sends nothing and spends nothing — except a DRIVEN body (an `external` hold: a module + * walking it, a peer's move stream), which refuses the impulse and is still logged and + * sent, because the hand did hit it. Otherwise: onto the wire, predicted locally when * the block says so. Either way it is logged HERE too — the sender never receives * its own broadcast. * @param {import('./knockMath').Probe} probe @param {any} object @param {number} speed @@ -321,7 +325,14 @@ function fireKnock(probe, object, speed, point, response) { probe: probe.id }; if (isInitiator()) { - if (!applyHit(hit)) return false; + // A body under an EXTERNAL hold is DRIVEN: the impulse is refused (the next write + // would erase it) but the hit is real, so it is logged and sent like every other — + // the rule the receive side already keeps for a held crate (noteRemoteHit logs, + // applyHit refuses, independently). Only a body nobody drives that still refuses + // (gone, not dynamic) sends nothing and spends nothing. 29-F: the waves template's + // walkers were unhittable on the one peer that steps the world and hittable from + // every other, which read as "knocks land at random". + if (!applyHit(hit) && bodyVelocityOf(object.uuid)?.hold !== 'external') return false; } else if (get(sceneKnock).predict) { startPrediction(object, response.linvel); } diff --git a/src/lib/physics.js b/src/lib/physics.js index 0c278a50..4e4ade6f 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -1280,9 +1280,13 @@ export function applyHit(data) { * A1: a dynamic body's EXACT velocity, for the knock's approach test on the peer that * steps the world. Null off the initiator (there is no body) — knock.js then falls * back to its own estimate off the poses it renders. `held` lets the probe skip a body - * somebody is carrying without a second lookup. + * somebody is CARRYING (a `user` hold) without a second lookup; `hold` is the raw kind, + * because an `external` hold is a different thing — a body some other writer drives (a + * module walking it every frame, a peer's move stream) is not carried, and a hand that + * meets it at speed has hit it. 29-F: `held: !!entry.hold` folded the two together, so + * the waves template's walkers could not be knocked on the one peer that steps the world. * @param {string} uuid - * @returns {{linvel: number[], angvel: number[], held: boolean} | null} + * @returns {{linvel: number[], angvel: number[], held: boolean, hold: 'user'|'external'|null} | null} */ export function bodyVelocityOf(uuid) { if (!world) return null; @@ -1290,7 +1294,7 @@ export function bodyVelocityOf(uuid) { if (!entry) return null; const l = entry.body.linvel(); const a = entry.body.angvel(); - return { linvel: [l.x, l.y, l.z], angvel: [a.x, a.y, a.z], held: !!entry.hold }; + return { linvel: [l.x, l.y, l.z], angvel: [a.x, a.y, a.z], held: entry.hold === 'user', hold: entry.hold }; } const FIXED_DT = 1 / 60; From 8897358a9371fa60dcaba14aeeb822c5262a86f4 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 20 Sep 2026 12:18:59 +0300 Subject: [PATCH 3/7] [feat] 29-F: the waves template def in MODULE_DEFS and the game-waves suite - scripts/author-templates.cjs: `waves` joins MODULE_DEFS (modules/waves/waves.def.json, emitted by `npm run build:waves`; both modules in installModules, so the file's derived requirement list names health + waves). Built against the lane at 5215; byte-stable across two builds (compare-authored SAME). Staged for the scenes repo in cloud-lane-29-staging/games/waves/ (scene 11701 B + thumb 1690 B + index-row + README with the release order and the #230 ref note). - tests/e2e/game-waves.test.cjs (90 checks; two peers + a late joiner; skip-never-fail): the card is picked in the REAL Games tab (the feed's index.json + scene served through a route, since the row is not released) and loadRemoteScene replaces a non-empty world; the file's 10 objects, both modules, the sunset env, the play block (grab/grounded/simOnPlay) and the knock block; the modules derive 4 enemies on a 3-wave curve, a goal, three spawn pads, five health rows; B over the handshake; Start -> playing on both -> the HUD screen; the run is ON, the first enemy WALKS (+z) and B places it where A does; HUD Text reads the Waves Value nodes and the bar the player's health; kills are KNOCKS through core's feedProbe (a slow sweep = one pulse, a hard one = three), the hit in B's hit log and B's ledger agreeing, B's knock (a non-initiator) completing wave 1, the kills rows per peer, wave 2 healing the survivors one death's worth; the late joiner reads wave 2 and the same ledger from the triggers handshake; waves 2 and 3 fall one enemy at a time -> DONE on all three -> over (won) -> the over screen -> ONE run logged on every peer; Again -> a new round with every enemy back at full health. - .claude/skills/e2e-verify/SKILL.md: the suite in the GAMES-TAB line. The suite reads core's own SCENES_BASE fallback out of sceneTemplates.js, so the #230 ref move follows by construction. Counterfactuals: the def's env preset `dusk` (not a core preset; PR #8's def) -> 1.6 red (studio), fixed to `sunset` on modules feat/29f-waves-template (def-only, module.js and the zip unchanged); `waves` out of MODULE_DEFS -> the build refuses the slug and the suite SKIPs (no scene to test); the knock fix reverted -> the initiator's knocks 4.1/4.2/4.5/4.6 red (its own commit). Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/e2e-verify/SKILL.md | 7 +- scripts/author-templates.cjs | 2 +- tests/e2e/game-waves.test.cjs | 426 +++++++++++++++++++++++++++++ 3 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/game-waves.test.cjs diff --git a/.claude/skills/e2e-verify/SKILL.md b/.claude/skills/e2e-verify/SKILL.md index ba77100a..3fc46de5 100644 --- a/.claude/skills/e2e-verify/SKILL.md +++ b/.claude/skills/e2e-verify/SKILL.md @@ -141,8 +141,11 @@ sibling modules checkout, every one SKIPS-never-fails when a source is missing): `game-towers` (20), `game-stars-room` (36), `game-football` (102, two peers + late joiner; FOOTBALL_TPSCENE / FOOTBALL_ZIP), `game-dungeon-realms` (64, two peers + late joiner, TWO zips: DUNGEON_REALMS_TPSCENE / DUNGEON_KIT_ZIP / DUNGEON_REALMS_ZIP / MODULES_REPO), `game-untangle` -(46; UNTANGLE_TPSCENE / UNTANGLE_ZIP). Before a scenes row is released, point the TPSCENE env -at the staged file. The SESSIONS line (roadmap 22 R4-R6): `session-scenes` (36, three peers), +(46; UNTANGLE_TPSCENE / UNTANGLE_ZIP), `game-waves` (90, two peers + late joiner, TWO zips: +WAVES_TPSCENE / HEALTH_ZIP / WAVES_ZIP / MODULES_REPO — it picks the card in the REAL Games tab +through a routed feed, kills enemies with core's feedProbe, and reads core's own SCENES_BASE +fallback out of sceneTemplates.js so the #230 ref move follows by construction). Before a scenes +row is released, point the TPSCENE env at the staged file. The SESSIONS line (roadmap 22 R4-R6): `session-scenes` (36, three peers), `scene-rename` (46), `scene-duplicate` (29). `embed-boot` (20) = the `?embed=1` flag. ## Assertion discipline (a check that cannot fail is not a check) diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index d5b4d8d6..44596ee1 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -1162,7 +1162,7 @@ const BEAT_DEF = { // `thumb.sceneGroups: ['dungeon-module']` to get it onto the card // untangle — 21-C C7: the thin template (pose + level + room + HUD + graph); its // board is scene-root content too (`thumb.sceneGroups: ['untangle-module']`) -const MODULE_DEFS = ['football', 'dungeon-realms', 'untangle']; +const MODULE_DEFS = ['football', 'dungeon-realms', 'untangle', 'waves']; const DEFS = [ { diff --git a/tests/e2e/game-waves.test.cjs b/tests/e2e/game-waves.test.cjs new file mode 100644 index 00000000..9755d98f --- /dev/null +++ b/tests/e2e/game-waves.test.cjs @@ -0,0 +1,426 @@ +// 29-F ACCEPTANCE — the Waves game template: wave survival composed from TWO modules +// (`health` holds the hit points, `waves` derives the wave from the enemies' counters and +// walks the living ones at the goal) on the REAL artefacts and nothing authored in-test: +// the scene — games/waves/scene.tpscene from the scenes FEED (core's own SCENES_BASE +// fallback, read out of src/lib/sceneTemplates.js so a ref move follows by +// construction — #230), or WAVES_TPSCENE=, or a sibling scenes checkout +// the zips — health.zip + waves.zip: HEALTH_ZIP / WAVES_ZIP, the MODULES_REPO checkout, +// a packed sibling modules checkout, or the modules CDN +// installed on TWO peers plus a LATE JOINER. Skip-never-fail when a source is missing. +// +// What it proves: +// 1 THE GAMES TAB: the card is picked in the real Templates modal (the feed's index.json +// and the scene are served through a route so the row exists before its release) and +// loadRemoteScene replaces the world — 10 objects, both modules in the requirement +// list, the sunset env, the play block (grab, grounded, sim on play), the knock block +// ON, the menu screen, and the modules' derivations: 4 enemies on a 3-wave curve, a +// goal, three spawn points, five health rows +// 2 B receives it over the handshake: same uuids, the same derivation, the HUD document +// 3 play: Start -> the shell is playing on both -> the HUD screen; the run is ON (wave 1 +// uses two of the four enemies), the first enemy WALKS toward the goal (+z) and B +// places it where A does; the parked enemies stand where the file put them; the HUD +// Text elements read the Waves Value nodes and the bar reads the player's health +// 4 kills are KNOCKS (the template's damage source is `hit`, scaled by speed): a slow +// sweep is one pulse (3 -> 2), a hard one three (dead) — core's feedProbe, the code a +// hand runs — the hit lands in B's hit log and B's ledger agrees (its own local pulses +// off the replicated `hit`); B's knock (a non-initiator) completes wave 1; the kills +// rows are per peer; after the interval wave 2 heals the survivors (3 alive) +// 5 the late joiner: C reads WAVE 2 from the counters in the triggers handshake, the same +// ledger, and the game shell +// 6 the last waves fall on A's knocks -> DONE on all three -> `over` +// -> Set Game State (outcome won) -> the over screen; ONE run logged on every peer; +// Again -> a new round with every enemy back at full health +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +/** core's own feed base — the fallback literal in sceneTemplates.js (#230: the ref is + * moving off `v2`, and a suite that pins its own copy would be the next stale reader) */ +function coreScenesBase() { + try { + const src = fs.readFileSync(path.resolve(__dirname, '../../src/lib/sceneTemplates.js'), 'utf8'); + const m = src.match(/SCENES_BASE = contentBase\([^,]+,\s*'([^']+)'\)/); + return m ? m[1] : null; + } catch { + return null; + } +} +const SCENES_BASE = (process.env.WAVES_SCENES_BASE || coreScenesBase() || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@main').replace(/\/$/, ''); +const MODULES_BASE = (process.env.WAVES_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); +const ROOT = path.resolve(__dirname, '../../..'); +const ENEMIES = ['Enemy 1', 'Enemy 2', 'Enemy 3', 'Enemy 4']; +const OBJECTS = ['Ground', 'Goal', 'Spawn 1', 'Spawn 2', 'Spawn 3', ...ENEMIES, 'Home']; +const ENEMY_HP = 3; +const INTERVAL_S = 3; + +/** bytes from a URL, or null with the reason logged (never throws) */ +async function fetchBytes(url) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(20000) }); + if (!res.ok) { + console.log(' (source) ' + url + ' -> HTTP ' + res.status); + return null; + } + return Buffer.from(await res.arrayBuffer()); + } catch (error) { + console.log(' (source) ' + url + ' -> ' + error.message); + return null; + } +} +/** the scene: env override -> the feed -> a sibling scenes checkout */ +async function sceneBytes() { + if (process.env.WAVES_TPSCENE) { + const p = process.env.WAVES_TPSCENE; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const feed = await fetchBytes(SCENES_BASE + '/games/waves/scene.tpscene'); + if (feed) return { bytes: feed, from: SCENES_BASE }; + for (const dir of ['theprototype.app-scenes', 'scenes']) { + const p = path.join(ROOT, dir, 'games/waves/scene.tpscene'); + if (fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + } + return null; +} +/** a module zip: env override -> MODULES_REPO -> a packed sibling modules checkout -> the CDN + * @param {string} id @param {string} envKey */ +async function zipBytes(id, envKey) { + if (process.env[envKey]) { + const p = process.env[envKey]; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const local = []; + if (process.env.MODULES_REPO) local.push(path.join(process.env.MODULES_REPO, id + '.zip')); + local.push(h.moduleZipPath(id)); + for (const dir of fs.readdirSync(ROOT)) if (/^(theprototype\.app-)?modules/.test(dir)) local.push(path.join(ROOT, dir, id + '.zip')); + for (const p of local) if (p && fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + const cdn = await fetchBytes(MODULES_BASE + '/' + id + '.zip'); + return cdn ? { bytes: cdn, from: MODULES_BASE } : null; +} +/** install zip bytes through the REAL manager (the helper only knows one folder) */ +async function installZip(peer, id, bytes, label) { + await peer.page.evaluate(() => window.__stores.modulesOpen.set(true)); + await peer.page.waitForTimeout(400); + await peer.page.getByRole('tab', { name: /^User/ }).click(); + await peer.page.waitForTimeout(200); + await peer.page.locator('#install-module-zip').setInputFiles({ name: id + '.zip', mimeType: 'application/zip', buffer: bytes }); + await h.eventually( + () => peer.page.evaluate(() => window.__stores.moduleSDK.loadedModules.map((m) => m.id)), + (ids) => ids.includes(id), + label + ': ' + id + ' installed from the real zip', + 20000 + ); + await peer.page.evaluate(() => window.__stores.modulesOpen.set(false)); + await peer.page.waitForTimeout(300); +} + +// ---- page probes ------------------------------------------------------------------------- + +/** the waves module's derivation of the ONE Waves node (its debug hook) */ +const snap = (page) => page.evaluate(() => window.__waves?.snapshot()[0] ?? null); +/** the health module's rows: hp per target */ +const healthSnap = (page) => page.evaluate(() => window.__health?.snapshot() ?? []); +/** name -> uuid for every object in the replicated group */ +const namesOf = (page) => + page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return Object.fromEntries((g?.children ?? []).map((c) => [c.name, c.uuid])); + }); +const posOf = (page, uuid) => + page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return g?.getObjectByProperty('uuid', uuid)?.position.toArray() ?? null; + }, uuid); +const gameOf = (page) => + page.evaluate(() => { + let g; + window.__stores.gameState.gameState.subscribe((v) => (g = v))(); + return { state: g?.state ?? null, outcome: g?.outcome ?? '' }; + }); +const gameStateOf = async (page) => (await gameOf(page)).state; +const screenOf = (page) => page.evaluate(() => window.__stores.hudDocs.visibleScreen('scene')?.id ?? null); +const hudText = async (page) => (await page.locator('#hud-layer').textContent().catch(() => '')) ?? ''; +const hudRuntime = (page) => + page.evaluate(() => { + let r; + window.__stores.hudDocs.hudRuntime.subscribe((v) => (r = v))(); + return r ?? {}; + }); +const simOf = (page) => + page.evaluate(() => { + const p = window.__stores.physics; + let own, remote; + p.simulating.subscribe((v) => (own = v))(); + p.remoteSimulating.subscribe((v) => (remote = v))(); + return { own: !!own, remote: remote ?? null }; + }); +const hudButton = (page, name) => page.getByRole('button', { name, exact: true }); +const myVar = (page, name) => page.evaluate((n) => window.__stores.peerVars.myPeerVar(n, null), name); +const lastHitOf = (page, uuid) => page.evaluate((u) => window.__stores.knock.hitLogSnapshot().last[u]?.at ?? null, uuid); + +/** a knock probe swept along +x through the enemy's CURRENT position at `speed` m/s — + * core's feedProbe, the same code a hand (or the desktop head probe) runs. The sweep is + * one synchronous loop, so the enemy the module walks every frame holds still under it. */ +const knock = (page, uuid, speed) => + page.evaluate( + ({ uuid, speed }) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g?.getObjectByProperty('uuid', uuid); + if (!o) return { hits: 0, armed: false }; + const k = window.__stores.knock; + const id = 'gw-' + Math.random().toString(36).slice(2, 7); + k.dropProbe(id); + const [bx, by, bz] = o.position.toArray(); + let hits = 0; + let armed = true; + let t = 1000; + const step = (speed * 16) / 1000; + for (let x = bx - 1.2; x <= bx + 0.05; x += step) { + const r = k.feedProbe(id, [x, by, bz], t); + hits += r.hits; + armed = armed && r.armed; + t += 16; + } + k.dropProbe(id); + return { hits, armed }; + }, + { uuid, speed } + ); +/** a knock that cannot miss: up to three sweeps, the first that lands wins */ +async function knockUntil(page, uuid, speed, label) { + let last = null; + for (let attempt = 0; attempt < 3; attempt++) { + last = await knock(page, uuid, speed); + if (last.hits >= 1) break; + await page.waitForTimeout(250); + } + h.check(!!last && last.hits >= 1 && last.armed, label + ' (hits ' + (last?.hits ?? 0) + ', armed ' + (last?.armed ?? false) + ')'); + return last; +} +const hpOf = async (page, uuid) => (await healthSnap(page)).find((s) => s.uuid === uuid)?.hp ?? null; + +h.run(async () => { + const scene = await sceneBytes(); + if (!scene) { + console.log('SKIP: games/waves/scene.tpscene unreachable (feed ' + SCENES_BASE + ', no WAVES_TPSCENE, no sibling scenes checkout)'); + return; + } + const healthZip = await zipBytes('health', 'HEALTH_ZIP'); + const wavesZip = await zipBytes('waves', 'WAVES_ZIP'); + if (!healthZip || !wavesZip) { + console.log('SKIP: health.zip / waves.zip unreachable (no env override, no MODULES_REPO, no packed sibling checkout, CDN ' + MODULES_BASE + ')'); + return; + } + console.log(' scene from ' + scene.from + ' (' + scene.bytes.length + ' bytes)'); + console.log(' health from ' + healthZip.from + ' (' + healthZip.bytes.length + ' bytes), waves from ' + wavesZip.from + ' (' + wavesZip.bytes.length + ' bytes)'); + + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); + const B = await h.setupPage(browser, 'B'); + for (const peer of [A, B]) { + await installZip(peer, 'health', healthZip.bytes, peer === A ? 'A' : 'B'); + await installZip(peer, 'waves', wavesZip.bytes, peer === A ? 'A' : 'B'); + } + + // ---- 1. THE GAMES TAB ------------------------------------------------------------------------ + // the feed is served through a route: the real index shape with the waves row, and the + // scene bytes this run resolved — so the card exists in the modal before the row is + // released, and the SAME path a user takes (pickEntry -> loadRemoteScene) is what loads it + await A.page.route('**/cdn.jsdelivr.net/**', (route) => { + const url = route.request().url(); + if (!url.includes('/theprototype-app/scenes@')) return route.continue(); + if (url.endsWith('/index.json')) + return route.fulfill({ + json: { + version: 2, + templates: [], + examples: [], + games: [ + { + slug: 'waves', + title: 'Waves', + description: 'Wave survival: hold the goal against waves of enemies walking in from the spawn points.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['vr', 'co-op', 'survival'], + modules: [ + { id: 'health', version: '1.0.0' }, + { id: 'waves', version: '1.0.0' } + ], + bytes: scene.bytes.length, + scene: 'games/waves/scene.tpscene', + thumb: 'games/waves/thumb.webp' + } + ] + } + }); + if (url.includes('games/waves/scene.tpscene')) return route.fulfill({ body: scene.bytes, contentType: 'application/zip' }); + if (url.endsWith('.webp')) return route.fulfill({ status: 404 }); + return route.continue(); + }); + // the counterfactual premise: the world is NOT empty before the card is picked + await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 1200)); + window.__stores.objectActions.deselectObject(); + }); + const before = await namesOf(A.page); + h.check(Object.keys(before).length === 1, '1.0 (premise) one box stands before the card is picked'); + await A.page.evaluate(() => window.__stores.templatesModalOpen.set(true)); + await A.page.waitForTimeout(400); + await A.page.locator('#templates-tab-games').click(); + const card = A.page.locator('#templates-modal .tpl-card', { hasText: 'Waves' }); + await h.eventually(() => card.count(), (n) => n === 1, '1.1 the Games tab lists the Waves card', 15000); + const needs = (await A.page.locator('#templates-modal .tpl-needs[data-needs="waves"]').textContent().catch(() => '')) ?? ''; + h.check(/health/i.test(needs) && /waves/i.test(needs), '1.2 the card names both modules it needs (' + needs.trim() + ')'); + await card.click(); + await h.eventually(() => namesOf(A.page).then((n) => Object.keys(n)), (n) => n.length === 10 && !n.includes('Box'), '1.3 picking the card loads the scene: the box is gone, 10 objects stand', 30000); + await A.page.waitForTimeout(1500); + const names = await namesOf(A.page); + h.check(OBJECTS.every((n) => !!names[n]), '1.4 the ground, the goal, three spawn pads, four enemies and the home pad (' + Object.keys(names).join(', ') + ')'); + const modules = await A.page.evaluate(() => { + let m; + window.__stores.moduleRequirements.sceneModules.subscribe((v) => (m = v))(); + return (m ?? []).map((e) => e.id); + }); + h.check(modules.includes('health') && modules.includes('waves'), '1.5 the file\'s requirement list names BOTH modules (' + modules.join(',') + ')'); + const env = await A.page.evaluate(() => { + let e; + window.__stores.environment.environment.subscribe((v) => (e = v))(); + return e?.preset ?? null; + }); + h.check(env === 'sunset', '1.6 the sunset environment preset (' + env + ')'); + const phys = await A.page.evaluate(() => window.__stores.scenePhysics.scenePhysicsDebug()); + h.check(phys.play?.interaction === 'grab' && phys.play?.grounded === true && phys.play?.simOnPlay === true, '1.7 play block: grab, grounded, sim on play (' + JSON.stringify(phys.play) + ')'); + h.check(phys.knock?.enabled === true && phys.knock?.maxSpeed === 10, '1.8 the knock block is ON (a hand knocks an enemy) (' + JSON.stringify(phys.knock) + ')'); + h.check((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.9 the game shell starts in menu with the menu screen'); + h.check(/WAVES/.test(await hudText(A.page)) && /Start/.test(await hudText(A.page)), '1.10 the menu renders its title and Start'); + await h.eventually(() => snap(A.page), (s) => !!s && s.enemies.length === 4 && s.wave === 1 && !s.running && !!s.goal && s.spawns === 3 && s.waves === 3, '1.11 waves derives from the file: 4 enemies, wave 1 of 3, idle, a goal, three spawn points', 10000); + const a1 = await snap(A.page); + h.check(a1.enemies.map((e) => e.label).join() === ENEMIES.join(), '1.12 enemy order is by name (' + a1.enemies.map((e) => e.label).join(', ') + ')'); + await h.eventually(() => healthSnap(A.page), (all) => all.length === 5 && all.filter((s) => s.scope === 'object').every((s) => s.hp === ENEMY_HP && !s.dead), '1.13 health derives five rows: four enemies at ' + ENEMY_HP + ' hp and the player'); + const order = a1.enemies.map((e) => e.uuid); + h.check(order.every((u, i) => names[ENEMIES[i]] === u), '1.14 the health targets are the four Enemy objects by uuid'); + + // ---- 2. B receives it over the handshake ------------------------------------------------------ + await h.connect(A, B); + await h.eventually(() => namesOf(B.page).then((n) => ({ uuids: Object.values(n), count: Object.keys(n).length })), (v) => v.count === 10 && order.every((u) => v.uuids.includes(u)), '2.1 B received the 10 objects with the same enemy uuids', 30000); + await h.eventually(() => snap(B.page), (s) => !!s && s.enemies.length === 4 && s.wave === 1 && !s.running && s.spawns === 3 && s.enemies.map((e) => e.uuid).join() === order.join(), '2.2 B: the graph replicated and its Waves node derives the SAME arena', 20000); + await h.eventually(() => healthSnap(B.page), (all) => all.length === 5, '2.3 B: five health rows'); + await h.eventually(() => screenOf(B.page), (v) => v === 'menu', '2.4 B: the HUD document arrived (menu screen)', 10000); + + // ---- 3. play: Start -> the shell -> the run is on, the enemies walk --------------------------- + await A.page.locator('#play-button').click(); + await h.eventually(() => simOf(A.page), (v) => v.own === true, '3.1 A simulates on entering play (simOnPlay)', 15000); + await B.page.locator('#play-button').click(); + await A.page.waitForTimeout(500); + await hudButton(A.page, 'Start').click(); + await h.eventually(() => gameStateOf(A.page), (v) => v === 'playing', '3.2 Start flips the shell to playing on A'); + await h.eventually(() => gameStateOf(B.page), (v) => v === 'playing', '3.3 ...and on B (the replicated state)'); + await h.eventually(() => screenOf(A.page), (v) => v === 'hud', '3.4 A sees the HUD screen', 6000); + await h.eventually(() => snap(A.page), (s) => s?.running && s.started && s.wave === 1 && s.size === 2 && s.alive === 2, '3.5 the run is ON: wave 1 uses two of the four enemies, both alive', 10000); + await h.eventually(() => snap(B.page), (s) => s?.running && s.started && s.wave === 1 && s.alive === 2, '3.6 B agrees', 10000); + const p0 = await posOf(A.page, order[0]); + await A.page.waitForTimeout(1500); + const p1 = await posOf(A.page, order[0]); + h.check(!!p0 && !!p1 && p1[2] > p0[2] + 1, '3.7 the first enemy walks toward the goal (+z): ' + p0?.[2].toFixed(1) + ' -> ' + p1?.[2].toFixed(1)); + const pA = await posOf(A.page, order[0]); + const pB = await posOf(B.page, order[0]); + h.check(!!pA && !!pB && Math.abs(pA[2] - pB[2]) < 1.0, '3.8 B places it where A does (same stamp, same clock): ' + pA?.[2].toFixed(1) + ' vs ' + pB?.[2].toFixed(1)); + const p3 = await posOf(A.page, order[3]); + h.check(!!p3 && Math.abs(p3[2] - -8) < 0.3, '3.9 the fourth enemy is not in wave 1: parked where the file put it (z ' + p3?.[2].toFixed(1) + ')'); + await h.eventually(() => hudRuntime(A.page), (r) => r['wv-wave']?.text === 'Wave 1' && r['wv-left']?.text === '2 left', '3.10 HUD Text reads the Waves Value nodes: Wave 1, 2 left'); + await h.eventually(() => hudRuntime(A.page), (r) => Math.abs((r['wv-hp']?.value ?? 0) - 1) < 0.01, '3.11 the HUD bar reads the player\'s health (full)'); + + // ---- 4. kills are knocks: speed-scaled pulses, both peers, the wave advances ------------------- + const hitBefore = await lastHitOf(B.page, order[0]); + await knockUntil(A.page, order[0], 1, '4.1 A sweeps a SLOW probe through Enemy 1'); + await h.eventually(() => hpOf(A.page, order[0]), (v) => v === ENEMY_HP - 1, '4.2 a slow knock is ONE pulse: ' + ENEMY_HP + ' -> ' + (ENEMY_HP - 1)); + await h.eventually(() => lastHitOf(B.page, order[0]), (t) => t !== null && t !== hitBefore, '4.3 the hit landed in B\'s hit log (the replicated `hit`)'); + await h.eventually(() => hpOf(B.page, order[0]), (v) => v === ENEMY_HP - 1, '4.4 B\'s ledger agrees (its own local pulse off the same hit)'); + await knockUntil(A.page, order[0], 9, '4.5 A sweeps a HARD probe through Enemy 1'); + await h.eventually(() => snap(A.page), (s) => s?.alive === 1 && s.enemies[0].kills === 1, '4.6 a hard knock is three pulses: Enemy 1 is dead, one left in wave 1'); + await h.eventually(() => myVar(A.page, 'kills'), (v) => v === 1, '4.7 A\'s own kills row reads 1 (the killing blow was its hand)'); + await h.eventually(() => hudRuntime(A.page), (r) => r['wv-left']?.text === '1 left', '4.8 HUD Text: 1 left'); + await knockUntil(B.page, order[1], 9, '4.9 B (not the initiator) sweeps a hard probe through Enemy 2'); + await h.eventually(() => snap(A.page), (s) => s?.completed === 1, '4.10 wave 1 is complete on A (B\'s hit replicated, A fired its own pulses)', 10000); + await h.eventually(() => myVar(B.page, 'kills'), (v) => v === 1, '4.11 B\'s own kills row reads 1'); + h.check((await myVar(A.page, 'kills')) === 1, '4.12 ...and A\'s is still 1: one writer per row'); + await h.eventually(() => snap(A.page), (s) => s?.wave === 2 && s.started && s.alive === 3, '4.13 after the interval: wave 2, three alive (the survivors healed with local pulses)', (INTERVAL_S + 6) * 1000); + await h.eventually(() => snap(B.page), (s) => s?.wave === 2 && s.started && s.alive === 3, '4.14 B: wave 2 too', 10000); + // the slow knock's one pulse plus the hard knock's three is FOUR hits; wave 2 heals one + // death's worth (3), so the survivor comes back at 2 — heals bank per death, they do not + // top up (the health module's documented rule) + await h.eventually(() => snap(A.page), (s) => s && s.enemies[0].hits === ENEMY_HP + 1 && s.enemies[0].heals === ENEMY_HP && s.enemies[0].hp === ENEMY_HP - 1 && s.enemies[3].hits === 0, '4.15 the ledger: Enemy 1 has ' + (ENEMY_HP + 1) + ' hits and ' + ENEMY_HP + ' heals (hp ' + (ENEMY_HP - 1) + ', the overkill pulse counted), Enemy 4 untouched'); + await h.eventually(() => hudRuntime(B.page), (r) => r['wv-wave']?.text === 'Wave 2' && r['wv-left']?.text === '3 left', '4.16 B\'s HUD Text: Wave 2, 3 left'); + + // ---- 5. the late joiner ------------------------------------------------------------------------ + await A.page.keyboard.press('Escape'); + await A.page.waitForTimeout(500); + const C = await h.setupPage(browser, 'C'); + await installZip(C, 'health', healthZip.bytes, 'C'); + await installZip(C, 'waves', wavesZip.bytes, 'C'); + await h.connect(C, A); + await A.page.locator('#play-button').click(); + await h.eventually(() => namesOf(C.page).then((n) => Object.keys(n).length), (n) => n === 10, '5.1 C received the arena', 30000); + await h.eventually(() => snap(C.page), (s) => !!s && s.wave === 2 && s.enemies.length === 4 && s.enemies[0].hits === ENEMY_HP + 1 && s.enemies[0].heals === ENEMY_HP, '5.2 C reads WAVE 2 with the same ledger — the counts arrived in the triggers handshake', 30000); + await h.eventually(() => gameStateOf(C.page), (v) => v === 'playing', '5.3 C: the game shell reads playing'); + h.check((await myVar(C.page, 'kills')) === null, '5.4 the joiner has no kills row yet'); + await C.page.locator('#play-button').click(); + await h.eventually(() => hudRuntime(C.page), (r) => r['wv-wave']?.text === 'Wave 2', '5.5 C\'s HUD Text reads Wave 2', 10000); + + // ---- 6. the last waves, over, the log, Again ------------------------------------------------------ + // (the template wires no pause: P is the editor's sim toggle and does nothing to the run) + // wave 2 uses three enemies; wave 3 all four. A's hand clears both — ONE enemy at a + // time, as early in the wave as it can (the walkers converge on the goal and, held + // kinematic, stand co-located there, where one sweep hits every body it overlaps — + // run 2 measured hits 3 on a sweep aimed at one), waiting for each to fall and skipping + // any a shared sweep already took, and never swinging again until the next wave is ON + // (a knock in the interval lands on a body the next wave is about to heal). + const clearWave = async (n, indices, label) => { + // the heals into a new wave land a tick or two after it starts (local pulses, counters + // republished ~6/s): swing only once every enemy the wave uses reads healed, or a body + // still at 0 from the last wave is skipped as "down" + await h.eventually(() => snap(A.page), (s) => s?.wave === n && s.started && indices.every((i) => s.enemies[i].hp > 0), label + ' (premise) wave ' + n + ' is on and its enemies are healed', 12000); + for (const i of indices) { + const before = await snap(A.page); + if (before.completed >= n) break; + if (before.enemies[i].hp <= 0) continue; + const kills = before.enemies[i].kills; + await knockUntil(A.page, order[i], 9, label + ' A knocks down ' + ENEMIES[i] + ' (wave ' + n + ')'); + await h.eventually(() => snap(A.page), (s) => s.enemies[i].kills > kills || s.completed >= n, label + ' ...' + ENEMIES[i] + ' is down', 8000); + } + await h.eventually(() => snap(A.page), (s) => s?.completed >= n, label + ' wave ' + n + ' is complete', 8000); + }; + await clearWave(2, [0, 1, 2], '6.1'); + await h.eventually(() => snap(A.page), (s) => s?.wave === 3 && s.started && s.alive === 4, '6.2 wave 3: all four alive', (INTERVAL_S + 8) * 1000); + await h.eventually(() => snap(C.page), (s) => s?.wave === 3 && s.alive === 4, '6.3 C: wave 3, four alive', 10000); + await clearWave(3, [0, 1, 2, 3], '6.4'); + await h.eventually(() => snap(A.page), (s) => s?.done === true && s.alive === 0, '6.5 the last enemy falls: DONE on A', 10000); + await h.eventually(() => snap(B.page), (s) => s?.done === true, '6.6 done on B', 8000); + await h.eventually(() => snap(C.page), (s) => s?.done === true, '6.7 done on C', 8000); + await h.eventually(() => gameOf(A.page), (g) => g.state === 'over' && g.outcome === 'won', '6.8 `over` -> Set Game State: the shell is OVER (won) on A', 8000); + await h.eventually(() => gameOf(B.page), (g) => g.state === 'over' && g.outcome === 'won', '6.9 ...and on B'); + await h.eventually(() => gameOf(C.page), (g) => g.state === 'over' && g.outcome === 'won', '6.10 ...and on C'); + await h.eventually(() => screenOf(A.page), (v) => v === 'over', '6.11 A sees the over screen', 6000); + h.check(/ARENA CLEARED/.test(await hudText(A.page)) && /Again/.test(await hudText(A.page)), '6.12 the over screen renders its title and Again'); + await h.eventually(() => snap(A.page), (s) => s?.log.length === 1 && s.log[0].cleared && s.log[0].waves === 3 && s.log[0].reached === 3, '6.13 ONE run logged in gameState.vars: cleared, 3 waves', 8000); + const logA = (await snap(A.page)).log[0]; + const kills = [await myVar(A.page, 'kills'), await myVar(B.page, 'kills'), await myVar(C.page, 'kills')].map((v) => (v === null ? 'none' : String(v))); + // nine kills in a round (2 + 3 + 4); B's hand took one, C's none, so A's took the rest — + // however many bodies one of A's sweeps caught at the goal, every kill is credited to a hand + h.check(kills.join() === '8,1,none', '6.14 kills rows: A 8, B 1, C none — each peer credited only its own blows (' + kills.join() + ')'); + await h.eventually(() => snap(B.page), (s) => s?.log.length === 1 && JSON.stringify(s.log[0]) === JSON.stringify(logA), '6.15 B holds the SAME run entry (idempotent by its stamp)', 8000); + await h.eventually(() => snap(C.page), (s) => s?.log.length === 1 && s.log[0].at === logA.at, '6.16 and so does C'); + await h.eventually(() => hudRuntime(A.page), (r) => (r['wv-kills,wv-kills-over']?.rows ?? []).length >= 2, '6.17 the leaderboard rows list the scorers (one node feeds both lists)'); + await hudButton(A.page, 'Again').click(); + await h.eventually(() => gameStateOf(B.page), (v) => v === 'playing', '6.18 Again -> a new round on B', 8000); + await h.eventually(() => snap(A.page), (s) => s?.running && s.wave === 1 && s.completed === 0 && s.enemies.every((e) => e.hp === ENEMY_HP), '6.19 A: wave 1 again, every enemy back at full health (the reset chains)', 10000); + await h.eventually(() => snap(C.page), (s) => s?.running && s.wave === 1 && s.enemies.every((e) => e.hp === ENEMY_HP), '6.20 C agrees', 10000); + h.check((await snap(A.page)).log.length === 1, '6.21 the log keeps the finished run'); + + for (const p of [A, B, C]) await p.page.evaluate(() => window.__stores.isLocked.set(false)).catch(() => {}); + await h.finish(browser); +}); From 2ffacd1cfff06aaa33af0dd62fd4d99f8cf27aee Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 20 Sep 2026 12:24:35 +0300 Subject: [PATCH 4/7] [fix] content refs: scenes@format-2 and packs@format-1, never a semver-looking name (#230) - SCENES_BASE default moves scenes@v2 -> scenes@format-2 and PACKS_BASE packs@v1 -> packs@format-1; contentBase() is untouched, so the deploy-time VITE_* override still wins (production keeps VITE_SCENES_BASE=...scenes@main until this release ships) - WHY: jsDelivr parses `v2` as a SEMVER VERSION (`x-jsd-version-type: version`, `cache-control: immutable` for a year), so `git tag -f v2` was a no-op forever and four purges changed nothing -- the Games tab shipped three games while the feed had six. Measured today: a plain TAG named `format-2` (scenes, a5ebe8f) and `format-1` (packs, 03b9568) are reported `x-jsd-version-type: branch` with `s-maxage=43200`, and scenes@format-2/index.json lists all six games (towers, stars-room, football, jam-room, dungeon-realms, untangle). packs@v1 was the identical trap, unexploded only because it had never been retagged, so it moves in the same change - the three games suites' feed fallback follows (game-untangle / game-football / game-dungeon-realms), plus the one packs@v1 asset URL in scripts/author-templates.cjs (sha256-pinned, same bytes at format-1) - tests/unit/contentBase.test.js (4): the override wins with a trailing slash trimmed; an absent override ships the fallback byte-identically; the two consumers' SOURCE literals are the format-N refs; and no shipped fallback names a ref jsDelivr would parse as a version. Counterfactual: scenes@v2 restored in sceneTemplates.js -> 2 of 4 red ("the scenes default is scenes@format-2" and "no shipped fallback names a ref jsDelivr would parse as a semver version"); restored byte-identically -> 4/4 - docs: the SCENES_BASE/PACKS_BASE JSDoc carries the ritual and the measurement; PACKS.md, CLAUDE.md (games-suite line + the #230 gotcha closed) and the e2e skill name the new refs. The scenes/packs READMEs and cloud's .env.deploy.example move in their own PRs (scenes#7, packs#1, cloud#28) - gates: svelte-check 336/47 with byte-identical message lists; vitest 182 (base 178); battery held (see the handover table); build green server-down Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/e2e-verify/SKILL.md | 2 +- CLAUDE.md | 16 ++++- PACKS.md | 7 ++- scripts/author-templates.cjs | 2 +- src/lib/contentBase.js | 10 ++-- src/lib/packs.js | 14 +++-- src/lib/sceneTemplates.js | 24 ++++++-- tests/e2e/game-dungeon-realms.test.cjs | 4 +- tests/e2e/game-football.test.cjs | 4 +- tests/e2e/game-untangle.test.cjs | 2 +- tests/unit/contentBase.test.js | 81 ++++++++++++++++++++++++++ 11 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 tests/unit/contentBase.test.js diff --git a/.claude/skills/e2e-verify/SKILL.md b/.claude/skills/e2e-verify/SKILL.md index ba77100a..8f592cfb 100644 --- a/.claude/skills/e2e-verify/SKILL.md +++ b/.claude/skills/e2e-verify/SKILL.md @@ -136,7 +136,7 @@ Rules: never run suites in parallel AGAINST THE SAME dev server, never edit sour while one runs (HMR reloads the pages mid-test — see "HMR churn makes runs LIE"). -The GAMES-TAB line (real scene from the scenes feed @v2 + real module zips from a packed +The GAMES-TAB line (real scene from the scenes feed @format-2 + real module zips from a packed sibling modules checkout, every one SKIPS-never-fails when a source is missing): `game-towers` (20), `game-stars-room` (36), `game-football` (102, two peers + late joiner; FOOTBALL_TPSCENE / FOOTBALL_ZIP), `game-dungeon-realms` (64, two peers + late joiner, TWO zips: diff --git a/CLAUDE.md b/CLAUDE.md index 19748088..15e1b4f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2129,7 +2129,7 @@ loadable play content. Everything a user does must be visible to connected peers Towers in eight numbers and Football's HUD is authored by its module). Suites `game-dungeon-realms` (64, two peers + late joiner; env DUNGEON_REALMS_TPSCENE / DUNGEON_KIT_ZIP / DUNGEON_REALMS_ZIP / MODULES_REPO), `game-untangle` (46; UNTANGLE_TPSCENE / - UNTANGLE_ZIP), `game-football` (102; scene from the scenes feed @v2, zip from a packed + UNTANGLE_ZIP), `game-football` (102; scene from the scenes feed @format-2, zip from a packed sibling modules checkout — skips, never fails, when every source misses). - `playMode.js` `embedMode` / `embedSceneId` / `embedOpenUrl()` (R29 fork 4): the additive `?embed=1` boot flag, read ONCE at module evaluation (before the cloud plugin clears the @@ -2243,6 +2243,20 @@ loadable play content. Everything a user does must be visible to connected peers purgeable). So a moving ref on jsDelivr must be a branch or a tag name that is not a version (`format-2`); `packs@v1` has the same trap waiting. Core ticket #230; the deploy-time unblock is `VITE_SCENES_BASE=…scenes@main` (what `contentBase()` exists for). + **29f CLOSED IT BY MEASUREMENT**: a plain TAG named `format-2` (scenes, at a5ebe8f) and + `format-1` (packs, at 03b9568) are reported `x-jsd-version-type: branch` with + `s-maxage=43200`, while `@v2`/`@v1` carry `cache-control: immutable` for a year — so the + tag form works and no branch was needed. `SCENES_BASE`/`PACKS_BASE` default to the + `format-N` refs now, the three games suites' feed fallback with them, and + `tests/unit/contentBase.test.js` reads both source files and refuses any fallback whose ref + matches jsDelivr's version rule (an optional v, then dotted digits, nothing else). `v1`/`v2` + are DEAD refs, left where jsDelivr first resolved them for the builds that shipped against + them. Production keeps the cloud `.env.deploy` `VITE_SCENES_BASE=…scenes@main` override until + the core release carrying this ships; the scenes/packs READMEs and cloud's + `.env.deploy.example` teach the new ritual. THE EDITING TRAP THIS FOUND: a JS + `String.replace(from, to)` with a STRING `to` expands `$\``, `$'`, `$&` — a doc edit whose + replacement text quoted a regex ending in `$` followed by a backtick pasted 2255 lines of + this file into itself. Use `split(from).join(to)` for literal replacement. - **flowbite-svelte's `Button` FREEZES its class string at mount.** `Button.svelte:34` reads the theme through a DESTRUCTURING `$derived` declaration, which evaluates its object ONCE — so a button BORN disabled wears `cursor-not-allowed opacity-50` forever, even after its diff --git a/PACKS.md b/PACKS.md index 07f9df7e..7152b904 100644 --- a/PACKS.md +++ b/PACKS.md @@ -10,9 +10,10 @@ There are two kinds of pack: - **Default packs** come from the pack repo's `index.json`, fetched from `PACKS_BASE` (`src/lib/packs.js`) — the jsDelivr CDN over [theprototype-app/packs](https://github.com/theprototype-app/packs), pinned to a - tag (`@v1`). If the CDN is unreachable, the app falls back to the MINIMAL starter - bundled at `static/library/libraryList.json` (offline / fresh clones are never - empty). + ref (`@format-1` — never a semver-looking name: jsDelivr resolves a version once + and a retag of it is a no-op, core #230). If the CDN is unreachable, the app falls + back to the MINIMAL starter bundled at `static/library/libraryList.json` (offline / + fresh clones are never empty). - **Remote / imported packs** are self-describing repos or `.zip` files using the `manifest.json` format below — drag a `.zip` in with **+ Import pack**. diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index d5b4d8d6..11b1e8f5 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -424,7 +424,7 @@ const STARS_HUD_PANEL = { const STARS_CHIME = { key: 'chime', name: 'impact-glass.ogg', - url: 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@v1/audio-essentials/assets/impact-glass.ogg', + url: 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@format-1/audio-essentials/assets/impact-glass.ogg', sha256: '9252d50bfb85edb17d6073c4a7806e10cdb9de56d3dbfc93a4b9727146d2df6d', credit: { what: 'Impact Glass', author: 'Kenney', license: 'CC0-1.0', source: 'https://kenney.nl/assets/impact-sounds' } }; diff --git a/src/lib/contentBase.js b/src/lib/contentBase.js index f07c6c00..752e3424 100644 --- a/src/lib/contentBase.js +++ b/src/lib/contentBase.js @@ -1,9 +1,11 @@ // The off-bundle CONTENT BASES, overridable at build time. // -// Three content repos are read over jsDelivr at pinned refs — `scenes@v2` -// (templates/examples/games), `modules@main` (the module gallery) and `packs@v1` -// (Explorer packs). Every one of them was a hardcoded const, which makes them the -// only build-time configuration in the app that CANNOT be pointed anywhere else: +// Three content repos are read over jsDelivr at pinned refs — `scenes@format-2` +// (templates/examples/games), `modules@main` (the module gallery) and `packs@format-1` +// (Explorer packs; 29f/#230: a ref must never look like a semver version, because +// jsDelivr resolves a version ONCE and a retag of it is a no-op forever). Every one +// of them was a hardcoded const, which makes them the only build-time configuration +// in the app that CANNOT be pointed anywhere else: // the ref a build reads is the ref production reads, so there was no way to try // unpublished content without publishing it to the ref real users are on. // diff --git a/src/lib/packs.js b/src/lib/packs.js index 2c0fda65..2ea82efe 100644 --- a/src/lib/packs.js +++ b/src/lib/packs.js @@ -15,10 +15,16 @@ import { safeStorage } from './safeStorage'; // // The pack repo/manifest structure is documented in PACKS.md. -/** Off-bundle base for remote packs (RP): the tagged jsDelivr mirror of - * github.com/theprototype-app/packs. Bump the tag when pack content changes — - * jsDelivr caches tags aggressively, so released builds stay stable. */ -export const PACKS_BASE = contentBase(import.meta.env.VITE_PACKS_BASE, 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@v1'); +/** Off-bundle base for remote packs (RP): the ref-pinned jsDelivr mirror of + * github.com/theprototype-app/packs. A content release re-points the ref there + * (`git tag -f format-1 && git push -f origin format-1`, then purge) — jsDelivr caches + * a ref for up to 12 hours, so released builds stay stable. + * + * 29f (#230): the ref must NOT look like a version. `packs@v1` was the same trap as + * `scenes@v2` (jsDelivr parses `v1` as a semver VERSION and caches it immutably, so a + * retag would have been a no-op the first time it was tried) and moved in the same + * change; see the SCENES_BASE note in sceneTemplates.js for the measurement. */ +export const PACKS_BASE = contentBase(import.meta.env.VITE_PACKS_BASE, 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@format-1'); const INSTALLED_KEY = 'installedPacks'; diff --git a/src/lib/sceneTemplates.js b/src/lib/sceneTemplates.js index 252cfc5f..293e2f12 100644 --- a/src/lib/sceneTemplates.js +++ b/src/lib/sceneTemplates.js @@ -22,13 +22,25 @@ import { communityProvider } from './cloudHooks'; // requestLoadSession): format confirm, "Backup before " stash, replicated // clear+rebuild, and the sessionproposal peer-consent flow all come for free. -/** Off-bundle base for curated templates/examples/games. Bump the tag when content - * changes — jsDelivr caches tags aggressively, so released builds stay stable. +/** Off-bundle base for curated templates/examples/games. A content release re-points + * the ref in the scenes repo (`git tag -f format-2 && git push -f origin format-2`, + * then purge) — jsDelivr caches a ref for up to 12 hours, so released builds stay + * stable and the app picks the change up without a redeploy. * - * C5.2: @v2 is a NEW tag, never a reused one, so a deployed older build cannot be - * handed an index whose `games` section it has no tab for. (Reusing @v1 would push - * v2 content at every build already in the wild.) */ -export const SCENES_BASE = contentBase(import.meta.env.VITE_SCENES_BASE, 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2'); + * C5.2: the ref tracks the INDEX FORMAT and a format bump takes a NEW ref, never a + * reused one, so a deployed older build cannot be handed an index whose `games` + * section it has no tab for. (Reusing the previous ref would push new-format content + * at every build already in the wild.) + * + * 29f (#230): THE REF MUST NOT LOOK LIKE A VERSION. jsDelivr parses `v2` as a SEMVER + * VERSION (`x-jsd-version-type: version`, `cache-control: immutable` for a year), so a + * retag of `v2` was a no-op forever and four purges changed nothing — the Games tab + * shipped three games while the feed had six. A ref jsDelivr cannot parse as a version + * (`format-2`, tag or branch) is reported as type `branch` with a 12-hour s-maxage, + * which is what makes the retag-and-purge ritual work. `scenes@v2` is a dead ref, left + * where jsDelivr first resolved it for the builds that shipped against it; + * `tests/unit/contentBase.test.js` asserts no semver-looking ref comes back. */ +export const SCENES_BASE = contentBase(import.meta.env.VITE_SCENES_BASE, 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2'); /** Community manifest (raw = fresh + CORS; see header note). */ export const GALLERY_JSON_URL = 'https://raw.githubusercontent.com/theprototype-app/community-gallery/main/gallery.json'; diff --git a/tests/e2e/game-dungeon-realms.test.cjs b/tests/e2e/game-dungeon-realms.test.cjs index 9bf08554..cbb17627 100644 --- a/tests/e2e/game-dungeon-realms.test.cjs +++ b/tests/e2e/game-dungeon-realms.test.cjs @@ -2,7 +2,7 @@ // `dungeon` Kit generates + renders the world from the graph's Dungeon node; the // `dungeon-realms` module plays it) on the REAL artefacts and nothing authored in-test: // the scene — games/dungeon-realms/scene.tpscene from the scenes FEED (SCENES_BASE, -// tag v2), or DUNGEON_REALMS_TPSCENE=, or a sibling scenes checkout +// ref format-2), or DUNGEON_REALMS_TPSCENE=, or a sibling scenes checkout // the zips — dungeon.zip + dungeon-realms.zip: DUNGEON_KIT_ZIP / DUNGEON_REALMS_ZIP, // the MODULES_REPO checkout, a packed sibling modules checkout, or the CDN // installed on TWO peers plus a LATE JOINER. Skip-never-fail: when the scene or a zip @@ -29,7 +29,7 @@ const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); -const SCENES_BASE = (process.env.DUNGEON_REALMS_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2').replace(/\/$/, ''); +const SCENES_BASE = (process.env.DUNGEON_REALMS_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2').replace(/\/$/, ''); const MODULES_BASE = (process.env.DUNGEON_REALMS_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); const ROOT = path.resolve(__dirname, '../../..'); const SEED = 1337; diff --git a/tests/e2e/game-football.test.cjs b/tests/e2e/game-football.test.cjs index d78a5171..03728113 100644 --- a/tests/e2e/game-football.test.cjs +++ b/tests/e2e/game-football.test.cjs @@ -1,7 +1,7 @@ // 24-B B4 ACCEPTANCE — the Football game (VR football on the knock; the RULES are the // `football` module, the physics is the template's data). Driven through the REAL // artefacts and nothing authored in-test: -// the scene — games/football/scene.tpscene from the scenes FEED (SCENES_BASE, tag v2), +// the scene — games/football/scene.tpscene from the scenes FEED (SCENES_BASE, ref format-2), // or FOOTBALL_TPSCENE=, or a sibling scenes checkout as the fallback // the module — football.zip: FOOTBALL_ZIP=, a sibling modules checkout's packed zip // (`npm run pack -- football` there), or the modules CDN @@ -31,7 +31,7 @@ const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); -const SCENES_BASE = (process.env.FOOTBALL_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2').replace(/\/$/, ''); +const SCENES_BASE = (process.env.FOOTBALL_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2').replace(/\/$/, ''); const MODULES_BASE = (process.env.FOOTBALL_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); const ROOT = path.resolve(__dirname, '../../..'); diff --git a/tests/e2e/game-untangle.test.cjs b/tests/e2e/game-untangle.test.cjs index 6ea1a78d..e45c2f8c 100644 --- a/tests/e2e/game-untangle.test.cjs +++ b/tests/e2e/game-untangle.test.cjs @@ -27,7 +27,7 @@ const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); -const SCENES_BASE = (process.env.UNTANGLE_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2').replace(/\/$/, ''); +const SCENES_BASE = (process.env.UNTANGLE_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2').replace(/\/$/, ''); const MODULES_BASE = (process.env.UNTANGLE_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); const ROOT = path.resolve(__dirname, '../../..'); const TEMPLATE_LEVEL = 2; diff --git a/tests/unit/contentBase.test.js b/tests/unit/contentBase.test.js new file mode 100644 index 00000000..8c44c12f --- /dev/null +++ b/tests/unit/contentBase.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { contentBase } from '../../src/lib/contentBase.js'; + +// 29f (core #230). Two things this module has to keep true, and neither is visible +// from a build that merely runs: the deploy-time override must WIN (it is how +// production was unblocked the day the feed went stale), and the default a build +// ships with NONE of the VITE_* vars set must be a ref jsDelivr will re-resolve. +// +// The second is the lesson of #230: jsDelivr parses a ref like `v2` as a SEMVER +// VERSION (`x-jsd-version-type: version`, `cache-control: immutable` for a year), so a +// retag of it is a no-op forever and a purge reports finished without re-resolving — +// the Games tab shipped three games while the feed had six. The refs are `format-N` +// now, and this test reads the two consumers' SOURCE so the literal they pass as the +// fallback can never drift back to a version-shaped name. The consumers themselves +// import svelte/store and the app's stores, which is why they are read as text here +// rather than imported (the unit layer's entry rule: modules that import nothing). + +const SRC = resolve(__dirname, '../../src/lib'); +/** what jsDelivr treats as a version: an optional v, then dotted digits and nothing else */ +const SEMVER_LIKE = /^v?\d+(\.\d+)*$/; +const SCENES_DEFAULT = 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2'; +const PACKS_DEFAULT = 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@format-1'; + +/** the string literal a consumer hands `contentBase()` as its fallback + * @param {string} file @param {string} constName */ +function fallbackOf(file, constName) { + const src = readFileSync(resolve(SRC, file), 'utf8'); + const m = src.match(new RegExp('export const ' + constName + " = contentBase\\([^,]+, '([^']+)'\\)")); + if (!m) throw new Error(constName + ' fallback literal not found in ' + file); + return m[1]; +} +/** the ref after the `@` of a jsDelivr gh url @param {string} url */ +function refOf(url) { + const m = url.match(/@([^/]+)$/); + return m ? m[1] : ''; +} + +describe('contentBase', () => { + it('an override wins, with a trailing slash trimmed so `${BASE}/index.json` never doubles it', () => { + expect(contentBase('https://cdn.jsdelivr.net/gh/theprototype-app/scenes@main', SCENES_DEFAULT)).toBe( + 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@main' + ); + expect(contentBase('https://example.test/scenes@dev/', SCENES_DEFAULT)).toBe('https://example.test/scenes@dev'); + expect(contentBase('https://example.test/scenes@dev///', SCENES_DEFAULT)).toBe('https://example.test/scenes@dev'); + }); + + it('an absent override ships the pinned fallback byte-identically', () => { + expect(contentBase(undefined, SCENES_DEFAULT)).toBe(SCENES_DEFAULT); + expect(contentBase('', SCENES_DEFAULT)).toBe(SCENES_DEFAULT); + expect(contentBase(null, PACKS_DEFAULT)).toBe(PACKS_DEFAULT); + // vite substitutes an unset VITE_* with undefined, never with a non-string; a + // non-string is still "absent" rather than a crash + expect(contentBase(42, PACKS_DEFAULT)).toBe(PACKS_DEFAULT); + }); + + it('the scenes default is scenes@format-2 and the packs default is packs@format-1', () => { + expect(fallbackOf('sceneTemplates.js', 'SCENES_BASE')).toBe(SCENES_DEFAULT); + expect(fallbackOf('packs.js', 'PACKS_BASE')).toBe(PACKS_DEFAULT); + }); + + it('no shipped fallback names a ref jsDelivr would parse as a semver version (#230)', () => { + for (const [file, name] of [ + ['sceneTemplates.js', 'SCENES_BASE'], + ['packs.js', 'PACKS_BASE'] + ]) { + const ref = refOf(fallbackOf(file, name)); + expect(ref, name + ' has a ref').not.toBe(''); + expect(SEMVER_LIKE.test(ref), name + ' ref "' + ref + '" must not look like a version').toBe(false); + } + // the rule itself, pinned against the two names that bit: this is what the + // assertion above would have refused before 29f + expect(SEMVER_LIKE.test('v2')).toBe(true); + expect(SEMVER_LIKE.test('v1')).toBe(true); + expect(SEMVER_LIKE.test('2')).toBe(true); + expect(SEMVER_LIKE.test('1.2.3')).toBe(true); + expect(SEMVER_LIKE.test('format-2')).toBe(false); + expect(SEMVER_LIKE.test('main')).toBe(false); + }); +}); From 4ac544af06acd1b96bb527d4b5422b4ec9d11a43 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 20 Sep 2026 12:39:49 +0300 Subject: [PATCH 5/7] [fix] 29-F: a peer receiving `simulate` while simulating yields by a deterministic rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE BUG, measured on a real two-peer Football match (24-B's handover): two Play presses inside the sim's start-up window both pass `playMode.maybeSimOnPlay`'s `simulating || remoteSimulating` guard, because the other side's `simulate` has not landed yet. Both peers then step a world and broadcast `move` at 30 Hz, each stream reads as an EXTERNAL write on the other, and every dynamic body sits under a `hold: 'external'` refreshed long before its 250 ms timeout can expire — 74 moves in ~2 s, the ball snapping back, `applyThrow` eaten, NO GOAL COULD SCORE. A late joiner that is already simulating meets the same shape. THE RULE: **the lower peer id keeps the world.** The guard cannot be fixed where it stands — a peer cannot know it is racing until the message arrives — so the rule is on the RECEIVE side, and it reads only two facts both sides already hold (my id, the id in the message), so both reach the same verdict with no round trip and no new message. Checked against what is already there before committing to it: PeerJS ids are non-empty strings and `<` is a total order, so exactly one winner is elected; `remoteSimulating` is already set from that same `peerId`; the handshake push is symmetric (both sides send one), so a joiner race resolves the same way; and the football module's `isAuthority()` ALREADY falls back to the lowest live id when no sim runs, so core's winner and a module's fallback authority are one peer by construction. - `src/lib/simAuthority.js` (NEW, imports nothing): `simulateVerdict` -> keep | yield | adopt | clear | ignore, with the reasoning. ADDITIVE: a message with no `peerId` (an older build) takes the pre-29-F path verbatim, and a session with no race in it never reaches a verdict but `adopt` and `clear`. - `physics.applySimulate` acts on the verdict. YIELDING IS CLEAN, NOT MERELY QUIET: `stopSimulation({yielded: true})` withholds the settling `move` per body (each would pin one of the winner's copies one last time — the very shape the yield exists to end) and the transformSet undo entry (Ctrl+Z over a layout nobody ever saw). - The winner has two mirror duties: `releaseExternalHoldsBy(peerId)` drops the holds the loser's moves had already claimed (the same `releaseHold` the 250 ms timeout would run, only sooner), and it ANSWERS the competing claim with its own start — redundant in an ordinary race, where the two starts cross, and the only thing that ever reaches a peer which never heard ours (one that travelled into the room after the run began: the push rides `sendHandshake` and is not repeated on arrival). - A SPECTATOR agrees with the racers: told about two simulators it keeps the lower id, and a stop from a peer it was not watching no longer blanks `remoteSimulating` — that store is what arms the knock probes and play-mode grab (24-A A2), so blanking it silently disarms a third peer mid-match. - `tests/unit/simAuthority.test.js` (14): the truth table, including that the two sides of a race reach OPPOSITE verdicts over a spread of real-shaped ids — the property two browsers cannot show in reasonable time. - `tests/e2e/game-football.test.cjs` section 7 (+32 checks, 102 -> 134): the race as it happens (both presses, nothing between them), then FORCED both ways because two presses do not reliably race, then the goal that scores. Section 2 keeps its ordered entry so the rest of the suite has a known authority. Counterfactuals, each broken and restored byte-identically: - the whole rule -> `adopt`: 111/8, section 7 red incl. "A GOAL SCORES" (and the winner was the HIGHER id that run — the nondeterminism the rule removes). - the `yielded` suppression removed: 132/3 — one settling move, undo 4 -> 5. - the spectator start rule removed: 132/3 — the spectator adopts the higher id. - the stop-side `ignore` removed: 129/6 — a loser's stop blanks the spectator. - the keep re-announce removed: 131/4 — the forced higher-id yield never resolves. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 35 ++++++++ src/lib/physics.js | 107 ++++++++++++++++++++-- src/lib/simAuthority.js | 77 ++++++++++++++++ tests/e2e/game-football.test.cjs | 150 +++++++++++++++++++++++++++++-- tests/unit/simAuthority.test.js | 94 +++++++++++++++++++ 5 files changed, 448 insertions(+), 15 deletions(-) create mode 100644 src/lib/simAuthority.js create mode 100644 tests/unit/simAuthority.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 19748088..50b65fdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -986,6 +986,23 @@ loadable play content. Everything a user does must be visible to connected peers (Euler differencing is wrong across a wrap and wrong in general — YXZ couples the axes) and a MAGNITUDE clamp (per-component clamping ROTATES the throw; measured 4.6 degrees off on a skewed vector). + · `simAuthority.js` (29-F, imports NOTHING) = `simulateVerdict`, the rule that ends a + DUAL-SIMULATOR race in one pure function of four facts (are we simulating, our id, + theirs, who we thought was stepping the world) -> keep | yield | adopt | clear | ignore. + **THE LOWER PEER ID KEEPS THE WORLD**, which both sides compute from data they already + hold, so no round trip and no new message decides it — and it is the SAME tie-break the + football module's `isAuthority()` already falls back to with no sim running, so core's + winner and a module's fallback authority are one peer by construction. `applySimulate` + is the only place the rule can live (a peer cannot know it is racing until the other + side's message lands, which is exactly what `maybeSimOnPlay`'s guard is still waiting + for), and `ignore` is what keeps a SPECTATOR honest: told about two simulators it keeps + the lower id, and a stop from a peer it was not watching must not blank + `remoteSimulating` — that store is what arms the knock probes and play-mode grab. + Yielding is `stopSimulation({yielded: true})`: see the gotcha for why quiet is not + enough. `keep` also ANSWERS with our own start — redundant in an ordinary race, where the + two starts cross, and the only thing that reaches a peer which never heard ours (one that + travelled in after the run began: the push rides `sendHandshake` and is not repeated on + arrival). Additive — a message with no `peerId` takes the pre-29-F path verbatim. · `playInteract.js` = play mode's own input path, deliberately NOT a lift of Scene's pick (the editor's select branch is a short STATIONARY click, its `$isLocked` bails guard six editor modes, and play mode's ray is NDC (0,0) @@ -2719,6 +2736,24 @@ loadable play content. Everything a user does must be visible to connected peers - **Never run `npm run build` while the lane's `vite dev` watches the same worktree** — it rewrites `.svelte-kit/output` under the server and kills it; the next ten suites report `ERR_CONNECTION_REFUSED`, which reads as a mass regression. +- **TWO PLAY PRESSES INSIDE THE SIM'S START-UP WINDOW START TWO SIMULATORS.** + `playMode.maybeSimOnPlay` guards on `simulating || remoteSimulating`, and both are still + FALSE on both peers until the other side's `simulate` arrives — a window that spans + `warmup()` plus the whole of `startSimulation`, so presses a second apart still both pass + it. Two authorities then broadcast `move` at 30 Hz, each stream reads as an EXTERNAL write + on the other, and every dynamic body sits under a `hold: 'external'` refreshed long before + its 250 ms timeout can expire. MEASURED on a real two-peer Football match: 74 moves in + ~2 s, the ball snapping back, `applyThrow` eaten, and NO GOAL COULD SCORE. Note what a + suite has to assert here: "the peer we expect is simulating" reads TRUE while both of + them are, so the load-bearing check is that a goal SCORES. Same shape for a + late joiner that is already simulating when the handshake `simulate` push lands + (symmetric: both sides push). The guard cannot be fixed where it stands, so the rule is + on the RECEIVE side (`simAuthority.js`, 29-F): the lower peer id keeps the world. + YIELDING MUST BE CLEAN, NOT MERELY QUIET — `stopSimulation({yielded: true})` also + withholds the settling `move` per body (which would pin every one of the winner's copies + one last time, the very shape the yield exists to end) and the transformSet undo entry + (Ctrl+Z over a layout nobody ever saw); and the winner drops the holds the loser's stream + already claimed instead of waiting out their timeout. - **A HELD body's `lastWritten` is stale by definition, so every release must refresh it.** The write-back skips a held body, so `lastWritten` still describes the pose it had when it was GRABBED — and the deviation detector diff --git a/src/lib/physics.js b/src/lib/physics.js index 0c278a50..c92e6d77 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -28,6 +28,9 @@ import { sceneKnock } from './scenePhysics'; import { velocityFromSamples, clampThrow, MAX_LINVEL, MAX_ANGVEL } from './throwVelocity'; +// 29-F: the lower-id-keeps-the-world rule, as a leaf that imports nothing — the whole +// decision is a pure function of four facts, so its truth table is a vitest unit. +import { simulateVerdict } from './simAuthority'; // B7: spawned objects are swept when the run ends. transientObjects is a LEAF (the two // stores only), so this edge closes nothing — unlike objectActions, which the // out-of-bounds delete has to reach dynamically. @@ -1197,6 +1200,33 @@ export function physicsExternalMove(uuid, peerId = null) { return true; } +/** + * 29-F: drop the external holds ONE peer's move stream claimed, now rather than at the + * 250 ms timeout. + * + * Called when that peer's stream is known to have ended — it yielded a Play race to us, + * or it told us its run stopped. Without this the bodies it was dragging stay kinematic + * for a further quarter of a second after there is anything left to drag them, which on a + * ball in flight is a visible stall; with it, the release is the SAME release the timeout + * would have performed (`releaseHold`'s own sample-derived estimate, so the body carries + * on along the path it was already on) and only the timing changes. + * + * Deliberately NOT extended to `physicsPeerDisconnected`: a disconnect already has the + * timeout as its answer, and a peer that dropped mid-carry has no "ended cleanly" moment + * to hang an immediate release on. + * @param {string|null|undefined} peerId @returns {number} how many were released + */ +function releaseExternalHoldsBy(peerId) { + if (!world || !peerId || !get(simulating)) return 0; + let released = 0; + bodies.forEach((entry) => { + if (entry.hold !== 'external' || entry.holdPeer !== peerId) return; + releaseHold(entry); + released++; + }); + return released; +} + /** * B5: a peer released something they were carrying, and told us EXACTLY how. * @@ -1649,8 +1679,9 @@ export function pauseSimulation(paused) { if (peer) peer.send({ type: 'simulate', running: true, paused: next, peerId: peer.peer.id }); } -/** @param {{reset?: boolean, reason?: string}=} opts reset restores the initial layout - * (no undo entry); 27-C passes a `reason` when a failing step stops the run. */ +/** @param {{reset?: boolean, reason?: string, yielded?: boolean}=} opts reset restores the + * initial layout (no undo entry); 27-C passes a `reason` when a failing step stops the run; + * 29-F passes `yielded` when this run lost a Play race (see below). */ export function stopSimulation(opts = {}) { if (!get(simulating)) return; setPostTick(null); // clear the hook BEFORE freeing the world @@ -1678,10 +1709,18 @@ export function stopSimulation(opts = {}) { object.scale.fromArray(before.scale); } const after = transformOf(object); - if (!opts.reset && JSON.stringify(before) !== JSON.stringify(after)) + // 29-F: A YIELDED RUN LEAVES NOTHING BEHIND. This run lost the race, so its poses + // were never authoritative and the winner's stream is the truth — broadcasting a + // settling `move` per body would put the WINNER's copy of every one of them under + // a fresh `hold: 'external'` on the way out (the exact shape the yield exists to + // end), and an undo entry would offer Ctrl+Z over a layout nobody ever saw. + // `notifyExternalMove` still runs either way: our local poses are about to be + // replaced by the winner's stream, and a half-applied interpolation must not + // survive that. + if (!opts.reset && !opts.yielded && JSON.stringify(before) !== JSON.stringify(after)) items.push({ uuid, before, after }); notifyExternalMove(uuid); - if (peer) + if (peer && !opts.yielded) peer.send({ type: 'move', uuid: uuid, pos: after.pos, rot: after.rot, scale: after.scale }); }); if (items.length > 0) recordTransformSet(items); @@ -1776,12 +1815,64 @@ export function setBodyVelocity(uuid, linvel, angvel) { return true; } -/** @param {any} data */ +/** + * A peer's run started, stopped or paused. + * + * 29-F: this is also where a DUAL-SIMULATOR RACE is resolved, and it is the only place + * it can be — a peer cannot know it is racing until the other side's message lands, which + * is precisely what `maybeSimOnPlay`'s "nothing is running anywhere" guard is still + * waiting for when both presses go through. `simulateVerdict` holds the rule (lower peer + * id keeps the world) and the reasoning; everything below is what each verdict COSTS. + * + * Yielding has to be clean, not merely quiet: the loser's 30 Hz `move` stream is what + * pins every one of the winner's bodies under a permanent `hold: 'external'`, so the run + * must actually end (`stopSimulation` clears the post-tick hook, which is what stops the + * stream) and must end without broadcasting the settling moves that would pin them one + * last time. The winner has two mirror duties: the moves that arrived before the verdict + * did have already claimed holds in its world, and those are dropped here; and it answers + * the competing claim with its own start, which is what reaches a peer that never heard + * the first one. + * @param {any} data + */ export function applySimulate(data) { - remoteSimulating.set(data.running ? data.peerId : null); + /** @type {any} */ + const peer = get(peers); + const theirs = typeof data?.peerId === 'string' ? data.peerId : null; + const verdict = simulateVerdict({ + running: !!data?.running, + mine: peer?.peer?.id ?? null, + theirs, + simulating: get(simulating) === true, + remote: get(remoteSimulating) + }); + if (verdict === 'ignore') { + // a stop from a peer we were not watching still ends ITS stream, so the holds it + // claimed in our world can go now (the race's loser sends exactly this) + if (!data?.running) releaseExternalHoldsBy(theirs); + return; + } + if (verdict === 'keep') { + releaseExternalHoldsBy(theirs); + // AND TELL THEM. In an ordinary race the two starts cross, so the loser reaches + // its own verdict from ours and this is redundant. It is not redundant for a peer + // that never heard our start at all — one that travelled into this room after the + // run began, since the `simulate` push rides `sendHandshake` and is not repeated + // on arrival — because nothing else will ever tell it, and it would step a second + // world forever. At most ONE of these per race (only the keeper sends, and the + // loser answers with a stop we `ignore`), so it cannot storm. + if (peer) peer.send({ type: 'simulate', running: true, paused: get(simPaused), peerId: peer.peer.id }); + return; + } + if (verdict === 'yield') { + stopSimulation({ yielded: true }); + showToast(nameOf(data.peerId) + ' is simulating too — handing the physics over (lower id keeps it)'); + } + if (verdict === 'clear') releaseExternalHoldsBy(theirs); + remoteSimulating.set(data?.running ? theirs : null); // a finished run must not leave an interpolation half-applied - if (!data.running) import('./moveSmoothing').then((m) => m.clearMoveSmoothing()).catch(() => {}); - if (data.running && !data.paused) showToast('▶ ' + nameOf(data.peerId) + ' is simulating physics'); + if (!data?.running) import('./moveSmoothing').then((m) => m.clearMoveSmoothing()).catch(() => {}); + if (data?.running && !data?.paused && verdict !== 'yield') + showToast('▶ ' + nameOf(data.peerId) + ' is simulating physics'); } /** @param {string} peerId */ diff --git a/src/lib/simAuthority.js b/src/lib/simAuthority.js new file mode 100644 index 00000000..079c8c86 --- /dev/null +++ b/src/lib/simAuthority.js @@ -0,0 +1,77 @@ +// 29-F: WHO KEEPS THE WORLD when two peers start simulating at once. +// +// THE BUG THIS EXISTS FOR, measured on a real two-peer Football match (24-B's handover): +// `playMode.maybeSimOnPlay` guards on `simulating || remoteSimulating`, and both are +// still false on BOTH peers for as long as it takes the other side's `simulate` message +// to arrive — a window that spans `warmup()` plus the whole of `startSimulation`, so two +// Play presses a second apart can still both pass it. Both peers then step a world and +// broadcast `move` at 30 Hz, each one's stream reads as an EXTERNAL write on the other, +// and every dynamic body sits under a `hold: 'external'` that is refreshed before its +// 250 ms timeout can ever expire. Measured: 74 moves in ~2 s, the ball snapping back +// under a permanent hold, `applyThrow` eaten, and NO GOAL COULD SCORE. A late joiner +// that is already simulating meets the same shape through the handshake push. +// +// THE RULE: **the lower peer id keeps the world.** It needs no negotiation and no new +// message, because the only two facts it reads — my id and the id in the message we just +// received — are already on both sides, so both peers reach the same verdict from the +// same data with no round trip. PeerJS ids are non-empty strings, stable for the life of +// a connection and compared with `<`, which is a TOTAL order: exactly one of two distinct +// ids is lower, so the rule can never elect two winners or none. (Our own id is the one +// the signalling server handed us, not something a message can claim — a peer cannot lie +// its way into keeping the world without also being the peer that owns that id.) +// +// ADDITIVE, absent = old behaviour: a message with no `peerId` (an older build) cannot be +// compared, so it takes the pre-29-F path verbatim — `adopt` — and a session with no race +// in it never reaches any verdict but `adopt` and `clear`. +// +// A LEAF that imports NOTHING, so the truth table is a vitest unit and the decision can +// be read without a browser, a peer or rapier (the `sessionClock`/`netBackoff` shape). + +/** + * The verdict for one incoming `simulate` message. + * + * - `adopt` — record them as the simulator (the old behaviour, and the normal one) + * - `keep` — we are simulating and we won: stay authoritative, ignore their claim + * - `yield` — we are simulating and we lost: stop, then adopt them + * - `clear` — their run ended and it was the one we were watching + * - `ignore` — the message says nothing about the peer we believe is stepping the world + * + * `ignore` on a STOP is what keeps a three-peer race honest: the loser of a race + * broadcasts `running: false` on its way out, and a spectator that had recorded the + * WINNER must not blank its `remoteSimulating` because a peer it was not watching + * stopped — that store is what arms the knock probes and play-mode grab (24-A A2), so + * blanking it silently disarms a spectator mid-match. `ignore` on a START is the same + * rule from the other side: a spectator told about two simulators keeps the LOWER id, so + * every peer in the mesh — not just the two racing — agrees on who the authority is. + * + * @param {object} state + * @param {boolean} state.running the message's `running` flag + * @param {string|null|undefined} state.mine our own peer id (null when we have none yet) + * @param {string|null|undefined} state.theirs the message's `peerId` (absent on older builds) + * @param {boolean} state.simulating whether WE are stepping a world right now + * @param {string|null|undefined} state.remote the peer we currently believe is stepping one + * @returns {'adopt'|'keep'|'yield'|'clear'|'ignore'} + */ +export function simulateVerdict({ running, mine, theirs, simulating, remote }) { + const them = typeof theirs === 'string' && theirs ? theirs : null; + const me = typeof mine === 'string' && mine ? mine : null; + const watching = typeof remote === 'string' && remote ? remote : null; + + if (!running) { + // no id to match against: the pre-29-F behaviour, which is to take any stop + if (!them) return 'clear'; + return watching === them ? 'clear' : 'ignore'; + } + // our own message coming back at us is not evidence about anybody else + if (them && me && them === me) return 'ignore'; + if (simulating) { + // nothing to compare (an older sender, or no id of our own yet): old behaviour + if (!them || !me) return 'adopt'; + return them < me ? 'yield' : 'keep'; + } + // not simulating. A start from a peer with a HIGHER id than the one we already + // believe is stepping the world is the losing half of a race we are watching from + // outside; the same comparison both racers make tells us to keep the lower one. + if (them && watching && watching !== them && watching < them) return 'ignore'; + return 'adopt'; +} diff --git a/tests/e2e/game-football.test.cjs b/tests/e2e/game-football.test.cjs index d78a5171..08ce9c07 100644 --- a/tests/e2e/game-football.test.cjs +++ b/tests/e2e/game-football.test.cjs @@ -294,13 +294,12 @@ h.run(async () => { await h.eventually(() => B.page.evaluate(() => window.__stores.scenePhysics.scenePhysicsDebug()), (p) => p.gravity === 0 && p.knock?.enabled === true, '1.12 B: the physics block reached B'); // ---- 2. play + the menu screen ----------------------------------------------------------- - // Play is entered in ORDER: A first, and B only once it has HEARD that A simulates. - // Two Play presses inside the sim's start-up window both pass maybeSimOnPlay's - // "nothing is running anywhere" guard (the `simulate` message has not landed yet), so - // BOTH peers simulate and every body is fought over by two authorities — measured - // here: a parked ball snapped back under a permanent `hold: external` fed by the other - // simulator's 30 Hz moves, and no goal could score. A core race, recorded for the - // integrator; this suite asserts the single-simulator premise instead of riding it. + // Play is entered in ORDER here: A first, and B only once it has HEARD that A + // simulates, so the rest of this suite has a KNOWN authority to drive (the touch and + // teleport helpers both take the authority's page). The race — both presses inside the + // sim's start-up window, where maybeSimOnPlay's "nothing is running anywhere" guard is + // still true on both peers — is run for real in section 7, where nothing downstream + // depends on which peer wins it. await A.page.locator('#play-button').click(); await h.eventually(() => simOf(A.page), (v) => v.own === true, '2.1 A simulates (simOnPlay)'); await h.eventually(() => simOf(B.page), (v) => v.remote === A.id, '2.2 B knows A simulates'); @@ -460,6 +459,143 @@ h.run(async () => { await h.eventually(() => screenOf(A.page), (v) => v === 'menu', '6.12 A sees the menu again', 6000); h.check((await myVar(B.page, 'goals')) === 1 && (await snap(B.page)).log.length === 1, '6.13 the session sheet and the saved log survive a new match'); + // ---- 7. THE PLAY RACE: two presses inside the sim's start-up window ----------------------------- + // 29-F. `maybeSimOnPlay` guards on "nothing is running anywhere", and that is still TRUE on + // both peers for as long as it takes the other side's `simulate` to arrive — a window that + // spans `warmup()` and the whole of `startSimulation`. Two presses inside it therefore both + // pass, both peers step a world, and each one's 30 Hz `move` stream pins every one of the + // other's bodies under a `hold: 'external'` that is refreshed long before its 250 ms timeout: + // measured as a ball that snapped back, an eaten `applyThrow` and NO GOAL COULD SCORE. + // The rule that ends it is computed from data both sides already hold — the LOWER PEER ID + // KEEPS THE WORLD — so it costs no round trip and no new message. The football module's own + // no-sim tie-break is the same one (`isAuthority` sorts the live ids), so core's winner and + // the module's fallback authority are the same peer by construction. + // Note what this section asserts and 2.2b cannot: "one simulator" was TRUE while the ball was + // unplayable, so the goal at the end is the check that matters. + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + await h.eventually(() => simOf(B.page), (v) => v.own === false && v.remote === null, ' (premise) the pitch is idle on B', 10000); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === null, ' (premise) ...and on C', 10000); + for (const p of [A, B]) await p.page.evaluate(() => window.__stores.isLocked.set(false)); + await A.page.waitForTimeout(2600); // the 2 s exit cooldown, so both presses are taken the same way + // nothing between the two presses: this IS the window + await Promise.all([A.page.locator('#play-button').click(), B.page.locator('#play-button').click()]); + const low = A.id < B.id ? A : B; + const high = A.id < B.id ? B : A; + const lowName = low === A ? 'A' : 'B'; + await h.eventually(() => simOf(low.page), (v) => v.own === true, `7.1 the LOWER peer id keeps the world (${lowName}: ${low.id} < ${high.id})`, 20000); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, '7.2 the higher id YIELDED and adopted the winner', 20000); + h.check((await simOf(low.page)).remote === null, '7.3 ...and the winner never recorded the loser as a simulator'); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === low.id, '7.4 C (a spectator to the race) agrees on the same winner', 20000); + // the measured shape, directly: the loser's stream must leave nothing pinned + const heldBy = (page, id) => + page.evaluate((id) => window.__stores.physics.physicsDebug().filter((e) => e.hold === 'external' && e.holdPeer === id).length, id); + await low.page.waitForTimeout(1500); + const pinned = await heldBy(low.page, high.id); + h.check(pinned === 0, `7.5 no body on the winner is pinned by the loser's move stream (${pinned})`); + + // ---- 7b. THE RACE, FORCED, BOTH WAYS --------------------------------------------------------- + // Two real presses do not RELIABLY race — sometimes the first peer's `simulate` lands + // before the second one's guard is read, and then 7.1-7.5 are true because nothing + // raced at all. So force it, in the one shape that has no timing in it: clearing + // `remoteSimulating` is exactly what a peer that never heard the start looks like (it + // travelled into this room after the run began — the handshake push rides + // `sendHandshake` and is not repeated on arrival), and its own Play then goes through. + // That peer never receives a start message of its own to reason about, so the winner + // has to ANSWER a competing claim with its own start, and these are the only checks + // that cover that half of the rule. + const forceStart = (peer) => + peer.page.evaluate(() => { + const p = window.__stores.physics; + p.remoteSimulating.set(null); + return p.toggleSimulation(); + }); + await forceStart(high); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, '7.6 a forced second world on the HIGHER id yields to the lower one', 25000); + h.check((await simOf(low.page)).own === true && (await simOf(low.page)).remote === null, '7.7 ...and the lower id kept stepping throughout, watching nobody'); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === low.id, '7.8 the spectator never moved off the winner', 10000); + const pinned2 = await heldBy(low.page, high.id); + h.check(pinned2 === 0, `7.9 nothing left pinned after the forced yield (${pinned2})`); + + // and the other way round: the LOWER id arriving on a world the HIGHER one holds + await low.page.evaluate(() => window.__stores.physics.stopSimulation()); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === null, ' (premise) the pitch is idle again', 10000); + await high.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually(() => simOf(low.page), (v) => v.own === false && v.remote === high.id, ' (premise) the higher id holds the world', 20000); + await forceStart(low); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, '7.10 ...and a forced world on the LOWER id takes it BACK from the higher one', 25000); + h.check((await simOf(low.page)).own === true, '7.11 the lower id holds it'); + // the yielded peer's stream ended, so its holds go on OUR side too — the `ignore`-a-stop + // release path (our `remoteSimulating` is null here, so the stop matches nobody) + await h.eventually(() => heldBy(low.page, high.id), (n) => n === 0, '7.12 ...with nothing left pinned by the world it took over', 10000); + + // ---- 7c. the two halves a race cannot prove, driven directly --------------------------------- + // THE SPECTATOR HALF FIRST, while C is still watching the winner: which of two competing + // starts reaches a third peer LAST is a coin, so the arbitration is driven through the + // real applier with ids whose order is known (`low.id + 'zzz'` is strictly greater than + // `low.id` for any id). MEASURED: with the spectator rule removed the race above stays + // green, so these two are its only cover. + const cRemote = () => C.page.evaluate(() => new Promise((r) => window.__stores.physics.remoteSimulating.subscribe(r)())); + h.check((await cRemote()) === low.id, ' (premise) the spectator is watching the winner'); + await C.page.evaluate((id) => window.__stores.physics.applySimulate({ running: true, paused: false, peerId: id + 'zzz' }), low.id); + const cAfterStart = await cRemote(); + h.check(cAfterStart === low.id, `7.13 a spectator told about a HIGHER-id simulator keeps the lower one (${cAfterStart})`); + await C.page.evaluate((id) => window.__stores.physics.applySimulate({ running: false, peerId: id + 'zzz' }), low.id); + const cAfterStop = await cRemote(); + h.check(cAfterStop === low.id, `7.14 ...and a stop from a peer it was not watching does not blank it (${cAfterStop})`); + + // THE `yielded` HALF. A yield resolves in about a tenth of a second, so the run it ends + // has barely moved anything and its settling broadcast is invisible in the aggregate — + // MEASURED: with the suppression removed the whole race above stays green. So the flag's + // contract is asserted where it can fail: on a run whose bodies HAVE moved, a yielded + // stop sends no settling `move` at all (each would put the winner's copy under a fresh + // `hold: 'external'` on the way out) and records no transformSet entry (Ctrl+Z over a + // layout nobody ever saw), while still telling the mesh the run ended. + await low.page.evaluate((uuid) => window.__stores.physics.applyThrow({ uuid, pos: [0, 2.4, 0.9], rot: [0, 0, 0], linvel: [0, 0, 0], angvel: [0, 0, 0] }), ball); + await low.page.waitForTimeout(600); + const yielded = await low.page.evaluate(() => { + const s = window.__stores; + let peer; + s.peers.subscribe((p) => (peer = p))(); + const send = peer.send.bind(peer); + let moves = 0; + let stops = 0; + peer.send = (/** @type {any} */ m) => { + if (m?.type === 'move') moves++; + if (m?.type === 'simulate' && m.running === false) stops++; + return send(m); + }; + let before, after; + const bodies = s.physics.physicsDebug().length; // BEFORE the stop frees them + s.history.undoStack.subscribe((/** @type {any[]} */ v) => (before = v.length))(); + s.physics.stopSimulation({ yielded: true }); + s.history.undoStack.subscribe((/** @type {any[]} */ v) => (after = v.length))(); + peer.send = send; + return { moves, stops, before, after, bodies }; + }); + h.check(yielded.bodies > 0 && yielded.moves === 0, `7.15 a yielded stop broadcasts NO settling move (${yielded.bodies} bodies, ${yielded.moves} moves)`); + h.check(yielded.after === yielded.before, `7.16 ...and records no undo entry (${yielded.before} -> ${yielded.after})`); + h.check(yielded.stops === 1, `7.17 ...while still telling the mesh the run ended (${yielded.stops} stop message)`); + + // put the world back for the goal + await low.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually(() => simOf(low.page), (v) => v.own === true, ' (premise) the winner steps a world again', 20000); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, ' (premise) and the loser follows it', 20000); + + // and the point of all of it: a goal scores + // section 6 left the match on a 30 s clock — put it back on goals, or this one ends + // itself halfway through + await setRules(A.page, { winBy: 'goals', goalsToWin: 20 }); + await h.eventually(() => snap(low.page), (s) => s?.rules.winBy === 'goals' && s.rules.goalsToWin === 20, ' (premise) back on goals, with room to spare', 10000); + await h.eventually(() => screenOf(low.page), (v) => v === 'menu', ' (premise) the menu screen is up on the winner', 10000); + await hudButton(A.page, 'Start match').click(); + await h.eventually(() => snap(low.page), (s) => s?.started === true, '7.18 the match restarts under the race winner', 15000); + await h.eventually(() => snap(high.page), (s) => s?.started === true && s.authority === false, '7.19 the loser follows it and claims no authority', 15000); + await h.eventually(() => snap(low.page), (s) => s?.started && s.serveAt === 0, ' (premise) re-served', 12000); + const before7 = (await snap(low.page)).score.blue; + await teleport(low.page, ball, redPos); + await h.eventually(() => snap(high.page), (s) => s?.score.blue === before7 + 1, `7.20 A GOAL SCORES through the race (blue ${before7} -> ${before7 + 1} on the loser's copy)`, 15000); + await h.eventually(() => snap(C.page), (s) => s?.score.blue === before7 + 1, '7.21 ...and on the spectator', 15000); + for (const p of [A, B, C]) await p.page.evaluate(() => window.__stores.isLocked.set(false)).catch(() => {}); await A.page.waitForTimeout(400); await h.finish(browser); diff --git a/tests/unit/simAuthority.test.js b/tests/unit/simAuthority.test.js new file mode 100644 index 00000000..b8cfa32e --- /dev/null +++ b/tests/unit/simAuthority.test.js @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { simulateVerdict } from '../../src/lib/simAuthority.js'; + +// 29-F. The whole dual-simulator rule is a pure function of four facts, so its truth +// table belongs here rather than behind two browsers: what an e2e run can show is that +// the rule REACHES a real match (game-football section 7); what it cannot show in a +// reasonable time is that the rule is SYMMETRIC — that the two sides of every race read +// the same message pair and reach opposite verdicts, so exactly one world survives. +// That is what these cover, plus the two ways the rule must stay out of the way: an +// older sender with no `peerId`, and a session with no race in it at all. + +/** @param {Record=} over */ +const start = (over) => ({ running: true, mine: 'bbb', theirs: 'aaa', simulating: false, remote: null, ...over }); + +describe('simulateVerdict — the normal session, with no race in it', () => { + it('adopts a peer that starts while we are idle', () => { + expect(simulateVerdict(start())).toBe('adopt'); + }); + it('adopts a pause/resume from the peer we are already watching', () => { + expect(simulateVerdict(start({ remote: 'aaa' }))).toBe('adopt'); + }); + it('clears when the peer we are watching stops', () => { + expect(simulateVerdict(start({ running: false, remote: 'aaa' }))).toBe('clear'); + }); + it('takes a stop with no id at all — the pre-29-F behaviour, verbatim', () => { + expect(simulateVerdict(start({ running: false, theirs: null, remote: 'aaa' }))).toBe('clear'); + }); +}); + +describe('simulateVerdict — the race is SYMMETRIC', () => { + // the two sides of one race, built from ONE pair of ids so the test cannot + // accidentally read two different worlds + /** @param {string} lo @param {string} hi */ + const race = (lo, hi) => [ + // the LOW-id peer hears the high one start + simulateVerdict({ running: true, mine: lo, theirs: hi, simulating: true, remote: null }), + // ...and the HIGH-id peer hears the low one + simulateVerdict({ running: true, mine: hi, theirs: lo, simulating: true, remote: null }) + ]; + + it('elects exactly one winner: the lower id keeps, the higher yields', () => { + expect(race('aaa', 'bbb')).toEqual(['keep', 'yield']); + }); + it('holds whichever way round the ids happen to fall', () => { + expect(race('0f3c1a', 'f001de')).toEqual(['keep', 'yield']); + expect(race('A', 'a')).toEqual(['keep', 'yield']); // '<' is codepoint order, not locale + }); + it('can never elect two winners or none, over a spread of real-shaped ids', () => { + const ids = ['0a1b2c', '4e86d', 'f0f0f0', 'zz', 'ZZ', 'abc123', '9', '-']; + for (const a of ids) + for (const b of ids) { + if (a === b) continue; + const verdicts = [ + simulateVerdict({ running: true, mine: a, theirs: b, simulating: true, remote: null }), + simulateVerdict({ running: true, mine: b, theirs: a, simulating: true, remote: null }) + ]; + expect(verdicts.filter((v) => v === 'keep')).toHaveLength(1); + expect(verdicts.filter((v) => v === 'yield')).toHaveLength(1); + } + }); +}); + +describe('simulateVerdict — a spectator agrees with the racers', () => { + it('keeps the LOWER id when told about two simulators', () => { + // told about 'aaa' first, then 'bbb': the second claim is the loser's + expect(simulateVerdict({ running: true, mine: 'zzz', theirs: 'bbb', simulating: false, remote: 'aaa' })).toBe('ignore'); + }); + it('...and in the other arrival order adopts the lower one over the higher', () => { + expect(simulateVerdict({ running: true, mine: 'zzz', theirs: 'aaa', simulating: false, remote: 'bbb' })).toBe('adopt'); + }); + it('does not blank its view of the winner when the LOSER stops', () => { + // the yielding peer broadcasts running:false on its way out; a spectator + // watching the winner must not read that as "nobody is simulating" — that store + // is what arms the knock probes and play-mode grab (24-A A2) + expect(simulateVerdict({ running: false, mine: 'zzz', theirs: 'bbb', simulating: false, remote: 'aaa' })).toBe('ignore'); + }); +}); + +describe('simulateVerdict — additive: absent = old behaviour', () => { + it('adopts a start from an older sender that carries no peerId, even mid-run', () => { + expect(simulateVerdict(start({ theirs: null, simulating: true }))).toBe('adopt'); + expect(simulateVerdict(start({ theirs: null, simulating: false }))).toBe('adopt'); + }); + it('adopts rather than guessing when we have no id of our own yet', () => { + expect(simulateVerdict(start({ mine: null, simulating: true }))).toBe('adopt'); + }); + it('ignores our own message coming back at us', () => { + expect(simulateVerdict(start({ mine: 'aaa', theirs: 'aaa', simulating: true }))).toBe('ignore'); + expect(simulateVerdict(start({ mine: 'aaa', theirs: 'aaa', simulating: false }))).toBe('ignore'); + }); + it('treats an empty-string id as no id', () => { + expect(simulateVerdict(start({ theirs: '', simulating: true }))).toBe('adopt'); + }); +}); From 22f351e196dcc893f196d0bccfa0a4dcc2474bef Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 20 Sep 2026 13:16:17 +0300 Subject: [PATCH 6/7] [fix] 29-F: the press race asserts the invariant two presses can actually carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FOUND by running the modules football flight against the fix: two real Play presses do NOT reliably race. The first peer's `simulate` often lands before the second peer's guard is read, and then nothing raced at all and whoever pressed first keeps the world — higher id or not. Measured on the flight: A (ce526) kept it while B (33ec2) never started, which is correct behaviour and made a "the lower id wins" assertion red. So the presses assert what they can carry — EXACTLY ONE simulator, and the other peer knowing who it is — and the ID RULE is left to 7b, where the race is forced and has no timing in it. Section 7 then hands the world to the lower id explicitly (a no-op when the presses did race) so 7b starts from the state the rule elects. Without this, 7.1 was a coin: it passed three runs and would have gone red the first time the presses happened not to race. The first counterfactual run had already shown the shape — with the rule removed the HIGHER id kept the world — and that reading was mistaken for the counterfactual biting rather than for what it also was. game-football 134 -> 136 checks, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) --- tests/e2e/game-football.test.cjs | 44 +++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/tests/e2e/game-football.test.cjs b/tests/e2e/game-football.test.cjs index 08ce9c07..e940948e 100644 --- a/tests/e2e/game-football.test.cjs +++ b/tests/e2e/game-football.test.cjs @@ -479,19 +479,40 @@ h.run(async () => { await A.page.waitForTimeout(2600); // the 2 s exit cooldown, so both presses are taken the same way // nothing between the two presses: this IS the window await Promise.all([A.page.locator('#play-button').click(), B.page.locator('#play-button').click()]); + // EXACTLY ONE WORLD is the invariant these presses can carry, and it is deliberately + // NOT "the lower id wins": two presses do not reliably race (the first peer's + // `simulate` often lands before the second's guard is read, and then nothing raced and + // whoever pressed first keeps it, higher id or not). The ID RULE is asserted in 7b, + // where the race is forced and has no timing in it. const low = A.id < B.id ? A : B; const high = A.id < B.id ? B : A; - const lowName = low === A ? 'A' : 'B'; - await h.eventually(() => simOf(low.page), (v) => v.own === true, `7.1 the LOWER peer id keeps the world (${lowName}: ${low.id} < ${high.id})`, 20000); - await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, '7.2 the higher id YIELDED and adopted the winner', 20000); - h.check((await simOf(low.page)).remote === null, '7.3 ...and the winner never recorded the loser as a simulator'); - await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === low.id, '7.4 C (a spectator to the race) agrees on the same winner', 20000); - // the measured shape, directly: the loser's stream must leave nothing pinned + await h.eventually( + () => Promise.all([simOf(A.page), simOf(B.page)]), + ([a, b]) => (a.own ? !b.own && b.remote === A.id : b.own && a.remote === B.id), + '7.1 the two presses leave exactly ONE simulator, and the other knows who it is', + 25000 + ); + const holder = (await simOf(A.page)).own ? A : B; + const follower = holder === A ? B : A; + h.check((await simOf(holder.page)).remote === null, '7.2 the peer stepping the world recorded nobody else as a simulator'); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === holder.id, '7.3 C (a spectator) agrees on the same one', 20000); + // the measured shape, directly: a loser's stream must leave nothing pinned const heldBy = (page, id) => page.evaluate((id) => window.__stores.physics.physicsDebug().filter((e) => e.hold === 'external' && e.holdPeer === id).length, id); - await low.page.waitForTimeout(1500); - const pinned = await heldBy(low.page, high.id); - h.check(pinned === 0, `7.5 no body on the winner is pinned by the loser's move stream (${pinned})`); + await holder.page.waitForTimeout(1500); + const pinned = await heldBy(holder.page, follower.id); + h.check(pinned === 0, `7.4 no body on it is pinned by the other peer's move stream (${pinned})`); + + // hand the world to the LOWER id, so 7b starts from the state the rule elects (when the + // presses DID race that is already true and this is a no-op) + if (holder !== low) { + await holder.page.evaluate(() => window.__stores.physics.stopSimulation()); + await h.eventually(() => simOf(low.page), (v) => v.own === false && v.remote === null, ' (premise) the pitch is idle', 10000); + await low.page.evaluate(() => window.__stores.physics.toggleSimulation()); + } + await h.eventually(() => simOf(low.page), (v) => v.own === true, `7.5 the lower id holds the world (${low === A ? 'A' : 'B'}: ${low.id} < ${high.id})`, 20000); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, ' (premise) the higher id follows it', 20000); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === low.id, ' (premise) and so does the spectator', 20000); // ---- 7b. THE RACE, FORCED, BOTH WAYS --------------------------------------------------------- // Two real presses do not RELIABLY race — sometimes the first peer's `simulate` lands @@ -503,6 +524,11 @@ h.run(async () => { // That peer never receives a start message of its own to reason about, so the winner // has to ANSWER a competing claim with its own start, and these are the only checks // that cover that half of the rule. + // NOTE, measured: there is deliberately no "the intruder really started" premise here. + // The forced world lives for about a fifth of a second before it yields, which is + // shorter than `eventually`'s poll, so such a premise reads {own:false} and fails on a + // race that DID happen. What proves these two are not vacuous is the counterfactual: + // remove the winner's re-announce and 7.6 goes red, which a vacuous check cannot do. const forceStart = (peer) => peer.page.evaluate(() => { const p = window.__stores.physics; From cd7562e3f18925559e9a5340c72a55b5be17aa87 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 22 Sep 2026 00:45:39 +0300 Subject: [PATCH 7/7] [docs] 1.16.0 "Waves, and one world to keep": changelog and CLAUDE.md status entry - CHANGELOG: the 1.16.0 section (the Waves game + the format-2/format-1 content refs, the simulate-race rule + the external-hold knock fix, the open-core-m1 housekeeping line), in the wording the four 29f lanes wrote for the integrator. - CLAUDE.md: the round-3 status entry; the older duplicate of the "TWO PLAY PRESSES" gotcha (round 1, still calling the rule an open ticket) dropped in favour of the copy PR #236 wrote. - Gates on the union (7d0010d = #233 #234 #235 #236 over v1.15.1): build green, svelte-check 336/47 identical list, vitest 196; battery under the e2e lock: open-core-m1 18/0, dial-metadata 44/0 (first run 29/1 "context destroyed by navigation", green on re-run), ai-presets 46/0, scene-physics-state 37/0, session-scenes 36/0, knock-node 50/0, knock-physics 77/0, throw-peer 28/0, play-interact 46/0, game-towers 20/0, game-stars-room 36/0, templates-modal 45/0, template-modules 27/0, game-untangle 46/0, game-dungeon-realms 64/0, game-football 136/0, game-waves 90/0; modules flights football 93/0, door-keypad 13/0. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ CLAUDE.md | 26 +++++++++++++++++--------- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19a4a5f5..f5ee71c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.16.0 — Waves, and one world to keep 🌊 + +### 🎮 A new game, and a Games tab that can move again + +- 🌊 **Waves — a new game on the Games tab.** Hold the goal against three waves of enemies + walking in from the spawn pads; knock them down, the round ends when the last one falls. Two + players plus a late joiner see the same wave, and every kill is credited to the hand that made + it. (Needs the `health` and `waves` modules; the card offers to install them.) +- 🎮 **Seven games, not three.** The scenes and packs feeds are pinned to `format-2` / `format-1` + refs instead of `v2` / `v1`. jsDelivr treated those as immutable version numbers, so the Games + tab could never see a scene released after the first resolve (#230). + +### 🥊 Physics that holds up with two players + +- ⚖️ **Two players pressing Play at the same moment no longer start two physics simulations.** + The lower peer id keeps the world and the other hands it over cleanly — a toast names the + handover, and a third player watching agrees with them. +- 🥊 **Knocking a moving target works on the host too.** A body another writer was moving (a + module walking it, a peer's stream) read as "carried" to the knock on the peer running the + simulation, so the host's hand passed through it. The hit is now logged and shared; the shove + alone is skipped. + +### 🧹 Housekeeping + +- 🧪 `open-core-m1`'s drawer-mount check had been asserting the pre-tabbed connect drawer since + 2026-07-25 and was carried as a known red ever since; it now drives the Rooms shortcut the app + actually offers, and the suite is green. No product change. + ## 1.15.1 — Knock, and a key you never typed 🔑 ### 🚪 Gated cloud rooms work end to end diff --git a/CLAUDE.md b/CLAUDE.md index 62c38d51..9de987e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4396,15 +4396,6 @@ loadable play content. Everything a user does must be visible to connected peers suite that polls `flowValues` for "all N lit" can only ever catch it for one publish. Assert the TRIGGER LOG instead, folded to seconds-of-day the way `retiredByRound` does. -- **TWO PLAY PRESSES INSIDE THE SIM START-UP WINDOW START TWO SIMULATORS.** `playMode - .maybeSimOnPlay` guards on `simulating || remoteSimulating`, and the `simulate` message has - not landed yet when the second peer's guard runs — so both simulate. Measured (24-B R1): after - B's hit the ball on A sat under a permanent `hold: external` fed by B's 30 Hz `move`s (74 in - ~2 s), applyThrow snapped back, no goal could score. The same shape for a late joiner if the - handshake `simulate` push is missing. `game-football` enters Play in ORDER and asserts it; the - modules football flight clicks both Play buttons back-to-back and rides the race. Open ticket: - a peer receiving `simulate` while simulating must yield by a deterministic rule (lower peer id - keeps it). - **A MESH NAME WITH A SPACE ARRIVES UNDERSCORED ON THE PEER** over the object sync (`Entrance plinth` -> `Entrance_plinth`; a LIGHT keeps its space). Graphs bind by uuid so games work; a suite asserting a peer's objects must do so by UUID (`game-dungeon-realms`). @@ -4799,6 +4790,23 @@ override for e2e — never share 5173 (the user's main-checkout server). locked (replicate the INDEX per-item opt-in; ONE mesh with scenes as tags; scene-is-primary renaming), and the vocabulary settled: **session = the mesh, room = who is in a scene, PocketBase rooms stay DISCOVERY** — that naming blocks R4. +- Status (2026-09-22): **1.16.0 "Waves, and one world to keep" — ROADMAP 29 ROUND 3, the follow-up + round closed by the integrator (lane `29f-integrate`).** Merged: core #236 + modules #14 (the + simulate-race rule, `simAuthority.js`: a peer receiving `simulate` while simulating yields to the + LOWER peer id — see the gotcha and the architecture bullet beside `throwVelocity`), on top of the + already-merged #233 (the standing `open-core-m1` red was the TEST asserting the pre-tabbed drawer + since 2026-07-25 — 18/18 now), #235 (content refs `scenes@format-2` / `packs@format-1`, core #230) + and #234 (the `waves` template def in `MODULE_DEFS` + suite `game-waves`, and the knock fix: a body + under an EXTERNAL physics hold — a module walking it — is HIT, not carried, on the initiator too). + Scenes: the `games/waves` row released and `format-2` retagged (NEVER `v2`); modules `dev` → `main` + (waves def env `sunset`, the door-keypad + football flight fixes) and the `waves` index row gained + `"template": "games/waves"` after the scene was served. Cloud: deployed from the tag with the + `VITE_SCENES_BASE=…scenes@main` stopgap REMOVED from `.env.deploy` (core defaults to `format-2` + now). Gates on the union: svelte-check 336/47 (identical list), vitest 196, build green, the serial + battery green (see the round-3 execution-log entry in the roadmap 29 master for the counts). OWED + on device: the two-player Play race handover toast + ball continuity at the yield, being hit in VR + in Waves, the Waves card thumbnail framing (`thumb.camera` in the def), the walkers stacking at the + goal, and one EYEBALL: the production Games tab shows seven cards. - Status (2026-09-20): **1.15.1 "Knock, and a key you never typed" — ROADMAP 29 ROUND 2, the two core seams, taken IN-HOUSE by the integrator.** The `29-core-seams` lane had sat ~11 h at a budget checkpoint with nothing committed and a resume prompt nobody sent, so `feat/1.15.1` (off