From 9d3277c4b4af07e5e3125c1b53ece07ca313ada7 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 13:09:39 +0300 Subject: [PATCH 1/5] [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 2/5] [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 5181f782b397212419fa31454ce4d9a5381958ae Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 17:17:48 +0300 Subject: [PATCH 3/5] [feat] S3a: api.flow.setNodeData is one undo step, attributed to the module - A module's node-data write went out on the wire and never touched the undo stack, so the next Ctrl+Z undid whatever came BEFORE it (the sdk-polish measurement: 60 writes, 0 entries, the undo removed the nodes' creation). - setNodeData now records a `flownodes` op:'data' entry with the patched keys' previous values (structuredClone'd, so a module mutating its patch later cannot rewrite history) and `moduleId`. Same `nodedata` message on the wire. - flownodes data items may carry their own graphId (overrides the entry's) so one entry can span graphs - the batch call in S3b needs it. Additive; every existing entry has none and behaves as before. - sdk-game-seams section 4b: one entry, attributed, one undo restores the previous data on BOTH peers, one redo re-applies, unknown id still false. - Counterfactual: record call removed -> 3 red (depth 5 -> 5, top entry was `game`; undo left the edit in place on both peers). - sdk-game-seams 65/65 (base 59/59); vitest 158/158; svelte-check 341/47, identical list; build green. Co-Authored-By: Claude Opus 5 --- src/lib/flowGraphs.js | 12 ++++++---- src/lib/moduleSDK.js | 40 +++++++++++++++++++++++++++---- tests/e2e/sdk-game-seams.test.cjs | 40 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/lib/flowGraphs.js b/src/lib/flowGraphs.js index ecb1f469..be26ef53 100644 --- a/src/lib/flowGraphs.js +++ b/src/lib/flowGraphs.js @@ -206,9 +206,12 @@ registerHistoryKind('flowgraph', (entry, state) => { /** * Record an undoable flow-node mutation. * op 'create'/'delete' take {nodes, edges} (serialized); op 'data' takes - * {items: [{id, before, after}]} of node-data patches. + * {items: [{id, before, after, graphId?}]} of node-data patches. An item's own + * `graphId` overrides the entry's (R29 S3: a module's group edit spans one graph per + * object, and it is still ONE undo step). `moduleId` attributes a module's write. * @param {{op: 'create'|'delete'|'data', graphId: string, nodes?: any[], - * edges?: any[], items?: {id: string, before: any, after: any}[]}} info + * edges?: any[], items?: {id: string, before: any, after: any, graphId?: string}[], + * moduleId?: string}} info */ export function recordFlowNodesEntry(info) { recordEntry({ kind: 'flownodes', ...info, before: 'before', after: 'after' }); @@ -222,8 +225,9 @@ registerHistoryKind('flownodes', (entry, state) => { if (entry.op === 'data') { for (const item of entry.items ?? []) { const data = undoing ? item.before : item.after; - updateFlowNodeData(item.id, data, graphId); - if (peer) peer.send({ type: 'nodedata', id: item.id, data, graphId }); + const gid = item.graphId ?? graphId; + updateFlowNodeData(item.id, data, gid); + if (peer) peer.send({ type: 'nodedata', id: item.id, data, graphId: gid }); } return true; } diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 47616ff0..069b5340 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -259,6 +259,35 @@ function makeApi(moduleId, moduleName = moduleId) { const disposals = (moduleDisposals[moduleId] ??= []); /** record an undo thunk deactivateModule runs at teardown (A2) @param {() => void} fn */ const onDispose = (fn) => disposals.push(fn); + /** A value frozen for the undo stack, so a module mutating its patch object later + * cannot rewrite history. @param {any} v */ + const frozen = (v) => { + try { + return structuredClone(v); + } catch { + return v; + } + }; + /** One node-data write through the replicated `nodedata` path; returns the undo + * item (the patched keys' previous values) or null when no node has the id. + * @param {string} id @param {Record} patch */ + const writeNodeData = (id, patch) => { + const found = findNodeAnyGraph((n) => n.id === id); + if (!found) return null; + const after = frozen(patch && typeof patch === 'object' ? patch : {}); + /** @type {Record} */ + const before = {}; + for (const key of Object.keys(after)) before[key] = frozen(found.node.data?.[key]); + sendNodeData(id, patch && typeof patch === 'object' ? patch : {}, found.graphId); + return { id, graphId: found.graphId, before, after }; + }; + /** ONE `flownodes` data entry for these writes (a no-op until flowGraphs is primed, + * which it is long before a module's UI can be pressed). + * @param {{id: string, graphId: string, before: any, after: any}[]} items */ + const recordNodeData = (items) => { + if (!items.length) return; + flowGraphsRef?.recordFlowNodesEntry({ op: 'data', graphId: items[0].graphId, items, moduleId }); + }; /** input scopes this module still holds — released at teardown * @type {Set<'keys'|'locomotion'>} */ const claimedScopes = new Set(); @@ -1096,12 +1125,15 @@ function makeApi(moduleId, moduleName = moduleId) { return flowRuntimeRef?.nodeTriggerStamp?.(id) ?? null; }, /** Replicated node-data MERGE (the editor's own `nodedata` path — same message, - * same merge). The manager toolbox's inline param edit. @param {string} id + * same merge) that is ALSO one undo step: a `flownodes` data entry holding the + * patched keys' previous values, attributed to this module (R29 S3 — before it, + * a module's write left the stack untouched and the next Ctrl+Z undid whatever + * came before). The manager toolbox's inline param edit. @param {string} id * @param {Record} patch @returns {boolean} found */ setNodeData(id, patch) { - const found = findNodeAnyGraph((n) => n.id === id); - if (!found) return false; - sendNodeData(id, patch ?? {}, found.graphId); + const item = writeNodeData(id, patch); + if (!item) return false; + recordNodeData([item]); return true; }, /** diff --git a/tests/e2e/sdk-game-seams.test.cjs b/tests/e2e/sdk-game-seams.test.cjs index fce0d398..40972986 100644 --- a/tests/e2e/sdk-game-seams.test.cjs +++ b/tests/e2e/sdk-game-seams.test.cjs @@ -247,6 +247,46 @@ h.run(async () => { ); h.check(dataOnB === true, 'setNodeData replicated the perRound patch (the nodedata path)'); + // ===================================================================== + // 4b. R29 S3: a module's setNodeData is ONE undo step, attributed to the module + // ===================================================================== + const dataOf = (peer, id) => + peer.page.evaluate((i) => { + const n = window.__seams.api.flow.nodes().find((x) => x.id === i); + return n ? { perRound: n.data.perRound, tag: n.data.tag ?? null } : null; + }, id); + const depthOf = (peer) => + peer.page.evaluate(() => { + let v; + window.__stores.history.undoStack.subscribe((x) => (v = x))(); + return v.length; + }); + const depth0 = await depthOf(A); + await A.page.evaluate((id) => window.__seams.api.flow.setNodeData(id, { perRound: false, tag: 's3' }), evId); + await A.page.waitForTimeout(600); + const top = await A.page.evaluate(() => { + let v; + window.__stores.history.undoStack.subscribe((x) => (v = x))(); + const e = v[v.length - 1]; + return { depth: v.length, kind: e?.kind, op: e?.op, moduleId: e?.moduleId, items: e?.items?.length }; + }); + h.check(top.depth === depth0 + 1 && top.kind === 'flownodes' && top.op === 'data', `setNodeData records ONE flownodes data entry (${depth0} -> ${top.depth}, ${top.kind}/${top.op})`); + h.check(top.moduleId === 'seams', `the entry is attributed to the module (${top.moduleId})`); + const edited = [await dataOf(A, evId), await dataOf(B, evId)]; + h.check(edited.every((d) => d && d.perRound === false && d.tag === 's3'), `premise: the write landed on both peers (${JSON.stringify(edited)})`); + await A.page.evaluate(() => window.__stores.history.undo()); + await A.page.waitForTimeout(800); + const undone = [await dataOf(A, evId), await dataOf(B, evId)]; + h.check(undone.every((d) => d && d.perRound === true && !d.tag), `one undo restores the node's previous data, on BOTH peers (${JSON.stringify(undone)})`); + await A.page.evaluate(() => window.__stores.history.redo()); + await A.page.waitForTimeout(800); + const redone = [await dataOf(A, evId), await dataOf(B, evId)]; + h.check(redone.every((d) => d && d.perRound === false && d.tag === 's3'), `and one redo re-applies it everywhere (${JSON.stringify(redone)})`); + await A.page.evaluate((id) => window.__seams.api.flow.setNodeData(id, { perRound: true }), evId); + await A.page.waitForTimeout(400); + const missing = await A.page.evaluate(() => window.__seams.api.flow.setNodeData('no-such-node', { x: 1 })); + h.check(missing === false, 'an unknown id still returns false (and records nothing)'); + // ===================================================================== // 5. api.flow.addNodes — one undo entry, canonical edge ids, spec defaults // ===================================================================== From 28d2c991b0cdfc7ec44e43e858c944925db8d336 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 17:24:24 +0300 Subject: [PATCH 4/5] [feat] S3b: api.flow.setNodesData, a group edit as ONE undo step - setNodesData([{id, patch}, ...]) writes each node through the same path and validation as setNodeData (unknown ids and junk items skipped), records ONE `flownodes` data entry for the whole batch (items carry their own graphId, so a group spanning one object graph per collectible is still one step), and returns how many nodes it wrote. An empty batch records nothing. - Wire: the ordinary per-node `nodedata`. There is no batched type, and adding one would leave older peers unconverged until nodesync - the plan's rule (undo correctness first, wire economy second); sdk-polish measured 60 messages at 7.1 ms, all delivered. - The flownodes data handler now undoes its items in REVERSE: a batch that writes one node twice recorded the second item's `before` after the first write, so forward order restored the intermediate value. - sdk-game-seams section 4c: 12 nodes over the scene graph + an object graph, one entry of 13 items spanning 2 graphs, replicated, one undo restores all on both peers (incl. the twice-written node), one redo, empty batch inert. - Counterfactual A: record per item -> 3 red (11 -> 24 entries, undo left 12 of 12 edited). Counterfactual B: forward-order undo -> 1 red (the twice-written node read "grp" after undo). - sdk-game-seams 72/72 (base 59/59); vitest 158/158; svelte-check 341/47, identical list; build green. Co-Authored-By: Claude Opus 5 --- src/lib/flowGraphs.js | 6 +++- src/lib/moduleSDK.js | 18 ++++++++++ tests/e2e/sdk-game-seams.test.cjs | 56 +++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/lib/flowGraphs.js b/src/lib/flowGraphs.js index be26ef53..1c57807c 100644 --- a/src/lib/flowGraphs.js +++ b/src/lib/flowGraphs.js @@ -223,7 +223,11 @@ registerHistoryKind('flownodes', (entry, state) => { const peer = get(peers); const graphId = entry.graphId; if (entry.op === 'data') { - for (const item of entry.items ?? []) { + // undo walks the items BACKWARDS: a batch that writes one node twice recorded the + // second item's `before` AFTER the first write, so only the reverse order lands on + // the first item's `before` last + const items = entry.items ?? []; + for (const item of undoing ? [...items].reverse() : items) { const data = undoing ? item.before : item.after; const gid = item.graphId ?? graphId; updateFlowNodeData(item.id, data, gid); diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 069b5340..c81f3631 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -1136,6 +1136,24 @@ function makeApi(moduleId, moduleName = moduleId) { recordNodeData([item]); return true; }, + /** + * Many node-data writes as ONE undo step (R29 S3): a toolbox's group edit over + * sixty collectibles is one Ctrl+Z, not sixty and not none. Each item is + * validated exactly as `setNodeData` (an unknown id is skipped) and may live in + * any graph. The wire is the ordinary per-node `nodedata` — there is no batched + * type, so a peer on any build converges; the measured cost is ~0.1 ms/node. + * @param {{id: string, patch: Record}[]} list + * @returns {number} how many nodes were written + */ + setNodesData(list) { + const items = []; + for (const entry of Array.isArray(list) ? list : []) { + const item = entry && writeNodeData(entry.id, entry.patch); + if (item) items.push(item); + } + recordNodeData(items); + return items.length; + }, /** * Create nodes (and edges) the way the editor does: replicated `nodecreate`/ * `edgecreate` per item plus ONE `flownodes` undo entry for the batch — so what a diff --git a/tests/e2e/sdk-game-seams.test.cjs b/tests/e2e/sdk-game-seams.test.cjs index 40972986..8bd71302 100644 --- a/tests/e2e/sdk-game-seams.test.cjs +++ b/tests/e2e/sdk-game-seams.test.cjs @@ -287,6 +287,62 @@ h.run(async () => { const missing = await A.page.evaluate(() => window.__seams.api.flow.setNodeData('no-such-node', { x: 1 })); h.check(missing === false, 'an unknown id still returns false (and records nothing)'); + // ===================================================================== + // 4c. R29 S3: setNodesData — a group edit across graphs is ONE undo step + // ===================================================================== + const obox = await makeBox(A); + await A.page.waitForTimeout(600); + const grpScene = await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ nodes: Array.from({ length: 6 }, (_, i) => ({ type: 'seamvalue', x: 700 + i * 20, y: 700, data: {} })) }) + ); + const grpObj = await A.page.evaluate( + (g) => window.__seams.api.flow.addNodes({ graphId: g, nodes: Array.from({ length: 6 }, (_, i) => ({ type: 'seamvalue', x: 60 + i * 20, y: 60, data: {} })) }), + obox + ); + await A.page.waitForTimeout(900); + const grp = [...grpScene, ...grpObj]; + const tagsOf = (peer) => + peer.page.evaluate((ids) => { + const nodes = window.__seams.api.flow.nodes(); + return ids.map((i) => nodes.find((n) => n.id === i)?.data?.tag ?? null); + }, grp); + const gDepth0 = await depthOf(A); + const written = await A.page.evaluate( + (ids) => + window.__seams.api.flow.setNodesData([ + ...ids.map((id) => ({ id, patch: { tag: 'grp' } })), + { id: 'no-such-node', patch: { tag: 'x' } }, + null, + // the SAME node twice in one batch: undo must still land on its original value + { id: ids[0], patch: { tag: 'twice' } } + ]), + grp + ); + await A.page.waitForTimeout(900); + const gTop = await A.page.evaluate(() => { + let v; + window.__stores.history.undoStack.subscribe((x) => (v = x))(); + const e = v[v.length - 1]; + return { depth: v.length, items: e?.items?.length, graphs: [...new Set((e?.items ?? []).map((i) => i.graphId))].length, moduleId: e?.moduleId }; + }); + h.check(written === 13, `setNodesData writes every known node, skips the unknown and the null (${written})`); + h.check(gTop.depth === gDepth0 + 1 && gTop.items === 13, `and records ONE entry for the whole batch (${gDepth0} -> ${gTop.depth}, ${gTop.items} items)`); + h.check(gTop.graphs === 2 && gTop.moduleId === 'seams', `the one entry spans both graphs, attributed (${gTop.graphs} graphs, ${gTop.moduleId})`); + const gAfter = [await tagsOf(A), await tagsOf(B)]; + const wantAfter = JSON.stringify(['twice', ...grp.slice(1).map(() => 'grp')]); + h.check(gAfter.every((t) => JSON.stringify(t) === wantAfter), `the batch replicated over the ordinary nodedata path (${JSON.stringify(gAfter[1])})`); + await A.page.evaluate(() => window.__stores.history.undo()); + await A.page.waitForTimeout(900); + const gUndo = [await tagsOf(A), await tagsOf(B)]; + h.check(gUndo.every((t) => t.every((x) => x === null)), `ONE undo restores all twelve, on both peers, incl. the node written twice (${JSON.stringify(gUndo[1])})`); + await A.page.evaluate(() => window.__stores.history.redo()); + await A.page.waitForTimeout(900); + const gRedo = [await tagsOf(A), await tagsOf(B)]; + h.check(gRedo.every((t) => JSON.stringify(t) === wantAfter), `and one redo re-applies the batch in order (${JSON.stringify(gRedo[0])})`); + const emptyWrite = await A.page.evaluate(() => window.__seams.api.flow.setNodesData([])); + const gDepth2 = await depthOf(A); + h.check(emptyWrite === 0 && gDepth2 === gTop.depth, `an empty batch writes nothing and records nothing (${emptyWrite}, depth ${gDepth2})`); + // ===================================================================== // 5. api.flow.addNodes — one undo entry, canonical edge ids, spec defaults // ===================================================================== From 9c9a345c257989160cdc48b3082cd1d4877af606 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 18:11:00 +0300 Subject: [PATCH 5/5] [feat] S3d: sdk-game-seams covers the measured case - sixty nodes, one Ctrl+Z, a late joiner - Section 7b reproduces sdk-polish's measurement shape: sixty nodes over the scene graph and two object graphs, created in three calls (so the entry under the edit is a CREATION), one setNodesData call (60 in ~5 ms), all sixty on the peer; a REAL Ctrl+Z restores all sixty on both peers and the sixty nodes are still there; after redo a late joiner (C dials A) holds the edited values from the ordinary full-state reply, and a later Ctrl+Z reverts them on the joiner too. - Counterfactual 1: skip the history entry -> 7 red; Ctrl+Z undid the creation (40 of 60 nodes left, the edit still in place), exactly the sdk-polish finding. - Counterfactual 2: drop the batch (one entry per node) -> 5 red; one Ctrl+Z reverted one node of sixty (tags ["bulk60","orig"]). - sdk-game-seams 78/78 (base 59/59); ai-flow-physics 38/38 and hud-actions 65/65 (the other flownodes users) held; vitest 158/158; svelte-check 341/47, identical list; build green. Co-Authored-By: Claude Opus 5 --- tests/e2e/sdk-game-seams.test.cjs | 76 +++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/e2e/sdk-game-seams.test.cjs b/tests/e2e/sdk-game-seams.test.cjs index 8bd71302..4a979ef9 100644 --- a/tests/e2e/sdk-game-seams.test.cjs +++ b/tests/e2e/sdk-game-seams.test.cjs @@ -606,6 +606,82 @@ h.run(async () => { 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})`); + // ===================================================================== + // 7b. R29 S3 — THE MEASURED CASE: a sixty-node group edit, one real Ctrl+Z, a late joiner + // ===================================================================== + // sixty nodes spread over the scene graph and two object graphs (a collectible group is + // one object graph per member), created in THREE calls — so the entry right under the + // edit is a CREATION, the one sdk-polish's measurement watched Ctrl+Z take away + await setPlay(A, null); + await gstate(A, 'menu'); + const g60a = await makeBox(A); + const g60b = await makeBox(A); + await A.page.waitForTimeout(600); + const sixty = []; + for (const graphId of [undefined, g60a, g60b]) + sixty.push( + ...(await A.page.evaluate( + (g) => + window.__seams.api.flow.addNodes({ + ...(g ? { graphId: g } : {}), + nodes: Array.from({ length: 20 }, (_, i) => ({ type: 'seamvalue', x: 900 + i * 12, y: 900, data: { tag: 'orig' } })) + }), + graphId ?? null + )) + ); + await A.page.waitForTimeout(1200); + h.check(sixty.length === 60, `premise: sixty nodes across three graphs (${sixty.length})`); + const sixtyOf = (peer) => + peer.page.evaluate((ids) => { + const nodes = window.__stores.allNodes(); + const got = ids.map((i) => nodes.find((n) => n.id === i)); + return { present: got.filter(Boolean).length, tags: [...new Set(got.map((n) => n?.data?.tag ?? null))] }; + }, sixty); + const s60 = await A.page.evaluate( + (ids) => { + const t0 = performance.now(); + const n = window.__seams.api.flow.setNodesData(ids.map((id) => ({ id, patch: { tag: 'bulk60' } }))); + return { n, ms: performance.now() - t0 }; + }, + sixty + ); + h.check(s60.n === 60, `one setNodesData call writes all sixty (${s60.n} in ${s60.ms.toFixed(1)} ms)`); + await h.eventually( + () => sixtyOf(B), + (r) => r.present === 60 && r.tags.length === 1 && r.tags[0] === 'bulk60', + 'all sixty land on the peer' + ); + // a REAL Ctrl+Z, the user's gesture — nothing focused that could swallow it + await A.page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur()); + await A.page.keyboard.press('Control+z'); + await A.page.waitForTimeout(900); + const z1 = [await sixtyOf(A), await sixtyOf(B)]; + h.check( + z1.every((r) => r.present === 60 && r.tags.length === 1 && r.tags[0] === 'orig'), + `ONE Ctrl+Z restores all sixty on both peers — and the nodes are still there, the creation was NOT undone (${JSON.stringify(z1)})` + ); + await A.page.evaluate(() => window.__stores.history.redo()); + await A.page.waitForTimeout(900); + // a LATE JOINER takes the edited values from the ordinary full-state reply + const C = await h.setupPage(browser, 'C'); + await C.page.waitForFunction(() => !!window.__stores?.allNodes, { timeout: 30000 }); + await h.connect(C, A); + await h.eventually( + () => sixtyOf(C), + (r) => r.present === 60 && r.tags.length === 1 && r.tags[0] === 'bulk60', + 'a late joiner has all sixty edited values', + 20000 + ); + // and an undo made AFTER it joined reaches it too (the entry replays per-node nodedata) + await A.page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur()); + await A.page.keyboard.press('Control+z'); + await h.eventually( + () => sixtyOf(C), + (r) => r.present === 60 && r.tags.length === 1 && r.tags[0] === 'orig', + 'and a later undo reverts it on the joiner as well' + ); + await C.page.close(); + // ===================================================================== // 8. the debug line + the action catalog seams, and their teardown // =====================================================================