From 9d3277c4b4af07e5e3125c1b53ece07ca313ada7 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 13:09:39 +0300 Subject: [PATCH 01/13] [feat] S1: api.flow node positions and freeRegion, the one placement rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api.flow.nodes() snapshots carry x/y (read-only) — addNodes took positions in, nothing gave them back, so a module building a graph could not ask where it was already occupied (DEVX #16) - api.flow.freeRegion({w, h, graphId}) answers where a block of new nodes lands: left-aligned under the lowest card, a margin from the origin in an empty graph - the rule lives in ONE leaf, src/lib/flowLayout.js (imports nothing); gameRecipes' copy left core in R3a, and the surviving right-of-everything copy in hudActions' addBinding now calls the same function with side 'right' (byte-identical x, pinned by a unit test) - suites: sdk-game-seams +7 checks (positions on both peers, two recipes in a row beside a user node overlap nothing and stack downward, the overlap detector catches a constant-placed block, unknown graph = margin); new unit test tests/unit/flowLayout.test.js (7) - counterfactual: freeRegion returning a constant -> "two recipes in a row land on nothing" and "each block lands BELOW" go red (e2e), 5 unit tests red - hud-actions 65/65 green (addBinding placement); svelte-check 341/47 = base, identical error list; vitest 143 -> 150 at this commit Co-Authored-By: Claude Opus 5 --- src/lib/flowLayout.js | 62 ++++++++++++++++++++++++++++ src/lib/hudActions.js | 7 +++- src/lib/moduleSDK.js | 30 ++++++++++++-- tests/e2e/sdk-game-seams.test.cjs | 67 ++++++++++++++++++++++++++++++- tests/unit/flowLayout.test.js | 55 +++++++++++++++++++++++++ 5 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 src/lib/flowLayout.js create mode 100644 tests/unit/flowLayout.test.js diff --git a/src/lib/flowLayout.js b/src/lib/flowLayout.js new file mode 100644 index 00000000..9659b26d --- /dev/null +++ b/src/lib/flowLayout.js @@ -0,0 +1,62 @@ +// R29 S1 — WHERE A BUILT GRAPH LANDS. Imports NOTHING (a leaf, vitest-covered). +// +// Anything that authors nodes on the user's behalf — a module recipe through +// `api.flow.freeRegion`, the HUD editor's action bindings — has to ask one question: +// "where can I put a block of nodes without landing on top of what is already there?" +// `gameRecipes` answered it once (below everything), `hudActions` answered it again +// (right of everything), and every module would have written a third copy. The rule +// lives HERE now and nowhere else. +// +// It is deliberately DETERMINISTIC and cheap: a pure function of the node positions, +// which are replicated, so two peers asking about the same graph get the same point. +// Node SIZE is an estimate — a serialized node does not reliably carry its measured +// box — and every card in the editor is `w-[150px]`, so width is known and height is +// the one guess (measured when the node happens to carry it). + +/** every flow card is `w-[150px]` */ +export const NODE_W = 150; +/** a typical card's height when the node carries no measured box */ +export const NODE_H = 150; +/** clearance between the existing graph and the new block */ +export const GAP_Y = 40; +export const GAP_X = 70; +/** where the first block of an EMPTY graph goes */ +export const MARGIN = 40; + +/** @param {any} n */ +function heightOf(n) { + const h = Number(n?.measured?.height ?? n?.height); + return Number.isFinite(h) && h > 0 ? h : NODE_H; +} + +/** + * A top-left point for a `w` x `h` block that overlaps nothing in `nodes`. + * + * `side: 'below'` (the default, the recipe rule): left-aligned with the graph's + * leftmost node, one gap under its lowest card. `side: 'right'` (the HUD bindings + * rule): one gap past the rightmost card, level with the top — with the empty graph + * read as a phantom card at x 0, which is what `hudActions` always did. + * + * Nothing is REJECTED: below/right of everything always has room, so `w`/`h` never + * change the answer today — they are part of the contract so a smarter packer can + * honour them without changing a caller. + * @param {any[]} nodes `{position: {x, y}}` (or `{x, y}`) records + * @param {{w?: number, h?: number, side?: 'below'|'right'}} [opts] + * @returns {{x: number, y: number, w: number, h: number}} + */ +export function freeRegion(nodes, opts = {}) { + const w = Math.max(0, Number(opts.w) || 0); + const h = Math.max(0, Number(opts.h) || 0); + const list = (Array.isArray(nodes) ? nodes : []).filter(Boolean); + const xOf = (/** @type {any} */ n) => Number(n.position?.x ?? n.x) || 0; + const yOf = (/** @type {any} */ n) => Number(n.position?.y ?? n.y) || 0; + if (opts.side === 'right') { + const right = list.reduce((max, n) => Math.max(max, xOf(n)), 0) + NODE_W + GAP_X; + const top = list.length ? Math.min(...list.map(yOf)) : MARGIN; + return { x: right, y: top, w, h }; + } + if (!list.length) return { x: MARGIN, y: MARGIN, w, h }; + const left = Math.min(...list.map(xOf)); + const bottom = Math.max(...list.map((n) => yOf(n) + heightOf(n))); + return { x: left, y: bottom + GAP_Y, w, h }; +} diff --git a/src/lib/hudActions.js b/src/lib/hudActions.js index 6c8bb3ae..5579f1c1 100644 --- a/src/lib/hudActions.js +++ b/src/lib/hudActions.js @@ -36,6 +36,8 @@ import { isInteractiveKind, isValuedKind } from './hudKinds'; // reaches nodesHandler/flowGraphs — the history family — so moduleSDK cannot import it; // the leaf is where both sides can meet, the moduleToolboxes rule) import { moduleHudActionList } from './moduleHudKinds'; +// R29 S1: a leaf (imports nothing) — the one placement rule, shared with api.flow.freeRegion +import { freeRegion } from './flowLayout'; /** The HUD node types that READ an element (a display binding), by element kind. */ // 21-E7.6: the PACK kinds map onto the SAME four display nodes rather than earning nodes @@ -454,8 +456,9 @@ export function addBinding(elementId, actionKey) { /** @type {any[]} */ const createdEdges = []; - // where to put them: past whatever is furthest right, in a column per binding - const baseX = nodes.reduce((max, n) => Math.max(max, Number(n.position?.x) || 0), 0) + 220; + // where to put them: past whatever is furthest right, in a column per binding (R29 S1: + // the rule is flowLayout's one copy now, shared with api.flow.freeRegion) + const baseX = freeRegion(nodes, { side: 'right' }).x; const baseY = 40 + bindingsFor(elementId).length * 150; if (action.role === 'value') { diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 7ae128b5..ee96d328 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -34,6 +34,8 @@ import { // close the history cycle. flowGraphs/nodeCatalog are NOT leaves and stay primed below. import { roundCutoff, roundUnderway, gameVar, setGameVar } from './gameState'; import { setPeerVar, myPeerVar, leaderboardRows } from './peerVars'; +// R29 S1: a leaf that imports NOTHING, so the edge cannot close a cycle +import { freeRegion as freeRegionIn } from './flowLayout'; import { createFlowNode, createFlowEdge, serializeNode, serializeEdge, setNodeData as sendNodeData } from './nodesHandler'; import { APP_VERSION } from './version.js'; import { ndcFromClient } from './canvasRect'; @@ -997,16 +999,38 @@ function makeApi(moduleId, moduleName = moduleId) { */ flow: { /** Every node (optionally one type) as plain snapshots: - * `{id, type, graphId, data}` — graphId 'scene' or the owner object's uuid. - * @param {string=} type @returns {any[]} */ + * `{id, type, graphId, x, y, data}` — graphId 'scene' or the owner object's uuid; + * x/y the node's position in its graph (R29 S1, read-only — move a node in the + * editor, never by writing these). @param {string=} type @returns {any[]} */ nodes(type) { const out = []; for (const n of allNodes()) { if (type && n.type !== type) continue; - out.push({ id: n.id, type: n.type, graphId: n.__graph ?? SCENE_GRAPH, data: { ...(n.data ?? {}) } }); + out.push({ + id: n.id, + type: n.type, + graphId: n.__graph ?? SCENE_GRAPH, + x: Number(n.position?.x) || 0, + y: Number(n.position?.y) || 0, + data: { ...(n.data ?? {}) } + }); } return out; }, + /** + * R29 S1: where a `w` x `h` block of new nodes can land without covering what is + * already in the graph — left-aligned under its lowest card (an empty graph gets + * a margin from the origin). Pass the result's x/y to `addNodes`; ask again before + * each block, since the answer moves as the graph grows. The rule is core's one + * copy (`flowLayout.freeRegion`), shared with the HUD editor's bindings. + * @param {{w?: number, h?: number, graphId?: string}=} opts + * @returns {{x: number, y: number, w: number, h: number}} + */ + freeRegion(opts) { + const graphId = opts?.graphId ?? SCENE_GRAPH; + const nodes = get(flowGraphs)?.[graphId]?.nodes ?? []; + return freeRegionIn(nodes, { w: opts?.w, h: opts?.h }); + }, /** Every edge, graph-tagged: `{id, source, target, sourceHandle, targetHandle, * graphId}`. @returns {any[]} */ edges() { diff --git a/tests/e2e/sdk-game-seams.test.cjs b/tests/e2e/sdk-game-seams.test.cjs index 2581414b..b727c92d 100644 --- a/tests/e2e/sdk-game-seams.test.cjs +++ b/tests/e2e/sdk-game-seams.test.cjs @@ -349,6 +349,72 @@ h.run(async () => { 'selectedUuids reads the SET, so a deselect empties it (never the sticky primary)' ); + // ===================================================================== + // 7b. R29 S1 — node POSITIONS come back, and api.flow.freeRegion keeps two + // recipes in a row off each other and off the user's own nodes + // ===================================================================== + await wipe([A, B]); + const placed = await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ nodes: [{ type: 'seamvalue', x: 123, y: 456, data: {} }] }) + ); + await A.page.waitForTimeout(700); + const snap = await Promise.all( + [A, B].map((p) => p.page.evaluate((id) => window.__seams.api.flow.nodes().find((n) => n.id === id), placed[0])) + ); + h.check( + snap.every((n) => n && n.x === 123 && n.y === 456), + `api.flow.nodes() snapshots carry x/y, on both peers (${JSON.stringify(snap.map((n) => n && [n.x, n.y]))})` + ); + // a "user" node dragged somewhere a fixed-row layout would collide with + await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ nodes: [{ type: 'seamvalue', x: 60, y: 700, data: {} }] }) + ); + /** run one two-node recipe at whatever freeRegion answers; return its ids */ + const recipe = (peer) => + peer.page.evaluate(() => { + const api = window.__seams.api; + const at = api.flow.freeRegion({ w: 370, h: 150 }); + return api.flow.addNodes({ + nodes: [ + { type: 'seamvalue', x: at.x, y: at.y, data: {} }, + { type: 'seamcollect', x: at.x + 220, y: at.y, data: {} } + ], + edges: [{ from: 1, to: 0 }] + }); + }); + const r1 = await recipe(A); + const r2 = await recipe(A); + await A.page.waitForTimeout(600); + /** every pair of 150x150 cards that overlap, over the scene graph */ + const overlapsOf = (peer) => + peer.page.evaluate(() => { + const ns = window.__seams.api.flow.nodes().filter((n) => n.graphId === 'scene'); + const hits = []; + for (let i = 0; i < ns.length; i++) + for (let j = i + 1; j < ns.length; j++) { + const a = ns[i], b = ns[j]; + if (a.x < b.x + 150 && b.x < a.x + 150 && a.y < b.y + 150 && b.y < a.y + 150) hits.push([a.id, b.id]); + } + return { n: ns.length, hits }; + }); + const ov = await overlapsOf(A); + h.check(r1.length === 2 && r2.length === 2 && ov.n === 6, `premise: two recipes built beside two nodes (${ov.n} nodes)`); + h.check(ov.hits.length === 0, `two recipes in a row land on nothing — no overlapping cards (${JSON.stringify(ov.hits)})`); + const ys = await A.page.evaluate( + (ids) => ids.map((id) => window.__seams.api.flow.nodes().find((n) => n.id === id)?.y), + [r1[0], r2[0]] + ); + h.check(ys[0] > 700 && ys[1] > ys[0], `each block lands BELOW everything before it (${JSON.stringify(ys)})`); + // the detector is live: a recipe at a CONSTANT point (what freeRegion used to be + // hand-rolled as) is caught — proves the check above cannot pass vacuously + await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ nodes: [{ type: 'seamvalue', x: 60, y: 700, data: {} }] }) + ); + const ovBad = await overlapsOf(A); + h.check(ovBad.hits.length > 0, `and a constant-placed block IS detected as an overlap (${ovBad.hits.length})`); + const scoped = await A.page.evaluate(() => window.__seams.api.flow.freeRegion({ graphId: 'no-such-graph' })); + h.check(scoped.x === 40 && scoped.y === 40, `an empty/unknown graph answers the margin (${JSON.stringify(scoped)})`); + // ===================================================================== // 8. the debug line + the action catalog seams, and their teardown // ===================================================================== @@ -376,6 +442,5 @@ h.run(async () => { }); h.check(!afterOff.lines.includes('seams: line-alive'), 'deactivate removes the debug line (journal)'); h.check(!afterOff.offered.includes('mod-seams-showseam'), 'and the catalog entry'); - await h.finish(browser); }); diff --git a/tests/unit/flowLayout.test.js b/tests/unit/flowLayout.test.js new file mode 100644 index 00000000..377c8683 --- /dev/null +++ b/tests/unit/flowLayout.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { freeRegion, NODE_W, NODE_H, GAP_X, GAP_Y, MARGIN } from '../../src/lib/flowLayout.js'; + +// R29 S1. The one "where does a built block of nodes land" rule — api.flow.freeRegion and +// the HUD editor's bindings both call it, so it is pinned here with no browser. + +/** does a w x h block at p overlap any NODE_W x NODE_H card? + * @param {any[]} nodes @param {{x: number, y: number}} p @param {number} w @param {number} h */ +const overlaps = (nodes, p, w, h) => + nodes.some((/** @type {any} */ n) => { + const x = n.position.x, y = n.position.y; + return p.x < x + NODE_W && x < p.x + w && p.y < y + NODE_H && y < p.y + h; + }); +/** @param {number} x @param {number} y */ +const at = (x, y) => ({ position: { x, y } }); + +describe('freeRegion', () => { + it('an empty graph gets the margin', () => { + expect(freeRegion([], { w: 300, h: 200 })).toEqual({ x: MARGIN, y: MARGIN, w: 300, h: 200 }); + }); + it('below: left-aligned, one gap under the lowest card', () => { + const nodes = [at(60, 40), at(280, 400), at(-20, 120)]; + const p = freeRegion(nodes); + expect(p.x).toBe(-20); + expect(p.y).toBe(400 + NODE_H + GAP_Y); + }); + it('a measured height is honoured over the estimate', () => { + const p = freeRegion([{ position: { x: 0, y: 0 }, measured: { height: 400 } }]); + expect(p.y).toBe(400 + GAP_Y); + }); + it('accepts flat {x, y} snapshots (what api.flow.nodes returns)', () => { + expect(freeRegion([{ x: 10, y: 20 }]).y).toBe(20 + NODE_H + GAP_Y); + }); + it('two blocks placed in a row never overlap each other or the graph', () => { + const graph = [at(60, 40), at(280, 40)]; + const first = freeRegion(graph, { w: 370, h: 150 }); + expect(overlaps(graph, first, 370, 150)).toBe(false); + graph.push(at(first.x, first.y), at(first.x + 220, first.y)); + const second = freeRegion(graph, { w: 370, h: 150 }); + expect(overlaps(graph, second, 370, 150)).toBe(false); + expect(second.y).toBeGreaterThan(first.y); + }); + it("right: the HUD bindings' rule, byte-identical to what hudActions always computed", () => { + const nodes = [at(100, 50), at(640, 90)]; + const legacy = nodes.reduce((max, n) => Math.max(max, n.position.x), 0) + 220; + expect(freeRegion(nodes, { side: 'right' }).x).toBe(legacy); + expect(NODE_W + GAP_X).toBe(220); + // the empty graph was a phantom card at 0, so the first binding sits at 220 + expect(freeRegion([], { side: 'right' }).x).toBe(220); + }); + it('garbage in is ignored, not thrown', () => { + expect(freeRegion(/** @type {any} */ (null)).x).toBe(MARGIN); + expect(freeRegion([null, at(0, 0)]).y).toBe(NODE_H + GAP_Y); + }); +}); From 31418d8d56b56f85b4397d0362334c3ad1dfb097 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 13:10:22 +0300 Subject: [PATCH 02/13] [feat] S2: api.flow/game/peerVars.onChange, a change signal coalesced in the seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api.flow.onChange(fn) (graph documents AND the trigger log — a node firing is what a collected-state list changes on), api.game.onChange(fn) (the game singleton), api.peerVars.onChange(fn) (my row + remote rows): thin subscribe wrappers over stores core already has, each journalled for teardown and also returning an off() for a toolbox that mounts and unmounts (DEVX #17) - coalesced INSIDE the seam (src/lib/coalesce.js, a leaf): the svelte subscribe's synchronous first call is swallowed, any number of ticks before the next frame run the handler once, a flush queued at teardown never runs, a throwing handler cannot break the store write. One FRAME, not a microtask: a microtask was measured to fold a local burst but not thirty edits arriving from a peer (+30 handler calls for 30 messages); a 100ms timer races the frame so a background tab still gets its call - suites: sdk-game-seams +15 checks (60 idle frames with a time node ticking fire nothing; a 30-tick bulk edit = 1 call; the peer's handler fires as edits arrive and folds them, +1 for 30; a node firing = 1; game-state change fires on both peers; 20 setVar in a burst = 1; a peer-var write fires mine and the peer's; the returned off() stops one subscriber; deactivate unsubscribes all three); tests/unit/coalesce.test.js (8) - counterfactual: debounce removed (flush called per tick) -> bulk edit 30, arriving +30, firing +30, setVar 20: five checks red; microtask instead of frame -> "arriving edits are coalesced" red (+30 for 30) - sdk-game-seams 61/61 (base 34), hud-actions 65/65, vitest 158 (base 143), svelte-check 341/47 = base with an identical error list, build green Co-Authored-By: Claude Opus 5 --- src/lib/coalesce.js | 74 ++++++++++++++++++ src/lib/moduleSDK.js | 43 ++++++++++- tests/e2e/sdk-game-seams.test.cjs | 110 +++++++++++++++++++++++++++ tests/unit/coalesce.test.js | 121 ++++++++++++++++++++++++++++++ 4 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 src/lib/coalesce.js create mode 100644 tests/unit/coalesce.test.js diff --git a/src/lib/coalesce.js b/src/lib/coalesce.js new file mode 100644 index 00000000..c63712e6 --- /dev/null +++ b/src/lib/coalesce.js @@ -0,0 +1,74 @@ +// R29 S2 — A CHANGE SIGNAL THAT CANNOT RUN PER STORE TICK. Imports NOTHING (a leaf, +// vitest-covered). +// +// `api.flow.onChange` / `api.game.onChange` / `api.peerVars.onChange` exist so a module +// toolbox can stop polling. The trap the plan wrote down: a handler that runs on every +// store tick and then reads the graph is NOT cheaper than polling — one bulk edit is +// dozens of flowGraphs writes, and edits ARRIVING from a peer are one message (one +// task) each, thirty of them inside a few milliseconds. So the seam coalesces to ONE +// FRAME: however many ticks land before the next frame, the handler runs once. A +// microtask was tried first and MEASURED insufficient — it folds a local burst but not +// thirty arriving messages (+30 handler calls for 30 edits). And because a frame never +// comes in a background tab, a timer races it: a module's logic must not stall because +// the window lost focus. Outside a browser (the unit tests) it falls back to a microtask. +// +// Svelte's subscribe also calls back SYNCHRONOUSLY with the current value; that is not +// a change, so it is swallowed. And a flush already queued when the subscriber is torn +// down must not run — a disabled module's handler firing once more is exactly the +// "dead code still acting" shape the teardown journal exists to prevent. + +/** the timer that stands in for a frame a hidden tab never paints */ +export const FRAME_FALLBACK_MS = 100; + +/** run `cb` once, on the next frame or after FRAME_FALLBACK_MS, whichever comes first + * @param {() => void} cb */ +export function nextFrame(cb) { + if (typeof requestAnimationFrame !== 'function') { + queueMicrotask(cb); + return; + } + let done = false; + const run = () => { + if (done) return; + done = true; + clearTimeout(timer); + cb(); + }; + const timer = setTimeout(run, FRAME_FALLBACK_MS); + requestAnimationFrame(run); +} + +/** + * Subscribe `fn` to every store in `stores`, coalesced: at most one call per burst. + * @param {{subscribe: (cb: (v: any) => void) => (() => void)}[]} stores + * @param {() => void} fn + * @param {(cb: () => void) => void} [schedule] defaults to `nextFrame` + * @returns {() => void} unsubscribe + */ +export function coalescedSubscribe(stores, fn, schedule) { + const later = schedule ?? nextFrame; + let live = true; + let queued = false; + let arming = true; + const flush = () => { + queued = false; + if (!live) return; + try { + fn(); + } catch (error) { + // a module's handler must never take a core store write down with it + console.warn('[module onChange] handler threw', error); + } + }; + const poke = () => { + if (arming || !live || queued) return; + queued = true; + later(flush); + }; + const offs = stores.map((s) => s.subscribe(poke)); + arming = false; + return () => { + live = false; + for (const off of offs) off(); + }; +} diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index ee96d328..47616ff0 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -4,7 +4,7 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked } from '../stores/sceneStore'; import { peers, showToast, modulesOpen, userdata } from '../stores/appStore'; -import { syncedAnimations, flowGraphs, flowValues, allNodes, findNodeAnyGraph, SCENE_GRAPH } from '../stores/flowStore'; +import { syncedAnimations, flowGraphs, flowValues, flowTriggers, allNodes, findNodeAnyGraph, SCENE_GRAPH } from '../stores/flowStore'; import { customGeometryBuilders } from './customGeometries'; // A1: moduleNodeIO imports NOTHING, so a static edge to it closes no cycle import { @@ -32,10 +32,11 @@ import { // R3a: all three are LEAVES (svelte stores only — gameState/peerVars say so in their own // headers; nodesHandler reaches only flowStore + appStore), so none of these edges can // close the history cycle. flowGraphs/nodeCatalog are NOT leaves and stay primed below. -import { roundCutoff, roundUnderway, gameVar, setGameVar } from './gameState'; -import { setPeerVar, myPeerVar, leaderboardRows } from './peerVars'; -// R29 S1: a leaf that imports NOTHING, so the edge cannot close a cycle +import { roundCutoff, roundUnderway, gameVar, setGameVar, gameState } from './gameState'; +import { setPeerVar, myPeerVar, leaderboardRows, peerVarsMine, peerVarsRemote } from './peerVars'; +// R29 S1/S2: both leaves import NOTHING, so neither edge can close a cycle import { freeRegion as freeRegionIn } from './flowLayout'; +import { coalescedSubscribe } from './coalesce'; import { createFlowNode, createFlowEdge, serializeNode, serializeEdge, setNodeData as sendNodeData } from './nodesHandler'; import { APP_VERSION } from './version.js'; import { ndcFromClient } from './canvasRect'; @@ -967,6 +968,15 @@ function makeApi(moduleId, moduleName = moduleId) { * @param {string} name @param {number} value */ setVar(name, value) { setGameVar(name, value); + }, + /** R29 S2: `fn()` runs after the game singleton changes (state, round, a + * variable) — COALESCED, at most once per frame however many writes land, never per + * store tick; torn down with the module (or earlier, by calling what it returns). + * Read what you need inside it. @param {() => void} fn @returns {() => void} off */ + onChange(fn) { + const off = coalescedSubscribe([gameState], fn); + onDispose(off); + return off; } }, /** @@ -989,6 +999,14 @@ function makeApi(moduleId, moduleName = moduleId) { * @param {{order?: 'desc'|'asc'}=} opts */ all(name, opts) { return leaderboardRows(name, opts); + }, + /** R29 S2: `fn()` runs after ANY peer's row changes (mine or a remote one) — + * coalesced to one call per frame, torn down with the module or by the returned + * `off`. @param {() => void} fn @returns {() => void} off */ + onChange(fn) { + const off = coalescedSubscribe([peerVarsMine, peerVarsRemote], fn); + onDispose(off); + return off; } }, /** @@ -1031,6 +1049,23 @@ function makeApi(moduleId, moduleName = moduleId) { const nodes = get(flowGraphs)?.[graphId]?.nodes ?? []; return freeRegionIn(nodes, { w: opts?.w, h: opts?.h }); }, + /** + * R29 S2: `fn()` runs after the flow graphs change — a node or edge created, + * deleted, moved or re-parameterised, on this peer or arriving from one — or + * after a node FIRES (the trigger log, which is what a latch, a counter or your + * own `triggerStamp` read derives from, so a manager listing collected state + * needs it as much as it needs the structure). It is + * COALESCED to one frame: however many store writes a gesture or a stream of + * arriving edits makes, the handler runs once, after them. Live VALUES (`nodeValue`) tick every frame + * and deliberately do not fire it. Torn down with the module, or earlier by + * calling the returned `off` (a toolbox that mounts and unmounts). + * @param {() => void} fn @returns {() => void} off + */ + onChange(fn) { + const off = coalescedSubscribe([flowGraphs, flowTriggers], fn); + onDispose(off); + return off; + }, /** Every edge, graph-tagged: `{id, source, target, sourceHandle, targetHandle, * graphId}`. @returns {any[]} */ edges() { diff --git a/tests/e2e/sdk-game-seams.test.cjs b/tests/e2e/sdk-game-seams.test.cjs index b727c92d..fce0d398 100644 --- a/tests/e2e/sdk-game-seams.test.cjs +++ b/tests/e2e/sdk-game-seams.test.cjs @@ -415,6 +415,101 @@ h.run(async () => { const scoped = await A.page.evaluate(() => window.__seams.api.flow.freeRegion({ graphId: 'no-such-graph' })); h.check(scoped.x === 40 && scoped.y === 40, `an empty/unknown graph answers the margin (${JSON.stringify(scoped)})`); + // ===================================================================== + // 7c. R29 S2 — onChange: fires on a node edit, a game-state change and a peer-var + // write; COALESCED (one call per burst, nothing while idle); torn down in §8 + // ===================================================================== + await wipe([A, B]); + for (const p of [A, B]) + await p.page.evaluate(() => { + const api = window.__seams.api; + const c = (window.__seamCounts = { flow: 0, game: 0, peer: 0 }); + api.flow.onChange(() => c.flow++); + api.game.onChange(() => c.game++); + api.peerVars.onChange(() => c.peer++); + // a toolbox-style subscriber that unmounts early: the returned off must stop it + c.early = 0; + const off = api.flow.onChange(() => c.early++); + window.__seamEarlyOff = off; + }); + const counts = (peer) => peer.page.evaluate(() => ({ ...window.__seamCounts })); + const frames = (peer, n) => + peer.page.evaluate( + (n) => new Promise((r) => { + let i = 0; + const step = () => (++i >= n ? r(i) : requestAnimationFrame(step)); + requestAnimationFrame(step); + }), + n + ); + // idle: sixty frames with the flow runtime ticking, a clock running, nothing edited + await A.page.evaluate(() => window.__seams.api.flow.addNodes({ nodes: [{ type: 'time', x: 60, y: 60, data: {} }] })); + await A.page.waitForTimeout(800); + const idle0 = await counts(A); + await frames(A, 60); + const idle1 = await counts(A); + h.check( + idle1.flow === idle0.flow && idle1.game === idle0.game && idle1.peer === idle0.peer, + `NOT once per frame: 60 idle frames fire nothing (${JSON.stringify(idle0)} -> ${JSON.stringify(idle1)})` + ); + // a node edit fires it, locally AND on the peer the edit arrives at + const bumpIds = await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ + nodes: Array.from({ length: 30 }, (_, i) => ({ type: 'seamvalue', x: 400 + i * 10, y: 60, data: {} })) + }) + ); + await A.page.waitForTimeout(900); + const e0 = [await counts(A), await counts(B)]; + // ONE burst: thirty node edits in one synchronous gesture (a toolbox bulk edit) + const ticks = await A.page.evaluate((ids) => { + const s = window.__stores; + let n = 0; + const off = s.flowGraphs.subscribe(() => n++); + n = 0; + for (const id of ids) window.__seams.api.flow.setNodeData(id, { tag: 'bulk' }); + off(); + return n; + }, bumpIds); + await A.page.waitForTimeout(900); + const e1 = [await counts(A), await counts(B)]; + h.check(ticks >= 30, `premise: the bulk edit is ${ticks} store ticks`); + h.check(e1[0].flow - e0[0].flow === 1, `a ${ticks}-tick bulk edit runs the flow handler ONCE (${e1[0].flow - e0[0].flow})`); + h.check(e1[1].flow > e0[1].flow, `and the peer's handler fires as the edits ARRIVE (+${e1[1].flow - e0[1].flow})`); + h.check(e1[0].early - e0[0].early === 1, `premise: the early subscriber saw the burst too (+${e1[0].early - e0[0].early})`); + await A.page.evaluate(() => window.__seamEarlyOff()); + await A.page.evaluate((id) => window.__seams.api.flow.setNodeData(id, { tag: 'after-off' }), bumpIds[0]); + await A.page.waitForTimeout(400); + const e2 = await counts(A); + h.check(e2.early === e1[0].early && e2.flow === e1[0].flow + 1, `the returned off() stops ONE subscriber and leaves the rest (${e2.early}, flow +${e2.flow - e1[0].flow})`); + h.check(e1[1].flow - e0[1].flow < ticks, `arriving edits are coalesced too, not one per message (+${e1[1].flow - e0[1].flow} for ${ticks})`); + // a node FIRING is a flow change too (the trigger log) — what a collected-state list needs + const f0 = await counts(A); + await A.page.evaluate(() => window.__seams.api.fireNodeTrigger('seamvalue', undefined, { replicate: false })); + await A.page.waitForTimeout(400); + const f1 = await counts(A); + h.check(f1.flow - f0.flow === 1, `a node firing runs the flow handler once (+${f1.flow - f0.flow})`); + // game state: a transition fires it on both peers; a variable burst is one call + const g0c = await counts(A); + await gstate(A, 'playing'); + await A.page.waitForTimeout(700); + const g1c = [await counts(A), await counts(B)]; + h.check(g1c[0].game > g0c.game, `a game-state change fires api.game.onChange (+${g1c[0].game - g0c.game})`); + h.check(g1c[1].game > 0, `and on the peer, from the replicated singleton (${g1c[1].game})`); + await A.page.evaluate(() => { + for (let i = 0; i < 20; i++) window.__seams.api.game.setVar('burst', i); + }); + await A.page.waitForTimeout(400); + const g2c = await counts(A); + h.check(g2c.game - g1c[0].game === 1, `twenty setVar calls in one burst = ONE game handler call (${g2c.game - g1c[0].game})`); + await gstate(A, 'menu'); + // peer vars: my write fires mine; the peer's write fires mine as it arrives + const p0 = [await counts(A), await counts(B)]; + await A.page.evaluate(() => window.__seams.api.peerVars.setMine('laps', 9)); + await A.page.waitForTimeout(900); + const p1 = [await counts(A), await counts(B)]; + h.check(p1[0].peer > p0[0].peer, `a peer-var write fires my handler (+${p1[0].peer - p0[0].peer})`); + h.check(p1[1].peer > p0[1].peer, `and the OTHER peer's, as the row arrives (+${p1[1].peer - p0[1].peer})`); + // ===================================================================== // 8. the debug line + the action catalog seams, and their teardown // ===================================================================== @@ -442,5 +537,20 @@ h.run(async () => { }); h.check(!afterOff.lines.includes('seams: line-alive'), 'deactivate removes the debug line (journal)'); h.check(!afterOff.offered.includes('mod-seams-showseam'), 'and the catalog entry'); + // R29 S2: the three onChange subscriptions went with the journal + const off0 = await counts(A); + await A.page.evaluate(() => { + const s = window.__stores; + s.gameState.setGameVar('after', 1); + s.peerVars.setPeerVar('after', 1); + s.flowGraphs.update((g) => ({ ...g })); + }); + await A.page.waitForTimeout(400); + const off1 = await counts(A); + h.check( + JSON.stringify(off0) === JSON.stringify(off1), + `deactivate unsubscribes every onChange (${JSON.stringify(off0)} -> ${JSON.stringify(off1)})` + ); + await h.finish(browser); }); diff --git a/tests/unit/coalesce.test.js b/tests/unit/coalesce.test.js new file mode 100644 index 00000000..19412f65 --- /dev/null +++ b/tests/unit/coalesce.test.js @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { coalescedSubscribe, nextFrame, FRAME_FALLBACK_MS } from '../../src/lib/coalesce.js'; + +// R29 S2. The debounce behind api.flow/game/peerVars.onChange. A minimal svelte-shaped +// store (subscribe calls back synchronously with the current value, as svelte's does). +/** @param {any} [value] */ +function store(value = 0) { + /** @type {Set<(v: any) => void>} */ + const subs = new Set(); + return { + /** @param {any} v */ + set(v) { + value = v; + for (const s of subs) s(value); + }, + /** @param {(v: any) => void} cb */ + subscribe(cb) { + subs.add(cb); + cb(value); + return () => subs.delete(cb); + }, + get count() { + return subs.size; + } + }; +} +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe('coalescedSubscribe', () => { + it('the synchronous subscribe callback is not a change', async () => { + let calls = 0; + coalescedSubscribe([store()], () => calls++); + await tick(); + expect(calls).toBe(0); + }); + it('a burst of sixty writes runs the handler ONCE, after the burst', async () => { + const s = store(); + let calls = 0; + let seen = -1; + coalescedSubscribe([s], () => { + calls++; + seen = 59; + }); + for (let i = 0; i < 60; i++) s.set(i); + expect(calls).toBe(0); // not inside the burst + await tick(); + expect(calls).toBe(1); + expect(seen).toBe(59); + }); + it('writes to SEVERAL stores in one burst still coalesce to one call', async () => { + const a = store(), b = store(); + let calls = 0; + coalescedSubscribe([a, b], () => calls++); + a.set(1); b.set(1); a.set(2); + await tick(); + expect(calls).toBe(1); + }); + it('separate bursts are separate calls', async () => { + const s = store(); + let calls = 0; + coalescedSubscribe([s], () => calls++); + s.set(1); + await tick(); + s.set(2); + await tick(); + expect(calls).toBe(2); + }); + it('teardown unsubscribes, and a flush already queued does not run', async () => { + const s = store(); + let calls = 0; + const off = coalescedSubscribe([s], () => calls++); + expect(s.count).toBe(1); + s.set(1); + off(); + expect(s.count).toBe(0); + await tick(); + expect(calls).toBe(0); + s.set(2); + await tick(); + expect(calls).toBe(0); + }); + it('a throwing handler does not break the next burst', async () => { + const s = store(); + let calls = 0; + const warn = console.warn; + console.warn = () => {}; + coalescedSubscribe([s], () => { + calls++; + throw new Error('module bug'); + }); + s.set(1); + await tick(); + s.set(2); + await tick(); + console.warn = warn; + expect(calls).toBe(2); + }); +}); + +describe('nextFrame', () => { + it('runs once on the frame, and the fallback timer does not run it again', async () => { + /** @type {any[]} */ + const frames = []; + /** @type {any} */ (globalThis).requestAnimationFrame = (/** @type {any} */ cb) => frames.push(cb); + let calls = 0; + nextFrame(() => calls++); + expect(calls).toBe(0); + frames.shift()(); + await new Promise((r) => setTimeout(r, FRAME_FALLBACK_MS + 30)); + expect(calls).toBe(1); + delete (/** @type {any} */ (globalThis)).requestAnimationFrame; + }); + it('a tab that never paints still gets its call (the timer races the frame)', async () => { + /** @type {any} */ (globalThis).requestAnimationFrame = () => {}; + let calls = 0; + nextFrame(() => calls++); + await new Promise((r) => setTimeout(r, FRAME_FALLBACK_MS + 30)); + expect(calls).toBe(1); + delete (/** @type {any} */ (globalThis)).requestAnimationFrame; + }); +}); From a988f4efade3a0f7364432c8c0c5a47704f2ad58 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 13:24:22 +0300 Subject: [PATCH 03/13] =?UTF-8?q?[feat]=20E:=20embed=3D1=20boot=20flag=20?= =?UTF-8?q?=E2=80=94=20chrome=20hidden=20for=20the=20page's=20life,=20play?= =?UTF-8?q?=20kept,=20a=20corner=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - playMode.js: `embedMode` (writable, set ONCE from `?embed=1` at module evaluation — before the cloud plugin clears the query with replaceState), `embedSceneId`, `embedOpenUrl()` (`/?s=`, or `/`). Absent, or any value but `1`, is the old app. - Menu.svelte: the chrome tree gets `id="editor-chrome"` and hides on `$isLocked || $embedMode` (sidebar, pill, panels, toasts, welcome — everything in it). - App.svelte: the editor windows block gates on `!$embedMode` too; the dock inset is off in an embed; `#embed-chrome` draws the two things an embed owns — `#embed-open-link` ("Open in theprototype.app", new tab) and `#embed-play` (▶, only while not playing, so an Esc inside the frame has a way back in; a real click = the gesture the pointer lock wants). - Why: roadmap 29 fork 4 — the community Worker's `/e/` frames the app at `/?s=&play=1&embed=1`; no separate viewer build. Suite `tests/e2e/embed-boot.test.cjs` (20 checks, single page): bare boot and `?embed=0` keep the old behaviour; `?s=abc123&embed=1` → embedMode, #editor-chrome hidden, link `/?s=abc123` target _blank, no dock inset whatever the pref, ▶ offered; a real click on ▶ enters play (▶ gone, link stays, canvas full-bleed); exitPlay leaves play and the chrome STAYS hidden, ▶ returns; `?embed=1` alone links to `/`; no page errors. Counterfactual: Menu.svelte reverted to `$isLocked` only → in an embed `#editor-chrome` renders visible while embedMode is true (measured with the suite's snapshot: chrome ""), so "the editor chrome is hidden" goes red; restored. Held suites: community-seams + contest-starters 102 (= base). svelte-check 341 errors / 47 warnings, identical message list to the base (line numbers shift only). vitest 143. Co-Authored-By: Claude Fable 5.1 --- src/App.svelte | 19 +++++- src/components/Menu.svelte | 5 +- src/lib/playMode.js | 35 +++++++++++ tests/e2e/embed-boot.test.cjs | 106 ++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/embed-boot.test.cjs 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/lib/playMode.js b/src/lib/playMode.js index e66883b1..6f573a36 100644 --- a/src/lib/playMode.js +++ b/src/lib/playMode.js @@ -59,6 +59,41 @@ export const willEnterAR = derived( ([$xr, $passthrough]) => $xr && !!$passthrough ); +/* --------------------------------------------------------------- embed (29-E) --- */ + +/** + * `?embed=1` — this page is the thing INSIDE an