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
30 changes: 23 additions & 7 deletions .claude/skills/e2e-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,16 @@ Rules: never run suites in parallel AGAINST THE SAME dev server, never edit sour
while one runs (HMR reloads the pages mid-test — see "HMR churn makes runs LIE").


The GAMES-TAB line (real scene from the scenes feed @v2 + real module zips from a packed
The GAMES-TAB line (real scene from the scenes feed @format-2 + real module zips from a packed
sibling modules checkout, every one SKIPS-never-fails when a source is missing):
`game-towers` (20), `game-stars-room` (36), `game-football` (102, two peers + late joiner;
FOOTBALL_TPSCENE / FOOTBALL_ZIP), `game-dungeon-realms` (64, two peers + late joiner, TWO zips:
DUNGEON_REALMS_TPSCENE / DUNGEON_KIT_ZIP / DUNGEON_REALMS_ZIP / MODULES_REPO), `game-untangle`
(46; UNTANGLE_TPSCENE / UNTANGLE_ZIP). Before a scenes row is released, point the TPSCENE env
at the staged file. The SESSIONS line (roadmap 22 R4-R6): `session-scenes` (36, three peers),
(46; UNTANGLE_TPSCENE / UNTANGLE_ZIP), `game-waves` (90, two peers + late joiner, TWO zips:
WAVES_TPSCENE / HEALTH_ZIP / WAVES_ZIP / MODULES_REPO — it picks the card in the REAL Games tab
through a routed feed, kills enemies with core's feedProbe, and reads core's own SCENES_BASE
fallback out of sceneTemplates.js so the #230 ref move follows by construction). Before a scenes
row is released, point the TPSCENE env at the staged file. The SESSIONS line (roadmap 22 R4-R6): `session-scenes` (36, three peers),
`scene-rename` (46), `scene-duplicate` (29). `embed-boot` (20) = the `?embed=1` flag.

## Assertion discipline (a check that cannot fail is not a check)
Expand Down Expand Up @@ -969,7 +972,10 @@ drops the P2P session.
+ `freshReload`, then asserts the seams (`window.__stores.cloudHooks.canApply`,
`profileSlot`/`drawerSlot` are functions, mounted DOM). The flowbite avatar Dropdown
is flaky to open headlessly — assert profile mounts at the STORE level, not by
clicking `#avatar-menu`. A `transition:slide` element stays in the DOM through the
clicking `#avatar-menu`. The DRAWER mount renders on the connect drawer's ROOMS tab,
not under the chevron (which opens on Info and keeps the last tab): reach it with
`#connect-rooms-button`, the Connect-pill shortcut that exists only while `$drawerSlot`
is set — which is also what keeps that check from passing vacuously. A `transition:slide` element stays in the DOM through the
~200ms out-transition — poll with `eventually`, don't assert `count===0` immediately.
- **HMR churn makes runs LIE** (cost ~4 cycles in #16-Q5): a page that loads while
vite is still re-transforming just-edited modules gets a half-mounted app —
Expand Down Expand Up @@ -1109,7 +1115,7 @@ drops the P2P session.
- KNOWN failing suites in the localhost env (2026-07-28, proven identical across a
full old-deps/new-deps baseline comparison — treat as the dirty baseline, not
regressions): the drag-drop-SIMULATION cluster (explorer-drop, explorer,
packs-drop) + user-modules (setup crash), open-core-m1 (1 drawer check),
packs-drop) + user-modules (setup crash),
dock-sidebar-inset, layout, panels, script-nodes, and a few
two-peer timing suites (module-sdk, scene-music, physics-kinematic,
physics-discoverability, roadmap-13-notifications-notes, scene-assets,
Expand All @@ -1129,10 +1135,20 @@ drops the P2P session.
suite in a PRISTINE sibling worktree on its OWN freshly started server and diffing the
PASS/FAIL lines — the only A/B that means anything (see the day-lived-server trap):
`flow-customnode-io` (1 check — "a stale snapshot cannot resurrect the pruned edge"),
`flow-object-embed` (`locator.dblclick` timeout). `open-core-m1`'s single drawer check
was re-confirmed on that same pair: 18 identical PASS/FAIL lines both sides. Two
`flow-object-embed` (`locator.dblclick` timeout). Two
worktrees is what makes this cheap — you never touch the tree under test, so there is
no stash to pop and no chance of the "restart fixed it" confound.
- **"PRE-EXISTING" IS A DIAGNOSIS ABOUT THE ENVIRONMENT, NOT A VERDICT ON THE CHECK —
AND `open-core-m1` SPENT TWO MONTHS ON THIS LIST BECAUSE OF THE DIFFERENCE.** Its one
red was A/B'd honestly every time (identical on pristine 1.14.0, 18 identical PASS/FAIL
lines across two worktrees) and every lane correctly moved on — but identical-on-base
only rules out YOUR diff. Here the check had simply been asserting a superseded
contract since the day after it was written: it opened the connect drawer with the
chevron and demanded the plugin's section, and the very next commit (4b7b8cf) made the
drawer TABBED and moved that mount behind the Rooms tab. `git log --follow` on the
suite against `git log` on the component answered it in one look. So when a red is
pre-existing, spend the two minutes asking WHEN it started and WHAT changed then; a
red nobody reads is a suite nobody reads.
- Long full-suite runs: the Bash tool caps at 10 min — launch the runner DETACHED
(PowerShell `Start-Process node -ArgumentList 'tests\e2e\run.cjs ...'` with
output redirects) and poll/Monitor the log. A dev server started via the Bash
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@
per release, newest first. HTML comments like this one are stripped before
rendering, so maintainer notes stay out of the user-facing window. -->

## 1.16.0 — Waves, and one world to keep 🌊

### 🎮 A new game, and a Games tab that can move again

- 🌊 **Waves — a new game on the Games tab.** Hold the goal against three waves of enemies
walking in from the spawn pads; knock them down, the round ends when the last one falls. Two
players plus a late joiner see the same wave, and every kill is credited to the hand that made
it. (Needs the `health` and `waves` modules; the card offers to install them.)
- 🎮 **Seven games, not three.** The scenes and packs feeds are pinned to `format-2` / `format-1`
refs instead of `v2` / `v1`. jsDelivr treated those as immutable version numbers, so the Games
tab could never see a scene released after the first resolve (#230).

### 🥊 Physics that holds up with two players

- ⚖️ **Two players pressing Play at the same moment no longer start two physics simulations.**
The lower peer id keeps the world and the other hands it over cleanly — a toast names the
handover, and a third player watching agrees with them.
- 🥊 **Knocking a moving target works on the host too.** A body another writer was moving (a
module walking it, a peer's stream) read as "carried" to the knock on the peer running the
simulation, so the host's hand passed through it. The hit is now logged and shared; the shove
alone is skipped.

### 🧹 Housekeeping

- 🧪 `open-core-m1`'s drawer-mount check had been asserting the pre-tabbed connect drawer since
2026-07-25 and was carried as a known red ever since; it now drives the Rooms shortcut the app
actually offers, and the suite is green. No product change.

## 1.15.1 — Knock, and a key you never typed 🔑

### 🚪 Gated cloud rooms work end to end
Expand Down
77 changes: 67 additions & 10 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 @@ -2129,7 +2146,7 @@ loadable play content. Everything a user does must be visible to connected peers
Towers in eight numbers and Football's HUD is authored by its module). Suites
`game-dungeon-realms` (64, two peers + late joiner; env DUNGEON_REALMS_TPSCENE /
DUNGEON_KIT_ZIP / DUNGEON_REALMS_ZIP / MODULES_REPO), `game-untangle` (46; UNTANGLE_TPSCENE /
UNTANGLE_ZIP), `game-football` (102; scene from the scenes feed @v2, zip from a packed
UNTANGLE_ZIP), `game-football` (102; scene from the scenes feed @format-2, zip from a packed
sibling modules checkout — skips, never fails, when every source misses).
- `playMode.js` `embedMode` / `embedSceneId` / `embedOpenUrl()` (R29 fork 4): the additive
`?embed=1` boot flag, read ONCE at module evaluation (before the cloud plugin clears the
Expand Down Expand Up @@ -2243,6 +2260,20 @@ loadable play content. Everything a user does must be visible to connected peers
purgeable). So a moving ref on jsDelivr must be a branch or a tag name that is not a
version (`format-2`); `packs@v1` has the same trap waiting. Core ticket #230; the deploy-time
unblock is `VITE_SCENES_BASE=…scenes@main` (what `contentBase()` exists for).
**29f CLOSED IT BY MEASUREMENT**: a plain TAG named `format-2` (scenes, at a5ebe8f) and
`format-1` (packs, at 03b9568) are reported `x-jsd-version-type: branch` with
`s-maxage=43200`, while `@v2`/`@v1` carry `cache-control: immutable` for a year — so the
tag form works and no branch was needed. `SCENES_BASE`/`PACKS_BASE` default to the
`format-N` refs now, the three games suites' feed fallback with them, and
`tests/unit/contentBase.test.js` reads both source files and refuses any fallback whose ref
matches jsDelivr's version rule (an optional v, then dotted digits, nothing else). `v1`/`v2`
are DEAD refs, left where jsDelivr first resolved them for the builds that shipped against
them. Production keeps the cloud `.env.deploy` `VITE_SCENES_BASE=…scenes@main` override until
the core release carrying this ships; the scenes/packs READMEs and cloud's
`.env.deploy.example` teach the new ritual. THE EDITING TRAP THIS FOUND: a JS
`String.replace(from, to)` with a STRING `to` expands `$\``, `$'`, `$&` — a doc edit whose
replacement text quoted a regex ending in `$` followed by a backtick pasted 2255 lines of
this file into itself. Use `split(from).join(to)` for literal replacement.
- **flowbite-svelte's `Button` FREEZES its class string at mount.** `Button.svelte:34` reads
the theme through a DESTRUCTURING `$derived` declaration, which evaluates its object ONCE
— so a button BORN disabled wears `cursor-not-allowed opacity-50` forever, even after its
Expand Down Expand Up @@ -2719,6 +2750,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 Expand Up @@ -4347,15 +4396,6 @@ loadable play content. Everything a user does must be visible to connected peers
suite that polls `flowValues` for "all N lit" can only ever catch it for one publish. Assert
the TRIGGER LOG instead, folded to seconds-of-day the way `retiredByRound` does.

- **TWO PLAY PRESSES INSIDE THE SIM START-UP WINDOW START TWO SIMULATORS.** `playMode
.maybeSimOnPlay` guards on `simulating || remoteSimulating`, and the `simulate` message has
not landed yet when the second peer's guard runs — so both simulate. Measured (24-B R1): after
B's hit the ball on A sat under a permanent `hold: external` fed by B's 30 Hz `move`s (74 in
~2 s), applyThrow snapped back, no goal could score. The same shape for a late joiner if the
handshake `simulate` push is missing. `game-football` enters Play in ORDER and asserts it; the
modules football flight clicks both Play buttons back-to-back and rides the race. Open ticket:
a peer receiving `simulate` while simulating must yield by a deterministic rule (lower peer id
keeps it).
- **A MESH NAME WITH A SPACE ARRIVES UNDERSCORED ON THE PEER** over the object sync
(`Entrance plinth` -> `Entrance_plinth`; a LIGHT keeps its space). Graphs bind by uuid so games
work; a suite asserting a peer's objects must do so by UUID (`game-dungeon-realms`).
Expand Down Expand Up @@ -4750,6 +4790,23 @@ override for e2e — never share 5173 (the user's main-checkout server).
locked (replicate the INDEX per-item opt-in; ONE mesh with scenes as tags;
scene-is-primary renaming), and the vocabulary settled: **session = the mesh, room =
who is in a scene, PocketBase rooms stay DISCOVERY** — that naming blocks R4.
- Status (2026-09-22): **1.16.0 "Waves, and one world to keep" — ROADMAP 29 ROUND 3, the follow-up
round closed by the integrator (lane `29f-integrate`).** Merged: core #236 + modules #14 (the
simulate-race rule, `simAuthority.js`: a peer receiving `simulate` while simulating yields to the
LOWER peer id — see the gotcha and the architecture bullet beside `throwVelocity`), on top of the
already-merged #233 (the standing `open-core-m1` red was the TEST asserting the pre-tabbed drawer
since 2026-07-25 — 18/18 now), #235 (content refs `scenes@format-2` / `packs@format-1`, core #230)
and #234 (the `waves` template def in `MODULE_DEFS` + suite `game-waves`, and the knock fix: a body
under an EXTERNAL physics hold — a module walking it — is HIT, not carried, on the initiator too).
Scenes: the `games/waves` row released and `format-2` retagged (NEVER `v2`); modules `dev` → `main`
(waves def env `sunset`, the door-keypad + football flight fixes) and the `waves` index row gained
`"template": "games/waves"` after the scene was served. Cloud: deployed from the tag with the
`VITE_SCENES_BASE=…scenes@main` stopgap REMOVED from `.env.deploy` (core defaults to `format-2`
now). Gates on the union: svelte-check 336/47 (identical list), vitest 196, build green, the serial
battery green (see the round-3 execution-log entry in the roadmap 29 master for the counts). OWED
on device: the two-player Play race handover toast + ball continuity at the yield, being hit in VR
in Waves, the Waves card thumbnail framing (`thumb.camera` in the def), the walkers stacking at the
goal, and one EYEBALL: the production Games tab shows seven cards.
- Status (2026-09-20): **1.15.1 "Knock, and a key you never typed" — ROADMAP 29 ROUND 2, the two
core seams, taken IN-HOUSE by the integrator.** The `29-core-seams` lane had sat ~11 h at a budget
checkpoint with nothing committed and a resume prompt nobody sent, so `feat/1.15.1` (off
Expand Down
7 changes: 4 additions & 3 deletions PACKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ There are two kinds of pack:
- **Default packs** come from the pack repo's `index.json`, fetched from
`PACKS_BASE` (`src/lib/packs.js`) — the jsDelivr CDN over
[theprototype-app/packs](https://github.com/theprototype-app/packs), pinned to a
tag (`@v1`). If the CDN is unreachable, the app falls back to the MINIMAL starter
bundled at `static/library/libraryList.json` (offline / fresh clones are never
empty).
ref (`@format-1` — never a semver-looking name: jsDelivr resolves a version once
and a retag of it is a no-op, core #230). If the CDN is unreachable, the app falls
back to the MINIMAL starter bundled at `static/library/libraryList.json` (offline /
fresh clones are never empty).
- **Remote / imported packs** are self-describing repos or `.zip` files using the
`manifest.json` format below — drag a `.zip` in with **+ Import pack**.

Expand Down
4 changes: 2 additions & 2 deletions scripts/author-templates.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ const STARS_HUD_PANEL = {
const STARS_CHIME = {
key: 'chime',
name: 'impact-glass.ogg',
url: 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@v1/audio-essentials/assets/impact-glass.ogg',
url: 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@format-1/audio-essentials/assets/impact-glass.ogg',
sha256: '9252d50bfb85edb17d6073c4a7806e10cdb9de56d3dbfc93a4b9727146d2df6d',
credit: { what: 'Impact Glass', author: 'Kenney', license: 'CC0-1.0', source: 'https://kenney.nl/assets/impact-sounds' }
};
Expand Down Expand Up @@ -1162,7 +1162,7 @@ const BEAT_DEF = {
// `thumb.sceneGroups: ['dungeon-module']` to get it onto the card
// untangle — 21-C C7: the thin template (pose + level + room + HUD + graph); its
// board is scene-root content too (`thumb.sceneGroups: ['untangle-module']`)
const MODULE_DEFS = ['football', 'dungeon-realms', 'untangle'];
const MODULE_DEFS = ['football', 'dungeon-realms', 'untangle', 'waves'];

const DEFS = [
{
Expand Down
Loading
Loading