From 4ac544af06acd1b96bb527d4b5422b4ec9d11a43 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 20 Sep 2026 12:39:49 +0300 Subject: [PATCH 1/2] [fix] 29-F: a peer receiving `simulate` while simulating yields by a deterministic rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE BUG, measured on a real two-peer Football match (24-B's handover): two Play presses inside the sim's start-up window both pass `playMode.maybeSimOnPlay`'s `simulating || remoteSimulating` guard, because the other side's `simulate` has not landed yet. Both peers then step a world and broadcast `move` at 30 Hz, each stream reads as an EXTERNAL write on the other, and every dynamic body sits under a `hold: 'external'` refreshed long before its 250 ms timeout can expire — 74 moves in ~2 s, the ball snapping back, `applyThrow` eaten, NO GOAL COULD SCORE. A late joiner that is already simulating meets the same shape. THE RULE: **the lower peer id keeps the world.** The guard cannot be fixed where it stands — a peer cannot know it is racing until the message arrives — so the rule is on the RECEIVE side, and it reads only two facts both sides already hold (my id, the id in the message), so both reach the same verdict with no round trip and no new message. Checked against what is already there before committing to it: PeerJS ids are non-empty strings and `<` is a total order, so exactly one winner is elected; `remoteSimulating` is already set from that same `peerId`; the handshake push is symmetric (both sides send one), so a joiner race resolves the same way; and the football module's `isAuthority()` ALREADY falls back to the lowest live id when no sim runs, so core's winner and a module's fallback authority are one peer by construction. - `src/lib/simAuthority.js` (NEW, imports nothing): `simulateVerdict` -> keep | yield | adopt | clear | ignore, with the reasoning. ADDITIVE: a message with no `peerId` (an older build) takes the pre-29-F path verbatim, and a session with no race in it never reaches a verdict but `adopt` and `clear`. - `physics.applySimulate` acts on the verdict. YIELDING IS CLEAN, NOT MERELY QUIET: `stopSimulation({yielded: true})` withholds the settling `move` per body (each would pin one of the winner's copies one last time — the very shape the yield exists to end) and the transformSet undo entry (Ctrl+Z over a layout nobody ever saw). - The winner has two mirror duties: `releaseExternalHoldsBy(peerId)` drops the holds the loser's moves had already claimed (the same `releaseHold` the 250 ms timeout would run, only sooner), and it ANSWERS the competing claim with its own start — redundant in an ordinary race, where the two starts cross, and the only thing that ever reaches a peer which never heard ours (one that travelled into the room after the run began: the push rides `sendHandshake` and is not repeated on arrival). - A SPECTATOR agrees with the racers: told about two simulators it keeps the lower id, and a stop from a peer it was not watching no longer blanks `remoteSimulating` — that store is what arms the knock probes and play-mode grab (24-A A2), so blanking it silently disarms a third peer mid-match. - `tests/unit/simAuthority.test.js` (14): the truth table, including that the two sides of a race reach OPPOSITE verdicts over a spread of real-shaped ids — the property two browsers cannot show in reasonable time. - `tests/e2e/game-football.test.cjs` section 7 (+32 checks, 102 -> 134): the race as it happens (both presses, nothing between them), then FORCED both ways because two presses do not reliably race, then the goal that scores. Section 2 keeps its ordered entry so the rest of the suite has a known authority. Counterfactuals, each broken and restored byte-identically: - the whole rule -> `adopt`: 111/8, section 7 red incl. "A GOAL SCORES" (and the winner was the HIGHER id that run — the nondeterminism the rule removes). - the `yielded` suppression removed: 132/3 — one settling move, undo 4 -> 5. - the spectator start rule removed: 132/3 — the spectator adopts the higher id. - the stop-side `ignore` removed: 129/6 — a loser's stop blanks the spectator. - the keep re-announce removed: 131/4 — the forced higher-id yield never resolves. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 35 ++++++++ src/lib/physics.js | 107 ++++++++++++++++++++-- src/lib/simAuthority.js | 77 ++++++++++++++++ tests/e2e/game-football.test.cjs | 150 +++++++++++++++++++++++++++++-- tests/unit/simAuthority.test.js | 94 +++++++++++++++++++ 5 files changed, 448 insertions(+), 15 deletions(-) create mode 100644 src/lib/simAuthority.js create mode 100644 tests/unit/simAuthority.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 19748088..50b65fdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -986,6 +986,23 @@ loadable play content. Everything a user does must be visible to connected peers (Euler differencing is wrong across a wrap and wrong in general — YXZ couples the axes) and a MAGNITUDE clamp (per-component clamping ROTATES the throw; measured 4.6 degrees off on a skewed vector). + · `simAuthority.js` (29-F, imports NOTHING) = `simulateVerdict`, the rule that ends a + DUAL-SIMULATOR race in one pure function of four facts (are we simulating, our id, + theirs, who we thought was stepping the world) -> keep | yield | adopt | clear | ignore. + **THE LOWER PEER ID KEEPS THE WORLD**, which both sides compute from data they already + hold, so no round trip and no new message decides it — and it is the SAME tie-break the + football module's `isAuthority()` already falls back to with no sim running, so core's + winner and a module's fallback authority are one peer by construction. `applySimulate` + is the only place the rule can live (a peer cannot know it is racing until the other + side's message lands, which is exactly what `maybeSimOnPlay`'s guard is still waiting + for), and `ignore` is what keeps a SPECTATOR honest: told about two simulators it keeps + the lower id, and a stop from a peer it was not watching must not blank + `remoteSimulating` — that store is what arms the knock probes and play-mode grab. + Yielding is `stopSimulation({yielded: true})`: see the gotcha for why quiet is not + enough. `keep` also ANSWERS with our own start — redundant in an ordinary race, where the + two starts cross, and the only thing that reaches a peer which never heard ours (one that + travelled in after the run began: the push rides `sendHandshake` and is not repeated on + arrival). Additive — a message with no `peerId` takes the pre-29-F path verbatim. · `playInteract.js` = play mode's own input path, deliberately NOT a lift of Scene's pick (the editor's select branch is a short STATIONARY click, its `$isLocked` bails guard six editor modes, and play mode's ray is NDC (0,0) @@ -2719,6 +2736,24 @@ loadable play content. Everything a user does must be visible to connected peers - **Never run `npm run build` while the lane's `vite dev` watches the same worktree** — it rewrites `.svelte-kit/output` under the server and kills it; the next ten suites report `ERR_CONNECTION_REFUSED`, which reads as a mass regression. +- **TWO PLAY PRESSES INSIDE THE SIM'S START-UP WINDOW START TWO SIMULATORS.** + `playMode.maybeSimOnPlay` guards on `simulating || remoteSimulating`, and both are still + FALSE on both peers until the other side's `simulate` arrives — a window that spans + `warmup()` plus the whole of `startSimulation`, so presses a second apart still both pass + it. Two authorities then broadcast `move` at 30 Hz, each stream reads as an EXTERNAL write + on the other, and every dynamic body sits under a `hold: 'external'` refreshed long before + its 250 ms timeout can expire. MEASURED on a real two-peer Football match: 74 moves in + ~2 s, the ball snapping back, `applyThrow` eaten, and NO GOAL COULD SCORE. Note what a + suite has to assert here: "the peer we expect is simulating" reads TRUE while both of + them are, so the load-bearing check is that a goal SCORES. Same shape for a + late joiner that is already simulating when the handshake `simulate` push lands + (symmetric: both sides push). The guard cannot be fixed where it stands, so the rule is + on the RECEIVE side (`simAuthority.js`, 29-F): the lower peer id keeps the world. + YIELDING MUST BE CLEAN, NOT MERELY QUIET — `stopSimulation({yielded: true})` also + withholds the settling `move` per body (which would pin every one of the winner's copies + one last time, the very shape the yield exists to end) and the transformSet undo entry + (Ctrl+Z over a layout nobody ever saw); and the winner drops the holds the loser's stream + already claimed instead of waiting out their timeout. - **A HELD body's `lastWritten` is stale by definition, so every release must refresh it.** The write-back skips a held body, so `lastWritten` still describes the pose it had when it was GRABBED — and the deviation detector diff --git a/src/lib/physics.js b/src/lib/physics.js index 0c278a50..c92e6d77 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -28,6 +28,9 @@ import { sceneKnock } from './scenePhysics'; import { velocityFromSamples, clampThrow, MAX_LINVEL, MAX_ANGVEL } from './throwVelocity'; +// 29-F: the lower-id-keeps-the-world rule, as a leaf that imports nothing — the whole +// decision is a pure function of four facts, so its truth table is a vitest unit. +import { simulateVerdict } from './simAuthority'; // B7: spawned objects are swept when the run ends. transientObjects is a LEAF (the two // stores only), so this edge closes nothing — unlike objectActions, which the // out-of-bounds delete has to reach dynamically. @@ -1197,6 +1200,33 @@ export function physicsExternalMove(uuid, peerId = null) { return true; } +/** + * 29-F: drop the external holds ONE peer's move stream claimed, now rather than at the + * 250 ms timeout. + * + * Called when that peer's stream is known to have ended — it yielded a Play race to us, + * or it told us its run stopped. Without this the bodies it was dragging stay kinematic + * for a further quarter of a second after there is anything left to drag them, which on a + * ball in flight is a visible stall; with it, the release is the SAME release the timeout + * would have performed (`releaseHold`'s own sample-derived estimate, so the body carries + * on along the path it was already on) and only the timing changes. + * + * Deliberately NOT extended to `physicsPeerDisconnected`: a disconnect already has the + * timeout as its answer, and a peer that dropped mid-carry has no "ended cleanly" moment + * to hang an immediate release on. + * @param {string|null|undefined} peerId @returns {number} how many were released + */ +function releaseExternalHoldsBy(peerId) { + if (!world || !peerId || !get(simulating)) return 0; + let released = 0; + bodies.forEach((entry) => { + if (entry.hold !== 'external' || entry.holdPeer !== peerId) return; + releaseHold(entry); + released++; + }); + return released; +} + /** * B5: a peer released something they were carrying, and told us EXACTLY how. * @@ -1649,8 +1679,9 @@ export function pauseSimulation(paused) { if (peer) peer.send({ type: 'simulate', running: true, paused: next, peerId: peer.peer.id }); } -/** @param {{reset?: boolean, reason?: string}=} opts reset restores the initial layout - * (no undo entry); 27-C passes a `reason` when a failing step stops the run. */ +/** @param {{reset?: boolean, reason?: string, yielded?: boolean}=} opts reset restores the + * initial layout (no undo entry); 27-C passes a `reason` when a failing step stops the run; + * 29-F passes `yielded` when this run lost a Play race (see below). */ export function stopSimulation(opts = {}) { if (!get(simulating)) return; setPostTick(null); // clear the hook BEFORE freeing the world @@ -1678,10 +1709,18 @@ export function stopSimulation(opts = {}) { object.scale.fromArray(before.scale); } const after = transformOf(object); - if (!opts.reset && JSON.stringify(before) !== JSON.stringify(after)) + // 29-F: A YIELDED RUN LEAVES NOTHING BEHIND. This run lost the race, so its poses + // were never authoritative and the winner's stream is the truth — broadcasting a + // settling `move` per body would put the WINNER's copy of every one of them under + // a fresh `hold: 'external'` on the way out (the exact shape the yield exists to + // end), and an undo entry would offer Ctrl+Z over a layout nobody ever saw. + // `notifyExternalMove` still runs either way: our local poses are about to be + // replaced by the winner's stream, and a half-applied interpolation must not + // survive that. + if (!opts.reset && !opts.yielded && JSON.stringify(before) !== JSON.stringify(after)) items.push({ uuid, before, after }); notifyExternalMove(uuid); - if (peer) + if (peer && !opts.yielded) peer.send({ type: 'move', uuid: uuid, pos: after.pos, rot: after.rot, scale: after.scale }); }); if (items.length > 0) recordTransformSet(items); @@ -1776,12 +1815,64 @@ export function setBodyVelocity(uuid, linvel, angvel) { return true; } -/** @param {any} data */ +/** + * A peer's run started, stopped or paused. + * + * 29-F: this is also where a DUAL-SIMULATOR RACE is resolved, and it is the only place + * it can be — a peer cannot know it is racing until the other side's message lands, which + * is precisely what `maybeSimOnPlay`'s "nothing is running anywhere" guard is still + * waiting for when both presses go through. `simulateVerdict` holds the rule (lower peer + * id keeps the world) and the reasoning; everything below is what each verdict COSTS. + * + * Yielding has to be clean, not merely quiet: the loser's 30 Hz `move` stream is what + * pins every one of the winner's bodies under a permanent `hold: 'external'`, so the run + * must actually end (`stopSimulation` clears the post-tick hook, which is what stops the + * stream) and must end without broadcasting the settling moves that would pin them one + * last time. The winner has two mirror duties: the moves that arrived before the verdict + * did have already claimed holds in its world, and those are dropped here; and it answers + * the competing claim with its own start, which is what reaches a peer that never heard + * the first one. + * @param {any} data + */ export function applySimulate(data) { - remoteSimulating.set(data.running ? data.peerId : null); + /** @type {any} */ + const peer = get(peers); + const theirs = typeof data?.peerId === 'string' ? data.peerId : null; + const verdict = simulateVerdict({ + running: !!data?.running, + mine: peer?.peer?.id ?? null, + theirs, + simulating: get(simulating) === true, + remote: get(remoteSimulating) + }); + if (verdict === 'ignore') { + // a stop from a peer we were not watching still ends ITS stream, so the holds it + // claimed in our world can go now (the race's loser sends exactly this) + if (!data?.running) releaseExternalHoldsBy(theirs); + return; + } + if (verdict === 'keep') { + releaseExternalHoldsBy(theirs); + // AND TELL THEM. In an ordinary race the two starts cross, so the loser reaches + // its own verdict from ours and this is redundant. It is not redundant for a peer + // that never heard our start at all — one that travelled into this room after the + // run began, since the `simulate` push rides `sendHandshake` and is not repeated + // on arrival — because nothing else will ever tell it, and it would step a second + // world forever. At most ONE of these per race (only the keeper sends, and the + // loser answers with a stop we `ignore`), so it cannot storm. + if (peer) peer.send({ type: 'simulate', running: true, paused: get(simPaused), peerId: peer.peer.id }); + return; + } + if (verdict === 'yield') { + stopSimulation({ yielded: true }); + showToast(nameOf(data.peerId) + ' is simulating too — handing the physics over (lower id keeps it)'); + } + if (verdict === 'clear') releaseExternalHoldsBy(theirs); + remoteSimulating.set(data?.running ? theirs : null); // a finished run must not leave an interpolation half-applied - if (!data.running) import('./moveSmoothing').then((m) => m.clearMoveSmoothing()).catch(() => {}); - if (data.running && !data.paused) showToast('▶ ' + nameOf(data.peerId) + ' is simulating physics'); + if (!data?.running) import('./moveSmoothing').then((m) => m.clearMoveSmoothing()).catch(() => {}); + if (data?.running && !data?.paused && verdict !== 'yield') + showToast('▶ ' + nameOf(data.peerId) + ' is simulating physics'); } /** @param {string} peerId */ diff --git a/src/lib/simAuthority.js b/src/lib/simAuthority.js new file mode 100644 index 00000000..079c8c86 --- /dev/null +++ b/src/lib/simAuthority.js @@ -0,0 +1,77 @@ +// 29-F: WHO KEEPS THE WORLD when two peers start simulating at once. +// +// THE BUG THIS EXISTS FOR, measured on a real two-peer Football match (24-B's handover): +// `playMode.maybeSimOnPlay` guards on `simulating || remoteSimulating`, and both are +// still false on BOTH peers for as long as it takes the other side's `simulate` message +// to arrive — a window that spans `warmup()` plus the whole of `startSimulation`, so two +// Play presses a second apart can still both pass it. Both peers then step a world and +// broadcast `move` at 30 Hz, each one's stream reads as an EXTERNAL write on the other, +// and every dynamic body sits under a `hold: 'external'` that is refreshed before its +// 250 ms timeout can ever expire. Measured: 74 moves in ~2 s, the ball snapping back +// under a permanent hold, `applyThrow` eaten, and NO GOAL COULD SCORE. A late joiner +// that is already simulating meets the same shape through the handshake push. +// +// THE RULE: **the lower peer id keeps the world.** It needs no negotiation and no new +// message, because the only two facts it reads — my id and the id in the message we just +// received — are already on both sides, so both peers reach the same verdict from the +// same data with no round trip. PeerJS ids are non-empty strings, stable for the life of +// a connection and compared with `<`, which is a TOTAL order: exactly one of two distinct +// ids is lower, so the rule can never elect two winners or none. (Our own id is the one +// the signalling server handed us, not something a message can claim — a peer cannot lie +// its way into keeping the world without also being the peer that owns that id.) +// +// ADDITIVE, absent = old behaviour: a message with no `peerId` (an older build) cannot be +// compared, so it takes the pre-29-F path verbatim — `adopt` — and a session with no race +// in it never reaches any verdict but `adopt` and `clear`. +// +// A LEAF that imports NOTHING, so the truth table is a vitest unit and the decision can +// be read without a browser, a peer or rapier (the `sessionClock`/`netBackoff` shape). + +/** + * The verdict for one incoming `simulate` message. + * + * - `adopt` — record them as the simulator (the old behaviour, and the normal one) + * - `keep` — we are simulating and we won: stay authoritative, ignore their claim + * - `yield` — we are simulating and we lost: stop, then adopt them + * - `clear` — their run ended and it was the one we were watching + * - `ignore` — the message says nothing about the peer we believe is stepping the world + * + * `ignore` on a STOP is what keeps a three-peer race honest: the loser of a race + * broadcasts `running: false` on its way out, and a spectator that had recorded the + * WINNER must not blank its `remoteSimulating` because a peer it was not watching + * stopped — that store is what arms the knock probes and play-mode grab (24-A A2), so + * blanking it silently disarms a spectator mid-match. `ignore` on a START is the same + * rule from the other side: a spectator told about two simulators keeps the LOWER id, so + * every peer in the mesh — not just the two racing — agrees on who the authority is. + * + * @param {object} state + * @param {boolean} state.running the message's `running` flag + * @param {string|null|undefined} state.mine our own peer id (null when we have none yet) + * @param {string|null|undefined} state.theirs the message's `peerId` (absent on older builds) + * @param {boolean} state.simulating whether WE are stepping a world right now + * @param {string|null|undefined} state.remote the peer we currently believe is stepping one + * @returns {'adopt'|'keep'|'yield'|'clear'|'ignore'} + */ +export function simulateVerdict({ running, mine, theirs, simulating, remote }) { + const them = typeof theirs === 'string' && theirs ? theirs : null; + const me = typeof mine === 'string' && mine ? mine : null; + const watching = typeof remote === 'string' && remote ? remote : null; + + if (!running) { + // no id to match against: the pre-29-F behaviour, which is to take any stop + if (!them) return 'clear'; + return watching === them ? 'clear' : 'ignore'; + } + // our own message coming back at us is not evidence about anybody else + if (them && me && them === me) return 'ignore'; + if (simulating) { + // nothing to compare (an older sender, or no id of our own yet): old behaviour + if (!them || !me) return 'adopt'; + return them < me ? 'yield' : 'keep'; + } + // not simulating. A start from a peer with a HIGHER id than the one we already + // believe is stepping the world is the losing half of a race we are watching from + // outside; the same comparison both racers make tells us to keep the lower one. + if (them && watching && watching !== them && watching < them) return 'ignore'; + return 'adopt'; +} diff --git a/tests/e2e/game-football.test.cjs b/tests/e2e/game-football.test.cjs index d78a5171..08ce9c07 100644 --- a/tests/e2e/game-football.test.cjs +++ b/tests/e2e/game-football.test.cjs @@ -294,13 +294,12 @@ h.run(async () => { await h.eventually(() => B.page.evaluate(() => window.__stores.scenePhysics.scenePhysicsDebug()), (p) => p.gravity === 0 && p.knock?.enabled === true, '1.12 B: the physics block reached B'); // ---- 2. play + the menu screen ----------------------------------------------------------- - // Play is entered in ORDER: A first, and B only once it has HEARD that A simulates. - // Two Play presses inside the sim's start-up window both pass maybeSimOnPlay's - // "nothing is running anywhere" guard (the `simulate` message has not landed yet), so - // BOTH peers simulate and every body is fought over by two authorities — measured - // here: a parked ball snapped back under a permanent `hold: external` fed by the other - // simulator's 30 Hz moves, and no goal could score. A core race, recorded for the - // integrator; this suite asserts the single-simulator premise instead of riding it. + // Play is entered in ORDER here: A first, and B only once it has HEARD that A + // simulates, so the rest of this suite has a KNOWN authority to drive (the touch and + // teleport helpers both take the authority's page). The race — both presses inside the + // sim's start-up window, where maybeSimOnPlay's "nothing is running anywhere" guard is + // still true on both peers — is run for real in section 7, where nothing downstream + // depends on which peer wins it. await A.page.locator('#play-button').click(); await h.eventually(() => simOf(A.page), (v) => v.own === true, '2.1 A simulates (simOnPlay)'); await h.eventually(() => simOf(B.page), (v) => v.remote === A.id, '2.2 B knows A simulates'); @@ -460,6 +459,143 @@ h.run(async () => { await h.eventually(() => screenOf(A.page), (v) => v === 'menu', '6.12 A sees the menu again', 6000); h.check((await myVar(B.page, 'goals')) === 1 && (await snap(B.page)).log.length === 1, '6.13 the session sheet and the saved log survive a new match'); + // ---- 7. THE PLAY RACE: two presses inside the sim's start-up window ----------------------------- + // 29-F. `maybeSimOnPlay` guards on "nothing is running anywhere", and that is still TRUE on + // both peers for as long as it takes the other side's `simulate` to arrive — a window that + // spans `warmup()` and the whole of `startSimulation`. Two presses inside it therefore both + // pass, both peers step a world, and each one's 30 Hz `move` stream pins every one of the + // other's bodies under a `hold: 'external'` that is refreshed long before its 250 ms timeout: + // measured as a ball that snapped back, an eaten `applyThrow` and NO GOAL COULD SCORE. + // The rule that ends it is computed from data both sides already hold — the LOWER PEER ID + // KEEPS THE WORLD — so it costs no round trip and no new message. The football module's own + // no-sim tie-break is the same one (`isAuthority` sorts the live ids), so core's winner and + // the module's fallback authority are the same peer by construction. + // Note what this section asserts and 2.2b cannot: "one simulator" was TRUE while the ball was + // unplayable, so the goal at the end is the check that matters. + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + await h.eventually(() => simOf(B.page), (v) => v.own === false && v.remote === null, ' (premise) the pitch is idle on B', 10000); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === null, ' (premise) ...and on C', 10000); + for (const p of [A, B]) await p.page.evaluate(() => window.__stores.isLocked.set(false)); + await A.page.waitForTimeout(2600); // the 2 s exit cooldown, so both presses are taken the same way + // nothing between the two presses: this IS the window + await Promise.all([A.page.locator('#play-button').click(), B.page.locator('#play-button').click()]); + const low = A.id < B.id ? A : B; + const high = A.id < B.id ? B : A; + const lowName = low === A ? 'A' : 'B'; + await h.eventually(() => simOf(low.page), (v) => v.own === true, `7.1 the LOWER peer id keeps the world (${lowName}: ${low.id} < ${high.id})`, 20000); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, '7.2 the higher id YIELDED and adopted the winner', 20000); + h.check((await simOf(low.page)).remote === null, '7.3 ...and the winner never recorded the loser as a simulator'); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === low.id, '7.4 C (a spectator to the race) agrees on the same winner', 20000); + // the measured shape, directly: the loser's stream must leave nothing pinned + const heldBy = (page, id) => + page.evaluate((id) => window.__stores.physics.physicsDebug().filter((e) => e.hold === 'external' && e.holdPeer === id).length, id); + await low.page.waitForTimeout(1500); + const pinned = await heldBy(low.page, high.id); + h.check(pinned === 0, `7.5 no body on the winner is pinned by the loser's move stream (${pinned})`); + + // ---- 7b. THE RACE, FORCED, BOTH WAYS --------------------------------------------------------- + // Two real presses do not RELIABLY race — sometimes the first peer's `simulate` lands + // before the second one's guard is read, and then 7.1-7.5 are true because nothing + // raced at all. So force it, in the one shape that has no timing in it: clearing + // `remoteSimulating` is exactly what a peer that never heard the start looks like (it + // travelled into this room after the run began — the handshake push rides + // `sendHandshake` and is not repeated on arrival), and its own Play then goes through. + // That peer never receives a start message of its own to reason about, so the winner + // has to ANSWER a competing claim with its own start, and these are the only checks + // that cover that half of the rule. + const forceStart = (peer) => + peer.page.evaluate(() => { + const p = window.__stores.physics; + p.remoteSimulating.set(null); + return p.toggleSimulation(); + }); + await forceStart(high); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, '7.6 a forced second world on the HIGHER id yields to the lower one', 25000); + h.check((await simOf(low.page)).own === true && (await simOf(low.page)).remote === null, '7.7 ...and the lower id kept stepping throughout, watching nobody'); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === low.id, '7.8 the spectator never moved off the winner', 10000); + const pinned2 = await heldBy(low.page, high.id); + h.check(pinned2 === 0, `7.9 nothing left pinned after the forced yield (${pinned2})`); + + // and the other way round: the LOWER id arriving on a world the HIGHER one holds + await low.page.evaluate(() => window.__stores.physics.stopSimulation()); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === null, ' (premise) the pitch is idle again', 10000); + await high.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually(() => simOf(low.page), (v) => v.own === false && v.remote === high.id, ' (premise) the higher id holds the world', 20000); + await forceStart(low); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, '7.10 ...and a forced world on the LOWER id takes it BACK from the higher one', 25000); + h.check((await simOf(low.page)).own === true, '7.11 the lower id holds it'); + // the yielded peer's stream ended, so its holds go on OUR side too — the `ignore`-a-stop + // release path (our `remoteSimulating` is null here, so the stop matches nobody) + await h.eventually(() => heldBy(low.page, high.id), (n) => n === 0, '7.12 ...with nothing left pinned by the world it took over', 10000); + + // ---- 7c. the two halves a race cannot prove, driven directly --------------------------------- + // THE SPECTATOR HALF FIRST, while C is still watching the winner: which of two competing + // starts reaches a third peer LAST is a coin, so the arbitration is driven through the + // real applier with ids whose order is known (`low.id + 'zzz'` is strictly greater than + // `low.id` for any id). MEASURED: with the spectator rule removed the race above stays + // green, so these two are its only cover. + const cRemote = () => C.page.evaluate(() => new Promise((r) => window.__stores.physics.remoteSimulating.subscribe(r)())); + h.check((await cRemote()) === low.id, ' (premise) the spectator is watching the winner'); + await C.page.evaluate((id) => window.__stores.physics.applySimulate({ running: true, paused: false, peerId: id + 'zzz' }), low.id); + const cAfterStart = await cRemote(); + h.check(cAfterStart === low.id, `7.13 a spectator told about a HIGHER-id simulator keeps the lower one (${cAfterStart})`); + await C.page.evaluate((id) => window.__stores.physics.applySimulate({ running: false, peerId: id + 'zzz' }), low.id); + const cAfterStop = await cRemote(); + h.check(cAfterStop === low.id, `7.14 ...and a stop from a peer it was not watching does not blank it (${cAfterStop})`); + + // THE `yielded` HALF. A yield resolves in about a tenth of a second, so the run it ends + // has barely moved anything and its settling broadcast is invisible in the aggregate — + // MEASURED: with the suppression removed the whole race above stays green. So the flag's + // contract is asserted where it can fail: on a run whose bodies HAVE moved, a yielded + // stop sends no settling `move` at all (each would put the winner's copy under a fresh + // `hold: 'external'` on the way out) and records no transformSet entry (Ctrl+Z over a + // layout nobody ever saw), while still telling the mesh the run ended. + await low.page.evaluate((uuid) => window.__stores.physics.applyThrow({ uuid, pos: [0, 2.4, 0.9], rot: [0, 0, 0], linvel: [0, 0, 0], angvel: [0, 0, 0] }), ball); + await low.page.waitForTimeout(600); + const yielded = await low.page.evaluate(() => { + const s = window.__stores; + let peer; + s.peers.subscribe((p) => (peer = p))(); + const send = peer.send.bind(peer); + let moves = 0; + let stops = 0; + peer.send = (/** @type {any} */ m) => { + if (m?.type === 'move') moves++; + if (m?.type === 'simulate' && m.running === false) stops++; + return send(m); + }; + let before, after; + const bodies = s.physics.physicsDebug().length; // BEFORE the stop frees them + s.history.undoStack.subscribe((/** @type {any[]} */ v) => (before = v.length))(); + s.physics.stopSimulation({ yielded: true }); + s.history.undoStack.subscribe((/** @type {any[]} */ v) => (after = v.length))(); + peer.send = send; + return { moves, stops, before, after, bodies }; + }); + h.check(yielded.bodies > 0 && yielded.moves === 0, `7.15 a yielded stop broadcasts NO settling move (${yielded.bodies} bodies, ${yielded.moves} moves)`); + h.check(yielded.after === yielded.before, `7.16 ...and records no undo entry (${yielded.before} -> ${yielded.after})`); + h.check(yielded.stops === 1, `7.17 ...while still telling the mesh the run ended (${yielded.stops} stop message)`); + + // put the world back for the goal + await low.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually(() => simOf(low.page), (v) => v.own === true, ' (premise) the winner steps a world again', 20000); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, ' (premise) and the loser follows it', 20000); + + // and the point of all of it: a goal scores + // section 6 left the match on a 30 s clock — put it back on goals, or this one ends + // itself halfway through + await setRules(A.page, { winBy: 'goals', goalsToWin: 20 }); + await h.eventually(() => snap(low.page), (s) => s?.rules.winBy === 'goals' && s.rules.goalsToWin === 20, ' (premise) back on goals, with room to spare', 10000); + await h.eventually(() => screenOf(low.page), (v) => v === 'menu', ' (premise) the menu screen is up on the winner', 10000); + await hudButton(A.page, 'Start match').click(); + await h.eventually(() => snap(low.page), (s) => s?.started === true, '7.18 the match restarts under the race winner', 15000); + await h.eventually(() => snap(high.page), (s) => s?.started === true && s.authority === false, '7.19 the loser follows it and claims no authority', 15000); + await h.eventually(() => snap(low.page), (s) => s?.started && s.serveAt === 0, ' (premise) re-served', 12000); + const before7 = (await snap(low.page)).score.blue; + await teleport(low.page, ball, redPos); + await h.eventually(() => snap(high.page), (s) => s?.score.blue === before7 + 1, `7.20 A GOAL SCORES through the race (blue ${before7} -> ${before7 + 1} on the loser's copy)`, 15000); + await h.eventually(() => snap(C.page), (s) => s?.score.blue === before7 + 1, '7.21 ...and on the spectator', 15000); + for (const p of [A, B, C]) await p.page.evaluate(() => window.__stores.isLocked.set(false)).catch(() => {}); await A.page.waitForTimeout(400); await h.finish(browser); diff --git a/tests/unit/simAuthority.test.js b/tests/unit/simAuthority.test.js new file mode 100644 index 00000000..b8cfa32e --- /dev/null +++ b/tests/unit/simAuthority.test.js @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { simulateVerdict } from '../../src/lib/simAuthority.js'; + +// 29-F. The whole dual-simulator rule is a pure function of four facts, so its truth +// table belongs here rather than behind two browsers: what an e2e run can show is that +// the rule REACHES a real match (game-football section 7); what it cannot show in a +// reasonable time is that the rule is SYMMETRIC — that the two sides of every race read +// the same message pair and reach opposite verdicts, so exactly one world survives. +// That is what these cover, plus the two ways the rule must stay out of the way: an +// older sender with no `peerId`, and a session with no race in it at all. + +/** @param {Record=} over */ +const start = (over) => ({ running: true, mine: 'bbb', theirs: 'aaa', simulating: false, remote: null, ...over }); + +describe('simulateVerdict — the normal session, with no race in it', () => { + it('adopts a peer that starts while we are idle', () => { + expect(simulateVerdict(start())).toBe('adopt'); + }); + it('adopts a pause/resume from the peer we are already watching', () => { + expect(simulateVerdict(start({ remote: 'aaa' }))).toBe('adopt'); + }); + it('clears when the peer we are watching stops', () => { + expect(simulateVerdict(start({ running: false, remote: 'aaa' }))).toBe('clear'); + }); + it('takes a stop with no id at all — the pre-29-F behaviour, verbatim', () => { + expect(simulateVerdict(start({ running: false, theirs: null, remote: 'aaa' }))).toBe('clear'); + }); +}); + +describe('simulateVerdict — the race is SYMMETRIC', () => { + // the two sides of one race, built from ONE pair of ids so the test cannot + // accidentally read two different worlds + /** @param {string} lo @param {string} hi */ + const race = (lo, hi) => [ + // the LOW-id peer hears the high one start + simulateVerdict({ running: true, mine: lo, theirs: hi, simulating: true, remote: null }), + // ...and the HIGH-id peer hears the low one + simulateVerdict({ running: true, mine: hi, theirs: lo, simulating: true, remote: null }) + ]; + + it('elects exactly one winner: the lower id keeps, the higher yields', () => { + expect(race('aaa', 'bbb')).toEqual(['keep', 'yield']); + }); + it('holds whichever way round the ids happen to fall', () => { + expect(race('0f3c1a', 'f001de')).toEqual(['keep', 'yield']); + expect(race('A', 'a')).toEqual(['keep', 'yield']); // '<' is codepoint order, not locale + }); + it('can never elect two winners or none, over a spread of real-shaped ids', () => { + const ids = ['0a1b2c', '4e86d', 'f0f0f0', 'zz', 'ZZ', 'abc123', '9', '-']; + for (const a of ids) + for (const b of ids) { + if (a === b) continue; + const verdicts = [ + simulateVerdict({ running: true, mine: a, theirs: b, simulating: true, remote: null }), + simulateVerdict({ running: true, mine: b, theirs: a, simulating: true, remote: null }) + ]; + expect(verdicts.filter((v) => v === 'keep')).toHaveLength(1); + expect(verdicts.filter((v) => v === 'yield')).toHaveLength(1); + } + }); +}); + +describe('simulateVerdict — a spectator agrees with the racers', () => { + it('keeps the LOWER id when told about two simulators', () => { + // told about 'aaa' first, then 'bbb': the second claim is the loser's + expect(simulateVerdict({ running: true, mine: 'zzz', theirs: 'bbb', simulating: false, remote: 'aaa' })).toBe('ignore'); + }); + it('...and in the other arrival order adopts the lower one over the higher', () => { + expect(simulateVerdict({ running: true, mine: 'zzz', theirs: 'aaa', simulating: false, remote: 'bbb' })).toBe('adopt'); + }); + it('does not blank its view of the winner when the LOSER stops', () => { + // the yielding peer broadcasts running:false on its way out; a spectator + // watching the winner must not read that as "nobody is simulating" — that store + // is what arms the knock probes and play-mode grab (24-A A2) + expect(simulateVerdict({ running: false, mine: 'zzz', theirs: 'bbb', simulating: false, remote: 'aaa' })).toBe('ignore'); + }); +}); + +describe('simulateVerdict — additive: absent = old behaviour', () => { + it('adopts a start from an older sender that carries no peerId, even mid-run', () => { + expect(simulateVerdict(start({ theirs: null, simulating: true }))).toBe('adopt'); + expect(simulateVerdict(start({ theirs: null, simulating: false }))).toBe('adopt'); + }); + it('adopts rather than guessing when we have no id of our own yet', () => { + expect(simulateVerdict(start({ mine: null, simulating: true }))).toBe('adopt'); + }); + it('ignores our own message coming back at us', () => { + expect(simulateVerdict(start({ mine: 'aaa', theirs: 'aaa', simulating: true }))).toBe('ignore'); + expect(simulateVerdict(start({ mine: 'aaa', theirs: 'aaa', simulating: false }))).toBe('ignore'); + }); + it('treats an empty-string id as no id', () => { + expect(simulateVerdict(start({ theirs: '', simulating: true }))).toBe('adopt'); + }); +}); From 22f351e196dcc893f196d0bccfa0a4dcc2474bef Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 20 Sep 2026 13:16:17 +0300 Subject: [PATCH 2/2] [fix] 29-F: the press race asserts the invariant two presses can actually carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FOUND by running the modules football flight against the fix: two real Play presses do NOT reliably race. The first peer's `simulate` often lands before the second peer's guard is read, and then nothing raced at all and whoever pressed first keeps the world — higher id or not. Measured on the flight: A (ce526) kept it while B (33ec2) never started, which is correct behaviour and made a "the lower id wins" assertion red. So the presses assert what they can carry — EXACTLY ONE simulator, and the other peer knowing who it is — and the ID RULE is left to 7b, where the race is forced and has no timing in it. Section 7 then hands the world to the lower id explicitly (a no-op when the presses did race) so 7b starts from the state the rule elects. Without this, 7.1 was a coin: it passed three runs and would have gone red the first time the presses happened not to race. The first counterfactual run had already shown the shape — with the rule removed the HIGHER id kept the world — and that reading was mistaken for the counterfactual biting rather than for what it also was. game-football 134 -> 136 checks, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) --- tests/e2e/game-football.test.cjs | 44 +++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/tests/e2e/game-football.test.cjs b/tests/e2e/game-football.test.cjs index 08ce9c07..e940948e 100644 --- a/tests/e2e/game-football.test.cjs +++ b/tests/e2e/game-football.test.cjs @@ -479,19 +479,40 @@ h.run(async () => { await A.page.waitForTimeout(2600); // the 2 s exit cooldown, so both presses are taken the same way // nothing between the two presses: this IS the window await Promise.all([A.page.locator('#play-button').click(), B.page.locator('#play-button').click()]); + // EXACTLY ONE WORLD is the invariant these presses can carry, and it is deliberately + // NOT "the lower id wins": two presses do not reliably race (the first peer's + // `simulate` often lands before the second's guard is read, and then nothing raced and + // whoever pressed first keeps it, higher id or not). The ID RULE is asserted in 7b, + // where the race is forced and has no timing in it. const low = A.id < B.id ? A : B; const high = A.id < B.id ? B : A; - const lowName = low === A ? 'A' : 'B'; - await h.eventually(() => simOf(low.page), (v) => v.own === true, `7.1 the LOWER peer id keeps the world (${lowName}: ${low.id} < ${high.id})`, 20000); - await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, '7.2 the higher id YIELDED and adopted the winner', 20000); - h.check((await simOf(low.page)).remote === null, '7.3 ...and the winner never recorded the loser as a simulator'); - await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === low.id, '7.4 C (a spectator to the race) agrees on the same winner', 20000); - // the measured shape, directly: the loser's stream must leave nothing pinned + await h.eventually( + () => Promise.all([simOf(A.page), simOf(B.page)]), + ([a, b]) => (a.own ? !b.own && b.remote === A.id : b.own && a.remote === B.id), + '7.1 the two presses leave exactly ONE simulator, and the other knows who it is', + 25000 + ); + const holder = (await simOf(A.page)).own ? A : B; + const follower = holder === A ? B : A; + h.check((await simOf(holder.page)).remote === null, '7.2 the peer stepping the world recorded nobody else as a simulator'); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === holder.id, '7.3 C (a spectator) agrees on the same one', 20000); + // the measured shape, directly: a loser's stream must leave nothing pinned const heldBy = (page, id) => page.evaluate((id) => window.__stores.physics.physicsDebug().filter((e) => e.hold === 'external' && e.holdPeer === id).length, id); - await low.page.waitForTimeout(1500); - const pinned = await heldBy(low.page, high.id); - h.check(pinned === 0, `7.5 no body on the winner is pinned by the loser's move stream (${pinned})`); + await holder.page.waitForTimeout(1500); + const pinned = await heldBy(holder.page, follower.id); + h.check(pinned === 0, `7.4 no body on it is pinned by the other peer's move stream (${pinned})`); + + // hand the world to the LOWER id, so 7b starts from the state the rule elects (when the + // presses DID race that is already true and this is a no-op) + if (holder !== low) { + await holder.page.evaluate(() => window.__stores.physics.stopSimulation()); + await h.eventually(() => simOf(low.page), (v) => v.own === false && v.remote === null, ' (premise) the pitch is idle', 10000); + await low.page.evaluate(() => window.__stores.physics.toggleSimulation()); + } + await h.eventually(() => simOf(low.page), (v) => v.own === true, `7.5 the lower id holds the world (${low === A ? 'A' : 'B'}: ${low.id} < ${high.id})`, 20000); + await h.eventually(() => simOf(high.page), (v) => v.own === false && v.remote === low.id, ' (premise) the higher id follows it', 20000); + await h.eventually(() => simOf(C.page), (v) => v.own === false && v.remote === low.id, ' (premise) and so does the spectator', 20000); // ---- 7b. THE RACE, FORCED, BOTH WAYS --------------------------------------------------------- // Two real presses do not RELIABLY race — sometimes the first peer's `simulate` lands @@ -503,6 +524,11 @@ h.run(async () => { // That peer never receives a start message of its own to reason about, so the winner // has to ANSWER a competing claim with its own start, and these are the only checks // that cover that half of the rule. + // NOTE, measured: there is deliberately no "the intruder really started" premise here. + // The forced world lives for about a fifth of a second before it yields, which is + // shorter than `eventually`'s poll, so such a premise reads {own:false} and fails on a + // race that DID happen. What proves these two are not vacuous is the counterfactual: + // remove the winner's re-announce and 7.6 goes red, which a vacuous check cannot do. const forceStart = (peer) => peer.page.evaluate(() => { const p = window.__stores.physics;