[fix] 29-F: a peer receiving simulate while simulating yields by a deterministic rule - #236
Merged
Merged
Conversation
…deterministic rule
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) <noreply@anthropic.com>
…ally carry 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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the dual-simulator race the 24-B football lane measured and recorded as a core ticket.
The bug
Two Play presses inside the sim's start-up window both pass
playMode.maybeSimOnPlay'ssimulating || remoteSimulatingguard, because the other side'ssimulatehas not landedyet — a window that spans
warmup()plus the whole ofstartSimulation. Both peers thenstep a world and broadcast
moveat 30 Hz; each stream reads as an EXTERNAL write on theother, and every dynamic body sits under a
hold: 'external'refreshed long before its250 ms timeout can expire. Measured on a real two-peer match: 74 moves in ~2 s, the ball
snapping back,
applyThroweaten, and no goal able to score. A late joiner that isalready 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 lives on the RECEIVE side
and 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 already exists before committing to it: PeerJS ids are non-empty
strings and
<is a total order, so exactly one winner is elected;remoteSimulatingisalready set from that same
peerId; the handshake push is symmetric, so the joiner shaperesolves identically; and the football module's
game.isAuthority()already falls backto the lowest live id when no sim runs — core's winner and a module's fallback authority
are one peer by construction.
What is in it
src/lib/simAuthority.js(new, imports nothing) —simulateVerdict→keep | yield | adopt | clear | ignore. Additive: a message with nopeerIdtakes thepre-29-F path verbatim.
stopSimulation({yielded: true})also withholds thesettling
moveper body (each would pin one of the winner's copies one last time — thevery shape the yield exists to end) and the transformSet undo entry.
ANSWERS the competing claim with its own start — the only thing that reaches a peer which
never heard ours (one that travelled into the room after the run began).
remoteSimulating(that store arms the knock probes and play-mode grab).Gates
npm run buildgreentests/unit/simAuthority.test.js, 14: the truth table and thesymmetry property two browsers cannot show in reasonable time)
game-football102 → 134, section 7 = the race as it happens, then forced both ways,then a goal that SCORES
game-towers 20/0 · game-stars-room 36/0
physics-kinematic10/5 (already listed in CLAUDE.md'sstanding set) and
flow-physics-actions35/2 — A/B'd: identical 35/2, same check 8.5,on base code in this worktree
Counterfactuals (each broken, measured, restored byte-identically)
adoptyieldedsuppressionignorekeepre-announceWorth knowing: two real Play presses do not reliably race. An early revision of section 7
passed for that reason, so the section forces the race in both directions as well, and the
yieldedand spectator halves are asserted directly — with either removed the press racealone stayed green.
Paired with modules PR (the football flight asserts the rule instead of riding it).
🤖 Generated with Claude Code