Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
107 changes: 99 additions & 8 deletions src/lib/physics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 */
Expand Down
77 changes: 77 additions & 0 deletions src/lib/simAuthority.js
Original file line number Diff line number Diff line change
@@ -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';
}
Loading
Loading