From b727168aeea7ef306d2bf1d77d0f7de90920fdcc Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 17:27:48 +0300 Subject: [PATCH 1/8] [physics] #14 On Impact fires on a hit from any side, not only on a landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The impact rule was "pre-step downward velocity >= 1.2 m/s", so a ball thrown sideways at a wall or a target never fired On Impact. A contact now also counts when the two bodies close along the contact normal (rapier's manifold) at >= 1.2 m/s. The downward test stays as its own channel with its own cooldown: On Impact gains `direction: any | down` — new nodes are seeded 'any', a node saved without the key reads 'down' (param `legacy`, honoured by the Flow panel), so old scenes fire exactly as before; emitters' "On impact" keep the landing rule. impactSpeed.js is the leaf (closingSpeed / impactStrengthFor), unit-tested; e2e flow-impact-any (red on 4cd3dafc: the 'any' nodes never fire on the wall/target hit). Co-Authored-By: Claude Opus 5.5 (1M context) --- NODES.md | 2 +- .../editors/nodes/ContactNode.svelte | 13 ++ src/lib/flowRuntime.js | 15 +- src/lib/graphContract.js | 4 +- src/lib/impactSpeed.js | 44 ++++ src/lib/nodeCatalog.js | 19 +- src/lib/nodeDocs.js | 2 +- src/lib/nodeProps.js | 2 +- src/lib/physics.js | 99 ++++++-- tests/e2e/flow-impact-any.test.cjs | 214 ++++++++++++++++++ tests/unit/impactSpeed.test.js | 76 +++++++ 11 files changed, 455 insertions(+), 35 deletions(-) create mode 100644 src/lib/impactSpeed.js create mode 100644 tests/e2e/flow-impact-any.test.cjs create mode 100644 tests/unit/impactSpeed.test.js diff --git a/NODES.md b/NODES.md index 52ef9b869..118e648be 100644 --- a/NODES.md +++ b/NODES.md @@ -31,7 +31,7 @@ Verdicts: OK · FIX(ed this batch) · DOC(umented quirk). | visibility | Logic | effect | on | OK | | onclick | Triggers | event | — | FIX: event→effect is now a legal drag (the typed sockets rejected wiring On Click into the Object Selector its own feature requires). Pulse window OK. | | counter | Triggers | number | pulse reset | FIX (21-E4): a wired `reset` EVENT zeroes it, which is what a round-2 score needs — the counting lives inside `applyNodeTrigger`, so the reset rides that same path and is handle-aware there rather than being a second code path. The `op` param is unchanged and still governs a counter with nothing wired into reset. | -| onimpact | Triggers | event | minStrength | NEW (PFX-C): pulses when the physics INITIATOR lands the object on the ground/another object (rapier contact-start + pre-step downward velocity ≥ 1.2 m/s + 300ms per-body cooldown). Targeting walks THROUGH intermediate nodes to the Object Selector (the fireObjectClick BFS) and honors the implicit-owner rule. Replicated via nodetrigger stamps; `minStrength` gates on impact speed (wirable). | +| onimpact | Triggers | event | minStrength, direction | NEW (PFX-C): pulses when the physics INITIATOR registers a contact-start for the object. fix-batch-1 #14: `direction: 'any'` (new nodes) = the pre-step CLOSING speed along the contact normal ≥ 1.2 m/s, from any side (walls, targets, a kinematic paddle); `'down'` = pre-step downward velocity ≥ 1.2 m/s, the only rule up to 1.43 and what a node saved WITHOUT `direction` keeps (`legacy: 'down'` on the param). Each channel has its own 300ms per-body cooldown; emitters' "On impact" stay on the landing channel. Targeting walks THROUGH intermediate nodes to the Object Selector (the fireObjectClick BFS) and honors the implicit-owner rule. Replicated via nodetrigger stamps; `minStrength` gates on impact speed (wirable). | | shake/spin/bounce/orbit | Animation | effect | per-param | OK (generic AnimationNode; params get ⓘ editors where sensible) | | rotor (Rotate / Motor) | Animation | effect | on (boolean) + per-param | NEW (36-fb F25): turns about a LOCAL axis and the object's origin, rpm + spin-up as a pure function of the shared clock (deterministic, kinematic for physics — pushes bodies, carries Fluid-emitter water through the measured collider motion); on a DYNAMIC body during a run it is a torque-limited motor on the initiator instead (POSE_FREE). | | flowfloat (Float Along Flow) | Animation | effect | path (object) + per-param | NEW (36-fb F24): rides a Flow path from the base pose at the path speed × speed, bob, faces the current, wraps; nearest path within 4 m when unwired. Pure function of (base, path, time) — nothing sent. | diff --git a/src/components/editors/nodes/ContactNode.svelte b/src/components/editors/nodes/ContactNode.svelte index 06bdbe929..573ebf04b 100644 --- a/src/components/editors/nodes/ContactNode.svelte +++ b/src/components/editors/nodes/ContactNode.svelte @@ -48,6 +48,19 @@ /> {(+(data.minStrength ?? 1)).toFixed(1)} + + {/if}
other diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 619962fa9..c1b892ee1 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -17,6 +17,7 @@ import { scriptInputs, scriptOutputs, isScriptValue, coerceInput } from './scrip import { findNodeDef } from './customNodes'; import { updateSounds } from './soundRuntime'; import { colliderSpecOf } from './colliderSpec'; // B6: pure THREE leaf +import { impactStrengthFor } from './impactSpeed'; // fix-batch-1 #14: a leaf import { updateParticles } from './particleRuntime'; // 36-sim: jiggle's park (a leaf over three + flowStore; closes no cycle) import { parkJiggle } from './sim/jiggleRuntime.js'; @@ -3568,15 +3569,21 @@ export function objectHasOnClick(uuid) { * stamp replicates (nodetrigger), so every peer computes the identical pulse. * 36 X6: `otherUuid` = the body it hit ('' = the ground) — gated by the node's * `filter` input and carried to every peer for its `other` output. - * @param {string} uuid @param {number} strength downward speed at contact (m/s) - * @param {string=} otherUuid + * fix-batch-1 #14: physics registers TWO channels per contact and each node reads the one + * its `direction` names — 'any' the closing speed along the contact normal (a wall, a + * target), 'down' the landing speed (the only rule up to 1.43, and what a node saved + * before the option keeps). null = that channel did not count this contact. + * @param {string} uuid @param {number | null} down downward speed at contact (m/s) + * @param {string=} otherUuid @param {number | null=} speed closing speed along the normal + * (m/s; omitted = `down`, a caller that only knows one number) */ -export function fireObjectImpact(uuid, strength, otherUuid = '') { +export function fireObjectImpact(uuid, down, otherUuid = '', speed = down) { const ctx = runtimeCtx(); nodes.forEach((node) => { if (node.type !== 'onimpact') return; const data = resolveInputs(node, nodes, edges, syncedNow(), ctx); - if (strength < num(data.minStrength ?? 0)) return; + const strength = impactStrengthFor(data.direction, down, speed ?? null); + if (strength === null || strength < num(data.minStrength ?? 0)) return; if (!contactPasses(data, otherUuid)) return; if (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid) fireContact(node, otherUuid); }); diff --git a/src/lib/graphContract.js b/src/lib/graphContract.js index 008b556d7..103432de7 100644 --- a/src/lib/graphContract.js +++ b/src/lib/graphContract.js @@ -59,13 +59,15 @@ export const SOCKET_TYPES = Object.freeze([ * min/max/step (range, number) · options (select) · placeholder/maxLength (text) * socket? false = never offer an input socket for it (a range gets one by default when the * spec has `inputs`; any key named in `inputs` is wire-overridable via resolveInputs) + * legacy? what a node saved BEFORE this key existed reads (`defaults` seeds new nodes; the + * runtime's own fallback must say the same — fix-batch-1 #14, On Impact `direction`) * Module nodes declare the same list in `api.registerNodeGroup` items (`params`). * @typedef {{ * key: string, * kind: 'range' | 'number' | 'select' | 'toggle' | 'text' | 'color' | 'vector3' | 'code', * label?: string, doc?: string, group?: string, * min?: number, max?: number, step?: number, options?: string[], - * placeholder?: string, maxLength?: number, socket?: boolean + * placeholder?: string, maxLength?: number, socket?: boolean, legacy?: any * }} PropSpec */ diff --git a/src/lib/impactSpeed.js b/src/lib/impactSpeed.js new file mode 100644 index 000000000..8ecf99c42 --- /dev/null +++ b/src/lib/impactSpeed.js @@ -0,0 +1,44 @@ +// fix-batch-1 #14: what counts as an IMPACT, as pure functions (physics.js measures, flowRuntime +// gates). Up to 1.43 only a landing counted — the pre-step DOWNWARD velocity had to reach 1.2 m/s, +// so a ball thrown sideways at a wall or a target never fired On Impact. Now a contact counts +// when the bodies CLOSE along the contact normal fast enough, from any side; the downward test +// is kept as its own channel so On Impact nodes saved before it (no `direction`) and the +// emitters' "On impact" burst behave exactly as they did. +// +// A LEAF: no imports, plain {x, y, z} objects, so the whole rule is unit-testable. + +export const IMPACT_MIN_DOWN_VY = 1.2; // m/s downward (pre-step) for a landing to count +export const IMPACT_MIN_SPEED = 1.2; // m/s closing speed along the contact normal (pre-step) + +/** the On Impact `direction` a node saved before the key existed has */ +export const LEGACY_IMPACT_DIRECTION = 'down'; + +/** @typedef {{x: number, y: number, z: number}} V3 */ + +/** + * How fast two bodies approach each other along the contact normal (m/s, ≥ 0). The normal's + * sign does not matter (rapier's points from collider 1 to 2, either side may be ours), and a + * graze along a wall reads ~0 however fast it is. No normal (no manifold) → the vertical, the + * old landing assumption. + * @param {V3} va pre-step velocity of this body @param {V3 | null} vb the other's (null = static) + * @param {V3 | null} n contact normal (any length) + */ +export function closingSpeed(va, vb, n) { + const rx = va.x - (vb?.x ?? 0); + const ry = va.y - (vb?.y ?? 0); + const rz = va.z - (vb?.z ?? 0); + const len = n ? Math.hypot(n.x, n.y, n.z) : 0; + if (!n || !(len > 1e-9)) return Math.abs(ry); + return Math.abs((rx * n.x + ry * n.y + rz * n.z) / len); +} + +/** + * The strength an On Impact node sees for one registered impact, or null when it does not fire. + * `down` / `speed` are the two channels physics registered (null = that channel did not count + * this contact, e.g. a sideways hit has no downward channel). + * @param {any} direction the node's `direction` ('any' | 'down'; missing = the legacy 'down') + * @param {number | null} down @param {number | null} speed + */ +export function impactStrengthFor(direction, down, speed) { + return (direction ?? LEGACY_IMPACT_DIRECTION) === 'down' ? down : speed; +} diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 19f94bf2d..006d7cce6 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -43,7 +43,7 @@ const V_MARK = '{v}'; * not see it", "my copies vanished when I stopped the sim". HudNode had hand-rolled * exactly this for `hudscreen`; a spec field means the next such node needs no card of * its own to say its one important thing. - * @typedef {{ key: string, kind: 'range' | 'select' | 'toggle' | 'text' | 'number' | 'color' | 'vector3' | 'code', label?: string, doc?: string, group?: string, socket?: boolean, min?: number, max?: number, step?: number, options?: string[], placeholder?: string, maxLength?: number }} NodeParam + * @typedef {{ key: string, kind: 'range' | 'select' | 'toggle' | 'text' | 'number' | 'color' | 'vector3' | 'code', label?: string, doc?: string, group?: string, socket?: boolean, min?: number, max?: number, step?: number, options?: string[], placeholder?: string, maxLength?: number, legacy?: any }} NodeParam * @typedef {{ type: string, label: string, defaults: Record, params?: NodeParam[], inputs?: string[], inputLabels?: Record, note?: string }} NodeSpec */ @@ -1147,12 +1147,23 @@ export const nodeCatalog = [ // input (only fire for that other object) and an `other` output, and Triggers // holds only input-less sources (the palette rule). Palette-only: no wire change. // PFX-C: fires when the physics sim lands this object on the ground / - // another object (initiator-detected, replicated trigger stamp) + // another object (initiator-detected, replicated trigger stamp). + // fix-batch-1 #14: from ANY side by default; a node saved before `direction` + // existed reads 'down' (`legacy`), so old scenes fire exactly as they did { type: 'onimpact', label: 'On Impact', - defaults: { pulse: 0.3, minStrength: 1 }, - params: [{ key: 'minStrength', kind: 'range', min: 0, max: 10, step: 0.1 }] + defaults: { pulse: 0.3, minStrength: 1, direction: 'any' }, + params: [ + { key: 'minStrength', kind: 'range', min: 0, max: 10, step: 0.1 }, + { + key: 'direction', + kind: 'select', + options: ['any', 'down'], + legacy: 'down', + doc: 'any = a hit from any side (speed along the contact); down = landings only' + } + ] }, // CL-C C2: sensor overlap edges (initiator-detected, replicated stamps) { type: 'onenter', label: 'On Enter', defaults: { pulse: 0.3 } }, diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index e2686bb78..91f3f156a 100644 --- a/src/lib/nodeDocs.js +++ b/src/lib/nodeDocs.js @@ -90,7 +90,7 @@ export const NODE_DOCS = { setuniform: "Writes one number inside an object's shader graph, so a trigger, a", onclick: "Fires a short pulse when its object is clicked - the bridge from user input into the graph.", keypress: "Fires a pulse while a keyboard key is pressed - the bridge from your keyboard into the graph.", - onimpact: "Fires a pulse when a physics simulation lands the connected object on the ground or another object.", + onimpact: "Fires a pulse when the connected object hits something in a physics simulation - from any side (a wall, a target), or only when it lands with From: landing only.", onhit: "Fires a pulse when a hand or a walking player knocks the object - with how hard it was hit (speed) and whether you did it (byMe).", onenter: "Fires a pulse when something enters a trigger volume - the checkpoint, doorway and pressure-plate node.", onexit: "Fires a pulse when something leaves a trigger volume - the other half of On Enter.", diff --git a/src/lib/nodeProps.js b/src/lib/nodeProps.js index 7a4820261..fc11962c6 100644 --- a/src/lib/nodeProps.js +++ b/src/lib/nodeProps.js @@ -84,7 +84,7 @@ export function propertyRows(node, spec, edges, extra = {}) { key: p.key, kind: p.kind, label: p.label ?? p.key, - value: data[p.key] ?? spec?.defaults?.[p.key], + value: data[p.key] ?? ('legacy' in p ? p.legacy : spec?.defaults?.[p.key]), group: p.group ?? '', min: p.min, max: p.max, diff --git a/src/lib/physics.js b/src/lib/physics.js index b03b944bf..930b2a14f 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -35,6 +35,9 @@ import { velocityFromSamples, clampThrow, MAX_LINVEL, MAX_ANGVEL } from './throw // 29-F: the lower-id-keeps-the-world rule, as a leaf that imports nothing — the whole // decision is a pure function of four facts, so its truth table is a vitest unit. import { simulateVerdict } from './simAuthority'; +// fix-batch-1 #14: the impact rule (closing speed along the contact normal, the legacy +// downward channel beside it) as a leaf of pure functions +import { closingSpeed, IMPACT_MIN_DOWN_VY, IMPACT_MIN_SPEED } from './impactSpeed'; // B7: spawned objects are swept when the run ends. transientObjects is a LEAF (the two // stores only), so this edge closes nothing — unlike objectActions, which the // out-of-bounds delete has to reach dynamically. @@ -90,7 +93,7 @@ export const remoteSimulating = writable(null); * lastSent?: {pos: THREE.Vector3, quat: THREE.Quaternion}, * colliders: any[], shapeKey: string, * oob?: boolean, restSince?: number | null, - * preVy?: number, ccd?: boolean, + * preV?: {x: number, y: number, z: number}, ccd?: boolean, * buoy?: {points: Float32Array, weights: Float32Array, volume: number, cellH: number}}} BodyEntry */ /** @type {BodyEntry[]} */ let bodies = []; @@ -130,9 +133,16 @@ let defaultsSubPrimed = false; let oobCount = 0; /** @type {any} */ let oobTimer = null; /** @type {string} */ let oobAction = 'respawn'; -/** @type {Map} uuid -> last impact stamp (step-now ms) */ +/** @type {Map} uuid -> last DOWNWARD impact stamp (step-now ms) */ let lastImpactAt = new Map(); -/** @type {{uuid: string, strength: number, other: string}[]} contacts collected inside the substep loop (36 X6: + the other body) */ +/** fix-batch-1 #14: uuid -> last any-direction impact stamp. Its OWN cooldown: a sideways + * bump must not swallow the landing a downward-only node (an old scene) is waiting for. + * @type {Map} */ +let lastAnyImpactAt = new Map(); +/** contacts collected inside the substep loop (36 X6: + the other body; fix-batch-1 #14: the + * two channels — `down` = downward speed, `speed` = closing speed along the contact normal, + * null where that channel did not count the contact) + * @type {{uuid: string, down: number | null, speed: number | null, other: string}[]} */ let pendingImpacts = []; /** CL-A: colliders attached to FIXED scenery bodies (rebuild bookkeeping — * BodyEntry only exists for dynamic/kinematic). @type {Map} */ @@ -174,7 +184,6 @@ export const PHYSICS_MATERIALS = { // two copies of a clamp is how they drift apart const EXTERNAL_HOLD_MS = 250; // peer-move silence before a held body drops const IMPACT_COOLDOWN_MS = 300; // per-body: a roll/settle can't machine-gun impacts -const IMPACT_MIN_DOWN_VY = 1.2; // m/s downward (pre-step) for a contact to count /** Pre-load the wasm module (also lets tests warm the vite dep cache) */ export async function warmup() { @@ -1063,6 +1072,7 @@ async function startSimulation() { eventQueue = new RAPIER.EventQueue(true); colliderOwner = new Map(); lastImpactAt = new Map(); + lastAnyImpactAt = new Map(); pendingImpacts = []; // 36 X2: static/kinematic scenery may pick the EXACT mesh (ColliderDesc.trimesh, // colliderSpec kind 'trimesh'); terrain from a heightfield is still deferred. @@ -1294,6 +1304,7 @@ export function physicsRemoveBody(uuid) { // cooldown would grow without bound. Purging is also strictly correct — a uuid is // never reused, so a surviving row could only ever suppress nothing. lastImpactAt.delete(uuid); + lastAnyImpactAt.delete(uuid); return true; } @@ -1859,9 +1870,15 @@ function stepInner(now) { feed.entry.body.setNextKinematicRotation({ x: stepQuat.x, y: stepQuat.y, z: stepQuat.z, w: stepQuat.w }); } // PFX-C: impact strength must be sampled BEFORE the step — contact - // resolution rewrites linvel during it (a bounce reads as upward after) + // resolution rewrites linvel during it (a bounce reads as upward after). + // fix-batch-1 #14: the whole vector (a wall hit is horizontal), every body + // (a kinematic mover or a held one is the OTHER side of somebody's impact) bodies.forEach((entry) => { - if (entry.mode === 'dynamic' && !entry.hold) entry.preVy = entry.body.linvel().y; + const v = entry.body.linvel(); + const pre = (entry.preV ??= { x: 0, y: 0, z: 0 }); + pre.x = v.x; + pre.y = v.y; + pre.z = v.z; }); for (const f of floaters) if (!f.entry.hold && f.entry.buoy) @@ -1950,37 +1967,69 @@ function stepInner(now) { } /** - * A contact-start seen inside the substep loop. Either side that is a falling - * DYNAMIC body (pre-step downward velocity above the threshold, not held, off - * cooldown) registers an impact — dynamic-vs-ground, dynamic-vs-scenery and - * dynamic-vs-dynamic all count. + * A contact-start seen inside the substep loop. Either side that is a DYNAMIC body (not held) + * registers an impact when it passes either channel, each with its own cooldown: + * down — the pre-step DOWNWARD velocity reaches 1.2 m/s (a landing; the only rule up to + * 1.43, kept for On Impact nodes set to "down" and every node saved before the + * option existed, and for the emitters' "On impact" burst) + * speed — fix-batch-1 #14: the two bodies CLOSE along the contact normal at ≥ 1.2 m/s, + * from any side (a ball thrown at a wall, a crate shoved into a target, a + * kinematic paddle hitting a ball at rest) + * Dynamic-vs-ground, dynamic-vs-scenery and dynamic-vs-dynamic all count. * * 49 P2: the body it landed ON hears it too when that body cannot register for itself — a * fixed or kinematic object (a pressure plate, a target, a moving platform) gets the same * impact with `other` = the mover. A DYNAMIC partner is left to its own test above, so two * dynamic bodies never fire twice for one contact; a sensor partner has On Enter instead. - * The passive event rides the mover's cooldown, so it cannot machine-gun either. + * The passive event rides the mover's cooldowns, so it cannot machine-gun either. * @param {number} h1 @param {number} h2 @param {number} now step clock (ms) */ function queueContact(h1, h2, now) { for (const handle of [h1, h2]) { if (groundHandles.has(handle)) continue; const entry = colliderOwner.get(handle)?.entry; - if (!entry || entry.mode !== 'dynamic' || entry.hold) continue; - const down = -(entry.preVy ?? 0); - if (down < IMPACT_MIN_DOWN_VY) continue; - const uuid = entry.object.uuid; - if (now - (lastImpactAt.get(uuid) ?? -Infinity) < IMPACT_COOLDOWN_MS) continue; - lastImpactAt.set(uuid, now); - // 36 X6: and what it hit — the other collider's owner, '' for the ground + if (!entry || entry.mode !== 'dynamic' || entry.hold || !entry.preV) continue; const other = handle === h1 ? h2 : h1; const hit = groundHandles.has(other) ? null : colliderOwner.get(other); - pendingImpacts.push({ uuid, strength: down, other: hit?.uuid ?? '' }); + const uuid = entry.object.uuid; + const fall = -entry.preV.y; + const down = fall >= IMPACT_MIN_DOWN_VY && now - (lastImpactAt.get(uuid) ?? -Infinity) >= IMPACT_COOLDOWN_MS ? fall : null; + let speed = null; + if (now - (lastAnyImpactAt.get(uuid) ?? -Infinity) >= IMPACT_COOLDOWN_MS) { + // the ground and fixed scenery stand still; anything else brings its own velocity + const closing = closingSpeed(entry.preV, hit?.entry?.preV ?? null, contactNormal(handle, other)); + if (closing >= IMPACT_MIN_SPEED) speed = closing; + } + if (down === null && speed === null) continue; + if (down !== null) lastImpactAt.set(uuid, now); + if (speed !== null) lastAnyImpactAt.set(uuid, now); + // 36 X6: and what it hit — the other collider's owner, '' for the ground + pendingImpacts.push({ uuid, down, speed, other: hit?.uuid ?? '' }); if (hit && !hit.sensor && hit.uuid !== uuid && hit.entry?.mode !== 'dynamic') - pendingImpacts.push({ uuid: hit.uuid, strength: down, other: uuid }); + pendingImpacts.push({ uuid: hit.uuid, down, speed, other: uuid }); } } +/** + * fix-batch-1 #14: the world-space normal of a contact that just started (its sign is + * irrelevant to the caller), or null when rapier holds no manifold for the pair — the + * caller then falls back to the vertical, the old landing assumption. + * @param {number} h1 @param {number} h2 @returns {{x: number, y: number, z: number} | null} + */ +function contactNormal(h1, h2) { + const c1 = world?.getCollider(h1); + const c2 = world?.getCollider(h2); + if (!c1 || !c2) return null; + /** @type {{x: number, y: number, z: number} | null} */ + let n = null; + world.contactPair(c1, c2, (/** @type {any} */ manifold) => { + if (n || !manifold.numContacts()) return; + const v = manifold.normal(); + n = { x: v.x, y: v.y, z: v.z }; + }); + return n; +} + /** * CL-A A3: a sensor pair crossed (started) or separated (stopped) — collect * the edge for BOTH tracked sides. Sensors bypass the downward-velocity @@ -2020,10 +2069,14 @@ function dispatchImpacts() { const impacts = pendingImpacts; pendingImpacts = []; const group = get(objectsGroup); - impacts.forEach(({ uuid, strength, other }) => { + impacts.forEach(({ uuid, down, speed, other }) => { // flow path: pulse On Impact nodes targeting this object — the trigger - // stamp replicates (nodetrigger), so every peer computes the same pulse - fireObjectImpact(uuid, strength, other); + // stamp replicates (nodetrigger), so every peer computes the same pulse. + // fix-batch-1 #14: each node picks its channel (direction any | down) + fireObjectImpact(uuid, down, other, speed); + // the emitters keep the landing rule: their "On impact" has no direction option, so + // a sideways contact bursting them would change every scene that uses one + if (down === null) return; // zero-flow path: an emitter set to "On impact" bursts for everyone // (replicated particleburst timestamp) — userData emitters checked on // the object, NODE emitters through the runtime diff --git a/tests/e2e/flow-impact-any.test.cjs b/tests/e2e/flow-impact-any.test.cjs new file mode 100644 index 000000000..28c4fbc44 --- /dev/null +++ b/tests/e2e/flow-impact-any.test.cjs @@ -0,0 +1,214 @@ +// fix-batch-1 #14: On Impact fires on a hit from ANY side, not only on a landing. A ball rolled +// sideways into a STATIC wall and a KINEMATIC (flow-animated) target fires its own On Impact +// and theirs (other = the ball) — up to 1.43 the rule was "pre-step downward velocity ≥ 1.2 +// m/s", so neither fired. Old scenes keep behaving: a node saved WITHOUT `direction` and one +// set to 'down' stay quiet on the sideways hits and still fire on a landing. Every fire is +// counted from the trigger log. Red on origin/feat/52 (4cd3dafc): the 'any' nodes never fire +// on the wall/target hit. EVIDENCE_DIR for the log line. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + await A.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + + const spec = await A.page.evaluate(async () => { + const cat = await window.__stores.nodeCatalog; + const s = (cat.findNodeSpec ?? cat.default?.findNodeSpec)?.('onimpact'); + return { + defaults: s?.defaults ?? null, + direction: s?.params?.find((p) => p.key === 'direction') ?? null + }; + }); + h.check( + spec.defaults?.direction === 'any', + `a NEW On Impact node is seeded direction 'any' (${JSON.stringify(spec.defaults)})` + ); + h.check( + spec.direction?.legacy === 'down', + `the Flow panel reads a node saved without it as 'down' (${JSON.stringify(spec.direction)})` + ); + + const ids = await A.page.evaluate(() => { + const s = window.__stores; + const cmd = s.commandsHandler.sceneCommand; + cmd('/create Box 0.4 2 3'); // wall (static) + cmd('/create Box 0.4 1 1.2'); // target (kinematic: a slow Spin animates it) + cmd('/create Sphere 0.2'); // ballW -> rolled into the wall + cmd('/create Sphere 0.2'); // ballT -> rolled into the target + cmd('/create Sphere 0.2'); // ballD -> dropped onto the ground (the landing control) + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const [wall, target, ballW, ballT, ballD] = g.children.slice(-5); + const place = (o, name, x, y, z, physics) => { + o.name = name; + o.position.set(x, y, z); + o.updateMatrixWorld(true); + if (physics) s.physics.setPhysicsFor(o.uuid, physics); + }; + // restitution 0: a bounce would hit a second time, and this suite counts hits + const ball = { mode: 'dynamic', mass: 1, collider: 'sphere', restitution: 0 }; + place(wall, 'Wall', 3, 1, 0, { mode: 'static', restitution: 0 }); + place(target, 'Target', 3, 0.5, 6, { mode: 'auto', mass: undefined, restitution: 0 }); // /create stamps dynamic + place(ballW, 'Ball to wall', 0, 0.2, 0, ball); + place(ballT, 'Ball to target', 0, 0.2, 6, ball); + place(ballD, 'Ball dropped', -6, 3, 0, ball); + window.__fx = { wall, target, ballW, ballT, ballD }; + return Object.fromEntries(Object.entries(window.__fx).map(([k, o]) => [k, o.uuid])); + }); + + await A.page.evaluate((u) => { + const sel = (id, uuid, y) => ({ + id, + type: 'objectselector', + position: { x: 300, y }, + data: { label: 'Object', selected: uuid } + }); + // direction: 'any' | 'down' | undefined (= a node saved before the option existed) + const imp = (id, y, direction) => ({ + id, + type: 'onimpact', + position: { x: 0, y }, + data: { + label: 'On Impact', + pulse: 0.3, + minStrength: 0.5, + ...(direction ? { direction } : {}) + } + }); + const rows = [ + ['impWallAny', u.wall, 'any'], + ['impWallOld', u.wall, undefined], + ['impTargetAny', u.target, 'any'], + ['impTargetDown', u.target, 'down'], + ['impBallWAny', u.ballW, 'any'], + ['impBallWOld', u.ballW, undefined], + ['impBallWDown', u.ballW, 'down'], + ['impBallTAny', u.ballT, 'any'], + ['impBallDAny', u.ballD, 'any'], + ['impBallDOld', u.ballD, undefined] + ]; + const nodes = []; + const edges = []; + rows.forEach(([id, uuid, dir], i) => { + nodes.push(imp(id, i * 140, dir), sel('s' + id, uuid, i * 140)); + edges.push({ id: `e-${id}-s${id}`, source: id, target: 's' + id }); + }); + // a slow Spin makes the target flow-animated -> a KINEMATIC body for the run + nodes.push({ + id: 'spin1', + type: 'spin', + position: { x: 0, y: 1600 }, + data: { label: 'Spin', speed: 0.02, axis: 'y' } + }); + nodes.push(sel('sSpin', u.target, 1600)); + edges.push({ id: 'e-spin1-sSpin', source: 'spin1', target: 'sSpin' }); + window.__stores.flowNodes.set(nodes); + window.__stores.flowEdges.set(edges); + // count every fire WITH what it hit: the trigger log keeps only the latest stamp per + // node, and `other` is set just before the stamp (fireContact). A rolling ball that + // hits a wall can climb it on its spin and LAND again — a real landing (other = the + // ground), which a 'down' node rightly fires on; the claims below are about the hit + window.__fires = {}; + const fr = window.__stores.flowRuntime; + window.__stores.flowTriggers.subscribe((log) => { + for (const [id, e] of Object.entries(log ?? {})) { + if (!id.startsWith('imp')) continue; + const seen = (window.__fires[id] ??= []); + if (typeof e?.lastT === 'number' && seen[seen.length - 1]?.t !== e.lastT) + seen.push({ t: e.lastT, other: fr.contactOtherOf(id) }); + } + }); + }, ids); + await A.page.waitForTimeout(600); + + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + // the dropped ball lands; the rolling balls settle on the ground first + await h.eventually( + () => A.page.evaluate(() => window.__fx.ballD.position.y), + (y) => y < 0.25, + 'premise: the dropped ball lands on the ground', + 15000 + ); + await A.page.waitForTimeout(400); + const beforeRoll = await A.page.evaluate(() => JSON.parse(JSON.stringify(window.__fires))); + const n0 = (id) => beforeRoll[id]?.length ?? 0; + h.check( + n0('impBallWAny') + n0('impBallTAny') + n0('impWallAny') + n0('impTargetAny') === 0, + `premise: nothing fired on the resting balls or the wall before the roll (${JSON.stringify(beforeRoll)})` + ); + + const pushed = await A.page.evaluate(() => { + const p = window.__stores.physics; + return [ + p.setBodyVelocity(window.__fx.ballW.uuid, [6, 0, 0], null), + p.setBodyVelocity(window.__fx.ballT.uuid, [6, 0, 0], null) + ]; + }); + h.check(pushed[0] && pushed[1], `premise: both balls were rolled sideways at 6 m/s (${pushed})`); + await h.eventually( + () => A.page.evaluate(() => [window.__fx.ballW.position.x, window.__fx.ballT.position.x]), + (xs) => xs[0] > 2.4 && xs[1] > 2.4, + 'premise: each ball reached its wall / target', + 10000 + ); + await A.page.waitForTimeout(800); + + const r = await A.page.evaluate(() => { + const kinds = Object.fromEntries( + window.__stores.physics.physicsDebug().map((e) => [e.uuid, e.mode]) + ); + return { + fires: window.__fires, + targetMode: kinds[window.__fx.target.uuid] ?? null, + ballWx: window.__fx.ballW.position.x + }; + }); + console.log('fires', JSON.stringify(r)); + const f = (id) => r.fires[id] ?? []; + /** fires of node `id` whose other is `uuid` */ + const hits = (id, uuid) => f(id).filter((e) => e.other === uuid).length; + h.check( + r.targetMode === 'kinematic', + `premise: the target is a kinematic body (${r.targetMode})` + ); + h.check( + r.ballWx < 2.85, + `premise: the ball stopped at the wall, not through it (x ${r.ballWx.toFixed(2)})` + ); + h.check( + hits('impBallWAny', ids.wall) === 1, + `a ball rolled into a wall fires its 'any' On Impact once with other = Wall (${hits('impBallWAny', ids.wall)})` + ); + h.check( + hits('impWallAny', ids.ballW) === 1 && f('impWallAny').length === 1, + `the STATIC wall hit sideways fires its 'any' On Impact once, other = the ball (${f('impWallAny').length})` + ); + h.check( + hits('impBallTAny', ids.target) === 1, + `a ball rolled into a target fires once with other = Target (${hits('impBallTAny', ids.target)})` + ); + h.check( + hits('impTargetAny', ids.ballT) === 1 && f('impTargetAny').length === 1, + `the KINEMATIC target hit sideways fires once, other = the ball (${f('impTargetAny').length})` + ); + h.check( + hits('impBallWOld', ids.wall) === 0 && f('impWallOld').length === 0, + `nodes saved before the option (no direction) stay quiet on the sideways hit (${hits('impBallWOld', ids.wall)}, ${f('impWallOld').length})` + ); + h.check( + hits('impBallWDown', ids.wall) === 0 && f('impTargetDown').length === 0, + `'down' nodes stay quiet on the sideways hit (${hits('impBallWDown', ids.wall)}, ${f('impTargetDown').length})` + ); + h.check( + f('impBallDOld').length === 1 && hits('impBallDOld', '') === 1, + `a legacy node still fires once on a landing, other = the ground (${f('impBallDOld').length})` + ); + h.check( + f('impBallDAny').length === 1, + `an 'any' node fires once on a landing too (${f('impBallDAny').length})` + ); + + await A.page.evaluate(() => window.__stores.physics.stopSimulation({ reset: true })); + await h.finish(browser); +}); diff --git a/tests/unit/impactSpeed.test.js b/tests/unit/impactSpeed.test.js new file mode 100644 index 000000000..5c7287e90 --- /dev/null +++ b/tests/unit/impactSpeed.test.js @@ -0,0 +1,76 @@ +// fix-batch-1 #14: what counts as an impact (src/lib/impactSpeed.js, a leaf). Up to 1.43 only +// the downward channel existed, so a sideways hit on a wall or a target never fired On Impact. +import { describe, test, expect } from 'vitest'; +import { + closingSpeed, + impactStrengthFor, + IMPACT_MIN_DOWN_VY, + IMPACT_MIN_SPEED, + LEGACY_IMPACT_DIRECTION +} from '../../src/lib/impactSpeed.js'; +import { propertyRows } from '../../src/lib/nodeProps.js'; + +/** @param {number} x @param {number} y @param {number} z */ +const v = (x, y, z) => ({ x, y, z }); +const UP = v(0, 1, 0); +const WALL = v(1, 0, 0); // a wall facing -x/+x + +describe('closingSpeed', () => { + test('a landing on still ground reads the downward speed (the old number)', () => { + expect(closingSpeed(v(0.3, -4, 0), null, UP)).toBeCloseTo(4); + }); + test('a sideways hit on a wall reads its speed along the normal; downward is ~0', () => { + const ball = v(5, 0, 0); + expect(closingSpeed(ball, null, WALL)).toBeCloseTo(5); + expect(-ball.y).toBeLessThan(IMPACT_MIN_DOWN_VY); // the old rule never fired here + expect(closingSpeed(ball, null, WALL)).toBeGreaterThanOrEqual(IMPACT_MIN_SPEED); + }); + test('an angled hit counts only the normal component, and a graze reads ~0', () => { + expect(closingSpeed(v(3, 0, 4), null, WALL)).toBeCloseTo(3); + expect(closingSpeed(v(0, 0, 9), null, WALL)).toBeCloseTo(0); + }); + test('the normal sign and length do not matter', () => { + expect(closingSpeed(v(2, 0, 0), null, v(-3, 0, 0))).toBeCloseTo(2); + }); + test('relative velocity: a mover hitting a body at rest, and two bodies moving together', () => { + expect(closingSpeed(v(0, 0, 0), v(-3, 0, 0), WALL)).toBeCloseTo(3); + expect(closingSpeed(v(2, 0, 0), v(2, 0, 0), WALL)).toBeCloseTo(0); + }); + test('no normal (no manifold) falls back to the vertical', () => { + expect(closingSpeed(v(5, -2, 0), null, null)).toBeCloseTo(2); + expect(closingSpeed(v(5, -2, 0), null, v(0, 0, 0))).toBeCloseTo(2); + }); +}); + +describe('impactStrengthFor', () => { + test("'any' reads the normal channel, 'down' the landing channel", () => { + expect(impactStrengthFor('any', null, 5)).toBe(5); + expect(impactStrengthFor('down', null, 5)).toBe(null); // sideways: a down node stays quiet + expect(impactStrengthFor('down', 4, 4)).toBe(4); + expect(impactStrengthFor('any', 4, null)).toBe(null); + }); + test('a node saved before the option (no direction) keeps the landing rule', () => { + expect(LEGACY_IMPACT_DIRECTION).toBe('down'); + expect(impactStrengthFor(undefined, null, 5)).toBe(null); + expect(impactStrengthFor(undefined, 4, 6)).toBe(4); + }); +}); + +describe('On Impact direction in the Flow ⚙ panel', () => { + const spec = { + type: 'onimpact', + defaults: { pulse: 0.3, minStrength: 1, direction: 'any' }, + params: [ + { key: 'minStrength', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'direction', kind: 'select', options: ['any', 'down'], legacy: 'down' } + ] + }; + test('an old node (no key) shows its legacy value, not the new default', () => { + const rows = propertyRows({ id: 'n', type: 'onimpact', data: { minStrength: 1 } }, spec, []); + expect(rows.find((r) => r.key === 'direction')?.value).toBe('down'); + }); + test('a new node shows what it was seeded with', () => { + const rows = propertyRows({ id: 'n', type: 'onimpact', data: { ...spec.defaults } }, spec, []); + expect(rows.find((r) => r.key === 'direction')?.value).toBe('any'); + }); +}); From 257e8f6a1f4d85bd608ff19f679903c41ba54324 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 17:50:02 +0300 Subject: [PATCH 2/8] [a11y] #23 profile menu opens on Enter/Space only; compact headers at 150 % on the folded N6 Profile circle (53-int Q5): it opened on focus, so Tab walked the whole panel. Now the ARIA menu-button pattern: Tab passes it, Enter / Space / Down open it with focus on the first row, Escape closes and returns focus to the circle. Compact header (53-int Q4): at a large UI scale a WindowChrome header wrapped before it ellipsized (flex lines break on hypothetical sizes). The title enters line-breaking at its minimum, header buttons stop growing at 120 %, the panel icon and the back label's text step aside (still the accessible name). The object list header's shed thresholds follow the scale and its search narrows. Settings / Inspector / object list headers are one row at 150 % folded. Rows: a11y-axe (profile menu keys + axe on the open panel), a11y-scale (three one-row header checks on the folded N6 at 150 %). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/Controls.svelte | 16 ++++++++--- src/components/menu/Users.svelte | 32 +++++++++++++++++++--- src/components/ui/WindowChrome.svelte | 36 +++++++++++++++++++++++++ tests/e2e/a11y-axe.test.cjs | 38 ++++++++++++++++++++++++++- tests/e2e/a11y-scale.test.cjs | 38 +++++++++++++++++++++++++++ tests/e2e/keyboard-walk.test.cjs | 2 +- 6 files changed, 154 insertions(+), 8 deletions(-) diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index b330b8cbf..15b391599 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -101,7 +101,7 @@ import { geometry as sidebarGeometry } from '$lib/sidebars'; import type { ToolbarLayout, LayoutConfig, Placements, Region } from '$lib/toolbarLayout'; import { pivotMode, pivotParentAvailable } from '$lib/multiTransform'; // 37 R1: the toolbar Pivot cell - import { cssPx, scaledPx } from '$lib/uiScale'; // 53 A1: hud.css px tokens read back as calc(...) + import { cssPx, scaledPx, uiScale } from '$lib/uiScale'; // 53 A1: hud.css px tokens read back as calc(...) import { VRButton, XRButton } from '@threlte/xr' // A panel is "shown" when it is open AND either the visible dock tab OR floating @@ -776,8 +776,10 @@ read(); return { destroy: () => ro.disconnect() }; } - const objHideSearch = $derived(objHeaderW < 260); - const objHideLabel = $derived(objHeaderW < 190); + // fix-batch-1 #23: the thresholds are 100 % widths — at 150 % the search and the label are 1.5x as + // wide, and an unscaled 260 kept the search on the folded N6 (a ~390 px header) and wrapped the ✕ + const objHideSearch = $derived(objHeaderW < (260 * $uiScale) / 100); + const objHideLabel = $derived(objHeaderW < (190 * $uiScale) / 100); // 38 R6: what the header element carries now that WindowChrome draws it — the width // probe above (as an attachment) and the drop-to-root target const objHeaderAttrs = { @@ -4397,6 +4399,14 @@ .obj-search :global(.sf) { width: 100%; } + /* fix-batch-1 #23: at a large UI scale the search enters the header's line-breaking at a minimum + and grows into what is left (WindowChrome's compact-header rule for the title), so it narrows + before the ✕ wraps to a second row */ + :global(:root[data-ui-large]) .obj-search { + flex: 3 1 0; /* most of what is left: the spacer after it only pushes the ✕ right */ + width: auto; + min-width: min(6em, 100%); + } .obj-filters { padding: 8px 10px; border-bottom: 1px solid var(--border); diff --git a/src/components/menu/Users.svelte b/src/components/menu/Users.svelte index 79434f5c3..ef0af5468 100644 --- a/src/components/menu/Users.svelte +++ b/src/components/menu/Users.svelte @@ -520,6 +520,16 @@ // viewport trap below), so the notch's circle sits exactly over the trigger wherever it is. dropdownEl.style.setProperty('--pm-right', `${Math.max(0, Math.round(window.innerWidth - r.right))}px`); } + /** fix-batch-1 #23: the trigger's keys — Enter / Space toggle, ↓ opens; opening moves focus in */ + function triggerKey(e: KeyboardEvent) { + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'ArrowDown') return; + e.preventDefault(); + const opening = e.key === 'ArrowDown' || !openDropdown; + openDropdown = opening; + if (opening) focusFirstRow = true; + } + /** set by a keyboard open: the panel's mount focuses its first row */ + let focusFirstRow = false; /** focus moving OUT of the menu or its trigger (to a real element) closes it */ function dropdownFocusOut(e: FocusEvent) { const to = e.relatedTarget as Node | null; @@ -558,8 +568,21 @@ openDropdown = false; }; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') openDropdown = false; + if (e.key !== 'Escape') return; + // fix-batch-1 #23: focus inside the panel goes back to the button that opened it + const inPanel = !!document.activeElement && node.contains(document.activeElement); + openDropdown = false; + if (inPanel) triggerEl?.focus(); }; + if (focusFirstRow) { + focusFirstRow = false; + queueMicrotask(() => { + // the first ROW — not the notch's avatar circle, which only closes the panel again + const rows = 'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])'; + const first = node.querySelector(`.pm-card :is(${rows})`) ?? node.querySelector(rows); + first?.focus(); + }); + } const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(placeDropdown) : null; if (triggerEl) ro?.observe(triggerEl); window.addEventListener('resize', placeDropdown); @@ -1015,7 +1038,10 @@ top-right corner. -->
- +
(openDropdown = !openDropdown)} - onfocusin={() => (openDropdown = true)} + onkeydown={triggerKey} onfocusout={dropdownFocusOut} > {@render avatarImg(effAvatar || undefined, 'bg-surface-2 h-12 w-12 border-2 border-border-strong')} diff --git a/src/components/ui/WindowChrome.svelte b/src/components/ui/WindowChrome.svelte index ebb982077..131ed64c0 100644 --- a/src/components/ui/WindowChrome.svelte +++ b/src/components/ui/WindowChrome.svelte @@ -208,6 +208,42 @@ :global(:root[data-ui-large]) .wc-title { min-width: min(6.5em, 100%); } + /* fix-batch-1 #23 (53-int Q4): …but the wrap is the LAST resort now, not the first. A flex line + breaks on its items' HYPOTHETICAL sizes, so a title as long as its text pushed the buttons down + while there was room to ellipsize it — at 150 % on the folded N6 (a ~260 px layout) the Settings + sheet's close button sat on a second row and the Inspector header took three. Compact header at + a large scale: the title enters line-breaking at its minimum (basis 0, grows into what is left), + the decorative icon and the back button's text step aside (the text stays its accessible name), + and the header buttons stop growing at 120 % — still a 44 px target and more — so the scale goes + to the words */ + :global(:root[data-ui-large]) .wc-head { + column-gap: calc(4px * var(--ui-scale, 1)); + padding-left: calc(8px * var(--ui-scale, 1)); + } + :global(:root[data-ui-large]) .wc-head .wc-title { + flex: 1 1 0; + min-width: min(4em, 100%); + } + :global(:root[data-ui-large]) .wc-icon { + display: none; + } + :global(:root[data-ui-large]) .wc-actions { + gap: calc(4px * var(--ui-scale, 1)); + } + :global(:root[data-ui-large]) .wc-nav .wc-back > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + :global(:root[data-ui-large]) .wc-modal .wc-btn, + :global(:root[data-ui-large]) .wc-panel .wc-btn, + :global(:root[data-ui-large]) .wc-panel .wc-head :global(.wc-act) { + width: calc(44px * min(var(--ui-scale, 1), 1.2)); + height: calc(44px * min(var(--ui-scale, 1), 1.2)); + } .wc-modal .wc-title { font-size: var(--fs-modal-title); letter-spacing: -0.01em; diff --git a/tests/e2e/a11y-axe.test.cjs b/tests/e2e/a11y-axe.test.cjs index 0b0902f6a..f482da4cb 100644 --- a/tests/e2e/a11y-axe.test.cjs +++ b/tests/e2e/a11y-axe.test.cjs @@ -1,5 +1,6 @@ // 53-reader (A4) — THE AXE GATE. axe-core (via @axe-core/playwright) over the main surfaces: the -// editor shell, the object list, the Inspector, Settings, the Asset Browser and the phone shell. +// editor shell, the object list, the Inspector, Settings, the Asset Browser, the profile menu and the phone shell. +// fix-batch-1 #23: + the profile menu's menu-button keys (Tab passes it, Enter/Space open, Escape returns). // // It FAILS only on `serious` and `critical` violations. `moderate` and `minor` are LISTED in the // report (stdout + `axe-.md` / `.json` in AXE_REPORT_DIR) and not fixed — the user took the @@ -152,6 +153,41 @@ h.run(async () => { await page.waitForTimeout(500); await gate(page, 'Asset Browser', '#asset-browser-window', '#asset-browser-window'); await page.locator('#asset-browser-close').click().catch(() => {}); + await page.waitForTimeout(300); + + // 5b. fix-batch-1 #23 (53-int Q5): the profile menu is a MENU BUTTON — reaching the circle with Tab + // does not open it (it opened on focus, so Tab walked the whole panel: sign-in + links on a cloud + // build); Enter opens it with focus on its first row; Escape closes it and focus goes back to the + // circle. COUNTERFACTUAL (4cd3dafc): the panel is open the moment the circle takes focus. + const profile = () => + page.evaluate(() => { + const dd = document.querySelector('#avatar-dropdown'); + const a = document.activeElement; + return { open: !!dd && dd.getBoundingClientRect().width > 0, onTrigger: a?.id === 'avatar-trigger', inRow: !!a?.closest('#avatar-dropdown .pm-card'), expanded: document.querySelector('#avatar-trigger')?.getAttribute('aria-expanded') }; + }); + await page.evaluate(() => /** @type {HTMLElement} */ (document.querySelector('#avatar-trigger'))?.focus()); + await page.waitForTimeout(300); + const p0 = await profile(); + h.check(p0.onTrigger && !p0.open && p0.expanded === 'false', `profile menu: focus on the circle does NOT open it (${JSON.stringify(p0)})`); + await page.keyboard.press('Tab'); + await page.waitForTimeout(200); + const p1 = await profile(); + h.check(!p1.open && !p1.inRow, `profile menu: Tab moves past the circle, not into the panel (${JSON.stringify(p1)})`); + await page.evaluate(() => /** @type {HTMLElement} */ (document.querySelector('#avatar-trigger'))?.focus()); + await page.keyboard.press('Enter'); + await page.waitForTimeout(400); + const p2 = await profile(); + h.check(p2.open && p2.inRow && p2.expanded === 'true', `profile menu: Enter opens it, focus on its first row (${JSON.stringify(p2)})`); + if (p2.open) await gate(page, 'profile menu', '#avatar-dropdown', '#avatar-dropdown'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + const p3 = await profile(); + h.check(!p3.open && p3.onTrigger, `profile menu: Escape closes it and focus returns to the circle (${JSON.stringify(p3)})`); + await page.keyboard.press(' '); + await page.waitForTimeout(400); + const p4 = await profile(); + h.check(p4.open && p4.inRow, `profile menu: Space opens it too (${JSON.stringify(p4)})`); + await page.keyboard.press('Escape'); await h.closePeer(A); // ---- 6. the phone shell (Find N6 folded) --------------------------------------------------- diff --git a/tests/e2e/a11y-scale.test.cjs b/tests/e2e/a11y-scale.test.cjs index 129ea0094..8f9315b3a 100644 --- a/tests/e2e/a11y-scale.test.cjs +++ b/tests/e2e/a11y-scale.test.cjs @@ -8,6 +8,7 @@ // `@media (prefers-reduced-motion)` rule stands down — the build transform re-keyed it) and "Reduce" works on // an OS that is not; CSS motion goes instant and the camera fly-to lands in one frame. // Counterfactual (v1.39.0): no #ui-scale / #motion rows, no data-motion, a media-query-only reduced motion — red. +// fix-batch-1 #23: at 150 % on the folded N6 the Settings / Inspector / object list headers are one row. const h = require('./helpers.cjs'); /** @param {any} page */ @@ -232,6 +233,43 @@ h.run(async () => { const fdlg = await box(fp, 'dialog.settings-dialog'); const docW = await fp.evaluate(() => document.documentElement.scrollWidth); h.check(inside(fdlg, FOLD) && docW <= FOLD.width, `folded 150 %: Settings fits the screen, no sideways overflow (${JSON.stringify(fdlg)}, doc ${docW})`); + + // fix-batch-1 #23 (53-int Q4): the compact header — at 150 % on the folded N6 (a ~260 px layout) the + // header stays ONE row with the ✕ on it: Settings' close used to sit on a second row, the Inspector's + // header took three, the object list's two. One row = the header is its 48 px minimum (× 1.5) and the + // ✕'s vertical centre is inside the title's/label's box. COUNTERFACTUAL (4cd3dafc): 136 px each. + /** @param {string} root @param {string} titleSel */ + const head = (root, titleSel) => + fp.evaluate( + ([r, t]) => { + const hd = [...document.querySelectorAll(`${r} .wc-head`)].find((e) => e.getBoundingClientRect().width > 0); + if (!hd) return null; + const b = hd.getBoundingClientRect(); + const title = hd.querySelector(t)?.getBoundingClientRect(); + const x = hd.querySelector('.wc-close')?.getBoundingClientRect(); + const mid = x ? x.top + x.height / 2 : -1; + return { h: Math.round(b.height), w: Math.round(b.width), titleW: Math.round(title?.width ?? 0), sameRow: !!title && !!x && mid >= title.top && mid <= title.bottom, closeIn: !!x && x.right <= innerWidth + 1 }; + }, + [root, titleSel] + ); + /** @param {string} what @param {any} r */ + const oneRow = (what, r) => + h.check(!!r && r.h <= 48 * 1.5 + 1 && r.sameRow && r.closeIn && r.titleW >= 40, `folded 150 %: the ${what} header is one row, ✕ beside the title, on screen (${JSON.stringify(r)})`); + oneRow('Settings', await head('dialog.settings-dialog', '.wc-title')); + await closeAll(fp); + await fp.evaluate(() => { + const s = /** @type {any} */ (window).__stores; + s.inspectorKind?.set?.('scene'); + s.inspectorClose.set(false); + }); + await fp.waitForSelector('#inspector', { state: 'visible', timeout: 15000 }).catch(() => {}); + await fp.waitForTimeout(500); + oneRow('Inspector', await head('#inspector', '.wc-title')); + await closeAll(fp); + await fp.evaluate(() => /** @type {any} */ (window).__stores.objectListClose.set(false)); + await fp.waitForTimeout(900); + oneRow('object list', await head('#object-list', '.wc-label')); + await fp.evaluate(() => /** @type {any} */ (window).__stores.objectListClose.set(true)); h.check(h.pageErrors(F).length === 0, `folded: no page errors (${JSON.stringify(h.pageErrors(F)).slice(0, 200)})`); await h.finish(browser); }); diff --git a/tests/e2e/keyboard-walk.test.cjs b/tests/e2e/keyboard-walk.test.cjs index 59aec2c20..a396a2b32 100644 --- a/tests/e2e/keyboard-walk.test.cjs +++ b/tests/e2e/keyboard-walk.test.cjs @@ -69,7 +69,7 @@ h.run(async () => { const order = []; let ringless = []; // walk until the toolbar, then a few stops past it (a cloud build has more top-bar stops: rooms, - // sign-in and the links inside the profile panel, which opens on focus) — no fixed window + // sign-in; since fix-batch-1 #23 the profile panel no longer opens on focus) — no fixed window let afterToolbar = -1; for (let i = 0; i < 40 && afterToolbar < 6; i++) { await press(page, 'Tab'); From 677d36934c5744115e21427bf801598c62170f70 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 18:47:19 +0300 Subject: [PATCH 3/8] [flow] draw the sockets the runtime reads: Set Game State, Set Variable, HUD Rows, HUD Set Input; HUD Timer honours start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrator add (docs-1 Q1). flowSockets typed these inputs and the runtime read them, but the cards drew no socket, so they could not be wired by hand (hudActions' own 'trigger' wires had nowhere to land): Set Game State (trigger) and Set Variable (trigger, value) gain catalog `inputs`; HudNode draws HUD Rows (trigger, text) and HUD Set Input (trigger, value, options). HUD Timer drew `start` but triggerStampFor only counted an unnamed/`trigger` wire — it now takes `start` as an alias. The audit (scripts/graph-audit.cjs) gains an undrawn-input guard for the two generic cards; it found Device Param (value) and Note Trigger (trigger, note, velocity) with the same gap, fixed here too. Unit guard red on 257e8f6a with exactly those six; e2e flow-sockets-wire wires each by mouse and checks the effect (11 red on 257e8f6a). Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/graph-audit.cjs | 41 ++++- src/components/editors/nodes/HudNode.svelte | 6 +- src/lib/flowRuntime.js | 11 +- src/lib/nodeCatalog.js | 14 +- tests/e2e/flow-sockets-wire.test.cjs | 185 ++++++++++++++++++++ tests/unit/nodeCatalogAudit.test.js | 12 ++ 6 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/flow-sockets-wire.test.cjs diff --git a/scripts/graph-audit.cjs b/scripts/graph-audit.cjs index ee7ba8318..e81acc140 100644 --- a/scripts/graph-audit.cjs +++ b/scripts/graph-audit.cjs @@ -24,7 +24,7 @@ const path = require('node:path'); const ROOT = path.join(__dirname, '..'); const read = (/** @type {string} */ p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); -/** @returns {{type: string, group: string, label: string, inputs: string[], params: string[], note: boolean}[]} */ +/** @returns {{type: string, group: string, label: string, inputs: string[], hasInputs: boolean, params: string[], ranges: string[], note: boolean}[]} */ function catalogTypes() { const src = read('src/lib/nodeCatalog.js'); const out = []; @@ -53,7 +53,8 @@ function catalogTypes() { .map((s) => s.trim().replace(/'/g, '')) .filter(Boolean); const params = [...body.matchAll(/key: '([^']+)'/g)].map((m) => m[1]); - out.push({ type: t[1], group, label, inputs, params, note: /\bnote:/.test(body) }); + const ranges = [...body.matchAll(/key: '([^']+)', kind: 'range'/g)].map((m) => m[1]); + out.push({ type: t[1], group, label, inputs, hasInputs: /\binputs: \[/.test(body), params, ranges, note: /\bnote:/.test(body) }); } // a type spelled twice (inside an options literal etc.) keeps its first row const seen = new Set(); @@ -82,6 +83,34 @@ function outputTable() { return Object.fromEntries([...block.matchAll(/([a-z0-9_-]+): '([a-z0-9]+)'/g)].map((m) => [m[1], m[2]])); } +/** flowSockets' INPUT table: type -> the handles the runtime types (and so reads) */ +function inputTable() { + const src = read('src/lib/flowSockets.js'); + const a = src.indexOf('const INPUT = {'); + const block = src.slice(a, src.indexOf('\n};', a)); + return Object.fromEntries([...block.matchAll(/^\t'?([a-z0-9_-]+)'?: \{([^}]*)\}/gm)].map((m) => [m[1], [...m[2].matchAll(/'?([A-Za-z0-9_]+)'?:/g)].map((k) => k[1])])); +} + +/** HudNode's own INPUTS map: type -> the sockets that card draws */ +function hudCardInputs() { + const src = read('src/components/editors/nodes/HudNode.svelte'); + const a = src.indexOf('const INPUTS'); + const block = src.slice(a, src.indexOf('};', a)); + return Object.fromEntries([...block.matchAll(/^\t\t([a-z0-9_-]+): \[([^\]]*)\]/gm)].map((m) => [m[1], [...m[2].matchAll(/'([^']+)'/g)].map((k) => k[1])])); +} + +/** + * fix-batch-1 (docs-1 Q1): the sockets a GENERIC card draws — AnimationNode draws the spec's + * `inputs` (+ a socket per range param when it has them) or, with no `inputs`, one per range + * param; HudNode draws its INPUTS map. null = a card of its own (it draws what it draws). + * @param {any} t @param {string} card @param {Record} hud + */ +function drawnInputs(t, card, hud) { + if (card === 'AnimationNode') return t.hasInputs ? [...t.inputs, ...t.ranges] : t.ranges; + if (card === 'HudNode') return hud[t.type] ?? []; + return null; +} + function docKeys() { const src = read('src/lib/nodeDocs.js'); return new Set([...src.matchAll(/^\t'?([a-z0-9_-]+)'?:/gm)].map((m) => m[1])); @@ -93,8 +122,10 @@ function catalogReport() { const { cases, valueTypes } = evaluatorInfo(); const outs = outputTable(); const docs = docKeys(); + const ins = inputTable(); + const hud = hudCardInputs(); const rows = []; - const flags = { noCard: [], valueNoEval: [], evalNotListed: [], noDoc: [], noOutType: [] }; + const flags = { noCard: [], valueNoEval: [], evalNotListed: [], noDoc: [], noOutType: [], undrawnInput: [] }; for (const t of types) { const f = []; if (!cards[t.type]) (f.push('NO CARD'), flags.noCard.push(t.type)); @@ -103,6 +134,10 @@ function catalogReport() { (f.push('evaluated but not a listed value source'), flags.evalNotListed.push(t.type)); if (!docs.has(t.type)) (f.push('no doc'), flags.noDoc.push(t.type)); if (!outs[t.type]) flags.noOutType.push(t.type); + // a handle the runtime types (and reads) that the card never draws: unwirable by hand + const drawn = drawnInputs(t, cards[t.type], hud); + const missing = drawn ? (ins[t.type] ?? []).filter((h) => !drawn.includes(h)) : []; + if (missing.length) (f.push('UNDRAWN INPUT ' + missing.join(',')), flags.undrawnInput.push(t.type + ':' + missing.join(','))); rows.push( `| ${t.type} | ${t.group} | ${cards[t.type] ?? '—'} | ${outs[t.type] ?? '—'} | ${t.inputs.join(' ') || '—'} | ${t.params.join(' ') || '—'} | ${cases.has(t.type) ? 'yes' : '—'} | ${f.join('; ') || 'OK'} |` ); diff --git a/src/components/editors/nodes/HudNode.svelte b/src/components/editors/nodes/HudNode.svelte index fa6244e2e..acd79d154 100644 --- a/src/components/editors/nodes/HudNode.svelte +++ b/src/components/editors/nodes/HudNode.svelte @@ -47,7 +47,11 @@ hudtimer: ['start', 'duration'], hudscreen: ['trigger'], hudlist: ['trigger'], - hudbutton: [] + hudbutton: [], + // fix-batch-1 (docs-1 Q1): the runtime read these (flowSockets types them) but the card + // drew no socket, so neither node could be wired by hand + hudrows: ['trigger', 'text'], + hudset: ['trigger', 'value', 'options'] }; $: inputs = INPUTS[data.type] ?? []; $: wiredSource = (key: string) => diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index c1b892ee1..53251109f 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1218,7 +1218,9 @@ function updateHudRuntime(time, ctx, now) { * @param {any} node @param {any} data @param {number} time @param {any} ctx */ function hudTimerRemaining(node, data, time, ctx) { const duration = Math.max(0, num(data.duration)); - const stamp = triggerStampFor(node.id, ctx); + // fix-batch-1 (docs-1 Q1): the card's socket is `start` (flowSockets types it), and only an + // unnamed / `trigger` wire counted — a Start wired by hand was ignored + const stamp = triggerStampFor(node.id, ctx, 'start'); if (stamp === null) return data.autostart === false ? duration : Math.max(0, duration - time); // a synced stamp can sit AHEAD of `time` by a frame; clamp both ends return Math.max(0, Math.min(duration, duration - (time - stamp))); @@ -2081,12 +2083,13 @@ function updateDerivedPulses(ctx, fired = scheduledFired) { } /** The shared timestamp of whatever event is wired into this node's `trigger` - * (the newest, with several sources fanned in). @param {string} nodeId @param {any} ctx */ -function triggerStampFor(nodeId, ctx) { + * (the newest, with several sources fanned in). `alias` = a second handle name that counts as + * the trigger (HUD Timer's `start`). @param {string} nodeId @param {any} ctx @param {string} [alias] */ +function triggerStampFor(nodeId, ctx, alias = '') { if (!ctx?.triggers) return null; let newest = null; for (const edge of edgesInto(edges, nodeId)) { - if (edge.targetHandle && edge.targetHandle !== 'trigger') continue; + if (edge.targetHandle && edge.targetHandle !== 'trigger' && edge.targetHandle !== alias) continue; // 21-E4: a Delay/Sequence source has NO entry in the trigger log - its stamp is // derived - so reading the log directly here left those nodes unable to drive // anything that acts on a trigger edge (hudscreen, setgamestate, playanim). diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 006d7cce6..b7786d536 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -154,6 +154,10 @@ export const nodeCatalog = [ type: 'setgamestate', label: 'Set Game State', defaults: { state: 'playing', outcome: '' }, + // fix-batch-1 (docs-1 Q1): the runtime always read a `trigger` wire (flowSockets + // types it, hudActions wires it) but the card drew no socket, so it could not be + // wired by hand and a built binding's wire had nowhere to land + inputs: ['trigger'], params: [ { key: 'state', kind: 'select', options: [...GAME_STATES] }, { key: 'outcome', kind: 'text', placeholder: 'won / lost', maxLength: 40 }, @@ -243,6 +247,9 @@ export const nodeCatalog = [ type: 'setvariable', label: 'Set Variable', defaults: { name: 'score', value: 0, op: 'set', scope: 'shared' }, + // fix-batch-1 (docs-1 Q1): sockets for what the runtime reads (the setgamestate note) + inputs: ['trigger', 'value'], + inputLabels: { value: 'value — wire a number (else the value below)' }, params: [ { key: 'name', kind: 'text', placeholder: 'score', maxLength: 40 }, { key: 'op', kind: 'select', options: ['set', 'add', 'subtract'] }, @@ -1353,14 +1360,17 @@ export const nodeCatalog = [ // writes ONE param of the device this graph belongs to (or the object a Selector // names) — LOCAL per peer, the setcolor rule: the value already arrives through the // replicated graph, so it is never broadcast again - { type: 'deviceparam', label: 'Device Param', defaults: { key: 'level', value: 0 }, params: [{ key: 'key', kind: 'text', placeholder: 'param key (level, freq, ...)', maxLength: 40 }] }, + // fix-batch-1: `value` is what the runtime writes (flowSockets types it) and had no + // socket — found by the audit's undrawn-input guard, the docs-1 Q1 class + { type: 'deviceparam', label: 'Device Param', defaults: { key: 'level', value: 0 }, inputs: ['value'], inputLabels: { value: 'value — wire an LFO, a slider' }, params: [{ key: 'key', kind: 'text', placeholder: 'param key (level, freq, ...)', maxLength: 40 }] }, // the device's live output level, 0..1 — feeds core nodes and the HUD { type: 'devicelevel', label: 'Device Level', defaults: {} }, // the shared transport as a number: beat, bar, phase (0..1 of the loop), bpm, playing { type: 'transportbeat', label: 'Transport', defaults: { read: 'beat' }, params: [{ key: 'read', kind: 'select', options: ['beat', 'bar', 'phase', 'bpm', 'playing', 'loopBeats'] }] }, // a note-on per trigger pulse: the pulse is already the replicated event, so every // peer synthesizes the note itself (once per pulse per peer) - { type: 'notetrigger', label: 'Note Trigger', defaults: { note: 60, velocity: 0.9 } } + // fix-batch-1: it drew NO socket, so a note could not be triggered by hand (the audit guard) + { type: 'notetrigger', label: 'Note Trigger', defaults: { note: 60, velocity: 0.9 }, inputs: ['trigger', 'note', 'velocity'] } ] }, // 34 R2 (T3): the game kit — one group per piece, GENERATED from the piece specs diff --git a/tests/e2e/flow-sockets-wire.test.cjs b/tests/e2e/flow-sockets-wire.test.cjs new file mode 100644 index 000000000..ef2db5f9a --- /dev/null +++ b/tests/e2e/flow-sockets-wire.test.cjs @@ -0,0 +1,185 @@ +// fix-batch-1 (docs-1 Q1): inputs the runtime reads but the card drew no socket for — Set Game State +// (trigger), Set Variable (trigger, value), HUD Rows (trigger, text), HUD Set Input (trigger, value, +// options), and HUD Timer, whose `start` socket was drawn but its pulse ignored. Each is WIRED BY HAND +// here (a real mouse drag from the source dot to the target dot in the node editor), then the source +// fires and the input must take effect. Device Param (value) and Note Trigger (trigger, note, +// velocity) are the same class found by the audit guard: their dots must be drawn. +// COUNTERFACTUAL (257e8f6a): no target dot to drop on, so no edge; the timer never starts. +// Run: APP_URL=https://theprototype.app:5591/ node tests/e2e/flow-sockets-wire.test.cjs +const h = require('./helpers.cjs'); + +const source = (node) => + `.svelteFlow .svelte-flow__handle.source[data-nodeid="${node}"]:not([data-handleid])`; +const N = (id, type, x, y, data = {}) => ({ + id, + type, + position: { x, y }, + data: { type, label: id, ...data }, + class: 'w-[150px]' +}); + +/** drag from one handle to another with the mouse — the gesture a person makes */ +async function wire(page, fromSel, toSel) { + const a = await page.locator(fromSel).first().boundingBox(); + const b = await page.locator(toSel).first().boundingBox(); + if (!a || !b) return false; + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move((a.x + b.x) / 2, (a.y + b.y) / 2, { steps: 6 }); + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 6 }); + await page.mouse.up(); + await page.waitForTimeout(400); + return true; +} + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { + context: { viewport: { width: 1440, height: 900 } } + }); + const p = A.page; + await p.locator('p[title="Node editor (N)"]').click(); + await p.waitForTimeout(1500); + + /** a fresh scene graph: an event source, an optional Number (7), and the node under test. The + * source gets a NEW id per round: the trigger log keeps an old source's stamp, and a timer wired + * to a source that already fired starts from that stamp (right, but not what round 5 measures) */ + let round = 0; + let SRC = 'src0'; + const load = async (target, data, withValue) => { + SRC = 'src' + ++round; + const nodes = [N(SRC, 'onclick', 0, 0), N('dut', target, 360, 0, data)]; + if (withValue) nodes.push(N('num', 'number', 0, 160, { value: 7, step: 1 })); + await p.evaluate((doc) => window.__stores.restoreGraphs(doc), { scene: { nodes, edges: [] } }); + await p.waitForTimeout(900); + }; + const edgeInto = (h2) => + p.evaluate( + (th) => + window.__stores.allEdges().some((e) => e.target === 'dut' && (e.targetHandle ?? '') === th), + h2 + ); + const drawn = (id) => + p.locator(`.svelteFlow .svelte-flow__handle[data-nodeid="dut"][data-handleid="${id}"]`).count(); + const fire = () => + p.evaluate( + (id) => + window.__stores.flowRuntime.applyNodeTrigger(id, (Date.now() % 86400000) / 1000, true), + SRC + ); + /** wire `from` into the node's `id` socket by hand and say whether the edge landed there */ + const byHand = async (from, id) => { + const n = await drawn(id); + if (n) + await wire( + p, + from, + `.svelteFlow .svelte-flow__handle[data-nodeid="dut"][data-handleid="${id}"]` + ); + return { drawn: n > 0, edge: await edgeInto(id) }; + }; + + // ---- 1. Set Game State: trigger + await p.evaluate(() => window.__stores.gameState.setGameState('menu')); + await load('setgamestate', { state: 'over', outcome: '' }, false); + const g1 = await byHand(source(SRC), 'trigger'); + h.check( + g1.drawn && g1.edge, + `Set Game State: the trigger socket is drawn and takes a wire by hand (${JSON.stringify(g1)})` + ); + await fire(); + await p.waitForTimeout(600); + const st = await p.evaluate( + () => new Promise((r) => window.__stores.gameState.gameState.subscribe((v) => r(v.state))()) + ); + h.check(st === 'over', `…and the pulse sets the game state (${st})`); + await p.evaluate(() => window.__stores.gameState.setGameState('menu')); + + // ---- 2. Set Variable: trigger + value + await load('setvariable', { name: 'fb1', op: 'set', value: 0, scope: 'shared' }, true); + const v1 = await byHand(source(SRC), 'trigger'); + const v2 = await byHand(source('num'), 'value'); + h.check( + v1.drawn && v1.edge && v2.drawn && v2.edge, + `Set Variable: trigger and value sockets take wires by hand (${JSON.stringify([v1, v2])})` + ); + await fire(); + await p.waitForTimeout(600); + const fb1 = await p.evaluate( + () => new Promise((r) => window.__stores.gameState.gameState.subscribe((v) => r(v.vars?.fb1))()) + ); + h.check(fb1 === 7, `…and the pulse writes the WIRED value, 7 (${fb1})`); + + // ---- 3. HUD Rows: trigger + text + await load('hudrows', { element: 'fb1list', op: 'append', text: '' }, true); + const r1 = await byHand(source(SRC), 'trigger'); + const r2 = await byHand(source('num'), 'text'); + h.check( + r1.drawn && r1.edge && r2.drawn && r2.edge, + `HUD Rows: trigger and text sockets take wires by hand (${JSON.stringify([r1, r2])})` + ); + await fire(); + await p.waitForTimeout(600); + const rows = await p.evaluate(() => window.__stores.flowRuntime.hudRowsOf('fb1list')); + h.check( + Array.isArray(rows) && rows.map(String).includes('7'), + `…and the pulse appends the wired row (${JSON.stringify(rows)})` + ); + + // ---- 4. HUD Set Input: trigger + value (+ options drawn) + await load('hudset', { element: 'fb1slider', value: 0 }, true); + const s1 = await byHand(source(SRC), 'trigger'); + const s2 = await byHand(source('num'), 'value'); + const sOpt = (await drawn('options')) > 0; + h.check( + s1.drawn && s1.edge && s2.drawn && s2.edge && sOpt, + `HUD Set Input: trigger, value (and options) sockets are drawn, wired by hand (${JSON.stringify([s1, s2, sOpt])})` + ); + await fire(); + await p.waitForTimeout(600); + const sv = await p.evaluate(() => window.__stores.hudDocs.hudValueOf('fb1slider')); + h.check(Number(sv) === 7, `…and the pulse moves the control to the wired value (${sv})`); + + // ---- 5. HUD Timer: the drawn `start` socket now starts it + await load( + 'hudtimer', + { element: 'fb1timer', duration: 60, format: '{v}', decimals: 0, autostart: false }, + false + ); + const t1 = await byHand(source(SRC), 'start'); + const before = await p.evaluate( + () => + new Promise((r) => window.__stores.hudDocs.hudRuntime.subscribe((v) => r(v.fb1timer?.text))()) + ); + await fire(); + await p.waitForTimeout(2600); + const after = await p.evaluate( + () => + new Promise((r) => window.__stores.hudDocs.hudRuntime.subscribe((v) => r(v.fb1timer?.text))()) + ); + h.check( + t1.drawn && t1.edge && before === '60', + `HUD Timer: start wired by hand, the timer waits at 60 (${JSON.stringify(t1)}, ${before})` + ); + h.check( + Number(after) < 60 && Number(after) >= 55, + `…and a pulse into start counts it down (${before} -> ${after})` + ); + + // ---- 6. the audit's two: their sockets are drawn + await load('notetrigger', { note: 60, velocity: 0.9 }, false); + const nt = await Promise.all(['trigger', 'note', 'velocity'].map(drawn)); + h.check( + nt.every((n) => n > 0), + `Note Trigger draws trigger, note and velocity sockets (${nt})` + ); + await load('deviceparam', { key: 'level', value: 0 }, false); + const dp = await drawn('value'); + h.check(dp > 0, `Device Param draws its value socket (${dp})`); + + h.check( + h.pageErrors(A).length === 0, + `no page errors (${JSON.stringify(h.pageErrors(A)).slice(0, 200)})` + ); + await h.finish(browser); +}); diff --git a/tests/unit/nodeCatalogAudit.test.js b/tests/unit/nodeCatalogAudit.test.js index 3b19da541..e29b02f85 100644 --- a/tests/unit/nodeCatalogAudit.test.js +++ b/tests/unit/nodeCatalogAudit.test.js @@ -23,4 +23,16 @@ describe('node catalog audit', () => { test('every palette type has a doc line', () => { expect(flags.noDoc).toEqual([]); }); + // fix-batch-1 (docs-1 Q1): a handle the runtime types in flowSockets' INPUT table (so reads) + // that a generic card never draws cannot be wired by hand — Set Game State, Set Variable, + // HUD Rows, HUD Set Input, Device Param and Note Trigger all shipped like that + test('every input the runtime reads is drawn on a generic card', () => { + expect(flags.undrawnInput).toEqual([]); + }); + test('the audit sees the sockets the generic cards draw', () => { + const t = (/** @type {string} */ type) => types.find((r) => r.type === type); + expect(t('setgamestate')?.inputs).toEqual(['trigger']); + expect(t('setvariable')?.inputs).toEqual(['trigger', 'value']); + expect(t('hudtimer')?.ranges).toContain('duration'); + }); }); From 10111ec37244b8bb511a0a5dbbb1e30fa394e2d1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 08:48:30 +0200 Subject: [PATCH 4/8] [flow] Rotate / Motor spin-up and HUD Timer autostart count from their start, not the clock's origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs-1 CORE BUGS (verified): the rotor's ramp and an autostart HUD Timer both measured from t = 0 of the clock they are handed, and the synced clock is seconds since UTC midnight — every wheel was past its spin-up from the first frame (full speed at once) and an autostart timer read 0 the moment it appeared. The rotor now counts from the moment this peer saw it switch on (off -> rests and spins up again from rest); an autostart timer from when this peer first saw it. Both re-base when the clock goes back (midnight, a session clock adopted on join) instead of freezing. e2e flow-clock-origin: 4 red on 4cd3dafc (rotor 4.70 of 5.22 rad/s in its first half-second; timer 0), branch: 0.55 vs 4.67 rad/s, timer 59 -> 55. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/flowRuntime.js | 17 +++- src/lib/sim/motionNodes.js | 23 +++++- tests/e2e/flow-clock-origin.test.cjs | 118 +++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/flow-clock-origin.test.cjs diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 53251109f..e97d80273 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1211,17 +1211,28 @@ function updateHudRuntime(time, ctx, now) { hudRuntime.set(next); } +/** fix-batch-1: node id -> the clock time an autostart HUD Timer was first seen @type {Map} */ +const hudTimerSeen = new Map(); + /** Seconds left on a HUD Timer. DERIVED from the shared trigger stamp, so every peer * reads the same number with no clock and no message of its own — the same reasoning - * as a looping sound's phase. An `autostart` timer with nothing wired counts from the - * runtime's own clock origin. + * as a looping sound's phase. An `autostart` timer with nothing wired counts from when THIS + * peer first saw the node (fix-batch-1, docs-1: it counted from the clock's origin, and the + * synced clock is seconds since midnight, so an autostart timer always read 0). That one is + * per peer — a timer everyone must agree on is wired to a shared pulse (`start`). * @param {any} node @param {any} data @param {number} time @param {any} ctx */ function hudTimerRemaining(node, data, time, ctx) { const duration = Math.max(0, num(data.duration)); // fix-batch-1 (docs-1 Q1): the card's socket is `start` (flowSockets types it), and only an // unnamed / `trigger` wire counted — a Start wired by hand was ignored const stamp = triggerStampFor(node.id, ctx, 'start'); - if (stamp === null) return data.autostart === false ? duration : Math.max(0, duration - time); + if (stamp === null) { + if (data.autostart === false) return duration; + const seen = hudTimerSeen.get(node.id); + // a clock that went BACK (midnight, a session clock adopted on join) re-bases + if (seen === undefined || time < seen) hudTimerSeen.set(node.id, time); + return Math.max(0, duration - (time - /** @type {number} */ (hudTimerSeen.get(node.id)))); + } // a synced stamp can sit AHEAD of `time` by a frame; clamp both ends return Math.max(0, Math.min(duration, duration - (time - stamp))); } diff --git a/src/lib/sim/motionNodes.js b/src/lib/sim/motionNodes.js index 63c41430b..a4f9d1bdd 100644 --- a/src/lib/sim/motionNodes.js +++ b/src/lib/sim/motionNodes.js @@ -34,8 +34,8 @@ const vPivot = new THREE.Vector3(); const radPerSec = (rpm) => (rpm * Math.PI * 2) / 60; /** - * The rotor's angle at `time` (s on the shared, pause-folded clock): a constant speed after a - * linear spin-up of `spinUp` seconds from t = 0 (continuous in angle and speed). + * The rotor's angle `time` seconds after it switched on: a constant speed after a linear + * spin-up of `spinUp` seconds from t = 0 (continuous in angle and speed). * @param {number} rpm @param {number} spinUp @param {number} time */ export function rotorAngle(rpm, spinUp, time) { @@ -58,8 +58,19 @@ export function applyRotor(object, base, data, time, ctx) { if (on) driveBody(object, axis, rpm, +data.torque || 0, ctx); return; } - if (!on) return; // rests at its base pose - const angle = rotorAngle(rpm, Math.max(0, +data.spinUp || 0), time); + if (!on) { + rotorOnSince.delete(ctx.key); // rests at its base pose, and spins up again from rest + return; + } + // fix-batch-1 (docs-1): the ramp counted from the CLOCK's origin, and the synced clock is + // seconds since midnight, so every wheel was already past its spin-up and turned at full speed + // from the first frame. It counts from the moment this peer saw it switch on (or appear); a peer + // joining later sees its own spin-up from rest, the price of a phase no message carries. + // a clock that went BACK (the synced clock wraps at midnight; joining a session swaps the + // local clock for the session's) re-bases rather than freezing the wheel until it catches up + const since = rotorOnSince.get(ctx.key); + if (since === undefined || time < since) rotorOnSince.set(ctx.key, time); + const angle = rotorAngle(rpm, Math.max(0, +data.spinUp || 0), time - /** @type {number} */ (rotorOnSince.get(ctx.key))); qBase.setFromEuler(eul.set(base.rot[0], base.rot[1], base.rot[2])); qSpin.setFromAxisAngle(axis, angle); object.quaternion.copy(qBase).multiply(qSpin); @@ -72,6 +83,9 @@ export function applyRotor(object, base, data, time, ctx) { } } +/** @type {Map} per rotor (node|object): the clock time it switched on */ +const rotorOnSince = new Map(); + /** @type {Map} the last frame time per drive (dt for the torque cap) */ const lastDrive = new Map(); @@ -109,6 +123,7 @@ function driveBody(object, axis, rpm, torque, ctx) { /** TEST: forget the drive clocks */ export function resetMotionNodes() { lastDrive.clear(); + rotorOnSince.clear(); floatCache.clear(); } diff --git a/tests/e2e/flow-clock-origin.test.cjs b/tests/e2e/flow-clock-origin.test.cjs new file mode 100644 index 000000000..8cd62a7b4 --- /dev/null +++ b/tests/e2e/flow-clock-origin.test.cjs @@ -0,0 +1,118 @@ +// fix-batch-1 (docs-1 CORE BUGS): two nodes counted from the CLOCK's origin instead of from when they +// started — and the synced clock is seconds since midnight, so the start was always long past: +// · Rotate / Motor's spin-up ramp: every wheel turned at full speed from the first frame; +// · HUD Timer with Autostart: `duration - time` read 0 the moment it appeared. +// Measured: a 60 rpm rotor with a 3 s spin-up turns well under its post-ramp speed in its first +// half-second; an autostart 60 s timer starts near 60 and counts down. +// COUNTERFACTUAL (4cd3dafc): the rotor is at full speed at once; the timer reads 0. +// Run: APP_URL=https://theprototype.app:5591/ node tests/e2e/flow-clock-origin.test.cjs +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const p = A.page; + + const uuid = await p.evaluate(() => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create Box 1 1 1'); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const box = g.children[g.children.length - 1]; + box.name = 'Wheel'; + window.__wheel = box; + return box.uuid; + }); + await p.waitForTimeout(600); + + /** the wheel's angle about +y (rad, unwrapped by the caller) */ + const angle = () => + p.evaluate(() => { + const q = window.__wheel.quaternion; + return 2 * Math.atan2(q.y, q.w); + }); + /** angular speed over `ms`, unwrapped for one turn at most */ + const speed = async (ms) => { + const a = await angle(); + const t0 = Date.now(); + await p.waitForTimeout(ms); + const b = await angle(); + const dt = (Date.now() - t0) / 1000; + let d = b - a; + while (d < -Math.PI) d += 2 * Math.PI; + while (d > Math.PI) d -= 2 * Math.PI; + return Math.abs(d) / dt; + }; + const FULL = 2 * Math.PI; // 60 rpm in rad/s + + await p.evaluate((u) => { + const s = window.__stores; + s.flowNodes.set([ + { + id: 'rot', + type: 'rotor', + position: { x: 0, y: 0 }, + data: { label: 'Rotate / Motor', axis: 'y', rpm: 60, torque: 50, spinUp: 3, on: true } + }, + { + id: 'sel', + type: 'objectselector', + position: { x: 300, y: 0 }, + data: { label: 'Object', selected: u } + }, + { + id: 'tmr', + type: 'hudtimer', + position: { x: 0, y: 200 }, + data: { + label: 'HUD Timer', + element: 'fb1auto', + duration: 60, + format: '{v}', + decimals: 0, + autostart: true + } + } + ]); + s.flowEdges.set([{ id: 'e-rot-sel', source: 'rot', target: 'sel' }]); + }, uuid); + await p.waitForTimeout(150); + const early = await speed(350); + const timerEarly = await p.evaluate( + () => + new Promise((r) => window.__stores.hudDocs.hudRuntime.subscribe((v) => r(v.fb1auto?.text))()) + ); + await p.waitForTimeout(3200); + const late = await speed(400); + const timerLate = await p.evaluate( + () => + new Promise((r) => window.__stores.hudDocs.hudRuntime.subscribe((v) => r(v.fb1auto?.text))()) + ); + console.log('rotor', JSON.stringify({ early, late, full: FULL }), 'timer', timerEarly, timerLate); + + // the page samples the pose per FRAME while the window is wall-clock (round-trips included), so an + // absolute rad/s reads ~20 % low on a headless page — the claims are a RATIO against the same + // wheel after its ramp, plus a floor that a stopped or crawling wheel cannot pass + h.check( + late > 0.6 * FULL, + `premise: after the 3 s ramp the wheel turns near 60 rpm (${late.toFixed(2)} of ${FULL.toFixed(2)} rad/s)` + ); + h.check( + early < 0.4 * late, + `Rotate / Motor spins UP: its first half-second is well under its speed after the ramp (${early.toFixed(2)} vs ${late.toFixed(2)} rad/s)` + ); + h.check( + Number(timerEarly) >= 58 && Number(timerEarly) <= 60, + `HUD Timer with Autostart starts at its duration, not 0 (${timerEarly})` + ); + h.check( + Number(timerLate) < Number(timerEarly) && Number(timerLate) >= 54, + `…and counts down (${timerEarly} -> ${timerLate})` + ); + + h.check( + h.pageErrors(A).length === 0, + `no page errors (${JSON.stringify(h.pageErrors(A)).slice(0, 200)})` + ); + await h.finish(browser); +}); From f42422de5e643bc2f86907f33b70b14efa4d7ac5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 08:50:25 +0200 Subject: [PATCH 5/8] [docs] correct thirteen one-line node descriptions (docs-1) Each checked against the node's catalog entry and runtime before rewriting: Sequence fires all four steps from one pulse (not one per pulse, no wrap); Move Input is WASD x/y only (no stick, no jump); All Players is a group gate, not an iterator; Character Controller sets the player's own movement model; Move Speed writes on `set`; Camera Follow has no distance/height; Gamepad Axis is the two sticks (L2/R2 are buttons); HUD Screen acts per pulse (show / hide / toggle); HUD List names the element, title and row cap; HUD Rows and Leaderboard write a HUD List element; HUD Timer a HUD Timer element; HUD Input also reads sliders, toggles, dropdowns and tabs. These lines feed the palette tooltip, the info pane and the docs-site node pages. Unit guard tests/unit/nodeDocsClaims.test.js: 6/6 red on 4cd3dafc. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/nodeDocs.js | 26 ++++++++++----------- tests/unit/nodeDocsClaims.test.js | 39 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 tests/unit/nodeDocsClaims.test.js diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index 91f3f156a..8dc0d289f 100644 --- a/src/lib/nodeDocs.js +++ b/src/lib/nodeDocs.js @@ -37,7 +37,7 @@ export const NODE_DOCS = { setlook: "Switches a camera's look (its post-processing) on or off when a pulse arrives, and by default looks through that camera too.", gamestart: "Fires once when the game starts (Play, or a round reset) - the place to spawn, reset counters and arm timers.", travel: "Loads another saved scene by name when a pulse arrives - a door to the next level, replicated to everyone.", - allplayers: "Iterates the connected players so a per-player value can be read, or a HUD row shown, for each of them.", + allplayers: "A group gate: every player in Play answers its condition for themselves, and it fires once when all of them say yes - travel together, start when everyone is ready.", setvariable: "Stores a named value in the replicated scene variables when a pulse arrives - shared state without an object.", getvariable: "Reads a named scene variable as a value - the read side of Set Variable, live on every peer.", storevalue: "Saves a value on THIS device when a pulse arrives (set, keep the max or min, or add) - a best score or a level reached that survives a reload; never sent to other players.", @@ -53,20 +53,20 @@ export const NODE_DOCS = { onclap: "Fires a pulse when a VR player brings both hands together and holds them - with where they met (point) and whether it was you (byMe); wire 'enabled' to a setting to switch it.", gametime: "Seconds since the game (or the round) started, the same on every peer - a clock that pauses with the game.", peervariable: "A value kept PER PLAYER (score, lives, team) - each peer reads its own row, or a named player's.", - charcontroller: "Turns the connected object into a walking, jumping character driven by the player's movement input.", - moveinput: "The player's movement input as numbers - stick or WASD axes and the jump button - for driving things by hand.", + charcontroller: "Sets how the player moves in Play - fly or walk, speed, jump and eye height, gravity - driving the player's own view rather than an object (the newest one in the scene wins).", + moveinput: "This player's WASD keys as two numbers, x (A/D) and y (W/S), each -1 to 1 - read on every device for its own player.", possessnode: "Puts the player in control of the connected object when a pulse arrives - a vehicle, a turret, a ball.", - camerafollow: "Makes the camera follow the connected object at a set distance and height - a third-person or chase view.", - movespeed: "Overrides the character's walking speed while a value is wired - sprint, mud, slow motion.", - hudscreen: "Shows or hides a whole HUD screen (a named layer of elements) while its condition holds.", + camerafollow: "On a pulse, the camera flies behind the target object and follows it until stop is pulsed - for an object something else moves (the sim, a clip).", + movespeed: "On a pulse into set, writes the player's movement speed - sprint, mud, slow motion.", + hudscreen: "Shows, hides or toggles a whole HUD screen (a named layer of elements) on each pulse.", hudtext: "Writes a number into a HUD text element through a format such as 'Gems: {v}' - a score, a count, a readout.", - hudtimer: "Runs a countdown in a HUD text element from its duration, formatted like '{v}s' - starts by itself or on a pulse.", + hudtimer: "Counts down in a HUD Timer element from its duration, formatted like '{v}s' - by itself (Autostart) or from a pulse into start.", hudbar: "Drives a HUD bar element's fill from a number - health, fuel, progress.", hudbutton: "Fires a pulse when a HUD button is pressed - the on-screen counterpart of On Click.", - hudlist: "Fills a HUD list element with the items a value provides - an inventory, objectives, players.", - leaderboard: "Ranks the players by their own per-player variable (laps, score) into a HUD element, highest or lowest first, top N rows.", - hudrows: "Appends or replaces rows in a HUD rows element from a pulse - a log, a chat, a scoreboard feed.", - hudinput: "Reads what the player typed into a HUD input element as a value.", + hudlist: "Names a HUD List element and sets its title and how many rows it shows; HUD Rows and Leaderboard fill it.", + leaderboard: "Ranks the players by their own per-player variable (laps, score) into a HUD List element, highest or lowest first, top N rows.", + hudrows: "Sets, appends or clears the rows of a HUD List element on each pulse - a log, a chat, a scoreboard feed.", + hudinput: "Reads a HUD control as a value - a slider's number, a toggle's on/off, a dropdown's or tabs' choice, a text field's text.", hudset: "Writes a value into a HUD input element when a pulse arrives - to prefill, clear or correct it.", script: "Your own JavaScript - an effect that moves its object every frame, or (with declared typed inputs and outputs) a small pure function whose return feeds other nodes. v2 scripts also get `api`: object(uuid or name), raycast(from, dir), and in effects keys() (this device only) and spawn('/create ...') (the host spawns once for everyone).", math: "Combines numbers (+ - * / min max mod pow) or shapes one: sin/cos (b scales them, 0 = 1), abs, round, floor, clamp to 0..b, negate. + input adds sockets (up to 8) for + - * / min max.", @@ -76,7 +76,7 @@ export const NODE_DOCS = { select: "Picks one of up to four values (a, b, c, d) by a rounded index.", latch: "Holds a boolean until told otherwise - set, reset and toggle pulses make it a memory bit.", delay: "Passes a pulse on after a set number of seconds - the timing of a fuse, or a door that closes later.", - sequence: "Fires its outputs one after another, one per pulse, then wraps around - a step-by-step trigger.", + sequence: "One pulse in, four pulses out in order - step1 to step4, each after its own wait (the waits add up from the trigger).", once: "Lets exactly one pulse through until it is re-armed - a checkpoint, a pickup, a first-time event.", counter: "Counts trigger pulses and outputs the running total.", loop: "Sweeps a number from one value to another over time, repeating - a ready-made animation ramp.", @@ -96,7 +96,7 @@ export const NODE_DOCS = { onexit: "Fires a pulse when something leaves a trigger volume - the other half of On Enter.", onrest: "Fires a pulse when a physics body has finished moving - the counterpart to On Impact, which fires when it starts.", gamepadbutton: "Fires a pulse (or holds a level) while a gamepad button is pressed - the pad's counterpart of Key Press.", - gamepadaxis: "A gamepad stick or trigger axis as a number between -1 and 1, with a dead zone.", + gamepadaxis: "A gamepad stick axis (left or right stick, x or y) as a number between -1 and 1, with a dead zone (the L2/R2 triggers are Gamepad Button inputs).", shake: "Jitters the connected object around its resting position - rumble, nervousness, impact feedback.", spin: "Rotates the connected object continuously around one axis.", rotor: "Turns the connected object about its own local axis (and its origin, so a wheel turns on its hub) at a set rpm with a spin-up. Paddles and scoops it turns carry water from Fluid emitters. On a dynamic physics body during a simulation it becomes a real motor: torque-limited, so a load slows it.", diff --git a/tests/unit/nodeDocsClaims.test.js b/tests/unit/nodeDocsClaims.test.js new file mode 100644 index 000000000..1c05da759 --- /dev/null +++ b/tests/unit/nodeDocsClaims.test.js @@ -0,0 +1,39 @@ +// fix-batch-1 (docs-1): one-line node descriptions that said something the node does not do. They feed +// the palette tooltip, the editor's info pane and the docs-site node pages, so a wrong line is a wrong +// manual. Each was checked against the node's catalog entry and runtime (see the commit). +import { describe, test, expect } from 'vitest'; +import { NODE_DOCS } from '../../src/lib/nodeDocs.js'; + +describe('node docs say what the node does', () => { + test('Sequence fires all four steps from ONE pulse (it does not step once per pulse or wrap)', () => { + expect(NODE_DOCS.sequence).not.toMatch(/one per pulse|wraps/i); + expect(NODE_DOCS.sequence).toMatch(/four/i); + }); + test('Move Input is WASD only (no stick, no jump)', () => { + expect(NODE_DOCS.moveinput).not.toMatch(/stick|jump/i); + expect(NODE_DOCS.moveinput).toMatch(/WASD/); + }); + test('All Players is a group gate, not an iterator', () => { + expect(NODE_DOCS.allplayers).not.toMatch(/iterat/i); + expect(NODE_DOCS.allplayers).toMatch(/gate/i); + }); + test("Character Controller drives the player's view, not an object", () => { + expect(NODE_DOCS.charcontroller).not.toMatch(/turns the connected object/i); + expect(NODE_DOCS.charcontroller).toMatch(/player/i); + }); + test('Move Speed writes on `set`, Camera Follow has no distance/height, Gamepad Axis is sticks only', () => { + expect(NODE_DOCS.movespeed).not.toMatch(/while a value is wired/i); + expect(NODE_DOCS.movespeed).toMatch(/\bset\b/); + expect(NODE_DOCS.camerafollow).not.toMatch(/distance|height/i); + expect(NODE_DOCS.gamepadaxis).not.toMatch(/stick or trigger axis/i); + }); + test('the HUD nodes name the element kind they write, and act per pulse', () => { + expect(NODE_DOCS.hudscreen).not.toMatch(/while/i); + expect(NODE_DOCS.hudrows).toMatch(/HUD List element/); + expect(NODE_DOCS.leaderboard).toMatch(/HUD List element/); + expect(NODE_DOCS.hudtimer).toMatch(/HUD Timer element/); + expect(NODE_DOCS.hudlist).toMatch(/title/i); + expect(NODE_DOCS.hudlist).not.toMatch(/items a value provides/i); + expect(NODE_DOCS.hudinput).toMatch(/slider/i); + }); +}); From d90b9607be0dafe1c455130e31a2363f57a3f414 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 08:52:39 +0200 Subject: [PATCH 6/8] [flow] Sequence card: the wait fields show their values at the standard card width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs-1: at the 150 px card a "wait" word on every row plus a 48 px step label left each field's input 12 px wide — the values read as empty boxes. "wait (s)" is now said once as the column heading (each field keeps an accessible name, "wait before step N (seconds)"), the step label is a fixed 36 px so the field does not jump when the ● lights, and the field takes the rest: 68 px, "10.50" fits. e2e sequence-card: base 4cd3dafc 12 px fields, all four values clipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../editors/nodes/SequenceNode.svelte | 12 +- tests/e2e/sequence-card.test.cjs | 107 ++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/sequence-card.test.cjs diff --git a/src/components/editors/nodes/SequenceNode.svelte b/src/components/editors/nodes/SequenceNode.svelte index 51a3323f3..d534240d7 100644 --- a/src/components/editors/nodes/SequenceNode.svelte +++ b/src/components/editors/nodes/SequenceNode.svelte @@ -31,11 +31,19 @@ trigger
+ + {#each STEPS as step (step)}
- wait setNodeData(id, { ['delay' + step]: v })} /> diff --git a/tests/e2e/sequence-card.test.cjs b/tests/e2e/sequence-card.test.cjs new file mode 100644 index 000000000..07f80e6f3 --- /dev/null +++ b/tests/e2e/sequence-card.test.cjs @@ -0,0 +1,107 @@ +// fix-batch-1 (docs-1): the Sequence card's four `wait` fields. At the standard 150 px card the two +// fixed labels left each field ~30 px and its values were clipped (they read as empty boxes). Every +// field must show its whole value, and the step label must fit while it is lit ("● step4"). +// COUNTERFACTUAL (4cd3dafc): the inputs are narrower than their values. +// Run: APP_URL=https://theprototype.app:5591/ node tests/e2e/sequence-card.test.cjs +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { + context: { viewport: { width: 1440, height: 900 } } + }); + const p = A.page; + await p.locator('p[title="Node editor (N)"]').click(); + await p.waitForTimeout(1500); + await p.evaluate(() => + window.__stores.restoreGraphs({ + scene: { + nodes: [ + { + id: 'src', + type: 'onclick', + position: { x: 0, y: 0 }, + data: { type: 'onclick', label: 'On Click' } + }, + { + id: 'seq', + type: 'sequence', + position: { x: 260, y: 0 }, + data: { + type: 'sequence', + label: 'Sequence', + delay1: 0.25, + delay2: 10.5, + delay3: 2, + delay4: 0.05 + }, + class: 'w-[150px]' + } + ], + edges: [{ id: 'e-src-seq.trigger', source: 'src', target: 'seq', targetHandle: 'trigger' }] + } + }) + ); + await p.waitForTimeout(900); + /** every wait field of the card: its width, whether its value fits, what it shows */ + const fields = () => + p.evaluate(() => + [...document.querySelectorAll('.svelteFlow .svelte-flow__node[data-id="seq"] input')].map( + (i) => { + const el = /** @type {HTMLInputElement} */ (i); + return { w: el.clientWidth, fits: el.scrollWidth <= el.clientWidth + 1, value: el.value }; + } + ) + ); + // offsetWidth, not the rect: the rect includes the editor's ZOOM, and the claim is about the card's own width + const card = await p.evaluate( + () => + /** @type {HTMLElement | null} */ ( + document.querySelector('.svelteFlow .svelte-flow__node[data-id="seq"]') + )?.offsetWidth ?? 0 + ); + const f = await fields(); + console.log('card', card, JSON.stringify(f)); + h.check( + card >= 140 && card <= 170 && f.length === 4, + `premise: one standard-width Sequence card with four wait fields (${card} px, ${f.length})` + ); + h.check( + f.every((x) => x.fits), + `every wait field shows its whole value (${JSON.stringify(f.map((x) => x.value + (x.fits ? '' : ' CLIPPED')))})` + ); + h.check( + f.every((x) => x.w >= 40), + `…in a field at least 40 px wide (${f.map((x) => x.w).join(', ')})` + ); + + // light a step: step1 fires 0.25 s after the pulse (the delays add up) + await p.evaluate(() => + window.__stores.flowRuntime.applyNodeTrigger('src', (Date.now() % 86400000) / 1000, true) + ); + await p.waitForTimeout(450); + const lit = await p.evaluate(() => + [...document.querySelectorAll('.svelteFlow .svelte-flow__node[data-id="seq"] span')] + .filter((s) => /step\d/.test(s.textContent ?? '')) + .map((s) => ({ t: (s.textContent ?? '').trim(), fits: s.scrollWidth <= s.clientWidth + 1 })) + ); + console.log('labels', JSON.stringify(lit)); + h.check( + lit.length === 4 && lit.every((x) => x.fits), + `the step labels fit, lit or not (${JSON.stringify(lit)})` + ); + h.check( + lit.some((x) => x.t.startsWith('●')), + `premise: a step is lit after the pulse (${lit.map((x) => x.t).join(' | ')})` + ); + await p + .locator('.svelteFlow .svelte-flow__node[data-id="seq"]') + .screenshot({ path: (process.env.EVIDENCE_DIR || '/tmp') + '/sequence-card.png' }) + .catch(() => {}); + + h.check( + h.pageErrors(A).length === 0, + `no page errors (${JSON.stringify(h.pageErrors(A)).slice(0, 200)})` + ); + await h.finish(browser); +}); From 0b5a1b152f6de82305a2d714ae6ea4fa9475cc67 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 08:55:00 +0200 Subject: [PATCH 7/8] [test] Rotate / Motor unit tests follow switch-on time (10111ec3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two pose tests called applyRotor once at t = 1 and expected the angle from the clock's origin; they now switch the wheel on at t = 0 first. New: a wheel switched on at t = 51 300 (mid-day on the synced clock) spins up from rest, off -> on spins up again, and a clock that goes back re-bases — red on 4cd3dafc's motionNodes.js. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/motionNodes.test.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/unit/motionNodes.test.js b/tests/unit/motionNodes.test.js index ee13e64b0..2aeb1e5aa 100644 --- a/tests/unit/motionNodes.test.js +++ b/tests/unit/motionNodes.test.js @@ -19,6 +19,9 @@ describe('Rotate / Motor', () => { const o = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); o.rotation.set(0, 0, Math.PI / 2); const b = base(o); + // fix-batch-1: the angle counts from SWITCH-ON (the first call), so switch it on at t = 0 + resetMotionNodes(); + applyRotor(o, b, { axis: 'x', rpm: 15, spinUp: 0 }, 0, { suspended: false, physics: null, pivot: null, key: 'k', now: 0 }); applyRotor(o, b, { axis: 'x', rpm: 15, spinUp: 0 }, 1, { suspended: false, physics: null, pivot: null, key: 'k', now: 0 }); // the local x axis is unchanged by a spin about it const ax = new THREE.Vector3(1, 0, 0).applyQuaternion(o.quaternion); @@ -33,10 +36,31 @@ describe('Rotate / Motor', () => { const o = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); o.position.set(2, 1, 0); const pivot = new THREE.Vector3(2, 0, 0); - applyRotor(o, base(o), { axis: 'z', rpm: 15, spinUp: 0 }, 1, { suspended: false, physics: null, pivot, key: 'k', now: 0 }); + const b = base(o); + resetMotionNodes(); + applyRotor(o, b, { axis: 'z', rpm: 15, spinUp: 0 }, 0, { suspended: false, physics: null, pivot, key: 'k', now: 0 }); + applyRotor(o, b, { axis: 'z', rpm: 15, spinUp: 0 }, 1, { suspended: false, physics: null, pivot, key: 'k', now: 0 }); expect(o.position.distanceTo(pivot)).toBeCloseTo(1, 6); expect(o.position.x).toBeCloseTo(1, 5); // a quarter turn about +z moves +y to -x }); + // fix-batch-1 (docs-1): the ramp counted from the clock's ORIGIN, and the synced clock is seconds + // since midnight — so a wheel switched on at 14:15 (t = 51 300) was "51 298 s into a 2 s spin-up" + it('spins up from SWITCH-ON, not from the clock origin; off -> on spins up again; a clock that goes back re-bases', () => { + const o = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); + const b = base(o); + const spin = (/** @type {any} */ data, /** @type {number} */ t) => { + applyRotor(o, b, { axis: 'y', rpm: 60, spinUp: 2, ...data }, t, { suspended: false, physics: null, pivot: null, key: 'r', now: 0 }); + return 2 * Math.atan2(o.quaternion.y, o.quaternion.w); + }; + resetMotionNodes(); + expect(spin({}, 51300)).toBeCloseTo(0, 9); // switched on: at rest + expect(spin({}, 51301)).toBeCloseTo(Math.PI / 2, 6); // 1 s into a 2 s ramp: ω t² / 2T, not a full turn + spin({ on: false }, 51302); // off (the runtime's base restore puts the pose back; the ramp is forgotten) + expect(spin({}, 51310)).toBeCloseTo(0, 9); // on again: from rest + expect(spin({}, 51311)).toBeCloseTo(Math.PI / 2, 6); + expect(spin({}, 3)).toBeCloseTo(0, 9); // the clock went BACK (midnight): re-based, not frozen + expect(spin({}, 4)).toBeCloseTo(Math.PI / 2, 6); + }); it('off = rests at its base pose; a dynamic body is never posed, it is driven (initiator only, torque-capped)', () => { const o = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 0.2)); o.userData.physics = { mode: 'dynamic', mass: 4 }; From 0522e70391bb041382711e52479fc64a2e1ba8b0 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 09:26:59 +0200 Subject: [PATCH 8/8] [flow] rotor / autostart timer: re-base only on a real clock jump (> 1 s back) Follow-up to 10111ec3: a frame of jitter before the switch-on time no longer moves the start; only a jump back of more than a second (midnight, a session clock adopted on join) re-bases. Unit test covers the jitter case. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/flowRuntime.js | 5 +++-- src/lib/sim/motionNodes.js | 8 ++++++-- tests/unit/motionNodes.test.js | 2 ++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index e97d80273..cf90feefb 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1229,8 +1229,9 @@ function hudTimerRemaining(node, data, time, ctx) { if (stamp === null) { if (data.autostart === false) return duration; const seen = hudTimerSeen.get(node.id); - // a clock that went BACK (midnight, a session clock adopted on join) re-bases - if (seen === undefined || time < seen) hudTimerSeen.set(node.id, time); + // a clock that went BACK by more than a second (midnight, a session clock adopted on join) + // re-bases; frame jitter does not (it would hold the timer at its full duration) + if (seen === undefined || time < seen - 1) hudTimerSeen.set(node.id, time); return Math.max(0, duration - (time - /** @type {number} */ (hudTimerSeen.get(node.id)))); } // a synced stamp can sit AHEAD of `time` by a frame; clamp both ends diff --git a/src/lib/sim/motionNodes.js b/src/lib/sim/motionNodes.js index a4f9d1bdd..c7c931dc3 100644 --- a/src/lib/sim/motionNodes.js +++ b/src/lib/sim/motionNodes.js @@ -67,9 +67,11 @@ export function applyRotor(object, base, data, time, ctx) { // from the first frame. It counts from the moment this peer saw it switch on (or appear); a peer // joining later sees its own spin-up from rest, the price of a phase no message carries. // a clock that went BACK (the synced clock wraps at midnight; joining a session swaps the - // local clock for the session's) re-bases rather than freezing the wheel until it catches up + // local clock for the session's) re-bases rather than freezing the wheel until it catches up. + // Only a real jump (> 1 s): a frame's worth of jitter between two callers must not re-base the + // wheel every frame, which holds it at rest (rotorAngle clamps a small negative to 0 anyway) const since = rotorOnSince.get(ctx.key); - if (since === undefined || time < since) rotorOnSince.set(ctx.key, time); + if (since === undefined || time < since - REBASE_S) rotorOnSince.set(ctx.key, time); const angle = rotorAngle(rpm, Math.max(0, +data.spinUp || 0), time - /** @type {number} */ (rotorOnSince.get(ctx.key))); qBase.setFromEuler(eul.set(base.rot[0], base.rot[1], base.rot[2])); qSpin.setFromAxisAngle(axis, angle); @@ -85,6 +87,8 @@ export function applyRotor(object, base, data, time, ctx) { /** @type {Map} per rotor (node|object): the clock time it switched on */ const rotorOnSince = new Map(); +/** a clock that goes back by more than this has jumped (midnight, a session clock adopted) */ +const REBASE_S = 1; /** @type {Map} the last frame time per drive (dt for the torque cap) */ const lastDrive = new Map(); diff --git a/tests/unit/motionNodes.test.js b/tests/unit/motionNodes.test.js index 2aeb1e5aa..bc6ab11f7 100644 --- a/tests/unit/motionNodes.test.js +++ b/tests/unit/motionNodes.test.js @@ -58,6 +58,8 @@ describe('Rotate / Motor', () => { spin({ on: false }, 51302); // off (the runtime's base restore puts the pose back; the ramp is forgotten) expect(spin({}, 51310)).toBeCloseTo(0, 9); // on again: from rest expect(spin({}, 51311)).toBeCloseTo(Math.PI / 2, 6); + spin({}, 51309.99); // a frame of jitter BEFORE the switch-on time is not a re-base: the ramp still counts from 51 310 + expect(spin({}, 51311.5)).toBeCloseTo((2 * Math.PI * 1.5 * 1.5) / 4, 6); expect(spin({}, 3)).toBeCloseTo(0, 9); // the clock went BACK (midnight): re-based, not frozen expect(spin({}, 4)).toBeCloseTo(Math.PI / 2, 6); });