Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions DEVX-REQUESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion index.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
{
"id": "collectible",
"name": "Collectibles",
"version": "1.0.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",
Expand Down
10 changes: 6 additions & 4 deletions modules/collectible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |

Expand Down
2 changes: 1 addition & 1 deletion modules/collectible/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "collectible",
"name": "Collectibles",
"version": "1.0.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",
Expand Down
130 changes: 107 additions & 23 deletions modules/collectible/module.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
export default {
id: 'collectible',
name: 'Collectibles',
version: '1.0.0',
version: '1.1.1',
description:
'One node makes an object collectible: click it or walk into it, it hides and counts.',

Expand Down Expand Up @@ -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
*/
Expand All @@ -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 */
Expand Down Expand Up @@ -663,8 +664,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;
Expand All @@ -673,11 +682,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 }]
});
Expand Down Expand Up @@ -732,7 +741,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}'
})
);

Expand All @@ -749,12 +759,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]
];
Expand All @@ -771,7 +796,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);
Expand Down Expand Up @@ -868,17 +893,31 @@ 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);
});
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');
Expand Down Expand Up @@ -977,6 +1016,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);
}
Expand Down
Loading