diff --git a/.claude/skills/e2e-verify/SKILL.md b/.claude/skills/e2e-verify/SKILL.md index ba77100a..48ea8a09 100644 --- a/.claude/skills/e2e-verify/SKILL.md +++ b/.claude/skills/e2e-verify/SKILL.md @@ -136,13 +136,16 @@ 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: 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) @@ -969,7 +972,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 +1115,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 +1135,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/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 19748088..9de987e5 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) @@ -2129,7 +2146,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 +2260,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 @@ -2719,6 +2750,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 @@ -4347,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`). @@ -4750,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 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..9d244779 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' } }; @@ -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/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/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/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/physics.js b/src/lib/physics.js index 0c278a50..1ed12567 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. * @@ -1280,9 +1310,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 +1324,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; @@ -1649,8 +1683,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 +1713,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 +1819,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/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/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-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..c476ccfb 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, '../../..'); @@ -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,169 @@ 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()]); + // 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; + 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 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 + // 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. + // 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; + 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/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/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); +}); 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); 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); + }); +}); 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'); + }); +});