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/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/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/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/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/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/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 619962fa9..cf90feefb 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'; @@ -1210,15 +1211,29 @@ 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)); - const stamp = triggerStampFor(node.id, ctx); - if (stamp === null) return data.autostart === false ? duration : Math.max(0, duration - time); + // 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) { + if (data.autostart === false) return duration; + const seen = hudTimerSeen.get(node.id); + // 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 return Math.max(0, Math.min(duration, duration - (time - stamp))); } @@ -2080,12 +2095,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). @@ -3568,15 +3584,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..b7786d536 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 */ @@ -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'] }, @@ -1147,12 +1154,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 } }, @@ -1342,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/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index e2686bb78..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.", @@ -90,13 +90,13 @@ 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.", 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/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/src/lib/sim/motionNodes.js b/src/lib/sim/motionNodes.js index 63c41430b..c7c931dc3 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,21 @@ 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. + // 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 - 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); object.quaternion.copy(qBase).multiply(qSpin); @@ -72,6 +85,11 @@ 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(); @@ -109,6 +127,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/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/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); +}); 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/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/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'); 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); +}); 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'); + }); +}); diff --git a/tests/unit/motionNodes.test.js b/tests/unit/motionNodes.test.js index ee13e64b0..bc6ab11f7 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,33 @@ 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); + 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); + }); 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 }; 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'); + }); }); 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); + }); +});