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..e940948e 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,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/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'); + }); +});