Skip to content

Release 1.16.0 — Waves, and one world to keep - #238

Merged
AlexZ005 merged 12 commits into
mainfrom
release/next
Sep 21, 2026
Merged

AlexZ005 merged 12 commits into
mainfrom
release/next

Conversation

@AlexZ005

Copy link
Copy Markdown
Collaborator

Roadmap 29 round 3: #233 (open-core-m1 test fix), #234 (waves template def + suite, the external-hold knock fix), #235 (content refs scenes@format-2 / packs@format-1, #230), #236 (the simulate-race rule), #237 (changelog + CLAUDE.md).

Gates on the union: build green, svelte-check 336/47, vitest 196, 17-suite serial battery green + two modules flights. Tag v1.16.0 follows via npm version minor on main.

🤖 Generated with Claude Code

AlexZ005 and others added 12 commits September 20, 2026 11:05
…drawer

The one red every lane in roadmap 29 carried as "pre-existing" is a TEST fault, not a
product one. No core code changes here - `git diff -- src/` is empty.

- WHEN IT STARTED: the check was written at 4799994 (roadmap 14 PM, 2026-07-24), when
  ConnectInfoDrawer rendered `{#if $drawerSlot}<CloudSlot ...>` inline in its single
  body, so opening the drawer with the chevron showed the plugin's section straight
  away. The NEXT DAY 4b7b8cf ("one tabbed drawer (Info/Rooms/Toasts)") moved the cloud
  mount behind the Rooms tab, and nobody updated the suite. It has been red for the
  whole life of the tabbed drawer - about two months.
- MEASURED with a throwaway probe rather than inferred: plugin loaded, chevron clicked
  -> {drawerOpen:true, drawerTab:"info", tabs:["Info","Rooms","Toasts"], roomsBtn:true,
  section:false}; the Rooms shortcut or the Rooms tab clicked -> section:true, and
  `.cloud-slot` count 1 -> 2. Inert build: no tabs, no Rooms button, no cloud slot.
