From 5b680565cce0aae7b81c7e1aecaeff5ef94c579f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 15:17:53 +0300 Subject: [PATCH 1/2] [feat] S4: collectible listens instead of polling, lands on freeRegion, radius on the form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the manager redraws on api.flow.onChange + api.game.onChange (core PR #224) instead of a 500ms interval; a clock runs only while a respawn countdown is on screen, an older recipe chain's latch value is re-read once 300ms after a change, and the panel re-reads on pointerenter (a rename in the viewport is not an event) - the recipe asks api.flow.freeRegion once per pair, so pairs stack under whatever is in the graph instead of on a row derived from collectibles.length (DEVX #16) - both seams are FEATURE-DETECTED: on a 1.14.0-shaped api the module keeps its 500ms poll and fixed rows (asserted by registering the same source against an api with the seams removed) - S4 plan items: the touch radius is on the form (disabled with the reason under a click trigger); a group header whose counts include older recipe chains says "+N older recipe chain(s) counted here (edit in the node editor)" - version 1.1.0 (manifest, module, index.json); DEVX-REQUESTS #16/#17 marked SHIPPED - flights: module-collectible 130/130 (+1 legacy-copy check; the R29 checks moved to their own file because the main flight sits near the runner's 8-minute cap — it was killed at 480s with them in), new module-collectible-signals 14/14, against core feat/29-sdk-polish on :5213 - counterfactual: forcing the poll branch -> "redraws on change signals" and "an idle manager touches nothing" red (12 DOM mutations in 2.5s) - S3 measured here too: one group press over 60 members = 60 nodedata messages, 7.1ms, 0 undo entries -> the batch seam stays held (see core PR #224) Co-Authored-By: Claude Opus 5 --- DEVX-REQUESTS.md | 21 +- index.json | 2 +- modules/collectible/manifest.json | 2 +- modules/collectible/module.js | 102 ++++++- tests/module-collectible-signals.test.cjs | 333 ++++++++++++++++++++++ tests/module-collectible.test.cjs | 10 +- 6 files changed, 453 insertions(+), 17 deletions(-) create mode 100644 tests/module-collectible-signals.test.cjs diff --git a/DEVX-REQUESTS.md b/DEVX-REQUESTS.md index 15e4ccb..92a6187 100644 --- a/DEVX-REQUESTS.md +++ b/DEVX-REQUESTS.md @@ -27,8 +27,8 @@ promise from core. | 13 | play contract is name-keyed to `'dungeon-module'` | `dungeon-realms` | yes — squats the core module's group name | | 14 | grounded (no-fly) play-mode option | `dungeon-realms` | yes — window-capture swallows Q/E while the game runs | | 15 | peer roster + disconnect hook | `dungeon-realms` | partly — any player can free a stuck P1/P2 slot from the menu | -| 16 | `api.flow.nodes()` carries no node POSITION | `collectible` | yes — the recipe derives its row from how many of its own nodes exist | -| 17 | no change signal for the graph / game state | `collectible` | yes — a 500ms toolbox poll and a ~10Hz frame-task sweep | +| 16 | ~~`api.flow.nodes()` carries no node POSITION~~ | `collectible` | **SHIPPED** (core PR #224) — snapshots carry `x`/`y` and `api.flow.freeRegion({w, h, graphId})` answers where a block lands; the recipe uses it and falls back to its fixed rows on an older app | +| 17 | ~~no change signal for the graph / game state~~ | `collectible` | **SHIPPED** (core PR #224) — `api.flow.onChange` (graph + trigger log), `api.game.onChange`, `api.peerVars.onChange`, coalesced to one call per frame; the manager listens and polls only on an older app (the ~10Hz touch/count sweep stays: it watches positions and the clock) | | 18 | ~~the flow TRIGGER LOG has no handshake reply, so a late joiner never learns past pulses~~ | `collectible` | **SHIPPED** — `gettriggers`/`triggers` carries the log; the module gained a first-sight rule so arriving history is not banked | | 19 | `api.game.setState(state, outcome)` — a module cannot move the game shell | `football` | yes — a Football Event node fires `start`/`over`/`reset` and the template wires it to Set Game State | | 20 | a scene-physics block write (`api.physics.setScene({gravity, knock, …})`) | `football` | yes — the template carries the block; the "Build pitch" recipe toasts the Inspector rows to set | @@ -295,7 +295,12 @@ vanished peer wedges the gate until someone frees their slot manually. **Ask:** `api.peers()` → `[{id, name}]` + `api.onPeerConnected/Disconnected`. The name half would also fix the HUD showing id prefixes instead of nicknames. -## 16. `api.flow.nodes()` snapshots carry no POSITION +## 16. `api.flow.nodes()` snapshots carry no POSITION — **SHIPPED** + +> Delivered both shapes (core PR #224): `x`/`y` on every snapshot, and +> `api.flow.freeRegion({w, h, graphId})` — left-aligned under the lowest card — so the +> placement rule is core's one copy (the HUD editor's bindings call the same function). +> The collectible recipe asks it once per pair. The original request is kept below. **Found in:** `modules/collectible` — the manager's "Make collectible" recipe creates a node pair per selected object and has to lay the rows out, but a @@ -311,7 +316,15 @@ deterministic and idempotent, and wrong the moment a user drags one of them. takes them on the way in), or an `api.flow.freeRegion({w, h})` that answers "somewhere empty" so layout stays core's problem. -## 17. No change signal for the graph, the trigger log or the game state +## 17. No change signal for the graph, the trigger log or the game state — **SHIPPED** + +> Delivered (core PR #224): `api.flow.onChange`, `api.game.onChange`, +> `api.peerVars.onChange`, journalled like every `register*` and also returning an `off()` +> for a toolbox that mounts and unmounts. Coalesced INSIDE the seam to one call per frame +> (a microtask was measured NOT to fold thirty arriving peer edits). `flow.onChange` covers +> the trigger log too, because a collected-state list changes when a node fires. The +> manager now redraws on these and keeps a clock only for a live respawn countdown; an +> idle panel writes nothing to the DOM (asserted). The original request is kept below. **Found in:** `modules/collectible` — the manager toolbox and the debug line are both views over the graph, so both POLL: the toolbox on a 500ms interval, diff --git a/index.json b/index.json index a14a010..4c65520 100644 --- a/index.json +++ b/index.json @@ -30,7 +30,7 @@ { "id": "collectible", "name": "Collectibles", - "version": "1.0.0", + "version": "1.1.0", "description": "One node makes an object collectible: click it or walk into it, it hides and counts. Shared or per-player score, with respawn and never-hiding checkpoints.", "author": "theprototype-app", "source": "modules/collectible", diff --git a/modules/collectible/manifest.json b/modules/collectible/manifest.json index 741283d..633802f 100644 --- a/modules/collectible/manifest.json +++ b/modules/collectible/manifest.json @@ -1,7 +1,7 @@ { "id": "collectible", "name": "Collectibles", - "version": "1.0.0", + "version": "1.1.0", "format": 1, "description": "One node makes an object collectible: click it or walk into it, it hides and counts. Shared or per-player, with respawn.", "entry": "module.js", diff --git a/modules/collectible/module.js b/modules/collectible/module.js index af09dec..23135de 100644 --- a/modules/collectible/module.js +++ b/modules/collectible/module.js @@ -40,7 +40,7 @@ export default { id: 'collectible', name: 'Collectibles', - version: '1.0.0', + version: '1.1.0', description: 'One node makes an object collectible: click it or walk into it, it hides and counts.', @@ -663,8 +663,16 @@ export default { const taken = new Set(); for (const node of collectibles) for (const uuid of targetsOf(node, bySource, byId)) taken.add(uuid); - // no positions in a flow snapshot (see DEVX-REQUESTS), so the row index comes - // from how many collectibles the graph already holds — same answer every time + // WHERE each pair lands. Core answers it when it can (`api.flow.freeRegion`, the + // one placement rule, DEVX #16): under everything already in the graph, asked + // again per pair because the answer moves as the graph grows. An older app has + // no freeRegion and no positions in a snapshot, so there the row index comes + // from how many collectibles the graph already holds — deterministic, and wrong + // only once a user drags one, which is exactly what the seam fixed. + const place = (/** @type {number} */ row) => + typeof api.flow.freeRegion === 'function' + ? api.flow.freeRegion({ w: COL + 150, h: 150, graphId: SCENE }) + : { x: START.x, y: START.y + row * ROW }; let row = collectibles.length; let built = 0; let skipped = 0; @@ -673,11 +681,11 @@ export default { skipped++; continue; } - const y = START.y + row * ROW; + const { x, y } = place(row); api.flow.addNodes({ nodes: [ - { type: 'collectible', x: START.x, y, data: { ...options, perRound: true, whilePlaying: true } }, - { type: 'objectselector', x: START.x + COL, y, data: { selected: uuid } } + { type: 'collectible', x, y, data: { ...options, perRound: true, whilePlaying: true } }, + { type: 'objectselector', x: x + COL, y, data: { selected: uuid } } ], edges: [{ from: 0, to: 1 }] }); @@ -732,7 +740,8 @@ export default { '.cm-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '.cm-status{flex:0 0 auto;font-size:10px;opacity:0.7;white-space:nowrap}' + '.cm-respawn{flex:0 0 auto;width:38px}' + - '.cm-empty{opacity:0.6;font-size:11px}' + '.cm-empty{opacity:0.6;font-size:11px}' + + '.cm-legacy{flex:1 1 100%;order:3;font-size:10px;opacity:0.6}' }) ); @@ -749,12 +758,27 @@ export default { const trigger = select(['click', 'touch'], 'click'); const hide = select(['on', 'off'], 'on'); const respawn = /** @type {HTMLInputElement} */ ( - elem('input', { type: 'number', min: '0', max: '120', step: '1', value: '0' }, INPUT_CSS) + elem('input', { type: 'number', min: '0', max: '120', step: '1', value: '0', className: 'cm-in-respawn' }, INPUT_CSS) ); + // S4: the touch RADIUS was fixed at 1.5 by this form and editable only on the node + // card afterwards. It only means something for a touch trigger, so it is disabled + // (with the reason) under click rather than hidden — the form keeps its shape. + const radius = /** @type {HTMLInputElement} */ ( + elem('input', { type: 'number', min: '0.5', max: '10', step: '0.5', value: '1.5', className: 'cm-in-radius' }, INPUT_CSS) + ); + const syncRadius = () => { + radius.disabled = trigger.value !== 'touch'; + radius.title = radius.disabled + ? 'Only a touch trigger has a radius — switch Trigger to touch' + : 'How close (metres) you must walk to collect it'; + }; + trigger.addEventListener('change', syncRadius); + syncRadius(); const rows = [ ['Counts into', variable], ['Scope', scope], ['Trigger', trigger], + ['Touch radius', radius], ['Hide', hide], ['Respawn (s)', respawn] ]; @@ -771,7 +795,7 @@ export default { scope: scope.value, trigger: trigger.value, hide: hide.value, - radius: 1.5, + radius: Math.max(0.5, Math.min(10, Number(radius.value) || 1.5)), respawn: Math.max(0, Math.min(120, Number(respawn.value) || 0)) }); refresh(true); @@ -868,10 +892,23 @@ export default { const counts = elem('span', { className: 'cm-counts', textContent: '' }); countEls.set(name, counts); disc.appendChild(counts); + // S4: the COUNTS include older recipe chains (21-F's seven-node shape) that + // count into the same variable, while only this module's nodes get rows — right + // by design (the variable is what a HUD reads), and confusing unless said. + const legacy = legacyLatches(name).length; + if (legacy) + head.appendChild( + elem('div', { + className: 'cm-legacy', + textContent: + '+' + legacy + ' older recipe chain' + (legacy === 1 ? '' : 's') + + ' counted here (edit in the node editor)' + }) + ); disc.addEventListener('click', () => { setCollapsed(name, !isCollapsed(name)); // the rows are DROPPED rather than hidden (see rebuild), so folding is a - // rebuild — and the flag rides the signature, so the 500ms refresh cannot + // rebuild — and the flag rides the signature, so a later refresh cannot // quietly unfold what you just closed refresh(true); }); @@ -977,6 +1014,51 @@ export default { } refresh(true); + + // WHEN TO REDRAW (DEVX #17). With core's change signals the panel stops polling: + // it redraws when the graph changes or a node FIRES (`api.flow.onChange` — both + // structure and collected state), and when the round moves (`api.game.onChange`, + // which retires perRound stamps). Core coalesces those to one call per frame. + // Two things are NOT events and keep a clock, each only while it is needed: + // - a respawn countdown ("back in 4s") ages with time, so a 1s tick runs only + // while some row is counting down; + // - an OLDER recipe chain's latch is read as a node VALUE, which core + // republishes a beat after the trigger, so a change is re-read once 300ms on. + // An object renamed or deleted in the viewport is neither; the panel re-reads on + // pointerenter, so it is fresh the moment you look at it. + // An older app has no onChange: the 500ms poll it always had. + /** @type {any} */ let settle = null; + /** @type {any} */ let countdown = null; + const counting = () => [...statusEls.values()].some((el) => /^back in /.test(el.textContent ?? '')); + const tickCountdown = () => { + countdown = null; + refresh(); + if (counting()) countdown = setTimeout(tickCountdown, 1000); + }; + const onSignal = () => { + refresh(); + clearTimeout(settle); + settle = setTimeout(() => { + settle = null; + refresh(); + if (!countdown && counting()) countdown = setTimeout(tickCountdown, 1000); + }, 300); + if (!countdown && counting()) countdown = setTimeout(tickCountdown, 1000); + }; + if (typeof api.flow.onChange === 'function' && typeof api.game.onChange === 'function') { + const offs = [api.flow.onChange(onSignal), api.game.onChange(onSignal)]; + const onEnter = () => refresh(); + el.addEventListener('pointerenter', onEnter); + if (counting()) countdown = setTimeout(tickCountdown, 1000); + el.dataset.refresh = 'signal'; + return () => { + for (const off of offs) if (typeof off === 'function') off(); + clearTimeout(settle); + clearTimeout(countdown); + el.removeEventListener('pointerenter', onEnter); + }; + } + el.dataset.refresh = 'poll'; const timer = setInterval(refresh, 500); return () => clearInterval(timer); } diff --git a/tests/module-collectible-signals.test.cjs b/tests/module-collectible-signals.test.cjs new file mode 100644 index 0000000..50271cd --- /dev/null +++ b/tests/module-collectible-signals.test.cjs @@ -0,0 +1,333 @@ +// collectible SIGNALS flight (R29 S4) — split out of module-collectible.test.cjs, which +// already runs close to the runner's 8-minute cap. It covers what the module does with +// core's R29 seams: the manager redraws on api.flow/game.onChange instead of a 500ms poll, +// the recipe lands on api.flow.freeRegion, the form carries the touch radius — and the +// SAME module source still runs on an app without those seams (a 1.14.0-shaped api). +// +// APP_URL=https://localhost:5183/ npm test -- collectible-signals +// +// (the original header of the main flight follows, for its helpers) +// collectible test-flight — the REAL zip on two peers (and a third that joins late), +// driving the module's own toolbox to build the graph and the app's own click dispatch +// to collect. +// +// What this flight is really checking is that the module BORROWS core's rules instead of +// re-implementing them: `whilePlaying` dormancy hands the object back, `perRound` retires +// a stamp on a round bump, and `{replicate:false}` keeps a per-player pulse — the hide, +// the latch AND the count — inside one peer. +// +// TWO THINGS IT DELIBERATELY DOES NOT ASSERT: +// - the SHARED score after a shared collect. `add` is a per-peer read-modify-write off +// one replicated stamp (core's standing Set Variable semantics), so two peers can +// bank one pickup twice. Assert the WORLD — is the gem hidden on both screens — and +// use `scope: 'player'` where an exact number matters. +// - a real mouse click on the gem. The pick is core's; what belongs to the module is the +// click HANDLER, so the flight dispatches through `moduleSDK.moduleClickHandlers` the +// way door-keypad does. +// +// APP_URL=https://localhost:5183/ npm test -- collectible +const h = require('./helpers.cjs'); + +const TOOLBOX = 'mod-collectible-manager'; + +// --- page reads ------------------------------------------------------------------- +const nodesOf = (peer, type) => + peer.page.evaluate( + (t) => + window.__stores + .allNodes() + .filter((n) => !t || n.type === t) + .map((n) => ({ id: n.id, type: n.type, graph: n.__graph ?? 'scene', data: { ...(n.data ?? {}) } })), + type ?? null + ); + +const edgesOf = (peer) => + peer.page.evaluate(() => { + let graphs; + window.__stores.flowGraphs.subscribe((v) => (graphs = v))(); + return Object.entries(graphs ?? {}).flatMap(([graphId, doc]) => + (doc.edges ?? []).map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + targetHandle: e.targetHandle ?? null, + graphId + })) + ); + }); + +const visibleOf = (peer, uuid) => + peer.page.evaluate((u) => { + let group; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + return group?.getObjectByProperty('uuid', u)?.visible ?? null; + }, uuid); + +/** core's own round-aware trigger-log read — the module polls exactly this */ +const stampOf = (peer, id) => + peer.page.evaluate((i) => window.__stores.flowRuntime.nodeTriggerStamp(i), id); + +/** a value node's published output (flowValues republishes ~6/s) */ +const valueOf = (peer, id) => + peer.page.evaluate((i) => { + let values; + window.__stores.flowValues.subscribe((v) => (values = v))(); + return values?.[i]; + }, id); + +const sharedVar = (peer, name) => + peer.page.evaluate((n) => window.__stores.gameState.gameVar(n, 0), name); +const myVar = (peer, name) => + peer.page.evaluate((n) => window.__stores.peerVars.myPeerVar(n, 0), name); + +const setPlay = (peer, value) => + peer.page.evaluate((v) => window.__stores.isLocked.set(v), value); +const setState = (peer, state) => + peer.page.evaluate((s) => window.__stores.gameState.setGameState(s), state); + +// --- page actions ----------------------------------------------------------------- +/** a fresh replicated box with a distinct name, deselected afterwards */ +async function makeBox(peer, name) { + const uuid = await peer.page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 1300)); + let group; + s.objectsGroup.subscribe((v) => (group = v))(); + const last = group.children[group.children.length - 1]; + s.objectActions.deselectObject(); + return last?.uuid ?? null; + }); + if (uuid) { + await peer.page.evaluate( + ({ uuid, name }) => window.__stores.objectActions.renameObject(uuid, name), + { uuid, name } + ); + await peer.page.waitForTimeout(400); + } + return uuid; +} + +/** the module's click handler, through the app's own dispatch array (door-keypad's recipe) */ +const clickObject = (peer, uuid) => + peer.page.evaluate((u) => { + let group; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const object = group?.getObjectByProperty('uuid', u); + if (!object) return 'no-object'; + window.__stores.moduleSDK.moduleClickHandlers.forEach((fn) => fn(object)); + return 'dispatched'; + }, uuid); + +/** open the manager toolbox (the real opener core gives a module) */ +async function openToolbox(peer) { + await peer.page.evaluate( + (id) => window.__stores.moduleToolboxes.openModuleToolbox(id), + TOOLBOX + ); + await peer.page.waitForTimeout(700); +} + +/** THE RECIPE, driven through the module's own form + button */ +async function runRecipe(peer, options) { + const panel = peer.page.locator('.collectible-manager'); + await panel.locator('.cm-form input[type=text]').fill(options.variable); + await panel.locator('.cm-form select').nth(0).selectOption(options.scope ?? 'shared'); + await panel.locator('.cm-form select').nth(1).selectOption(options.trigger ?? 'click'); + await panel.locator('.cm-form select').nth(2).selectOption(options.hide ?? 'on'); + if (options.radius != null) await panel.locator('.cm-form input.cm-in-radius').fill(String(options.radius)); + await panel.locator('.cm-form input.cm-in-respawn').fill(String(options.respawn ?? 0)); + await panel.getByRole('button', { name: 'Make collectible' }).click(); + await peer.page.waitForTimeout(1400); +} + +/** select one object, run the recipe on it */ +async function collectibleFrom(peer, uuid, options) { + await peer.page.evaluate((u) => window.__stores.objectActions.selectObject(u), uuid); + await peer.page.waitForTimeout(400); + await runRecipe(peer, options); + await peer.page.evaluate(() => window.__stores.objectActions.deselectObject()); + await peer.page.waitForTimeout(300); + // the node whose selector names this object + const nodes = await nodesOf(peer, 'collectible'); + const edges = await edgesOf(peer); + const selectors = (await nodesOf(peer, 'objectselector')) + .filter((n) => n.data.selected === uuid) + .map((n) => n.id); + const node = nodes.find((n) => edges.some((e) => e.source === n.id && selectors.includes(e.target))); + return node ?? null; +} + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.installModule(A, 'collectible'); + await h.installModule(B, 'collectible'); + await h.connect(A, B); + for (const peer of [A, B]) + await peer.page.evaluate(() => { + const s = window.__stores; + s.clearGraphs(); + s.gameState.clearGameState(); + s.peerVars.clearPeerVars(false); + s.isLocked.set(null); + }); + await A.page.waitForTimeout(1000); + await A.page.evaluate(() => { + window.__stores.moduleSDK.initModules([ + { id: 'ctest', name: 'collectible flight helper', version: '1.0.0', register(api) { window.__ct = api; } } + ]); + }); + await openToolbox(A); + // three rubies through the real form — rows for the manager to hold + const rubies = [await makeBox(A, 'Ruby1'), await makeBox(A, 'Ruby2'), await makeBox(A, 'Ruby3')]; + await A.page.evaluate((uuids) => { + for (const u of uuids) window.__stores.objectActions.selectObject(u, false, true); + }, rubies); + await A.page.waitForTimeout(400); + await runRecipe(A, { variable: 'rubies', scope: 'shared', trigger: 'click', hide: 'on', respawn: 0 }); + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); + await A.page.waitForTimeout(700); + const rubyNodes = async (peer) => + (await nodesOf(peer, 'collectible')).filter((n) => n.data.variable === 'rubies'); + h.check((await rubyNodes(A)).length === 3, 'premise: three rubies built through the form'); + const rubyYs = (await A.page.evaluate(() => window.__ct.flow.nodes('collectible').map((n) => n.y))).sort((a, b) => a - b); + h.check( + rubyYs.length === 3 && rubyYs[0] < rubyYs[1] && rubyYs[1] < rubyYs[2], + 'each pair asked freeRegion again, so they stack instead of landing on one another (' + JSON.stringify(rubyYs) + ')' + ); + + // ===================================================================== + // 11. R29 S4 — the manager listens instead of polling, the recipe lands on + // api.flow.freeRegion, the form carries the touch radius — and the module + // still runs on an app WITHOUT those seams (1.14.0-shaped) + // ===================================================================== + const panel3 = A.page.locator('.collectible-manager'); + h.check( + (await panel3.getAttribute('data-refresh')) === 'signal', + 'the manager redraws on core change signals (data-refresh=' + (await panel3.getAttribute('data-refresh')) + ')' + ); + // IDLE: nothing changes for 2.5s, so a listening panel writes NOTHING to the DOM. The + // 500ms poll rewrote the count text on every pass, which a MutationObserver sees. + const idleMutations = await panel3.evaluate( + (el) => + new Promise((resolve) => { + let n = 0; + const mo = new MutationObserver((list) => (n += list.length)); + mo.observe(el, { subtree: true, childList: true, characterData: true, attributes: true }); + setTimeout(() => { + mo.disconnect(); + resolve(n); + }, 2500); + }) + ); + h.check(idleMutations === 0, 'an idle manager touches nothing — no poll (' + idleMutations + ' DOM mutations in 2.5s)'); + // ...and a PEER's edit still reaches it, through the signal + const rubyTarget = (await rubyNodes(A))[0]; + await B.page.evaluate( + (id) => { + const n = window.__stores.allNodes().find((x) => x.id === id); + window.__stores.nodesHandler.setNodeData(id, { respawn: 7 }, n?.__graph ?? 'scene'); + }, + rubyTarget.id + ); + await h.eventually( + () => panel3.locator('.cm-row[data-node="' + rubyTarget.id + '"] input.cm-respawn').inputValue(), + (v) => v === '7', + "a peer's edit redraws the row through the change signal (respawn 7)" + ); + + // THE RECIPE lands on freeRegion: a user node parked where the old fixed-row rule would + // put the next pair must not be covered + const nextRowY = await A.page.evaluate(() => { + const count = window.__ct.flow.nodes('collectible').length; + return 40 + count * 190; + }); + await A.page.evaluate((y) => window.__ct.flow.addNodes({ nodes: [{ type: 'time', x: 60, y, data: {} }] }), nextRowY); + const tiles = [await makeBox(A, 'Tile1'), await makeBox(A, 'Tile2')]; + await A.page.evaluate((uuids) => { + window.__stores.objectActions.deselectObject(); + for (const u of uuids) window.__stores.objectActions.selectObject(u, false, true); + }, tiles); + await A.page.waitForTimeout(400); + const picked = await A.page.evaluate(() => window.__ct.selectedUuids()); + h.check(picked.length === 2, 'premise: two tiles selected (' + picked.length + ')'); + // the TOUCH radius is on the form now, and refused (with the reason) under click + const radiusIn = panel3.locator('.cm-form input.cm-in-radius'); + await panel3.locator('.cm-form select').nth(1).selectOption('click'); + h.check(await radiusIn.isDisabled(), 'the radius field is disabled under a click trigger'); + await runRecipe(A, { variable: 'tiles', scope: 'player', trigger: 'touch', hide: 'on', respawn: 0, radius: 3.5 }); + const tileNodes = (await nodesOf(A, 'collectible')).filter((n) => n.data.variable === 'tiles'); + h.check( + tileNodes.length === 2 && tileNodes.every((n) => Number(n.data.radius) === 3.5 && n.data.trigger === 'touch'), + 'the form\'s touch radius reaches the nodes (' + JSON.stringify(tileNodes.map((n) => n.data.radius)) + ')' + ); + const layout = await A.page.evaluate(() => { + const ns = window.__ct.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.type, b.type, a.x, a.y]); + } + return { n: ns.length, hits }; + }); + h.check(layout.hits.length === 0, 'the new pairs land on free space — no card overlaps another (' + JSON.stringify(layout.hits.slice(0, 3)) + ', ' + layout.n + ' nodes)'); + + // THE FALLBACK: the same module source, registered against an api with the R29 seams + // REMOVED — what a user running core 1.14.0 hands it. It must still build and list. + const D = await h.setupPage(browser, 'D'); + const source = require('fs').readFileSync(require('path').join(__dirname, '..', 'modules', 'collectible', 'module.js'), 'utf8'); + await D.page.evaluate(async (src) => { + const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' })); + const mod = (await import(url)).default; + await window.__stores.moduleSDK.initModules([ + { + id: 'collectible', + name: 'Collectibles (1.14 api)', + version: mod.version, + description: 'fallback probe', + register(api) { + const old = { + ...api, + flow: { ...api.flow, onChange: undefined, freeRegion: undefined }, + game: { ...api.game, onChange: undefined }, + peerVars: { ...api.peerVars, onChange: undefined } + }; + window.__oldApi = old; + mod.register(old); + } + } + ]); + }, source); + await D.page.evaluate(() => window.__stores.moduleToolboxes.openModuleToolbox('mod-collectible-manager')); + await D.page.waitForTimeout(800); + const panelD = D.page.locator('.collectible-manager'); + h.check((await panelD.getAttribute('data-refresh')) === 'poll', 'on an app without onChange the manager falls back to its poll'); + const oldBoxes = []; + for (const name of ['Old1', 'Old2']) oldBoxes.push(await makeBox(D, name)); + await D.page.evaluate((uuids) => { + for (const u of uuids) window.__stores.objectActions.selectObject(u, false, true); + }, oldBoxes); + await D.page.waitForTimeout(400); + await runRecipe(D, { variable: 'old', scope: 'shared', trigger: 'click', hide: 'on', respawn: 0 }); + const oldPos = await D.page.evaluate(() => + window.__stores + .allNodes() + .filter((n) => n.type === 'collectible') + .map((n) => n.position.y) + .sort((a, b) => a - b) + ); + h.check( + JSON.stringify(oldPos) === JSON.stringify([40, 230]), + 'and the recipe falls back to its fixed rows (' + JSON.stringify(oldPos) + ')' + ); + await h.eventually( + () => panelD.locator('.cm-row').count(), + (n) => n === 2, + 'and the polled panel lists them' + ); + + await h.finish(browser); +}); diff --git a/tests/module-collectible.test.cjs b/tests/module-collectible.test.cjs index 8c310b1..f8478fc 100644 --- a/tests/module-collectible.test.cjs +++ b/tests/module-collectible.test.cjs @@ -126,7 +126,8 @@ async function runRecipe(peer, options) { await panel.locator('.cm-form select').nth(0).selectOption(options.scope ?? 'shared'); await panel.locator('.cm-form select').nth(1).selectOption(options.trigger ?? 'click'); await panel.locator('.cm-form select').nth(2).selectOption(options.hide ?? 'on'); - await panel.locator('.cm-form input[type=number]').fill(String(options.respawn ?? 0)); + if (options.radius != null) await panel.locator('.cm-form input.cm-in-radius').fill(String(options.radius)); + await panel.locator('.cm-form input.cm-in-respawn').fill(String(options.respawn ?? 0)); await panel.getByRole('button', { name: 'Make collectible' }).click(); await peer.page.waitForTimeout(1400); } @@ -683,6 +684,13 @@ h.run(async () => { troveCounts === '2 collected, 0 left of 2', 'and the header agrees with the count node (' + troveCounts + ')' ); + // S4: the counts include the hand-built LEGACY chain while only module nodes get rows — + // the header now says so instead of leaving a count that disagrees with the rows + const legacyLines = await panel.locator('.cm-legacy').evaluateAll((els) => els.map((el) => el.textContent)); + h.check( + legacyLines.length === 1 && /^\+1 older recipe chain counted here/.test(legacyLines[0] ?? ''), + 'a group whose count includes an older recipe chain says so (' + JSON.stringify(legacyLines) + ')' + ); const gateRow = panel.locator('.cm-row').filter({ hasText: 'Gate' }); h.check(await gateRow.count() === 1, 'a row is named after its target object'); await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); From 25ccc1aaabfa9169b00b3bf926a4d6e39e731bca Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 18:05:20 +0300 Subject: [PATCH 2/2] [feat] collectible: a group edit is ONE undo step through api.flow.setNodesData - bulkApply collects the members that differ and hands them to core's api.flow.setNodesData (core R29 S3b) in one call: one Ctrl+Z undoes the whole group change. Feature-detected - on a core without it the module writes per node exactly as before. Members that already agree are still skipped; the wire is unchanged (one nodedata per member). - README: the group-edit paragraph and the toolbox row say so. v1.1.1. - module-collectible-signals section 12: a 20-member group press is ONE flownodes data entry of 20 items attributed to `collectible`, replicates to the peer, one undo restores all twenty on both peers; the 1.14-shaped api (setNodesData removed) still flips the whole group per node. - Counterfactual: batch call disabled (forced fallback) -> 3 red (17 -> 37 entries, one undo restored one member). - module-collectible-signals 21/21 (base 14/14); module-collectible 130/130 (base 130), against core feat/29-s3-followup on :5213. Co-Authored-By: Claude Opus 5 --- index.json | 2 +- modules/collectible/README.md | 10 +++-- modules/collectible/manifest.json | 2 +- modules/collectible/module.js | 30 +++++++------ tests/module-collectible-signals.test.cjs | 55 ++++++++++++++++++++++- tests/module-collectible.test.cjs | 8 ++-- 6 files changed, 82 insertions(+), 25 deletions(-) diff --git a/index.json b/index.json index 4c65520..f0b911c 100644 --- a/index.json +++ b/index.json @@ -30,7 +30,7 @@ { "id": "collectible", "name": "Collectibles", - "version": "1.1.0", + "version": "1.1.1", "description": "One node makes an object collectible: click it or walk into it, it hides and counts. Shared or per-player score, with respawn and never-hiding checkpoints.", "author": "theprototype-app", "source": "modules/collectible", diff --git a/modules/collectible/README.md b/modules/collectible/README.md index b8f9fa3..b12b70f 100644 --- a/modules/collectible/README.md +++ b/modules/collectible/README.md @@ -51,9 +51,11 @@ sixty gems — so the settings sit with whoever owns them: Nothing in there is a second source of truth. The rows read the graph and the trigger log, and every edit is written back through the same replicated `nodedata` path the node editor's own cards use — so a change made here behaves exactly as if you had typed it -into the card, peers included. A group-wide change is one such write **per member**, -because there is no batched node-data write to reach for; members that already hold the -value are skipped, so the cost follows the *difference* rather than the group size. +into the card, peers included. A group-wide change is one such write **per member**; +members that already hold the value are skipped, so the cost follows the *difference* +rather than the group size. On core 1.15+ the whole press goes through +`api.flow.setNodesData`, so **one Ctrl+Z undoes the whole group change**; an older core +writes the members one by one. Show the score with **Set Variable ▸ HUD Text** as usual, or the count itself with the **Collectibles** value node (`left` / `collected` / `total`) — the HUD editor's @@ -98,7 +100,7 @@ reason this module is thin. | The counts | Derived from the **graph**, never from the score. A score only goes up, so `left` would go negative the first time something respawned. `collected + left === total` by construction. | | Legacy scenes | The count node also finds core's old seven-node chains (`SetVariable ← Once ← event → Latch`) and reads each Latch through `api.flow.nodeValue` — core's own round-aware answer, not a second implementation. | | Late joiners | No `registerStateSync` at all. Every bit of state is already replicated: the graph, the trigger log, the game singleton and the peer rows. AUTHORING.md §4.3 calls that the better design when you can get it. | -| The toolbox | LOCAL, like every toolbox. What it *changes* goes through the replicated paths — `api.flow.setNodeData` for an edit, `api.flow.addNodes` for the recipe. | +| The toolbox | LOCAL, like every toolbox. What it *changes* goes through the replicated paths — `api.flow.setNodeData` for an edit (`setNodesData` for a group edit, one undo step), `api.flow.addNodes` for the recipe. | | Where a setting is edited | By **who owns it**, which is what a sixty-gem scene forces: `trigger` and `scope` are what a whole score's worth of pickups share, so they live on the **group** header; `respawn` is genuinely per-object, so it stays on the **row**. That is also why a group is a fold-away unit — sixty rows can be one line. | | A group control over members that disagree | An **em-dash**, never one of the values (core's Inspector rule for a multi-selection). Showing "click" over a mixed set is a lie the next pointer trip silently makes true. Picking a value out of the mixed control applies it to everyone, which is the reason the control exists. | diff --git a/modules/collectible/manifest.json b/modules/collectible/manifest.json index 633802f..1c4eef7 100644 --- a/modules/collectible/manifest.json +++ b/modules/collectible/manifest.json @@ -1,7 +1,7 @@ { "id": "collectible", "name": "Collectibles", - "version": "1.1.0", + "version": "1.1.1", "format": 1, "description": "One node makes an object collectible: click it or walk into it, it hides and counts. Shared or per-player, with respawn.", "entry": "module.js", diff --git a/modules/collectible/module.js b/modules/collectible/module.js index 23135de..d739cb6 100644 --- a/modules/collectible/module.js +++ b/modules/collectible/module.js @@ -40,7 +40,7 @@ export default { id: 'collectible', name: 'Collectibles', - version: '1.1.0', + version: '1.1.1', description: 'One node makes an object collectible: click it or walk into it, it hides and counts.', @@ -602,10 +602,11 @@ export default { /** * Apply one setting to every member of a group, through the SAME replicated path a - * row edit uses — there is no batch write in the SDK and a module does not get to - * invent one. So N members is N `nodedata` messages, and members that ALREADY agree - * are skipped: making a mixed group uniform sends only the difference, and pressing a - * value a group already holds sends nothing at all. + * row edit uses. Members that ALREADY agree are skipped: making a mixed group uniform + * sends only the difference, and pressing a value a group already holds sends nothing + * at all. Core 1.15's `api.flow.setNodesData` makes the whole press ONE undo step + * (still one `nodedata` per member on the wire); an older core has only the per-node + * call, so the fallback writes them one by one. * @param {any[]} items @param {string} key @param {string} value @param {string} fallback * @returns {number} how many members actually changed */ @@ -614,13 +615,13 @@ export default { // writer that reaches here with it does nothing rather than writing an empty // string over the whole group if (value === MIXED_VALUE) return 0; - let changed = 0; - for (const item of items) { - if (String(item.data?.[key] ?? fallback) === value) continue; - api.flow.setNodeData(item.id, { [key]: value }); - changed++; - } - return changed; + const writes = items + .filter((item) => String(item.data?.[key] ?? fallback) !== value) + .map((item) => ({ id: item.id, patch: { [key]: value } })); + if (!writes.length) return 0; + if (typeof api.flow.setNodesData === 'function') api.flow.setNodesData(writes); + else for (const w of writes) api.flow.setNodeData(w.id, w.patch); + return writes.length; } /** rows grouped by variable, with the target object resolved for display */ @@ -914,8 +915,9 @@ export default { }); head.appendChild(disc); - // THE BULK CONTROLS. Both write through api.flow.setNodeData, per member — the - // replicated path a row edit always used, just aimed at the whole group. + // THE BULK CONTROLS. Both write through bulkApply — the replicated path a row + // edit always used, aimed at the whole group, and one undo step where core has + // the batch call. const bulk = elem('div', { className: 'cm-bulk' }); bulk.appendChild(elem('span', { className: 'cm-bulk-label', textContent: 'all' })); const groupTrigger = bulkSelect(['click', 'touch'], agreedOn(items, 'trigger', 'click'), 'Trigger'); diff --git a/tests/module-collectible-signals.test.cjs b/tests/module-collectible-signals.test.cjs index 50271cd..30d3243 100644 --- a/tests/module-collectible-signals.test.cjs +++ b/tests/module-collectible-signals.test.cjs @@ -275,6 +275,52 @@ h.run(async () => { }); h.check(layout.hits.length === 0, 'the new pairs land on free space — no card overlaps another (' + JSON.stringify(layout.hits.slice(0, 3)) + ', ' + layout.n + ' nodes)'); + // ===================================================================== + // 12. R29 S3 — a group press is ONE undo step (core's api.flow.setNodesData) + // ===================================================================== + await A.page.evaluate(() => + window.__ct.flow.addNodes({ + nodes: Array.from({ length: 20 }, (_, i) => ({ + type: 'collectible', + x: 2400, + y: 40 + i * 60, + data: { variable: 'bulk', scope: 'player', trigger: 'click', hide: 'on', respawn: 0, perRound: true, whilePlaying: true } + })) + }) + ); + const bulkGroup = panel3.locator('.cm-group[data-var="bulk"]'); + await h.eventually(() => bulkGroup.locator('.cm-row').count(), (n) => n === 20, 'premise: a twenty-member group in the manager'); + const bulkNodes = async (peer) => (await nodesOf(peer, 'collectible')).filter((n) => n.data.variable === 'bulk'); + const undoTop = () => + 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, items: e?.items?.length ?? 0, moduleId: e?.moduleId }; + }); + const before12 = await undoTop(); + await bulkGroup.locator('.cm-bulk select').nth(0).selectOption('touch'); + await A.page.waitForTimeout(600); + const after12 = await undoTop(); + h.check( + after12.depth === before12.depth + 1 && after12.kind === 'flownodes' && after12.op === 'data' && after12.items === 20, + 'one group press is ONE undo entry holding all twenty (' + before12.depth + ' -> ' + after12.depth + ', ' + after12.items + ' items)' + ); + h.check(after12.moduleId === 'collectible', 'attributed to the module (' + after12.moduleId + ')'); + await h.eventually( + () => bulkNodes(B), + (ns) => ns.length === 20 && ns.every((n) => n.data.trigger === 'touch'), + 'the press still replicates node by node — all twenty on the peer', + 25000 + ); + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually( + async () => [await bulkNodes(A), await bulkNodes(B)], + ([a, b]) => a.length === 20 && b.length === 20 && [...a, ...b].every((n) => n.data.trigger === 'click'), + 'ONE undo puts all twenty back, on both peers' + ); + h.check((await undoTop()).depth === before12.depth, 'and leaves the stack where the press found it'); + // THE FALLBACK: the same module source, registered against an api with the R29 seams // REMOVED — what a user running core 1.14.0 hands it. It must still build and list. const D = await h.setupPage(browser, 'D'); @@ -291,7 +337,7 @@ h.run(async () => { register(api) { const old = { ...api, - flow: { ...api.flow, onChange: undefined, freeRegion: undefined }, + flow: { ...api.flow, onChange: undefined, freeRegion: undefined, setNodesData: undefined }, game: { ...api.game, onChange: undefined }, peerVars: { ...api.peerVars, onChange: undefined } }; @@ -328,6 +374,13 @@ h.run(async () => { (n) => n === 2, 'and the polled panel lists them' ); + // and a group press without setNodesData still writes every member, one by one + await panelD.locator('.cm-group[data-var="old"] .cm-bulk select').nth(0).selectOption('touch'); + await h.eventually( + async () => (await nodesOf(D, 'collectible')).filter((n) => n.data.variable === 'old').map((n) => n.data.trigger), + (t) => t.length === 2 && t.every((x) => x === 'touch'), + 'without setNodesData the group press falls back to per-node writes' + ); await h.finish(browser); }); diff --git a/tests/module-collectible.test.cjs b/tests/module-collectible.test.cjs index f8478fc..f8085d1 100644 --- a/tests/module-collectible.test.cjs +++ b/tests/module-collectible.test.cjs @@ -1097,10 +1097,10 @@ h.run(async () => { ); // ---- 10i. WHAT A BULK APPLY COSTS AT SCALE ---------------------------------- - // There is no batch node-data write in the SDK, so a bulk apply is N `nodedata` - // messages. This measures the SYNCHRONOUS cost of one press on a twenty-member group and - // proves all twenty land on the peer — inventing a core seam is not a module's call, so - // the number is the deliverable. + // A bulk apply is N `nodedata` messages on the wire (core 1.15's setNodesData batches + // the UNDO, not the wire — the signals flight asserts the one entry). This measures the + // SYNCHRONOUS cost of one press on a twenty-member group and proves all twenty land on + // the peer. const probeIds = await A.page.evaluate(() => { const nodes = []; for (let i = 0; i < 20; i++)