diff --git a/.claude/skills/e2e-verify/SKILL.md b/.claude/skills/e2e-verify/SKILL.md index d3780037..0831da1e 100644 --- a/.claude/skills/e2e-verify/SKILL.md +++ b/.claude/skills/e2e-verify/SKILL.md @@ -27,7 +27,7 @@ coverage this repo has of the gizmo-grab uv path and of the late-joiner object s The GAME line: `game-state`, `hud-actions`, `logic-nodes`, `collectibles-v2`, `peer-variables`, `game-presence`, `scene-levels`, `project-manifest`, `project-file`, `scene-folders` and the `game-loop-v2/v3/v4` acceptance suites, plus (R3a) -`sdk-game-seams` — the module-facing seams api.game/peerVars/flow/playerPosition, the +`sdk-game-seams` (78 since 29: onChange/freeRegion/setNodesData) — the module-facing seams api.game/peerVars/flow/playerPosition, the `{replicate:false}` local pulse, the round-aware `ctx.trigger`, and the counterfactual that the migrated collectible pieces are GONE from core. `module-toolbox` covers the toolbox seams incl. the `sidebar: false` opt-out and openToolbox/closeToolbox/ @@ -129,6 +129,16 @@ the host's rAF cadence, not your code. Rules: never run suites in parallel AGAINST THE SAME dev server, never edit sources 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 +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), +`scene-rename` (46), `scene-duplicate` (29). `embed-boot` (20) = the `?embed=1` flag. + ## Assertion discipline (a check that cannot fail is not a check) - **A REPRO THAT DOES NOT REPRODUCE gives the most confidently wrong answer there is.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 598038f5..26574c8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.15.0 — Where everyone is 👥 + +### 👥 One session, and you can see who is in which scene (roadmap #22, the last three rounds) + +- 👥 **See who is in each scene, right on the scene card.** Every scene in the Explorer shows + who is standing in it (you included), and its menu offers **Join ** to travel there — + even for a scene you have not downloaded yet. One session, scenes as tags: the invite link is + unchanged. +- ✏️ **Rename a scene; its files follow.** Double-click the scene name in the Explorer header + (or right-click it, or a scene card's **Rename scene…**). Every version file takes the new + name, on every peer, and Travel nodes keep working — by name and by version. The header now + reads `Scene [file.tpscene]`. +- 📋 **Paste a scene, get a scene.** Ctrl+C / Ctrl+V on a scene makes "Arena copy" — its own + file, its own version history — instead of a second card for the same file. + +### 🎮 Two more games on the Games tab, and Football gets its proof + +- 🏰 **Dungeon Realms** — a seeded five-floor co-op crawl built from two modules: the Dungeon + Kit generates, Realms plays. Gems, portals that unseal, a dragon's hoard at the top. +- 🧩 **Untangle** — a planar-graph puzzle on a lit board; drag the dots until nothing crosses + and the next level appears, the same board on every peer. +- ⚽ **Football's whole match is now a test the app runs against itself** (two peers and a late + joiner, the real scene and the real module) — a known race is recorded below. +- 🧱 Templates are read from each module's own definition, so a card and its module cannot drift. + +### 🖼️ Embeds + +- 🖼️ **A scene can live inside another page.** Open the app with `?embed=1` and the editor + chrome stays hidden for the page's life, play keeps working, and a small corner link opens the + full app. The community site's `/e/` pages frame scenes with it. + +### 🧰 Module SDK + +- 🧰 **A module can ask where a graph fits, and listen instead of polling.** `api.flow.nodes()` + returns node positions, `api.flow.freeRegion()` finds room for a built block, and + `api.flow / game / peerVars.onChange()` fire once per frame when something changed. +- ↩️ **A module's node edits are undoable.** `api.flow.setNodeData` is one undo step, and + `api.flow.setNodesData([...])` makes a whole group edit one step — the collectible manager's + group settings use it. + +### 🐞 Known + +- Two players pressing Play inside the physics start-up window can both start a simulator + (the `simulate` message has not landed yet when the second guard runs). Football's suite + enters Play in order; a deterministic yield rule is the next ticket. + ## 1.14.0 — Room for one more 🗜️ ### 🗜️ Heavy models stop being a cliff (roadmap #26, the last held phase) @@ -176,7 +222,7 @@ The object count in the status line carries a coloured dot that names whatever is over budget, and the numbers ride along in a diagnostics bundle. - 🛑 **An oversized scene asks before it arrives.** A scene big enough to hurt is held at - the door with **Load all / Load the first N / Cancel** instead of arriving and + the door with **Open anyway / Cancel** instead of arriving and wedging the tab, and opening an oversized file warns you first. - ⏸️ **A window that cannot keep up pauses instead of freezing.** A simulation that falls too far behind stops once, with a Resume button, and a viewport that has stopped diff --git a/CLAUDE.md b/CLAUDE.md index 393395eb..c4bb3135 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2058,6 +2058,64 @@ loadable play content. Everything a user does must be visible to connected peers is re-found by POSITION after the swap. `applyPivotTransform` now covers the falloff for rotate and scale by slerping the rotation and lerping the scale toward identity per vertex. +- **THE VOCABULARY (roadmap 22 R4, locked):** a **session** is the mesh — one per invite link + — and owns the project: its library, manifest, scenes and who is where. A **scene** is where a + peer currently is inside the session, a TAG on the peer rather than a second connection. A + **room** is the PocketBase discovery record only. New core/plugin surface uses these words; no + PB field was renamed. +- `src/lib/sceneRename.js` (R22 R5, a LEAF, imports nothing): `manifest.renames = {from:{to,at}}` + is what lets a rename survive the union merge (the wipe protection would otherwise resurrect + the old key from any stale document); `foldRenamedScenes` folds a scene under a renamed key + into where the name points, by LINEAGE (a shared hash) — a fresh scene reusing an old name + stays and SPENDS the record; `resolveSceneName` is transitive with a cycle limit; + `rewriteTravelNodes` patches by-name (`sceneName`) and by-hash (`levelName`) Travel nodes, + idempotent. THE DATA HAZARD answer: graphs in files on disk are NOT rewritten (that would mint + a version of every referencing scene); the LIVE graphs are rewritten on every peer locally off + the replicated record (`levels.applySceneRenames`, deterministic — nothing sent), and + `travelToScene` resolves a stale name at fire time. `travelToLevel` names an arrived scene by + `sceneOfHash` FIRST (a renamed scene's file keeps the old name inside). The applier re-stamps + the open scene's signature after a rewrite, or the dirty check honestly reads "differs from + the saved version". A module-level `projectManifest.subscribe` in levels.js sits LAST (the TDZ + rule) and applies renames in a MICROTASK — the applier renames library records and the + shared-library sweep writes the manifest back. `explorer.renameItemsWhere(hashes, from, to)` + = both shelves, by hash AND name; `levels.renameScene(from, to)` = project (replicated) vs + loose (file + open name); `duplicateScene(item, name, {folderId})` additive opt (R6: a PASTED + scene mints a scene copy under the copy name — the 24-C per-kind rule stays for every other + kind). Suites `scene-rename` (46, two peers), `scene-duplicate` (29), vitest `sceneRename`. +- `peerScenes.peersAtScene(map, scene, mine, host)` (R22 R4) — the per-scene session roster, + us included, through the same `roomOf`/`roomCtx` the popover uses, so card and popover cannot + disagree. Explorer scene cards carry a presence badge in both views (`.explorer-here`, + `data-here=`, a coloured dot per person) and "Join " menu entries (held AND + not-on-this-device cards) routed through `travelToPeerScene` (the guarded Go-to path). The + invite link carries NO scene hint — the link IS the session, where the host stands is presence. + Suite `session-scenes` (36, three peers). +- `src/lib/flowLayout.js` + `src/lib/coalesce.js` (R29 S1/S2, both LEAVES, vitest-covered): + `freeRegion({w,h,graphId})` is the ONE placement rule for anything that authors nodes on the + user's behalf (`hudActions.addBinding` calls it, side 'right', byte-identical); the SDK + `onChange` seams (`api.flow`/`game`/`peerVars`, each returning `off()`, journalled) coalesce + to ONE call per frame (+100 ms timer fallback) because arriving peer edits are one task each. + `flow.onChange` also fires on the trigger log (a collected-state list changes on a fire). +- `scripts/author-templates.cjs` (21-C C6-b): a `MODULE_DEFS` LOADER region (`football`, + `dungeon-realms`, `untangle`) read from the sibling modules checkout as + `modules//.def.json` — a template's def is OWNED BY ITS MODULE so the card and the + module cannot drift — and an additive `thumb.sceneGroups` (scene-root module groups cloned + into the card render: a dungeon's world is scene-root content, so the card showed a lone + arch). Absent = byte-identical. The HUD-helper region is a SEPARATE region (24-B R2's + `gameShellHud` extraction was measured and declined: Stars Room's pause panel differs from + 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 + 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 + query). Menu's chrome wrapper is `#editor-chrome`, hidden on `$isLocked || $embedMode`; + App.svelte gates the editor windows on `!$embedMode`, drops the dock inset, and draws the two + things an embed owns — `#embed-open-link` ("Open in theprototype.app", new tab) and + `#embed-play` (▶ while not playing, so an Esc inside the frame has a way back in). Absent = the + old app. The community Worker's `/e/` frames the app at `/?s=&play=1&embed=1`; no + separate viewer build. Suite `embed-boot` (20). + ## Replication golden rules 1. Every mutation = apply locally + `$peers.send({type, ...})`; receivers apply WITHOUT @@ -4256,6 +4314,49 @@ 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`). +- **THE MODULES MANAGER'S USER TAB LABEL GROWS A COUNT AFTER AN INSTALL** ("User (1)"), so a + second install on one peer hangs an `exact: true` locator — match `/^User/`. The modules repo's + shared `tests/helpers.cjs` does since 29; four of its flights (door-keypad, sabers, + template-install, tutorial-room) still carry their own exact locator and time out on the + SECOND open of the manager — pre-existing, A/B'd against 1.14.0, a one-line fix each. +- **A MODULE'S NODE-DATA WRITES WERE NOT UNDOABLE AT ALL.** Measured (29 S3): one collectible + group press over 60 members = 60 `nodedata` messages, 7.1 ms synchronous, ZERO undo entries — + the next Ctrl+Z undid the nodes' CREATION. The brief had gated a batch seam on "sixty messages + AND sixty undo entries"; the finding was the opposite problem. `api.flow.setNodeData` records + ONE `flownodes` op:'data' entry now (moduleId-attributed, before-values structuredClone'd) and + `setNodesData` ONE across graphs; the wire stays per-node `nodedata` (no batched type exists). + Data undo replays items in REVERSE (a batch writing one node twice). A key absent before the + write undoes to `{key: undefined}` — nodedata cannot delete keys, the AI flow tool's own rule. +- **A MODULE EFFECT NODE PINS ITS TARGET'S POSE** — the runtime re-seats an effect target's base + every frame (football's "no node may target the ball"), so a module that must let its object + MOVE (a damageable enemy that walks) names it through a `target` OBJECT INPUT on a VALUE node + instead. And two clocks: `api.game.roundCutoff()`/`startedAt` are session ms while `api.now()` + and every trigger stamp are seconds of day; `nodeValue` republishes at ~6 Hz (DEVX #23-#25, + from the health/waves modules). +- **A SPAWNED COPY IS A TRANSIENT CLONE WITH NO FLOW NODES**, its uuid minted by the initiator, + alive only while the simulation runs, and a `nodetrigger` carries only `{id, t}` — so + per-copy state (health) cannot ride the trigger log. A module CAN author and pulse core's + `spawn` node through `api.flow.addNodes` + `fireNodeTrigger` (the 29 health spike proved it on + two peers + a joiner, no core seam needed), but enemies that carry state are PRE-PLACED. +- **A SAME-SPOT APPEND FROM TWO LANES MERGES AS A CONFLICT WHOSE UNION IS RIGHT — EXCEPT WHERE + THE HUNK BOUNDARY SPLITS A BRACE OR BOTH SIDES DECLARE THE SAME CONST.** The 1.15 cloud smoke + merge (three lanes appending sections to one script) needed: one `startPocketBase` (both sides + had edited the same statement), `onState` renamed on one side (two `const`s in one block), and + `roomsAccess()`'s closing brace put back (the boundary fell between the return and the `}`). + `node --check` after every union; a brace-depth scan counts braces inside strings and lies. + ## Verification (mandatory before commit) PIXEL features (post-processing, outlines, AO) are asserted through the helpers @@ -4616,6 +4717,20 @@ 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-19): **1.15.0 "Where everyone is" — ROADMAP 29's finished lanes INTEGRATED.** + Core `feat/1.15` = six lane PRs merged clean (#223 sessions R4/R5/R6 · #224 sdk-polish S1/S2 · + #227 s3-followup S3a/b/d · #226 football suite · #225 c6-template · #222 embed); vitest 178; + svelte-check **341/47, list identical to base**; build green. Modules `dev` took #4 #7 #6 #5 #8 + (collectible 1.1.1, dungeon 2.0.0 = the Kit, dungeon-realms 2.0.0, untangle 2.0.0, health 1.0.0, + waves 1.0.0; 20 zips). Cloud `main` took #25 rooms-access (knock + code; the plugin FAILS + CLOSED until core lands `cloudApi.dialMeta`/`authProvider.decide`), #24 scene versions + + `/e/` + email OTP (OFF until SMTP), #23 hosted-AI preset (INERT until core lands + `api.aiPresets`) — PB rules 147, Worker 174. Scenes: Dungeon Realms + Untangle rows on `v2`. + Docs PR #4 (1.12-1.14 pages). STILL OPEN → round 2 as 1.15.x: lane `29-core-seams` (the two + core seams above, brief `lanes/29/task-29-core-seams.md`, nothing committed yet). Recorded + follow-ups: the dual-simulator race; a `games/waves` template (needs `waves` in MODULE_DEFS + + a suite); the four modules flights with an exact User-tab locator. Plan + log: cloud + `plans/core/roadmap-29-loose-ends-and-everything-queued.md`. - Status (2026-09-03): **R22 ROUND 36 — DELETED KEEPS ITS STRUCTURE, on lane `core-lane-bin` (branch `fix/22-deleted-folders` off release/next @26190af, port 5205), COMMITTED, NOT PUSHED.** Folder rows + `folderId`/`path` in `manifest.deleted`; the bin as a navigable @@ -6056,3 +6171,24 @@ drag previews + authoritative move, LOCKSTEP win/level advance with NO win messa same positions → same result on every peer). Script nodes run arbitrary replicated code deterministically (pure function of object/base/data/time) — never stream outputs. + +**R29 additions (S1-S3 + the health/waves and dungeon lanes' findings):** `api.flow.nodes(type?)` +snapshots carry `x`/`y` and `api.flow.freeRegion({w?,h?,graphId?})` answers where a built block +lands (`{x,y,w,h}`, the `flowLayout` leaf — the same rule `hudActions.addBinding` uses); +`api.flow.onChange(fn)` / `api.game.onChange(fn)` / `api.peerVars.onChange(fn)` return `off()`, +fire ONE call per frame (`coalesce` leaf), are torn down with the module, and `flow` also fires +on a node firing — a manager listens instead of polling (the collectible module keeps a 1.14 +fallback, feature-detected). `api.flow.setNodeData(id, patch)` is ONE undo step attributed to +the module (`flownodes` op:'data', before-values cloned; items may carry their own `graphId`) +and `api.flow.setNodesData([{id, patch}])` is ONE step across graphs (returns the count written; +the wire stays one `nodedata` per node; undo replays in REVERSE). Ruled and measured: no batched +wire type (nothing carries multi-node data and undo correctness was the ask — 60 nodes = 4.8 ms +sync, 60 messages, all delivered); `api.spawn` still does NOT exist (a module authors core's +`spawn` node through `addNodes` and pulses it — proven). Inter-module seam pattern (Dungeon +Kit -> Dungeon Realms): share the SCENE — `userData.play` (data) plus a function record +(`userData.kit`: generate/showFloor/clear/setMarkers/setGrounded) on the Kit's persistent +scene-root group, never serialized, no new SDK surface; a rule module renders what it plays +with in its OWN group and the generator publishes no markers itself. DEVX filed by 29: +#23 `api.teleportPlayer` (a play-mode respawn cannot move the rig; `flyTo` moves the editor +camera) / play-mode menus as module DOM, #24 a live trigger count (`nodeValue` is ~6 Hz), +#25 two clocks on the api. diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index ad4ed655..d5b4d8d6 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -1151,6 +1151,19 @@ const BEAT_DEF = { } }; +// ---- module-owned DEFS (the LOADER region) ------------------------------------------- +// Each slug here is read from the sibling modules checkout as +// modules//.def.json (moduleDef above), emitted by that module's own build so the +// def and the module cannot drift. `--only ` authors one of them; the def's +// `installModules` installs the zips first so the card shows the game. +// football — 24-B (PR #218) +// dungeon-realms — 21-C C6-b: the TWO-MODULE case (`modules: dungeon + dungeon-realms`); +// its world is scene-root module content, so the def names +// `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 DEFS = [ { kind: 'template', @@ -1297,7 +1310,7 @@ const DEFS = [ MIRROR_DEF, BEAT_DEF, // the def is the module's (see moduleDef); a checkout without it cannot author it - ...['football'].map((id) => moduleDef(id) ?? { slug: id, missingModuleDef: true }) + ...MODULE_DEFS.map((id) => moduleDef(id) ?? { slug: id, missingModuleDef: true }) ]; (async () => { @@ -1677,6 +1690,17 @@ const DEFS = [ scene.add(sun); const clone = new T.ObjectLoader().parse(group.toJSON()); scene.add(clone); + // 21-C C6-b: a module's WORLD lives at the scene root (golden rule 5), so a + // card rendered from objectsGroup alone shows a dungeon template as a lone + // arch. `thumb.sceneGroups` names scene-root groups to include — cloned into + // the offscreen scene, never moved; absent, the picture is what it always was. + /** @type {any} */ let liveScene; + s.globalScene.subscribe((v) => (liveScene = v))(); + for (const name of d.thumb?.sceneGroups ?? []) { + const live = liveScene?.getObjectByName(name); + if (live) clone.add(live.clone(true)); + else console.log(' WARN thumb.sceneGroups: no scene-root group named ' + name); + } const box = new T.Box3().setFromObject(clone); const size = Math.max(box.getSize(new T.Vector3()).length(), 1); const center = box.getCenter(new T.Vector3()); diff --git a/src/App.svelte b/src/App.svelte index 51688c3b..f93a67e8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -36,6 +36,9 @@ import SplineToolbar from './components/menu/SplineToolbar.svelte' import ModuleToolboxLayer from './components/ui/ModuleToolboxLayer.svelte' import { isLocked } from './stores/sceneStore' + // 29-E: `?embed=1` — the editor windows and the dock inset stand down for the page's + // life; a corner link and a ▶ button are the only chrome an embed draws + import { embedMode, embedOpenUrl, requestPlay } from './lib/playMode' import { objectsGroup, globalRenderer } from './stores/sceneStore' import { startFlowRuntime, resumeFlowRuntime } from '$lib/flowRuntime' // 27-G: the one overlay that must sit above everything, because nothing else on @@ -506,7 +509,7 @@ import { startMusicToolbox } from './lib/musicToolbox' -{#if !$isLocked} +{#if !$isLocked && !$embedMode} @@ -523,6 +526,18 @@ import { startMusicToolbox } from './lib/musicToolbox' {/if} +{#if $embedMode} + +
+ Open in theprototype.app ↗ + {#if $isLocked !== true} + + {/if} +
+{/if} {#if $isLocked && $helpersInPlay} @@ -566,7 +581,7 @@ import { startMusicToolbox } from './lib/musicToolbox' already sits above the canvas at the default 0. No `transition` either: each step reallocates the composer's render targets, so animating the inset turns one realloc into one per frame. --> -
+
diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte index 9603dc1e..e0eaabb5 100644 --- a/src/components/Menu.svelte +++ b/src/components/Menu.svelte @@ -37,6 +37,8 @@ import WhatsNew from './menu/WhatsNew.svelte'; import { isLocked } from '../stores/sceneStore' + // 29-E: `?embed=1` (playMode.embedMode) hides the editor chrome for the page's life + import { embedMode } from '../lib/playMode' @@ -44,7 +46,8 @@ -
+ +
diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index eea23e3d..81d76926 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -79,6 +79,8 @@ saveSceneAsLevel, newLevel, renameOpenLooseScene, + // ROADMAP 22 R5: the header's double-click and the card's "Rename scene…" + renameScene, travelToLevel, levelSceneName, currentLevel, @@ -99,7 +101,7 @@ import { sceneDirty } from '$lib/sceneIdentity'; // R22 round 30 B1: the shared unsaved-changes guard, one copy for every authoring // route that replaces the world. - import { guardSceneReplace } from '$lib/sceneOpenGuard'; + import { guardSceneReplace, travelToPeerScene } from '$lib/sceneOpenGuard'; import VersionHistory from './VersionHistory.svelte'; // 21-G2: the "update available" dot on old scene versions. The manifest store is // passed as the reactive dependency — a helper reading through get() registers none @@ -208,6 +210,13 @@ volumeUpdateBytes, volumeItems } from '$lib/mountedVolumes'; + // ROADMAP 22 R4 — WHO IS IN THIS SCENE, on the card. The session is the mesh and every + // peer in it carries a scene tag (peerScenes); a scene card reads the tags for the scene + // it stands for and says who is standing there, you included, and offers to JOIN them. + // The store is passed as the reactive dependency (the get()-registers-nothing rule). + import { peerScenes, peersAtScene } from '$lib/peerScenes'; + import { sessionHost } from '$lib/connectionState'; + import { nameOf, peerColor } from '$lib/lockControl'; // R22 round 13 P2: the header reading opens a breakdown of it. The leaf owns the // open store, so the chip, this view's background menu and Settings all reach one // action with no component owning the state. @@ -226,6 +235,43 @@ logKeyOf } from '$lib/projectManifest'; const staleScene = (_manifest: any, hash: string) => staleSceneHash(hash); + /** + * ROADMAP 22 R4 — THE SESSION ROSTER OF ONE SCENE CARD: who is standing in the scene + * this file belongs to, us included. The scene is found by HASH first (a version file + * of "Arena" is Arena whatever it is called) and by file name only for a loose file the + * project has never recorded. Every argument is a store VALUE so the badge re-renders + * when anybody moves — a helper reading through get() registers no dependency. + */ + function sceneRoster(map: any, level: any, host: any, item: any) { + const scene = sceneOfHash(item.hash) ?? levelSceneName(item.name); + const mine = level?.private ? '' : String(level?.name ?? ''); + return peersAtScene(map, scene, mine, host); + } + /** the badge's title: "You are here" / "Alice is here" / "You, Alice and Bob are here" */ + function rosterTitle(roster: { peerIds: string[]; me: boolean }) { + const names = roster.peerIds.map((id: string) => nameOf(id)); + if (roster.me) names.unshift('You'); + if (names.length === 1) return roster.me ? 'You are here' : names[0] + ' is here'; + return names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1] + ' are here'; + } + /** how many people the badge counts */ + function rosterCount(roster: { peerIds: string[]; me: boolean }) { + return roster.peerIds.length + (roster.me ? 1 : 0); + } + /** + * "Join " — one entry per peer standing in this scene, travelling there through + * the guarded route the peers popover's Go to already uses (the unsaved-changes guard + * runs inside it). Nothing for ourselves: you cannot join where you already are. + */ + function joinEntries(item: any) { + const roster = sceneRoster($peerScenes, $currentLevel, $sessionHost, item); + return roster.peerIds.slice(0, 6).map((id: string) => ({ + label: 'Join ' + nameOf(id), + icon: 'arrow-right', + tooltip: 'Travel to this scene, where ' + nameOf(id) + ' is — you leave the one you are in', + action: () => void travelToPeerScene(id) + })); + } // 21-I5 REVISED: the ONE filesystem sanitiser, plus the version-date stamp the zip // entries and the panel's per-row download both name their files with. import { fileNameBase, versionStamp } from '$lib/saveName'; @@ -1304,6 +1350,56 @@ let projectEdit: string | null = $state(null); const projectLabel = $derived($projectManifest.name || 'Untitled project'); const openSceneHash = $derived($currentLevel?.hash ?? null); + // ROADMAP 22 R5 — RENAME THE OPEN SCENE FROM THE CHIP. Fork 3, locked: the scene is + // primary and its files follow, so the chip shows both halves — the scene NAME (the + // manifest key) and, in brackets, the FILE it is currently loaded from — and renaming + // the name is what moves the file. Double-click or the chip's own context menu opens + // the same inline editor the project name uses (Enter/blur commit, Escape cancels; the + // no-prompt rule). `renameScene` decides whether it is a project rename (replicated) + // or a loose file's (local), and says so in its toast. + let sceneEdit: string | null = $state(null); + /** the file the open scene was loaded from, on either shelf; '' for an adopted identity */ + const openSceneFile = $derived( + openSceneHash + ? ([...$explorerItems, ...$hiddenItems].find((i: any) => i.hash === openSceneHash)?.name ?? '') + : '' + ); + function startSceneEdit() { + if (!$currentLevel?.name) return; + settlePendingEdit(); + sceneEdit = $currentLevel.name; + } + function commitSceneEdit() { + if (sceneEdit === null) return; + const next = sceneEdit.trim(); + const from = $currentLevel?.name ?? ''; + sceneEdit = null; + if (!next || next === from) return; + if (!isValidName(next)) return showToast('A scene name cannot be empty or contain * \\ /'); + renameScene(from, next); + } + function sceneKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') commitSceneEdit(); + else if (e.key === 'Escape') sceneEdit = null; + e.stopPropagation(); + } + function sceneChipMenu(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + menu = { + x: e.clientX, + y: e.clientY, + items: [ + { + label: 'Rename scene…', + icon: 'pencil', + tooltip: 'Rename "' + ($currentLevel?.name ?? '') + '" — its files follow, on every peer', + action: startSceneEdit + }, + { label: 'Find its file', icon: 'search', action: () => void revealOpenScene() } + ] + }; + } function startProjectEdit() { projectEdit = $projectManifest.name ?? ''; } @@ -2215,6 +2311,30 @@ value: item.name }; } + /** + * ROADMAP 22 R5: rename the SCENE a card belongs to — the manifest key, not the file + * name — through the same inline editor, prefilled with the scene's name. Only for a + * file the project records (a loose file's identity IS its file, and plain Rename + * already carries the open scene's name for it). + */ + function startRenameScene(item: any) { + const sceneName = sceneOfHash(item.hash); + if (!sceneName) return; + settlePendingEdit(); + editing = { mode: 'rename-scene', itemId: item.id, sceneName, value: sceneName }; + } + function renameSceneEntry(item: any) { + const sceneName = item?.kind === 'scene' && !item.remoteScene ? sceneOfHash(item.hash) : null; + if (!sceneName) return []; + return [ + { + label: 'Rename scene…', + icon: 'pencil', + tooltip: 'Rename "' + sceneName + '" — every version file follows, on every peer', + action: () => startRenameScene(item) + } + ]; + } // 21-H2: a PREFAB renames through the SAME inline editor — never window.prompt(), // which is what the deleted Library modal used (fork 14's rule, one surface over). // Its own mode because the id addresses the prefab library, not `explorerItems`. @@ -2323,6 +2443,8 @@ const renamed = $explorerItems.find((i) => i.id === edit.itemId); if (renamed?.kind === 'scene') renameOpenLooseScene(renamed.hash, edit.value); } + // R5: the scene rename — replicated through the manifest, its files follow + else if (edit.mode === 'rename-scene') renameScene(edit.sceneName, edit.value); else if (edit.mode === 'rename-prefab') renamePrefab(edit.prefabId, edit.value); else if (edit.mode === 'rename-pack') renamePack(edit.packName, edit.value); // 21-G9 (union): land the scene where the user is looking — Scenes when the @@ -3619,7 +3741,18 @@ const made = await duplicateFolder(e.id, into); if (made) landed.push(made.id); } else { - const made = await duplicateItem(e.id, { folderId: into }); + // ROADMAP 22 R6: a SCENE pastes as a scene COPY under the Finder copy name + // ("Arena copy"), the file a Save would have made — its own name inside the + // file, its own hash, its own version history. A plain record copy would put + // a second card named "Arena.tpscene" beside the first, pointing at the SAME + // hash and carrying the same name inside: one scene, two cards, and the next + // fold would hide one of them. Every other kind is its content hash, and a + // second record of it is exactly what Copy/Paste means (24-C2). + const item = $explorerItems.find((i: any) => i.id === e.id); + const made = + item?.kind === 'scene' + ? await duplicateScene(item, sceneCopyName(item.name), { folderId: into }) + : await duplicateItem(e.id, { folderId: into }); if (made) landed.push(made.id); } } @@ -4331,7 +4464,9 @@ icon: 'download', tooltip: 'This project scene is not on this device yet — opening it fetches it from a peer', action: () => void openSceneItem(item) - } + }, + // R4: the peers standing there are the reason to fetch it + ...joinEntries(item) ] }; return; @@ -4363,7 +4498,11 @@ icon: 'history', tooltip: 'Earlier versions of this scene — restore, pin or free their bytes', action: () => showProperties({ kind: 'item', item }) - } + }, + // R4: one Join per peer standing in this scene + ...joinEntries(item), + // R5: a PROJECT scene renames as a scene (files follow); a loose file keeps plain Rename + ...renameSceneEntry(item) ] : []), // 24-C1: a copy is a new RECORD with the same bytes; Copy/Cut go to the in-app @@ -6209,7 +6348,7 @@ > {/if} - {#if (editing?.mode === 'rename' && editing.inGrid && (editing.cardId ?? editing.folderId) === id) || (editing?.mode === 'rename-item' && editing.itemId === id) || (editing?.mode === 'rename-prefab' && editing.prefabId === item?.prefabId)} + {#if (editing?.mode === 'rename' && editing.inGrid && (editing.cardId ?? editing.folderId) === id) || (editing?.mode === 'rename-item' && editing.itemId === id) || (editing?.mode === 'rename-scene' && editing.itemId === id) || (editing?.mode === 'rename-prefab' && editing.prefabId === item?.prefabId)} {@render cardEdit()} {:else} {/if} + {#if item.kind === 'scene'} + {@const roster = sceneRoster($peerScenes, $currentLevel, $sessionHost, item)} + {#if roster.me || roster.peerIds.length} + {rosterCount(roster)} here + {/if} + {/if} {:else if sharingOn && (folder.share === 'mine' || folder.share === 'peer')} - + {#if sceneEdit !== null} + (sceneEdit = e.currentTarget.value)} + onkeydown={sceneKeydown} + onblur={commitSceneEdit} + /> + {:else} + + {#if openSceneFile} + + [{openSceneFile}] + {/if} + {/if} + + {#if roster.me}{/if} + {#each roster.peerIds.slice(0, 3) as id (id)}{/each} + {rosterCount(roster)} + + {/if} + {/if} {#if item.packEntry} {#if packThumb(item)} @@ -7313,7 +7506,7 @@ {/if} - {#if (editing?.mode === 'rename-item' && editing.itemId === item.id) || (editing?.mode === 'rename-prefab' && editing.prefabId === item.prefabId)} + {#if (editing?.mode === 'rename-item' && editing.itemId === item.id) || (editing?.mode === 'rename-scene' && editing.itemId === item.id) || (editing?.mode === 'rename-prefab' && editing.prefabId === item.prefabId)} {@render cardEdit()} {:else}