- THE PRODUCT IS RIGHT and stays untouched. The chevron opening on Info is deliberate
  (toggleInfo keeps the last tab; the drawer's own job is connection and server info),
  and the plugin's content has a first-class way in: Connect grows `#connect-rooms-button`
  exactly when `$drawerSlot` is set, and `openRooms` opens the drawer on that tab.
- THE CHECK now drives that shortcut - the way the app offers the mount - and asserts
  the section renders on the Rooms tab.
- NEW CHECK `M1d: no Rooms affordance in the inert build`: the Rooms shortcut exists
  only because a plugin mounted drawer content, which is what stops the corrected check
  passing vacuously. 17 checks -> 18. Nothing was deleted.
- The e2e skill drops open-core-m1 from both dirty-baseline lists, says where the drawer
  mount now lives, and records the lesson: identical-on-base only rules out YOUR diff,
  so when a red is pre-existing, spend two minutes on when it started and what changed
  then.

Counterfactuals (product broken, then restored byte-identically):
- removed `<CloudSlot mount={$drawerSlot} />` from the Rooms tab -> `PM: drawer section
  renders on the drawer Rooms tab` FAILS.
- dropped `$drawerSlot &&` from Connect's Rooms-button gate -> `M1d: no Rooms affordance
  in the inert build` FAILS.
- both breaks together: exactly 2 FAILURES, no others; restored -> 18/18.

Gates: open-core-m1 16/1 -> 18/18; battery under the lock ALL PASS (ai-presets,
approval-timeout, connect-states, dial-metadata, join-result, net-handshake,
open-core-m1 - 7 suites, 203 checks, 399s); svelte-check 336/47 unchanged (identical
by construction - no src change); vitest 178; build green server-down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- src/lib/physics.js: `bodyVelocityOf` reports `held` for a `user` hold only (the grab it was
  documented for) and adds the raw `hold` kind. `held: !!entry.hold` folded the EXTERNAL hold —
  the engine yielding to another writer (a module walking a body every frame, a peer's move
  stream) — into "somebody is carrying it".
- src/lib/knock.js: `evaluateProbe` still skips a carried body; on the initiator `fireKnock`
  treats a body that refused the impulse because it is DRIVEN (`hold === 'external'`) as a real
  hit — logged and sent like every other, the impulse alone refused (the next write would erase
  it) — which is the rule the receive side already keeps for a held crate (noteRemoteHit logs,
  applyHit refuses, independently). A body nobody drives that still refuses (gone, not dynamic)
  sends and spends nothing, as before.
- Found by the waves template (29-F): its enemies are dynamic bodies the waves module walks each
  frame and its damage source is `hit`. On the one peer stepping the world every sweep read 0 hits
  with the body at `hold: 'external'` (a solo probe, eight sweeps), while a non-initiator (no body,
  `held: false`) hit them fine — the host could not damage a walking enemy.

Counterfactual (game-waves against the staged scene): `held: !!entry.hold` restored -> 58/25, red
from 4.1/4.2 (the initiator's slow knock reads 0 hits) and 4.3/4.4 (nothing for B to log) through the
whole round (6.1-6.12); fix restored, physics.js identical. knock-physics 1.15 (a CARRIED body is never
knocked) held in the same battery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… suite

- scripts/author-templates.cjs: `waves` joins MODULE_DEFS (modules/waves/waves.def.json, emitted by
  `npm run build:waves`; both modules in installModules, so the file's derived requirement list
  names health + waves). Built against the lane at 5215; byte-stable across two builds
  (compare-authored SAME). Staged for the scenes repo in cloud-lane-29-staging/games/waves/
  (scene 11701 B + thumb 1690 B + index-row + README with the release order and the #230 ref note).
- tests/e2e/game-waves.test.cjs (90 checks; two peers + a late joiner; skip-never-fail): the card is
  picked in the REAL Games tab (the feed's index.json + scene served through a route, since the row
  is not released) and loadRemoteScene replaces a non-empty world; the file's 10 objects, both
  modules, the sunset env, the play block (grab/grounded/simOnPlay) and the knock block; the
  modules derive 4 enemies on a 3-wave curve, a goal, three spawn pads, five health rows; B over the
  handshake; Start -> playing on both -> the HUD screen; the run is ON, the first enemy WALKS (+z)
  and B places it where A does; HUD Text reads the Waves Value nodes and the bar the player's
  health; kills are KNOCKS through core's feedProbe (a slow sweep = one pulse, a hard one = three),
  the hit in B's hit log and B's ledger agreeing, B's knock (a non-initiator) completing wave 1, the
  kills rows per peer, wave 2 healing the survivors one death's worth; the late joiner reads wave 2
  and the same ledger from the triggers handshake; waves 2 and 3 fall one enemy at a time -> DONE on
  all three -> over (won) -> the over screen -> ONE run logged on every peer; Again -> a new round
  with every enemy back at full health.
- .claude/skills/e2e-verify/SKILL.md: the suite in the GAMES-TAB line. The suite reads core's own
  SCENES_BASE fallback out of sceneTemplates.js, so the #230 ref move follows by construction.

Counterfactuals: the def's env preset `dusk` (not a core preset; PR #8's def) -> 1.6 red (studio),
fixed to `sunset` on modules feat/29f-waves-template (def-only, module.js and the zip unchanged);
`waves` out of MODULE_DEFS -> the build refuses the slug and the suite SKIPs (no scene to test);
the knock fix reverted -> the initiator's knocks 4.1/4.2/4.5/4.6 red (its own commit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r-looking name (#230)

- SCENES_BASE default moves scenes@v2 -> scenes@format-2 and PACKS_BASE packs@v1 ->
  packs@format-1; contentBase() is untouched, so the deploy-time VITE_* override still
  wins (production keeps VITE_SCENES_BASE=...scenes@main until this release ships)
- WHY: jsDelivr parses `v2` as a SEMVER VERSION (`x-jsd-version-type: version`,
  `cache-control: immutable` for a year), so `git tag -f v2` was a no-op forever and four
  purges changed nothing -- the Games tab shipped three games while the feed had six.
  Measured today: a plain TAG named `format-2` (scenes, a5ebe8f) and `format-1` (packs,
  03b9568) are reported `x-jsd-version-type: branch` with `s-maxage=43200`, and
  scenes@format-2/index.json lists all six games (towers, stars-room, football, jam-room,
  dungeon-realms, untangle). packs@v1 was the identical trap, unexploded only because it
  had never been retagged, so it moves in the same change
- the three games suites' feed fallback follows (game-untangle / game-football /
  game-dungeon-realms), plus the one packs@v1 asset URL in scripts/author-templates.cjs
  (sha256-pinned, same bytes at format-1)
- tests/unit/contentBase.test.js (4): the override wins with a trailing slash trimmed; an
  absent override ships the fallback byte-identically; the two consumers' SOURCE literals
  are the format-N refs; and no shipped fallback names a ref jsDelivr would parse as a
  version. Counterfactual: scenes@v2 restored in sceneTemplates.js -> 2 of 4 red
  ("the scenes default is scenes@format-2" and "no shipped fallback names a ref jsDelivr
  would parse as a semver version"); restored byte-identically -> 4/4
- docs: the SCENES_BASE/PACKS_BASE JSDoc carries the ritual and the measurement; PACKS.md,
  CLAUDE.md (games-suite line + the #230 gotcha closed) and the e2e skill name the new refs.
  The scenes/packs READMEs and cloud's .env.deploy.example move in their own PRs
  (scenes#7, packs#1, cloud#28)
- gates: svelte-check 336/47 with byte-identical message lists; vitest 182 (base 178);
  battery held (see the handover table); build green server-down

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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>
[fix] open-core-m1: the drawer-mount check was asserting the pre-tab drawer
[fix] content refs: scenes@format-2 and packs@format-1, never a semver-looking name (#230)
[feat] 29-F: the waves template, and a knock fix it found
…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>
[fix] 29-F: a peer receiving `simulate` while simulating yields by a deterministic rule
… status entry

- CHANGELOG: the 1.16.0 section (the Waves game + the format-2/format-1 content refs, the
  simulate-race rule + the external-hold knock fix, the open-core-m1 housekeeping line), in the
  wording the four 29f lanes wrote for the integrator.
- CLAUDE.md: the round-3 status entry; the older duplicate of the "TWO PLAY PRESSES" gotcha
  (round 1, still calling the rule an open ticket) dropped in favour of the copy PR #236 wrote.
- Gates on the union (7d0010d = #233 #234 #235 #236 over v1.15.1): build green, svelte-check
  336/47 identical list, vitest 196; battery under the e2e lock: open-core-m1 18/0,
  dial-metadata 44/0 (first run 29/1 "context destroyed by navigation", green on re-run),
  ai-presets 46/0, scene-physics-state 37/0, session-scenes 36/0, knock-node 50/0,
  knock-physics 77/0, throw-peer 28/0, play-interact 46/0, game-towers 20/0, game-stars-room
  36/0, templates-modal 45/0, template-modules 27/0, game-untangle 46/0, game-dungeon-realms
  64/0, game-football 136/0, game-waves 90/0; modules flights football 93/0, door-keypad 13/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
[docs] 1.16.0 "Waves, and one world to keep": changelog + CLAUDE.md
@AlexZ005
AlexZ005 merged commit 41427bd into main Sep 21, 2026
8 checks passed
@AlexZ005
AlexZ005 deleted the release/next branch September 21, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant