From c162c20cc2950da9274ccdb1c086656fbaf8db8d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:33:05 +0300 Subject: [PATCH 01/46] [fix] 30 P0: the pointer-capture throw in play (~100 errors on a Quest = one per press) - three 0.185's OrbitControls.onPointerDown calls domElement.setPointerCapture with no guard, and the editor stays MOUNTED in play. Chromium refuses a capture with InvalidStateError while the page holds a pointer lock, and the throw lands before _addPointer, so EVERY press in play threw once and later surfaced as "Something went wrong ... Copy diagnostics" (the Quest diagnostics log, nodes/2.*.js:372:15736). - Half 1: Scene's frame task stands the active OrbitControls down while isLocked === true and restores exactly what it stood down when play ends. Per FRAME, not on the isLocked edge: TransformControls unmounts in play and its auto-pause cleanup writes enabled = true unconditionally a tick later, which would stomp an edge write. - Half 2: setPointerCapture is guarded on the element the controls listen on (skipped while document.pointerLockElement is set). Smaller than subclassing, which would mean replacing threlte's with a hand-built ; it also covers the camera preview's OrbitControls, which listens on the same element. It calls the prototype method at call time, and is removed on destroy. - New suite pointer-capture-play (12): Chromium's rule modelled in-page (a pointerLockElement getter + a prototype setPointerCapture that throws under a lock, checked as a premise), 20 real CDP presses in play, a hand re-enable to prove the guard alone, no diagnostics toast after leaving play, and the editor still orbits. - Counterfactuals: both halves removed -> 20 errors for 20 presses, the toast, 6 red (the reported bug, reproduced); capture guard alone removed -> the re-enabled-controls check red (2 errors) + the toast. - svelte-check 336/47 = base (line shifts only); vitest 196/196; build green; selection-extras 19/19 at base. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 47 +++++- tests/e2e/pointer-capture-play.test.cjs | 192 ++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/pointer-capture-play.test.cjs diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 8a36b46d..6973b668 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -95,7 +95,51 @@ import { Mesh, Vector3 } from 'three' - let { scene, camera, renderer, dpr } = useThrelte(); + let { scene, camera, renderer, dpr, dom } = useThrelte(); + + // 30 P0: THE POINTER-CAPTURE THROW — ~100 errors on a Quest were one per press. + // three 0.185's OrbitControls.onPointerDown calls `domElement.setPointerCapture` with + // no guard (its TransformControls sibling guards exactly this), and Chromium refuses a + // capture with InvalidStateError while the page holds a pointer lock — which is every + // press in play, since the editor controls stay mounted there. The throw lands before + // `_addPointer`, so it fired once per press and surfaced as "Something went wrong". + // Two halves: the controls stand down while playing (syncOrbitForPlay, per frame), and + // the capture itself is guarded on the element they listen on — the smaller of the two + // ways to reach it (a subclass would mean replacing threlte's with a + // hand-built ), and it covers the camera preview's OrbitControls too, which + // listens on the same element. It calls the PROTOTYPE method at call time rather than + // a captured copy, so nothing else on the element is shadowed for good. + const pointerCaptureGuard = (el: any) => { + if (!el || typeof el.setPointerCapture !== 'function' || el.__captureGuarded) return () => {}; + el.__captureGuarded = true; + el.setPointerCapture = function (this: any, pointerId: number) { + if (this.ownerDocument?.pointerLockElement) return; // the lock already owns the pointer + return Element.prototype.setPointerCapture.call(this, pointerId); + }; + return () => { + delete el.setPointerCapture; + delete el.__captureGuarded; + }; + }; + onDestroy(pointerCaptureGuard(dom)); + // the first half: while playing, every OrbitControls that drives the view stands + // down. Per FRAME rather than on the isLocked edge, because + // unmounts in play and its auto-pause CLEANUP writes `enabled = true` + // unconditionally a tick later (the enableZoom note on the mount below) — a write on + // the edge would be stomped. Whatever this stood down comes back when play ends. + const orbitStoodDown = new Set(); + function syncOrbitForPlay() { + const controls: any = $activeOrbit; + if ($isLocked === true) { + if (controls && controls.enabled !== false) { + controls.enabled = false; + orbitStoodDown.add(controls); + } + } else if (orbitStoodDown.size) { + for (const stood of orbitStoodDown) stood.enabled = true; + orbitStoodDown.clear(); + } + } // 26-D: the quality governor's two knobs that live here. Resolution goes through // threlte's OWN dpr (renderer.setPixelRatio directly would be undone by threlte's resize @@ -276,6 +320,7 @@ useTask((delta) => { rotation += 0.25 * delta; + syncOrbitForPlay(); // 30 P0: the editor camera controls stand down while playing // 21-B B3: play-mode grab/carry. The ray is NDC (0,0) every frame, so it // belongs in the frame loop rather than on a pointer event. tickPlayInteract(delta, camera.current); diff --git a/tests/e2e/pointer-capture-play.test.cjs b/tests/e2e/pointer-capture-play.test.cjs new file mode 100644 index 00000000..bdc1d008 --- /dev/null +++ b/tests/e2e/pointer-capture-play.test.cjs @@ -0,0 +1,192 @@ +// 30 P0 — THE POINTER-CAPTURE THROW. ~100 errors on a Quest were one per press. +// +// three 0.185's OrbitControls.onPointerDown calls `domElement.setPointerCapture` with +// no guard, and the editor's stays MOUNTED in play (only its zoom +// stands down). Chromium refuses a pointer capture while the page holds a pointer +// lock — InvalidStateError — and the throw lands before `_addPointer`, so EVERY press +// in play threw once and, after leaving play, surfaced as "Something went wrong". +// +// Headless has no real pointer lock, so the suite supplies Chromium's rule itself: a +// `document.pointerLockElement` getter that answers the canvas while "locked", and a +// prototype `setPointerCapture` that throws InvalidStateError exactly when one is set. +// That model is checked FIRST (premise), so the suite cannot pass by the stub doing +// nothing. The fix has two independent halves and each has its own check: +// (1) the editor OrbitControls is DISABLED while isLocked === true, so its +// onPointerDown returns before the capture (checked on the instance itself); +// (2) the capture on the canvas wrapper is GUARDED under a lock, for the moment +// something re-enables the controls (TransformControls' unmount cleanup does, +// unconditionally) — checked by re-enabling it by hand and pressing again. + +const h = require('./helpers.cjs'); + +const PRESSES = 20; + +/** real CDP presses at the canvas centre; returns how many pointerdowns the canvas saw */ +async function pressCanvas(page, n) { + const box = await page.evaluate(() => { + let renderer = null; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + const r = renderer.domElement.getBoundingClientRect(); + window.__p0downs = 0; + renderer.domElement.addEventListener('pointerdown', () => window.__p0downs++, { once: false }); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }); + for (let i = 0; i < n; i++) { + await page.mouse.move(box.x + (i % 5), box.y + (i % 3)); + await page.mouse.down(); + await page.mouse.up(); + await page.waitForTimeout(30); + } + return page.evaluate(() => window.__p0downs); +} + +const captureErrors = (peer) => h.pageErrors(peer).filter((m) => /setPointerCapture|InvalidStateError/.test(m)); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + // Chromium's rule, installed on the prototype so every element obeys it + await page.evaluate(() => { + let locked = null; + Object.defineProperty(document, 'pointerLockElement', { configurable: true, get: () => locked }); + window.__p0lock = (on) => { + let renderer = null; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + locked = on ? renderer.domElement : null; + }; + const real = Element.prototype.setPointerCapture; + Element.prototype.setPointerCapture = function (id) { + if (document.pointerLockElement) + throw new DOMException("Failed to execute 'setPointerCapture' on 'Element': InvalidStateError", 'InvalidStateError'); + return real.call(this, id); + }; + }); + + // a game-like scene: a floor, a crate, a collectible-ish marker + await page.evaluate(() => { + const cmd = window.__stores.commandsHandler.sceneCommand; + cmd('/create box'); + cmd('/create sphere'); + }); + await page.waitForTimeout(800); + + // ---- premise: the model throws, the way Chromium does ---- + const premise = await page.evaluate(() => { + window.__p0lock(true); + try { + document.body.setPointerCapture(1); + return 'no throw'; + } catch (e) { + return e.name; + } finally { + window.__p0lock(false); + } + }); + h.check(premise === 'InvalidStateError', `premise: a capture under the stubbed lock throws InvalidStateError (${premise})`); + + // ---- enter play with the lock held ---- + const before = captureErrors(A).length; + await page.evaluate(() => { + window.__p0lock(true); + window.__stores.isLocked.set(true); + }); + await page.waitForTimeout(600); + const orbitInPlay = await page.evaluate(() => { + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + return oc ? oc.enabled : 'none'; + }); + h.check(orbitInPlay === false, `the editor OrbitControls stands down in play (enabled=${orbitInPlay})`); + + const downs = await pressCanvas(page, PRESSES); + h.check(downs === PRESSES, `premise: the canvas really received ${PRESSES} pointerdowns (${downs})`); + const inPlay = captureErrors(A).length - before; + h.check(inPlay === 0, `ZERO setPointerCapture errors over ${PRESSES} presses in play (${inPlay})`); + + // ---- guard (2): something re-enables the controls mid-play (TransformControls' + // unmount cleanup does exactly this) — the capture itself must still not throw ---- + const guardBefore = captureErrors(A).length; + await page.evaluate(() => { + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + oc.enabled = true; + }); + // press straight away, before the next frame can stand it down again + const stomped = await page.evaluate(() => { + let renderer = null; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + const wrapper = renderer.domElement.parentElement; + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + oc.enabled = true; + const errors = []; + const onErr = (e) => { if (/setPointerCapture|InvalidStateError/.test(String(e.message))) errors.push(String(e.message)); }; + window.addEventListener('error', onErr); + const ev = new PointerEvent('pointerdown', { pointerId: 1, button: 0, bubbles: true, clientX: 300, clientY: 300 }); + renderer.domElement.dispatchEvent(ev); + window.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, button: 0, bubbles: true })); + document.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, button: 0, bubbles: true })); + window.removeEventListener('error', onErr); + return { sameEl: oc.domElement === wrapper || oc.domElement === renderer.domElement, errors }; + }); + await page.waitForTimeout(300); + h.check(stomped.sameEl, 'premise: the OrbitControls listens on the canvas or its wrapper'); + const stompErrors = captureErrors(A).length - guardBefore + stomped.errors.length; + h.check(stompErrors === 0, `a re-enabled OrbitControls under the lock still captures nothing (${stompErrors} errors)`); + const reDisabled = await page.evaluate(() => { + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + return oc.enabled; + }); + h.check(reDisabled === false, `...and the next frame stands it down again (enabled=${reDisabled})`); + + // ---- leave play: no "Something went wrong", and the editor still orbits ---- + await page.evaluate(() => { + window.__p0lock(false); + window.__stores.isLocked.set(false); + }); + await page.waitForTimeout(2600); // Controls' own effect walks false -> null + const toast = await page.evaluate(() => document.body.innerText.includes('Something went wrong')); + h.check(!toast, 'no "Something went wrong" toast after leaving play'); + const locked = await page.evaluate(() => { + let v; + window.__stores.isLocked.subscribe((x) => (v = x))(); + return v; + }); + h.check(locked !== true, `back in the editor (isLocked=${locked})`); + const enabledAfter = await page.evaluate(() => { + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + return oc.enabled; + }); + h.check(enabledAfter === true, `the editor OrbitControls is enabled again (${enabledAfter})`); + + const camBefore = await page.evaluate(() => { + let cam = null; + window.__stores.globalCamera.subscribe((v) => (cam = v))(); + return cam.position.toArray(); + }); + const box = await page.evaluate(() => { + let renderer = null; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + const r = renderer.domElement.getBoundingClientRect(); + return { x: r.left + r.width * 0.3, y: r.top + r.height * 0.6 }; + }); + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) await page.mouse.move(box.x + i * 18, box.y - i * 4); + await page.mouse.up(); + await page.waitForTimeout(600); + const camAfter = await page.evaluate(() => { + let cam = null; + window.__stores.globalCamera.subscribe((v) => (cam = v))(); + return cam.position.toArray(); + }); + const moved = Math.hypot(camAfter[0] - camBefore[0], camAfter[1] - camBefore[1], camAfter[2] - camBefore[2]); + h.check(moved > 0.2, `a drag orbits the editor camera after Esc (moved ${moved.toFixed(2)})`); + h.check(captureErrors(A).length === 0, `no capture error anywhere in the run (${captureErrors(A).length})`); + + await h.finish(browser); +}); From 10e71fd028d2ef7af98e8c778cc269ee006f5a13 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:35:13 +0300 Subject: [PATCH 02/46] [feat] author-kit p0: primitives, light kinds, physical/toon materials, object flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build() gains capsule / plane / ring / icosahedron / dodecahedron (core three geometries, rebuilt from their parameters by ObjectLoader) and box `bevel` (+ bevelSegments): a port of three/examples RoundedBoxGeometry baked into a PLAIN BufferGeometry, because toJSON writes a subclass type ObjectLoader cannot rebuild (core Box params carry no bevel) - light `kind`: spot (angle, penumbra, target) / directional (castShadow, shadowMapSize, frustum FITTED to the built meshes in the light's own frame; `fit: false` opts out) / hemisphere (groundColor); spot + directional follow createLight (cast by default, the V-1 bias pair) and aim by ROTATION at `target` (24-E1). point (no kind) is untouched - materials: `physical: true` or any physical-only field (clearcoat, clearcoatRoughness, transmission, thickness, ior, sheen, sheenColor, sheenRoughness, iridescence, specularIntensity) -> MeshPhysicalMaterial; `toon: true` -> MeshToonMaterial; `flatShading`, `side: 'double'|'back'`; emissiveIntensity as before - flags: `pick: 'through'` -> userData.pick; `origin: [x,y,z]` -> userData.origin; `particles: '' | {preset, ...patch}` -> userData.particles from particlePresets; `anim: '' | [..]` -> animationPreview.applyPreset (by key or name), an ordinary authored clip (not playing — a Play Animation node runs it) - loader: `--def ` adds defs from JSON (one or an array); a same-slug def REPLACES the DEFS entry. Unknown object types now throw instead of silently becoming cones - suite author-kit (new): authors a def-under-test via --def/--only into a temp dir, loads it back through sessions.readSessionZip + applySession, one check per feature: 40 PASS - byte-identity: level-blockout/towers/stars-room/untangle/waves authored before and after, scripts/compare-authored.cjs reports SAME for all five (thumbs identical in size) - counterfactual: 'clearcoat' dropped from PHYSICAL_KEYS + the pick line removed -> exactly "physical: clearcoat 0.8 survives (0)" and "pick: 'through' lands on userData.pick" FAIL - held: templates-modal 47 / template-modules 29 PASS lines at base (no src change in this commit); svelte-check 336/47 identical list; vitest 196; build green Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 251 ++++++++++++++++++++++++++++++++-- tests/e2e/author-kit.test.cjs | 244 +++++++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/author-kit.test.cjs diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 9d244779..3a9a0b6a 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -95,6 +95,22 @@ const ONLY = .split(',') .filter(Boolean) : null; +// 30 author-kit: `--def ` adds defs from JSON files — each file holds +// ONE def or an ARRAY of them. A file def whose slug matches a DEFS entry REPLACES it (so a +// lane can iterate on a def without editing DEFS); any other slug is appended. Combine with +// `--only ` to author just that def. This is also how the author-kit suite authors its +// def-under-test into a scratch folder. +const defFlag = process.argv.indexOf('--def'); +const FILE_DEFS = + defFlag !== -1 + ? String(process.argv[defFlag + 1] ?? '') + .split(',') + .filter(Boolean) + .flatMap((file) => { + const parsed = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); + return Array.isArray(parsed) ? parsed : [parsed]; + }) + : []; // ---- declarative scene definitions ------------------------------------------ // objects: {type:'box'|'cylinder'|'sphere'|'cone', name, color, pos, rot?, ...dims, @@ -1332,7 +1348,11 @@ const DEFS = [ /** @type {Record} */ const built = {}; - const defs = ONLY ? DEFS.filter((d) => ONLY.includes(d.slug)) : DEFS; + // 30 author-kit: `--def` files replace a same-slug DEFS entry in place, or append + const allDefs = DEFS.map((d) => FILE_DEFS.find((f) => f.slug === d.slug) ?? d).concat( + FILE_DEFS.filter((f) => !DEFS.some((d) => d.slug === f.slug)) + ); + const defs = ONLY ? allDefs.filter((d) => ONLY.includes(d.slug)) : allDefs; if (ONLY && defs.length !== ONLY.length) console.log(' WARN --only names a slug DEFS does not have: ' + ONLY.join(',')); // a module-owned def whose file is absent must not quietly drop its row from a full @@ -1393,6 +1413,90 @@ const DEFS = [ s.objectsGroup.subscribe((g) => (group = g))(); /** @param {number} n */ const hex = (n) => '#' + Number(n).toString(16).padStart(6, '0'); + // 30 author-kit: material fields only MeshPhysicalMaterial has (a def using any of + // them gets one), the directional lights whose shadow frustum is fitted once the + // whole scene is built, and the animation presets applied once objects have uuids + const PHYSICAL_KEYS = ['clearcoat', 'clearcoatRoughness', 'transmission', 'thickness', 'ior', 'sheen', 'sheenColor', 'sheenRoughness', 'iridescence', 'specularIntensity']; + /** @type {any[]} */ const fitShadows = []; + /** @type {{object: any, anim: any}[]} */ const animQueue = []; + // 30 author-kit: a ROUNDED box. Core's Box params carry no bevel, so this is a port of + // three/examples' RoundedBoxGeometry (MIT) — same vertex placement, normals and UVs — + // BAKED into a plain BufferGeometry: toJSON writes a subclass's `type` and ObjectLoader + // cannot rebuild 'RoundedBoxGeometry', while a plain BufferGeometry round-trips its + // buffers. @param {number} width @param {number} height @param {number} depth + // @param {number} segments @param {number} radius + const roundedBox = (width, height, depth, segments, radius) => { + const total = segments * 2 + 1; + radius = Math.min(width / 2, height / 2, depth / 2, radius); + const src = new T.BoxGeometry(1, 1, 1, total, total, total).toNonIndexed(); + const positions = src.attributes.position.array; + const normals = src.attributes.normal.array; + const uvs = src.attributes.uv.array; + const position = new T.Vector3(); + const normal = new T.Vector3(); + const temp = new T.Vector3(); + const faceDir = new T.Vector3(); + const box = new T.Vector3(width, height, depth).divideScalar(2).subScalar(radius); + const faceTris = positions.length / 6; + const half = 0.5 / total; + /** @param {string} uvAxis @param {string} projAxis @param {number} side */ + const getUv = (uvAxis, projAxis, side) => { + const arc = (2 * Math.PI * radius) / 4; + const centre = Math.max(side - 2 * radius, 0); + temp.copy(normal); + /** @type {any} */ (temp)[projAxis] = 0; + temp.normalize(); + const arcUv = (0.5 * arc) / (arc + centre); + const arcAngle = 1.0 - temp.angleTo(faceDir) / (Math.PI / 4); + if (Math.sign(/** @type {any} */ (temp)[uvAxis]) === 1) return arcAngle * arcUv; + return centre / (arc + centre) + arcUv + arcUv * (1.0 - arcAngle); + }; + for (let i = 0, j = 0; i < positions.length; i += 3, j += 2) { + position.fromArray(positions, i); + normal.copy(position); + normal.x -= Math.sign(normal.x) * half; + normal.y -= Math.sign(normal.y) * half; + normal.z -= Math.sign(normal.z) * half; + normal.normalize(); + positions[i] = box.x * Math.sign(position.x) + normal.x * radius; + positions[i + 1] = box.y * Math.sign(position.y) + normal.y * radius; + positions[i + 2] = box.z * Math.sign(position.z) + normal.z * radius; + normals[i] = normal.x; + normals[i + 1] = normal.y; + normals[i + 2] = normal.z; + const face = Math.floor(i / faceTris); + if (face === 0) { + faceDir.set(1, 0, 0); + uvs[j] = getUv('z', 'y', depth); + uvs[j + 1] = 1.0 - getUv('y', 'z', height); + } else if (face === 1) { + faceDir.set(-1, 0, 0); + uvs[j] = 1.0 - getUv('z', 'y', depth); + uvs[j + 1] = 1.0 - getUv('y', 'z', height); + } else if (face === 2) { + faceDir.set(0, 1, 0); + uvs[j] = 1.0 - getUv('x', 'z', width); + uvs[j + 1] = getUv('z', 'x', depth); + } else if (face === 3) { + faceDir.set(0, -1, 0); + uvs[j] = 1.0 - getUv('x', 'z', width); + uvs[j + 1] = 1.0 - getUv('z', 'x', depth); + } else if (face === 4) { + faceDir.set(0, 0, 1); + uvs[j] = 1.0 - getUv('x', 'y', width); + uvs[j + 1] = 1.0 - getUv('y', 'x', height); + } else { + faceDir.set(0, 0, -1); + uvs[j] = getUv('x', 'y', width); + uvs[j + 1] = 1.0 - getUv('y', 'x', height); + } + } + const geo = new T.BufferGeometry(); + geo.setAttribute('position', new T.BufferAttribute(positions, 3)); + geo.setAttribute('normal', new T.BufferAttribute(normals, 3)); + geo.setAttribute('uv', new T.BufferAttribute(uvs, 2)); + return geo; + }; // 28-G: ONE recursive builder. The four original primitives take exactly the // steps they always did, in the same order, so every earlier def is byte-identical. // `mirror` reflects across x = 0: position x negated, yaw and roll NEGATED — a @@ -1409,6 +1513,29 @@ const DEFS = [ if (o.type === 'group' || o.type === 'empty') { object = new T.Group(); for (const child of o.children ?? []) object.add(build(child, opts)); + } else if (o.type === 'light' && o.kind && o.kind !== 'point') { + // 30 author-kit: spot / directional / hemisphere. Spot and directional follow + // createLight's convention (cast shadows by default, the V-1 bias pair) and aim + // by ROTATION (24-E1: they shine along local -Z; lightHelpers places the target + // on that forward) — `target` is a WORLD point applied with lookAt once the + // position is set, below. A directional's ortho frustum is FITTED to the built + // scene after every object exists (`fitShadows`), unless `fit: false`. + if (o.kind === 'hemisphere') { + object = new T.HemisphereLight(o.color ?? 0xffffff, o.groundColor ?? 0x444444, o.intensity ?? 1); + } else if (o.kind === 'spot' || o.kind === 'directional') { + object = + o.kind === 'spot' + ? new T.SpotLight(o.color ?? 0xffffff, o.intensity ?? 1, o.distance ?? 0, o.angle ?? Math.PI / 6, o.penumbra ?? 0.3, o.decay ?? 2) + : new T.DirectionalLight(o.color ?? 0xffffff, o.intensity ?? 1); + object.castShadow = o.castShadow ?? true; + object.shadow.bias = -0.0002; + object.shadow.normalBias = 0.02; + if (o.shadowMapSize) { + object.userData.shadowMapSize = o.shadowMapSize; + object.shadow.mapSize.set(o.shadowMapSize, o.shadowMapSize); + } + if (o.kind === 'directional' && object.castShadow && o.fit !== false) fitShadows.push(object); + } else throw new Error('light: unknown kind "' + o.kind + '" (point | spot | directional | hemisphere)'); } else if (o.type === 'light') { // a point light: a viewpoint's worth of scenery it lights, no shadow map object = new T.PointLight(o.color ?? 0xffffff, o.intensity ?? 1, o.distance ?? 0, o.decay ?? 2); @@ -1449,16 +1576,44 @@ const DEFS = [ ); } else { let geo; - if (o.type === 'box') geo = new T.BoxGeometry(o.size[0], o.size[1], o.size[2]); + if (o.type === 'box' && o.bevel > 0) geo = roundedBox(o.size[0], o.size[1], o.size[2], o.bevelSegments ?? 3, o.bevel); + else if (o.type === 'box') geo = new T.BoxGeometry(o.size[0], o.size[1], o.size[2]); else if (o.type === 'cylinder') geo = new T.CylinderGeometry(o.r, o.r2 ?? o.r, o.h, 24); else if (o.type === 'sphere') geo = new T.SphereGeometry(o.r, 24, 16); else if (o.type === 'torus') geo = new T.TorusGeometry(o.r, o.tube ?? o.r * 0.2, 16, 40); - else geo = new T.ConeGeometry(o.r, o.h, 24); - const mat = new T.MeshStandardMaterial({ - color: o.color, - roughness: o.roughness ?? 0.85, - metalness: o.metalness ?? 0 - }); + // 30 author-kit: four more primitives, all core three geometries ObjectLoader + // rebuilds from their parameters (so the .tpscene stays small). A plane faces +Z + // (rotate it -90deg on x to lie flat); a ring is the flat annulus, also +Z. + else if (o.type === 'capsule') geo = new T.CapsuleGeometry(o.r, o.h ?? o.length ?? 1, 8, 16); + else if (o.type === 'plane') geo = new T.PlaneGeometry(o.size[0], o.size[1]); + else if (o.type === 'ring') geo = new T.RingGeometry(o.inner ?? o.r * 0.5, o.r, 48); + else if (o.type === 'icosahedron') geo = new T.IcosahedronGeometry(o.r, o.detail ?? 0); + else if (o.type === 'dodecahedron') geo = new T.DodecahedronGeometry(o.r, o.detail ?? 0); + else if (o.type === 'cone') geo = new T.ConeGeometry(o.r, o.h, 24); + else throw new Error('object "' + o.name + '": unknown type "' + o.type + '"'); + // 30 author-kit: MeshPhysicalMaterial when a def asks for `physical` or uses any + // field only it has; MeshToonMaterial for `toon`. Absent all of those it is the + // same MeshStandardMaterial as ever (byte-identical defs). + const physical = o.physical || PHYSICAL_KEYS.some((k) => o[k] != null); + /** @type {any} */ + let mat; + if (o.toon) mat = new T.MeshToonMaterial({ color: o.color }); + else if (physical) { + mat = new T.MeshPhysicalMaterial({ color: o.color, roughness: o.roughness ?? 0.85, metalness: o.metalness ?? 0 }); + for (const k of PHYSICAL_KEYS) { + if (o[k] == null) continue; + if (k === 'sheenColor') mat.sheenColor = new T.Color(o[k]); + else mat[k] = o[k]; + } + } else + mat = new T.MeshStandardMaterial({ + color: o.color, + roughness: o.roughness ?? 0.85, + metalness: o.metalness ?? 0 + }); + if (o.flatShading) mat.flatShading = true; + if (o.side === 'double') mat.side = T.DoubleSide; + else if (o.side === 'back') mat.side = T.BackSide; // B8: material EMISSIVE + opacity, so a game can glow a pad or float a // translucent marker without a shader doc (which the user found "strange"). // emissiveIntensity multiplies the emissive COLOUR, so both are needed. @@ -1482,6 +1637,35 @@ const DEFS = [ if (pos && o.type !== 'spline') object.position.set(pos[0], pos[1], pos[2]); if (rot) object.rotation.set(rot[0], rot[1], rot[2]); if (o.physics && !mirror) object.userData.physics = o.physics; + // 30 author-kit: object FLAGS. Each lands where the app itself keeps it, so the + // .tpscene carries it the ordinary way (userData rides toJSON; a clip rides the + // animations block). A mirror ghost takes none of them (it is decoration). + if (!mirror) { + // select-through: a shell (wall, ceiling, glass) the editor's pick passes by + if (o.pick === 'through') object.userData.pick = 'through'; + // the transform ORIGIN (objectOrigin's local pivot offset) — a Door preset + // swings about it, so a hinge is authored here + if (Array.isArray(o.origin)) object.userData.origin = o.origin.map(Number); + // a particle emitter from a preset (particlePresets), optionally patched — + // the config addParticlesPreset writes to userData.particles + if (o.particles) { + const spec = typeof o.particles === 'string' ? { preset: o.particles } : o.particles; + if (!s.particlePresets.PARTICLE_PRESETS.some((/** @type {any} */ p) => p.key === spec.preset)) + throw new Error('object "' + o.name + '": no particle preset "' + spec.preset + '"'); + const base = s.particlePresets.particlePreset(spec.preset); + if (!base) throw new Error('object "' + o.name + '": no particle preset "' + spec.preset + '"'); + const { preset: _preset, ...patch } = spec; + object.userData.particles = { ...structuredClone(base), ...patch }; + } + // animation presets are applied once the object has a uuid in the scene + if (o.anim) animQueue.push({ object, anim: o.anim }); + } + // a spot/directional aims by rotation at a WORLD point (see the light branch) + if (o.target && (object.isSpotLight || object.isDirectionalLight)) { + const t = o.target; + object.updateMatrixWorld(true); + object.lookAt(mirror ? -t[0] : t[0], t[1], t[2]); + } if (o.shadow === false || opts.shadow === false) { // shadowDefaults sweeps cast/receive back ON unless the object opts out object.castShadow = false; @@ -1508,6 +1692,41 @@ const DEFS = [ } group.add(build(o)); } + // 30 author-kit: FIT each shadow-casting directional light's ortho frustum to the + // meshes just built — the 8 corners of their world box carried into the light's own + // frame (it looks down -Z, and the shadow camera is posed from the light toward its + // target on that same forward). Saved with the light: LightShadow.toJSON carries the + // camera, so the fitted frustum is what the file (and every peer) renders with. + if (fitShadows.length) { + group.updateMatrixWorld(true); + const bounds = new T.Box3(); + group.traverse((/** @type {any} */ n) => { + if (n.isMesh && !n.userData?.camera) bounds.expandByObject(n); + }); + if (!bounds.isEmpty()) { + const corners = []; + for (const x of [bounds.min.x, bounds.max.x]) + for (const y of [bounds.min.y, bounds.max.y]) + for (const z of [bounds.min.z, bounds.max.z]) corners.push(new T.Vector3(x, y, z)); + for (const light of fitShadows) { + light.updateMatrixWorld(true); + const inv = light.matrixWorld.clone().invert(); + const local = new T.Box3().setFromPoints(corners.map((c) => c.clone().applyMatrix4(inv))); + const pad = Math.max(local.max.x - local.min.x, local.max.y - local.min.y) * 0.05 + 0.5; + const cam = light.shadow.camera; + // symmetric about the light's axis (the shadow camera is centred on it) + const rx = Math.max(Math.abs(local.min.x), Math.abs(local.max.x)) + pad; + const ry = Math.max(Math.abs(local.min.y), Math.abs(local.max.y)) + pad; + cam.left = -rx; + cam.right = rx; + cam.bottom = -ry; + cam.top = ry; + cam.near = Math.max(0.1, -local.max.z - pad); + cam.far = Math.max(cam.near + 1, -local.min.z + pad); + cam.updateProjectionMatrix(); + } + } + } s.objectsGroup.update((v) => v); // ---- C5.3: the scene DATA a game carries beyond its objects ------------- @@ -1542,6 +1761,22 @@ const DEFS = [ } s.animationPreview.animationsRestore(sets, false); } + // 30 author-kit: `anim: ''` (or a list of them) — through applyPreset, the + // Animation window's own preset path, so each is an ORDINARY authored clip keyed + // from where the object stands (and, for a Door, about its `origin`). A clip is + // authored, not playing: a Play Animation node (or the Animation window) runs it. + if (animQueue.length && s.animationPreview) { + const presets = s.animationPreview.PRESETS; + for (const { object, anim } of animQueue) { + for (const name of Array.isArray(anim) ? anim : [anim]) { + const key = Object.keys(presets).find( + (k) => k === String(name).toLowerCase() || presets[k].name.toLowerCase() === String(name).toLowerCase() + ); + if (!key) throw new Error('object "' + object.name + '": no animation preset "' + name + '" (' + Object.keys(presets).join(', ') + ')'); + s.animationPreview.applyPreset(key, object.uuid, object); + } + } + } // 28-G: THE TRACK. Into the Explorer (content-hashed, so re-runs dedupe) and into // the scene's music slot with playing OFF — the graph plays it from Start. The // hash is what a Sound node addresses, hence the `'$music'` remap below. diff --git a/tests/e2e/author-kit.test.cjs b/tests/e2e/author-kit.test.cjs new file mode 100644 index 00000000..e205a0bc --- /dev/null +++ b/tests/e2e/author-kit.test.cjs @@ -0,0 +1,244 @@ +// 30 author-kit: what a template/game DEF can say. The builder (scripts/author-templates.cjs) +// grew primitives, light kinds, physical/toon materials and object flags; each is only real +// if it survives the .tpscene round trip, so this suite AUTHORS a def-under-test through the +// real script (`--def --only --out `), then LOADS the written file back +// through sessions.readSessionZip + applySession — the Templates modal's own read path — and +// reads each object's geometry / material / userData / clip. One check per feature. +// +// Run: APP_URL=https://theprototype.app:5233/ npm run e2e -- author-kit +// (the authoring pass drives its own headless browser against the same APP_URL) +const h = require('./helpers.cjs'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const SLUG = 'author-kit-test'; + +/** The def-under-test: every new field once, on a named object. */ +const DEF = { + kind: 'template', + slug: SLUG, + title: 'Author kit test', + description: 'every author-kit field, once', + license: 'CC0-1.0', + author: 'theprototype', + objects: [ + { type: 'box', name: 'Floor', color: 0x808890, size: [20, 0.2, 20], pos: [0, -0.1, 0] }, + { type: 'box', name: 'Rounded', color: 0xd97706, size: [2, 1, 1.2], bevel: 0.2, pos: [-4, 0.5, 0] }, + { type: 'capsule', name: 'Capsule', color: 0x3b82f6, r: 0.4, h: 1.2, pos: [-2, 1, 0] }, + { type: 'plane', name: 'Plane', color: 0x22c55e, size: [2, 3], pos: [0, 1.5, -3] }, + { type: 'ring', name: 'Ring', color: 0xeab308, r: 1, inner: 0.6, pos: [2, 1.5, -3] }, + { type: 'icosahedron', name: 'Ico', color: 0xa855f7, r: 0.6, detail: 1, pos: [2, 0.6, 0] }, + { type: 'dodecahedron', name: 'Dodeca', color: 0xec4899, r: 0.6, pos: [4, 0.6, 0] }, + { + type: 'sphere', + name: 'Glass', + color: 0xffffff, + r: 0.6, + pos: [0, 0.6, 2], + clearcoat: 0.8, + clearcoatRoughness: 0.15, + transmission: 0.9, + thickness: 0.5, + ior: 1.45, + sheen: 0.4, + sheenColor: 0xff0000, + roughness: 0.1 + }, + { type: 'sphere', name: 'Plain physical', color: 0x999999, r: 0.3, pos: [-2, 0.3, 2], physical: true }, + { type: 'box', name: 'Glow', color: 0x111111, size: [0.5, 0.5, 0.5], pos: [2, 0.25, 2], emissive: 0x00ffcc, emissiveIntensity: 2.5 }, + { type: 'cone', name: 'Faceted', color: 0x999999, r: 0.4, h: 0.8, pos: [4, 0.4, 2], flatShading: true, side: 'double' }, + { type: 'sphere', name: 'Toon', color: 0x44aa88, r: 0.5, pos: [-4, 0.5, 2], toon: true }, + { type: 'box', name: 'Shell', color: 0xffffff, size: [8, 3, 0.1], pos: [0, 1.5, -5], opacity: 0.1, pick: 'through' }, + { type: 'cylinder', name: 'Coin', color: 0xfacc15, r: 0.3, h: 0.05, pos: [-4, 1.5, -2], rot: [Math.PI / 2, 0, 0], anim: 'Turntable' }, + { type: 'box', name: 'Door', color: 0x8b5a2b, size: [1, 2, 0.1], pos: [4, 1, -2], origin: [-0.5, 0, 0], anim: ['door', 'Pulse'] }, + { type: 'sphere', name: 'Fountain', color: 0x999999, r: 0.2, pos: [0, 0.2, 4], particles: { preset: 'sparkles', count: 33 } }, + { type: 'box', name: 'Brazier', color: 0x333333, size: [0.4, 0.4, 0.4], pos: [-2, 0.2, 4], particles: 'fire' }, + { type: 'light', kind: 'spot', name: 'Spot', color: 0xffeecc, intensity: 20, angle: 0.5, penumbra: 0.4, pos: [0, 6, 4], target: [0, 0, 0] }, + { type: 'light', kind: 'directional', name: 'Key', color: 0xffffff, intensity: 2, pos: [10, 14, 8], target: [0, 0, 0], shadowMapSize: 1024 }, + { type: 'light', kind: 'hemisphere', name: 'Fill', color: 0xbbddff, groundColor: 0x332211, intensity: 0.6, pos: [0, 5, 0] }, + { type: 'light', name: 'Bulb', color: 0xffaa55, intensity: 3, distance: 8, pos: [3, 2, 3] } + ] +}; + +/** Author the def through the real script into a fresh temp folder; returns the .tpscene + * bytes and the thumbnail bytes. @param {any} def @param {string} tag */ +function author(def, tag) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'author-kit-' + tag + '-')); + const file = path.join(dir, 'def.json'); + fs.writeFileSync(file, JSON.stringify(def)); + const out = path.join(dir, 'out'); + const script = path.join(__dirname, '../../scripts/author-templates.cjs'); + const log = execFileSync('node', [script, '--def', file, '--only', def.slug, '--out', out], { + env: { ...process.env, APP_URL: h.URL }, + encoding: 'utf8', + timeout: 240000 + }); + console.log(log.trim().split('\n').filter((l) => /B, thumb|WARN|FATAL|PAGEERROR/.test(l)).join('\n')); + const section = def.kind === 'game' ? 'games' : def.kind === 'template' ? 'templates' : 'examples'; + const base = path.join(out, section, def.slug); + return { + scene: fs.readFileSync(path.join(base, 'scene.tpscene')), + thumb: fs.existsSync(path.join(base, 'thumb.webp')) ? fs.readFileSync(path.join(base, 'thumb.webp')) : null + }; +} + +/** Load a .tpscene into the page through the Templates modal's own read path. */ +async function load(page, bytes) { + return page.evaluate(async (b64) => { + const s = window.__stores; + const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const payload = await s.sessions.readSessionZip(bin.buffer); + if (!payload) return false; + await s.sessions.applySession(payload, { backup: false, replicate: false }); + return true; + }, bytes.toString('base64')); +} + +h.run(async () => { + // ---- P0: primitives, lights, materials, flags ------------------------------------- + const authored = author(DEF, 'p0'); + check(authored.scene.length > 1000, 'the def authored a .tpscene (' + authored.scene.length + ' B)'); + + const browser = await h.launch({ args: h.GPU_ARGS }); + const peer = await h.setupPage(browser, 'author-kit', { context: { viewport: { width: 1540, height: 774 } } }); + const page = peer.page; + await page.waitForFunction(() => !!window.__stores?.sessions && !!window.__stores?.animationPreview, { timeout: 30000 }); + check(await load(page, authored.scene), 'the authored file loads through readSessionZip + applySession'); + await page.waitForTimeout(800); + + const r = await page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let group; + s.objectsGroup.subscribe((g) => (group = g))(); + const by = (/** @type {string} */ n) => group.getObjectByName(n); + const geo = (/** @type {string} */ n) => by(n)?.geometry; + const mat = (/** @type {string} */ n) => by(n)?.material; + /** @type {any} */ let anims; + s.animationPreview.animations.subscribe((v) => (anims = v))(); + const clipNames = (/** @type {string} */ n) => Object.values(anims[by(n)?.uuid]?.clips ?? {}).map((/** @type {any} */ c) => c.name); + const rounded = geo('Rounded'); + rounded.computeBoundingBox(); + const bb = rounded.boundingBox; + // a rounded box's corner is CUT: no vertex reaches the sharp corner's |x|+|y|+|z| + const pos = rounded.attributes.position.array; + let cornerMax = 0; + for (let i = 0; i < pos.length; i += 3) cornerMax = Math.max(cornerMax, Math.abs(pos[i]) + Math.abs(pos[i + 1]) + Math.abs(pos[i + 2])); + const aim = (/** @type {string} */ n, /** @type {number[]} */ t) => { + const l = by(n); + l.updateMatrixWorld(true); + const wp = l.getWorldPosition(new s.THREE.Vector3()); + const fwd = new s.THREE.Vector3(0, 0, -1).applyQuaternion(l.getWorldQuaternion(new s.THREE.Quaternion())); + return fwd.dot(new s.THREE.Vector3(...t).sub(wp).normalize()); + }; + const key = by('Key'); + const cam = key?.shadow?.camera; + return { + roundedType: rounded.type, + roundedVerts: rounded.attributes.position.count, + roundedSize: [bb.max.x - bb.min.x, bb.max.y - bb.min.y, bb.max.z - bb.min.z], + cornerMax, + capsule: geo('Capsule')?.type, + capsuleRadius: geo('Capsule')?.parameters?.radius, + plane: geo('Plane')?.type, + ring: geo('Ring')?.type, + ringInner: geo('Ring')?.parameters?.innerRadius, + ico: geo('Ico')?.type, + icoDetail: geo('Ico')?.parameters?.detail, + dodeca: geo('Dodeca')?.type, + glass: { + physical: !!mat('Glass')?.isMeshPhysicalMaterial, + clearcoat: mat('Glass')?.clearcoat, + clearcoatRoughness: mat('Glass')?.clearcoatRoughness, + transmission: mat('Glass')?.transmission, + thickness: mat('Glass')?.thickness, + ior: mat('Glass')?.ior, + sheen: mat('Glass')?.sheen, + sheenColor: mat('Glass')?.sheenColor?.getHexString() + }, + plainPhysical: !!mat('Plain physical')?.isMeshPhysicalMaterial, + floorStandard: mat('Floor')?.type, + glow: { emissive: mat('Glow')?.emissive?.getHexString(), intensity: mat('Glow')?.emissiveIntensity }, + faceted: { flat: mat('Faceted')?.flatShading, side: mat('Faceted')?.side, double: s.THREE.DoubleSide }, + toon: !!mat('Toon')?.isMeshToonMaterial, + shellPick: by('Shell')?.userData?.pick, + floorPick: by('Floor')?.userData?.pick ?? null, + coinClips: clipNames('Coin'), + doorClips: clipNames('Door'), + doorOrigin: by('Door')?.userData?.origin, + fountain: by('Fountain')?.userData?.particles, + brazier: by('Brazier')?.userData?.particles?.preset, + spot: { + is: !!by('Spot')?.isSpotLight, + angle: by('Spot')?.angle, + penumbra: by('Spot')?.penumbra, + shadow: by('Spot')?.castShadow, + aim: aim('Spot', [0, 0, 0]) + }, + key: { + is: !!key?.isDirectionalLight, + shadow: key?.castShadow, + aim: aim('Key', [0, 0, 0]), + frustum: cam ? [cam.left, cam.right, cam.bottom, cam.top, cam.near, cam.far] : null, + mapSize: key?.shadow?.mapSize?.x + }, + hemi: { is: !!by('Fill')?.isHemisphereLight, ground: by('Fill')?.groundColor?.getHexString() }, + bulb: { is: !!by('Bulb')?.isPointLight, distance: by('Bulb')?.distance } + }; + }); + console.log(JSON.stringify(r)); + + // primitives + check(r.roundedType === 'BufferGeometry', 'bevel: the rounded box is baked into a plain BufferGeometry (ObjectLoader rebuilds it) — ' + r.roundedType); + check(r.roundedVerts > 36, 'bevel: it carries the rounded tessellation (' + r.roundedVerts + ' verts)'); + check(r.roundedSize.every((v, i) => Math.abs(v - [2, 1, 1.2][i]) < 1e-3), 'bevel: its outer size is still the authored size ' + r.roundedSize.map((v) => v.toFixed(3))); + check(r.cornerMax < 2.1 - 0.05, 'bevel: the corners are cut (max |x|+|y|+|z| ' + r.cornerMax.toFixed(3) + ' < a sharp 2.1)'); + check(r.capsule === 'CapsuleGeometry' && Math.abs(r.capsuleRadius - 0.4) < 1e-6, 'capsule: CapsuleGeometry r=0.4 (' + r.capsule + ')'); + check(r.plane === 'PlaneGeometry', 'plane: PlaneGeometry (' + r.plane + ')'); + check(r.ring === 'RingGeometry' && Math.abs(r.ringInner - 0.6) < 1e-6, 'ring: RingGeometry inner 0.6 (' + r.ring + ')'); + check(r.ico === 'IcosahedronGeometry' && r.icoDetail === 1, 'icosahedron: IcosahedronGeometry detail 1 (' + r.ico + ')'); + check(r.dodeca === 'DodecahedronGeometry', 'dodecahedron: DodecahedronGeometry (' + r.dodeca + ')'); + // materials + check(r.glass.physical, 'physical: a physical-only field makes a MeshPhysicalMaterial'); + check(Math.abs(r.glass.clearcoat - 0.8) < 1e-6, 'physical: clearcoat 0.8 survives (' + r.glass.clearcoat + ')'); + check(Math.abs(r.glass.clearcoatRoughness - 0.15) < 1e-6, 'physical: clearcoatRoughness 0.15 survives'); + check(Math.abs(r.glass.transmission - 0.9) < 1e-6, 'physical: transmission 0.9 survives (' + r.glass.transmission + ')'); + check(Math.abs(r.glass.thickness - 0.5) < 1e-6, 'physical: thickness 0.5 survives'); + check(Math.abs(r.glass.ior - 1.45) < 1e-6, 'physical: ior 1.45 survives (' + r.glass.ior + ')'); + check(Math.abs(r.glass.sheen - 0.4) < 1e-6 && r.glass.sheenColor === 'ff0000', 'physical: sheen 0.4 + sheenColor #ff0000 survive (' + r.glass.sheenColor + ')'); + check(r.plainPhysical, 'physical: `physical: true` alone makes a MeshPhysicalMaterial'); + check(r.floorStandard === 'MeshStandardMaterial', 'an object using none of it stays MeshStandardMaterial (' + r.floorStandard + ')'); + check(r.glow.emissive === '00ffcc' && Math.abs(r.glow.intensity - 2.5) < 1e-6, 'emissive + emissiveIntensity 2.5 survive (' + JSON.stringify(r.glow) + ')'); + check(r.faceted.flat === true, 'flatShading survives'); + check(r.faceted.side === r.faceted.double, "side: 'double' survives as DoubleSide"); + check(r.toon, 'toon: MeshToonMaterial'); + // flags + check(r.shellPick === 'through', "pick: 'through' lands on userData.pick"); + check(r.floorPick === null, 'an unflagged object carries no pick key'); + check(r.coinClips.includes('Turntable'), 'anim: the Turntable preset is an authored clip on the coin (' + r.coinClips + ')'); + check(r.doorClips.includes('Door') && r.doorClips.includes('Pulse'), 'anim: a list of presets, by key or by name (' + r.doorClips + ')'); + check(Array.isArray(r.doorOrigin) && r.doorOrigin[0] === -0.5, 'origin: the hinge offset lands on userData.origin (' + JSON.stringify(r.doorOrigin) + ')'); + check(r.fountain?.preset === 'sparkles' && r.fountain?.count === 33 && r.fountain?.sprite === 'star', 'particles: a preset + a patch (count 33) on userData.particles'); + check(r.brazier === 'fire', "particles: a bare preset name ('fire')"); + // lights + check(r.spot.is && Math.abs(r.spot.angle - 0.5) < 1e-6 && Math.abs(r.spot.penumbra - 0.4) < 1e-6, 'spot: SpotLight with angle 0.5 + penumbra 0.4'); + check(r.spot.shadow === true, 'spot: casts shadows by default (createLight convention)'); + check(r.spot.aim > 0.999, 'spot: aimed by rotation at its target (forward . to-target = ' + r.spot.aim.toFixed(4) + ')'); + check(r.key.is && r.key.shadow === true, 'directional: DirectionalLight casting shadows'); + check(r.key.aim > 0.999, 'directional: aimed at its target (' + r.key.aim.toFixed(4) + ')'); + check( + !!r.key.frustum && r.key.frustum[1] > 5.5 && r.key.frustum[1] < 40 && r.key.frustum[4] > 0.1 && r.key.frustum[5] > r.key.frustum[4] + 10, + 'directional: its shadow frustum is FITTED to the scene, not three\'s default ±5 (' + (r.key.frustum ?? []).map((v) => v.toFixed(1)) + ')' + ); + check(r.key.mapSize === 1024, 'directional: shadowMapSize 1024 survives (' + r.key.mapSize + ')'); + check(r.hemi.is && r.hemi.ground === '332211', 'hemisphere: HemisphereLight with its ground colour'); + check(r.bulb.is && r.bulb.distance === 8, 'point: the existing point light is unchanged'); + + await h.finish(browser); +}); + +/** @param {boolean} ok @param {string} label */ +function check(ok, label) { + h.check(ok, label); +} From 688dc407712e45ac6bc65a513d9ab870f76218ad Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:38:06 +0300 Subject: [PATCH 03/46] [fix] 30 P0: console hygiene - the PCF shadow warning, the router pushState warning, rapier's init warning - App.svelte: . threlte defaults to PCFSoftShadowMap, which three 0.185 deprecates and downgrades to PCF on the first shadow render - so the picture is identical and the warning is gone. - playMode.js: the back-button marker is a SvelteKit SHALLOW entry (pushState from $app/navigation) instead of a raw history.pushState, which the router answers in dev with "will conflict with SvelteKit's router". Same url, same navigation index, so the Back that spends it never navigates. New playMarkerState() reads the state under sveltekit:states. - physics.js: the wasm "deprecated parameters for the initialization function" warning is rapier-compat's OWN init() handing wasm-bindgen a bare byte array; the call is inside the frozen dependency, so there is no single-object form to pass. It is filtered for the length of that one call (exact prefix, restored in finally) - every other warn passes. - helpers.cjs: page.__console collects every console message from BEFORE the first navigation (the PCF warning fires on frame one; a listener attached after boot misses it). - touch-play: reads the marker through playMarkerState() (the state moved under the router's key). Suite console-hygiene (new) 13/13. Counterfactual: all three source fixes reverted at once -> 4 red (the PCF warning, the marker not being a shallow entry, the router warning, the wasm warning), restored -> green. touch-play 39/39. Held at base: game-state 45, game-presence 61, hud-actions 65, game-towers 20, play-interact 46. svelte-check 336/47 (no new message), vitest 196, build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 6 +- src/lib/physics.js | 26 ++++++- src/lib/playMode.js | 38 +++++++-- tests/e2e/console-hygiene.test.cjs | 121 +++++++++++++++++++++++++++++ tests/e2e/helpers.cjs | 6 ++ tests/e2e/touch-play.test.cjs | 4 +- 6 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/console-hygiene.test.cjs diff --git a/src/App.svelte b/src/App.svelte index f93a67e8..4da8044a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,6 +2,10 @@ import { safeStorage } from '$lib/safeStorage'; import { onMount } from 'svelte' import { Canvas } from '@threlte/core' + // 30 P0: threlte's Canvas defaults `shadows` to PCFSoftShadowMap, which three 0.185 + // deprecates and silently downgrades to PCFShadowMap on the first shadow render (with a + // console warning). Asking for PCF directly is the identical picture, minus the warning. + import { PCFShadowMap } from 'three' import Scene from './components/Scene.svelte' import Menu from './components/Menu.svelte' import ConfirmModal from './components/menu/ConfirmModal.svelte' @@ -582,7 +586,7 @@ import { startMusicToolbox } from './lib/musicToolbox' step reallocates the composer's render targets, so animating the inset turns one realloc into one per frame. -->
- +
diff --git a/src/lib/physics.js b/src/lib/physics.js index 1ed12567..14f0268b 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -148,10 +148,34 @@ const IMPACT_MIN_DOWN_VY = 1.2; // m/s downward (pre-step) for a contact to coun export async function warmup() { if (RAPIER) return; const module = await import('@dimforge/rapier3d-compat'); - await module.init(); + await initQuietly(module); RAPIER = module; } +// 30 P0: rapier-compat's own `init()` hands wasm-bindgen's init a bare byte array — the +// pre-0.2.93 calling convention — and wasm-bindgen answers with "using deprecated +// parameters for the initialization function; pass a single object instead". The call +// is INSIDE rapier's bundle (`init` takes no arguments and the inner init is not +// exported), so there is no single-object form for us to pass, and rapier is a FROZEN +// dependency (solver behaviour). So the one warning is filtered for the length of that +// call and nothing else: an exact-prefix match, restored in `finally`, every other +// console.warn passes straight through. +const RAPIER_INIT_WARNING = 'using deprecated parameters for the initialization function'; + +/** @param {any} module */ +async function initQuietly(module) { + const warn = console.warn; + console.warn = (/** @type {any[]} */ ...args) => { + if (typeof args[0] === 'string' && args[0].startsWith(RAPIER_INIT_WARNING)) return; + warn.apply(console, args); + }; + try { + await module.init(); + } finally { + console.warn = warn; + } +} + /** @param {any} object */ function transformOf(object) { return { diff --git a/src/lib/playMode.js b/src/lib/playMode.js index 6f573a36..a19f886b 100644 --- a/src/lib/playMode.js +++ b/src/lib/playMode.js @@ -3,6 +3,12 @@ import { isLocked, isVRMode, vrOverride, vrPassthrough } from '../stores/sceneSt // appStore is a LEAF (svelte/store and nothing else — sceneStore already pulls in more // than it does), so this does not widen the cycle surface the note below is about. import { showToast } from '../stores/appStore'; +// 30 P0: the back-button marker goes through SvelteKit's shallow-routing entry, not a raw +// `history.pushState` — the router patches the raw call in dev with a "will conflict with +// SvelteKit's router" warning, and it is right: its own popstate handler reads the index +// keys only ITS pushState writes. `$app/navigation` is SSR-safe to IMPORT (only a CALL on +// the server throws, and the marker is only ever pushed from a browser play press). +import { pushState } from '$app/navigation'; // THE PLAY STATE MACHINE, lifted out of Controls.svelte so the play FAB, the FAB's // right-click mode menu and (next) a keyboard shortcut all press the same button. @@ -154,20 +160,38 @@ export function exitPlay() { // no history to go back to, pushing and popping our own entry is invisible. // // It is safe to pop because the marker is ALWAYS the top entry when we hold one: this -// app keeps no state in the URL and nothing else in it calls pushState (grep), so -// there is no router to fight and nothing of the user's can be underneath ours. The -// flag is the whole guard — see `consumePlayMarker` for the double-pop it prevents. +// app keeps no state in the URL and nothing else in it pushes history entries (grep), so +// nothing of the user's can be underneath ours. The flag is the whole guard — see +// `consumePlayMarker` for the double-pop it prevents. +// +// 30 P0: the entry is a SvelteKit SHALLOW entry (`pushState` from $app/navigation) — +// same url, same navigation index, so the router's popstate handler treats the Back +// that spends it as a state change and never navigates; our listener below still sees +// the popstate. The state object lands under the router's `sveltekit:states` key, which +// is where `playMarkerState()` reads it. let backMarker = false; function pushPlayMarker() { if (backMarker || typeof history === 'undefined') return; try { - // the SAME url with a state object: no navigation, nothing for a router to - // resolve, and `history.state.tpPlay` is a readable answer to "is the marker up?" - history.pushState({ tpPlay: true }, ''); + // the SAME url ('' resolves to the current one) with a state object: no + // navigation, nothing to load, and `playMarkerState()` is a readable answer to + // "is the marker up?" + pushState('', { tpPlay: true }); backMarker = true; } catch { - /* a sandboxed frame can refuse pushState; play simply keeps its normal exits */ + /* a sandboxed frame can refuse pushState (and the router refuses before it has + started); play simply keeps its normal exits */ + } +} + +/** the marker's state object as the router stored it on the CURRENT entry (test/debug view) */ +export function playMarkerState() { + try { + const st = /** @type {any} */ (typeof history === 'undefined' ? null : history.state); + return !!st?.['sveltekit:states']?.tpPlay; + } catch { + return false; } } diff --git a/tests/e2e/console-hygiene.test.cjs b/tests/e2e/console-hygiene.test.cjs new file mode 100644 index 00000000..10ae74ce --- /dev/null +++ b/tests/e2e/console-hygiene.test.cjs @@ -0,0 +1,121 @@ +// 30 P0 — CONSOLE HYGIENE. Three warnings every session printed, each a real fault in +// miniature and each reported from the diagnostics log of a user's Quest: +// +// 1. `WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead.` +// threlte's defaults `shadows` to PCFSoftShadowMap; three 0.185 downgrades +// it on the first shadow render. App.svelte asks for PCFShadowMap directly now — +// the identical picture, and this suite also asserts the renderer's type IS PCF. +// 2. `Avoid using history.pushState(...)` — SvelteKit's router, answering the play +// marker's raw pushState. playMode.js pushes a SvelteKit SHALLOW entry instead. +// 3. `using deprecated parameters for the initialization function` — wasm-bindgen, +// answering rapier-compat's own init(). physics.js filters exactly that line for +// the length of the call (the call is inside rapier, which is a frozen dependency). +// +// The console is read from BEFORE the first navigation (helpers.setupPage attaches its +// listener ahead of page.goto), because the PCF warning fires on the very first frame +// with a shadow-casting light — a listener attached after boot would never see it and +// this suite would pass over the bug. +const h = require('./helpers.cjs'); + +const BANNED = [ + { id: 'pcf', re: /PCFSoftShadowMap has been deprecated/ }, + { id: 'pushState', re: /Avoid using `history\.pushState/ }, + { id: 'wasm-init', re: /using deprecated parameters for the initialization function/ } +]; + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + /** @param {string} id */ + const seen = (id) => { + const rule = BANNED.find((b) => b.id === id); + return (page.__console ?? []).filter((m) => rule.re.test(m.text)); + }; + + h.check(Array.isArray(page.__console), 'the console is captured from before the first navigation'); + + // a lit, shadow-casting scene: a box under the default environment's sun + await page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 800)); + }); + await page.waitForTimeout(1500); + + // ---- 1. the shadow map -------------------------------------------------------- + const shadow = await page.evaluate(() => { + const THREE = window.__stores.THREE; + let renderer; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + return { + enabled: !!renderer?.shadowMap?.enabled, + type: renderer?.shadowMap?.type, + pcf: THREE.PCFShadowMap, + soft: THREE.PCFSoftShadowMap, + lights: (() => { + let n = 0; + let scene; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + scene?.traverse((o) => { + if (o.isLight && o.castShadow) n++; + }); + return n; + })() + }; + }); + h.check(shadow.lights > 0, `premise: a shadow-casting light is in the scene (${shadow.lights})`); + h.check(shadow.enabled, 'shadows stay enabled'); + h.check(shadow.type === shadow.pcf, `the renderer asks for PCFShadowMap (type ${shadow.type}, PCF ${shadow.pcf}, soft ${shadow.soft})`); + h.check(seen('pcf').length === 0, `no PCFSoftShadowMap deprecation warning (${seen('pcf').length})`); + + // ---- 2 + 3. enter play (pushes the back marker, warms rapier), exit --------------- + await page.evaluate(async () => { + const s = window.__stores; + await s.physics.warmup(); + s.playMode.requestPlay(); + }); + await h.eventually( + () => page.evaluate(() => window.__stores.playMode.playBackMarker()), + (v) => v === true, + 'entering play pushes the back marker', + 6000 + ); + const marker = await page.evaluate(() => window.__stores.playMode.playMarkerState?.() ?? false); + h.check(marker === true, 'the marker is a SvelteKit shallow entry (sveltekit:states.tpPlay)'); + await page.evaluate(() => window.__stores.playMode.exitPlay()); + await h.eventually( + () => page.evaluate(() => window.__stores.playMode.playBackMarker()), + (v) => v === false, + 'leaving play spends the marker', + 6000 + ); + await h.eventually( + () => page.evaluate(() => { let v; window.__stores.isLocked.subscribe((x) => (v = x))(); return v; }), + (v) => v === null, + 'and play settles back to the editor', + 6000 + ); + await page.waitForTimeout(600); + const rapierReady = await page.evaluate(async () => { + try { + await window.__stores.physics.warmup(); + return true; + } catch { + return false; + } + }); + h.check(rapierReady, 'premise: rapier initialised (its init is what prints the wasm warning)'); + h.check(seen('pushState').length === 0, `no SvelteKit router pushState warning (${seen('pushState').length})`); + h.check(seen('wasm-init').length === 0, `no wasm "deprecated parameters" warning (${seen('wasm-init').length})`); + + // the filter must not eat anything ELSE: an ordinary warning still gets through + await page.evaluate(() => console.warn('console-hygiene: an ordinary warning')); + await page.waitForTimeout(200); + h.check( + (page.__console ?? []).some((m) => /console-hygiene: an ordinary warning/.test(m.text)), + 'an ordinary console.warn still reaches the console (the filter is scoped to one call)' + ); + + await h.finish(browser); +}); diff --git a/tests/e2e/helpers.cjs b/tests/e2e/helpers.cjs index ca8d20c0..28a62fae 100644 --- a/tests/e2e/helpers.cjs +++ b/tests/e2e/helpers.cjs @@ -119,6 +119,12 @@ async function setupPage(browser, name, options = {}) { page.__errors.push(err.message ?? String(err)); console.log(`[${name} pageerror] ` + err.stack); }); + // 30 P0: every console message, from BEFORE the first navigation — a warning printed on + // the first frame (three's shadow-map deprecation) is invisible to a listener attached + // after boot. `{type, text}`; `console-hygiene` reads it. + /** @type {{type: string, text: string}[]} */ + page.__console = []; + page.on('console', (message) => page.__console.push({ type: message.type(), text: message.text() })); // 27-D: `options.hash` loads the app WITH a hash (`{ hash: '#safe' }`). It has to be // on the initial navigation, not set afterwards: safe mode is read once during // onMount, so a hash assigned to a live page arrives long after the decision. diff --git a/tests/e2e/touch-play.test.cjs b/tests/e2e/touch-play.test.cjs index 9fb0e0e9..3d0f1e72 100644 --- a/tests/e2e/touch-play.test.cjs +++ b/tests/e2e/touch-play.test.cjs @@ -94,7 +94,9 @@ const watchClaims = (page) => const historyState = (page) => page.evaluate(() => ({ - tpPlay: !!(history.state && history.state.tpPlay), + // 30 P0: the marker is a SvelteKit SHALLOW entry now, so its state object sits under + // the router's `sveltekit:states` key — playMarkerState() reads it there + tpPlay: window.__stores.playMode.playMarkerState(), marker: window.__stores.playMode.playBackMarker(), href: location.href, mounted: !!document.querySelector('#play-button') || !!document.querySelector('canvas') From 4b1e7edb018493c13446f2e8cdbb1a2af3759aab Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:53:50 +0300 Subject: [PATCH 04/46] =?UTF-8?q?[feat]=2030=20P1:=20edit=20and=20interact?= =?UTF-8?q?=20=E2=80=94=20two=20editor=20click=20modes,=20click=20handlers?= =?UTF-8?q?=20routed=20by=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New LOCAL store `editorMode` ('edit' | 'interact', sceneStore; never sent, never saved). EDIT (default): a viewport click SELECTS first; a module click handler runs only if it registered for Edit; On Click nodes do NOT fire from the editor pick. INTERACT: a click is play's tap aimed by the cursor (module groups, handlers that run in 'interact', On Click nodes, the miss), nothing is selected and the gizmo is put away; a press on a dynamic body while a sim runs CARRIES it along the cursor ray with playInteract's own hold (one spring, one throw, one cancel discipline — only the aim differs) and the camera controls stand down for that gesture. Play is unchanged. - SDK: `api.registerClickHandler(fn, {modes})`, modes any of 'edit'|'interact'|'play'; ABSENT = ['interact', 'play'], so a module that never passes it stops eating editor clicks (the reported Jam Room case: Piano / Drum machine / Sampler never selectable). One dispatch `runClickHandlers(object, mode)` for the editor pick, Interact and the play tap. A core handler pushed straight into the array (vrPatch's plug click — patching is authoring) runs in every mode. VR's trigger passes no mode and keeps offering every handler first (VR has no mode toggle yet — recorded). - UI: an Edit/Interact toggle cell ('Interact mode (I)', a real + {#if open} + {#each rows as row (row.name)} +
+
+ +
+ {#if expanded[row.name]} + {#each row.children as child (child.uuid)} +

+ {child.name} +

+ {/each} + {#if row.more} +

+{row.more} more

+ {/if} + {#if !row.children.length && !row.more} +

No named parts

+ {/if} + {/if} +
+ {/each} + {/if} + +{/if} + +{#if menu} + (menu = null)} /> +{/if} diff --git a/src/lib/moduleContent.js b/src/lib/moduleContent.js new file mode 100644 index 00000000..457e8afc --- /dev/null +++ b/src/lib/moduleContent.js @@ -0,0 +1,220 @@ +// 30 P3: MODULE CONTENT in the object list — a LEAF (THREE + svelte/store + sceneStore + +// helperLayer). moduleSDK writes the registry, the object list reads it, objectActions +// clears the selection; none of them may import each other through here, which is why +// it is its own file (moduleSDK primes objectActions DYNAMICALLY to dodge that cycle, and +// a registry living in either one would close it). +// +// THE FINDING (roadmap 30, "Selection and the object list", cause 3): scene-root module +// content — the dungeon, the untangle board, the piano, the sabers — lives outside +// `objectsGroup` by golden rule 5, so it was never picked and never LISTED. Only the +// advanced System filter polled `systemGroupNames`, and it named no module. Every module +// group is listed now, READ-ONLY: the module owns it and regenerates it from its own +// state, so a rename, a delete or a reparent here would be undone on the next rebuild +// (or, worse, not undone and never replicated). +// +// Selecting one selects a PROXY, never the group: a scene-root box on the helper layer +// sized to the group's bounds. It is not in objectsGroup, so it never replicates, never +// saves and never enters the selection SET (applySelectionSet filters uuids it cannot +// find there) — the selection outline, the gizmo and every "act on the selection" +// command stay exactly what they were. +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { globalScene } from '../stores/sceneStore'; +import { markHelper } from './helperLayer'; + +/** named children listed per group, and how deep */ +export const LIST_CAP = 200; +export const LIST_DEPTH = 2; + +/** + * @typedef {{ name: string, moduleId: string, moduleName: string, label: string, + * icon: string | null, kinds: Set }} ModuleGroup + */ + +/** @type {Map} scene-root group name -> who registered it */ +const groups = new Map(); + +/** bumps on every registry change so the list re-derives */ +export const moduleGroupsRevision = writable(0); + +/** + * Record a module's scene-root group. `kind` is which register* call named it + * ('interactive' | 'system' | 'listed'); the row lives while any kind still holds it. + * @param {string} name @param {{id: string, name?: string}} owner @param {string} kind + * @param {{label?: string, icon?: string}} [opts] + */ +export function noteModuleGroup(name, owner, kind, opts = {}) { + if (!name || !owner?.id) return; + const entry = groups.get(name) ?? { + name, + moduleId: owner.id, + moduleName: owner.name || owner.id, + label: '', + icon: null, + kinds: new Set() + }; + entry.kinds.add(kind); + if (opts.label) entry.label = String(opts.label).slice(0, 80); + if (opts.icon) entry.icon = String(opts.icon); + groups.set(name, entry); + moduleGroupsRevision.update((n) => n + 1); +} + +/** @param {string} name @param {string} kind */ +export function forgetModuleGroup(name, kind) { + const entry = groups.get(name); + if (!entry) return; + entry.kinds.delete(kind); + if (kind === 'listed') { + entry.label = ''; + entry.icon = null; + } + if (!entry.kinds.size) groups.delete(name); + if (get(moduleSelection)?.name === name && !groups.has(name)) clearModuleSelection(); + moduleGroupsRevision.update((n) => n + 1); +} + +/** every registered module group, in a stable order (module, then name) */ +export function moduleGroupList() { + return [...groups.values()].sort( + (a, b) => a.moduleName.localeCompare(b.moduleName) || a.name.localeCompare(b.name) + ); +} + +/** @param {string} name */ +export function moduleGroupOf(name) { + return groups.get(name) ?? null; +} + +/** A group name is an id ('untangle-module'); until the module names it with + * registerListedGroup, show it the way a person would write it ('Untangle module'). + * @param {string} name */ +export function humanize(name) { + const words = String(name).replace(/[-_]+/g, ' ').trim(); + return words ? words[0].toUpperCase() + words.slice(1) : String(name); +} + +/** + * The rows the list draws: one per registered group PRESENT in the scene, with its named + * descendants down to LIST_DEPTH, capped at LIST_CAP (the rest counted, not dropped + * silently). A group a module registered but has not built yet is not listed — there is + * nothing to frame or select. + * @param {any} scene + */ +export function moduleContentRows(scene) { + if (!scene) return []; + /** @type {any[]} */ + const rows = []; + for (const entry of moduleGroupList()) { + const root = scene.getObjectByName(entry.name); + if (!root || root.parent !== scene) continue; + /** @type {{name: string, depth: number, uuid: string}[]} */ + const children = []; + let total = 0; + /** @param {any} node @param {number} depth */ + const walk = (node, depth) => { + for (const child of node.children ?? []) { + if (child.userData?.isModuleProxy) continue; + if (child.name) { + total++; + if (children.length < LIST_CAP) children.push({ name: child.name, depth, uuid: child.uuid }); + } + if (depth < LIST_DEPTH) walk(child, depth + 1); + } + }; + walk(root, 1); + rows.push({ + name: entry.name, + label: entry.label || humanize(entry.name), + moduleId: entry.moduleId, + moduleName: entry.moduleName, + icon: entry.icon, + object: root, + visible: root.visible !== false, + children, + more: Math.max(0, total - children.length) + }); + } + return rows; +} + +// --- the selection: a proxy, never the group ---------------------------------------- + +/** @type {import('svelte/store').Writable<{name: string, moduleId: string, moduleName: string, label: string} | null>} */ +export const moduleSelection = writable(null); + +/** @type {any} */ +let proxy = null; +const box = new THREE.Box3(); +let lastFit = 0; + +/** @param {any} root */ +function fitProxy(root) { + if (!proxy || !root) return false; + box.makeEmpty(); + // bounds of the GROUP only — the proxy is a scene-root sibling, never inside it + box.setFromObject(root); + if (box.isEmpty()) { + const at = root.getWorldPosition(new THREE.Vector3()); + box.setFromCenterAndSize(at, new THREE.Vector3(0.5, 0.5, 0.5)); + } + proxy.box.copy(box); + proxy.updateMatrixWorld(true); + return true; +} + +/** + * Select a module group: park the proxy around it and publish the selection. Returns the + * group's world bounds (the caller frames them), or null when the group is not in the scene. + * @param {string} name + */ +export function selectModuleGroup(name) { + const scene = /** @type {any} */ (get(globalScene)); + const root = scene?.getObjectByName(name); + const entry = groups.get(name); + if (!root || !entry) return null; + if (!proxy) { + proxy = new THREE.Box3Helper(new THREE.Box3(), new THREE.Color(0xf59e0b)); + proxy.name = 'module-content-proxy'; + proxy.userData.isModuleProxy = true; + proxy.material.depthTest = false; + proxy.material.transparent = true; + proxy.renderOrder = 999; + markHelper(proxy); + } + if (proxy.parent !== scene) scene.add(proxy); + fitProxy(root); + moduleSelection.set({ name, moduleId: entry.moduleId, moduleName: entry.moduleName, label: entry.label || humanize(name) }); + return box.clone(); +} + +export function clearModuleSelection() { + if (proxy?.parent) proxy.parent.remove(proxy); + if (get(moduleSelection)) moduleSelection.set(null); +} + +/** Per frame (Scene's task): module content moves on its own, so the box follows it — + * at a few Hz, because setFromObject walks the whole group. */ +export function tickModuleProxy() { + const selection = get(moduleSelection); + if (!selection || !proxy?.parent) return; + const now = performance.now(); + if (now - lastFit < 250) return; + lastFit = now; + const root = proxy.parent.getObjectByName(selection.name); + if (!root) { + clearModuleSelection(); + return; + } + fitProxy(root); +} + +/** test/debug view */ +export function moduleContentDebug() { + return { + groups: moduleGroupList().map((g) => ({ name: g.name, moduleId: g.moduleId, label: g.label, kinds: [...g.kinds] })), + selection: get(moduleSelection), + proxyInScene: !!proxy?.parent, + proxyBox: proxy?.parent ? { min: proxy.box.min.toArray(), max: proxy.box.max.toArray() } : null + }; +} diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index e2ffb17b..da0f691d 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -42,6 +42,9 @@ import { APP_VERSION } from './version.js'; import { ndcFromClient } from './canvasRect'; // 27-B: recovery paths report through the diagnostics ring (hardening audit H4) import { log } from './diagnostics'; +// 30 P3: the object list's Module content registry — a LEAF, so no cycle +import { noteModuleGroup, forgetModuleGroup } from './moduleContent'; +export { moduleContentDebug } from './moduleContent'; import { safeStorage } from './safeStorage'; // Module SDK v1 — in-repo modules under src/modules// register through @@ -554,20 +557,37 @@ function makeApi(moduleId, moduleName = moduleId) { registerInteractiveGroup(name) { moduleInteractiveGroups.push(name); registerSystemGroup(name); // clickable module content is also listable + noteModuleGroup(name, { id: moduleId, name: moduleName }, 'interactive'); // 30 P3 onDispose(() => { arrayRemove(moduleInteractiveGroups, name); arrayRemove(systemGroupNames, name); + forgetModuleGroup(name, 'interactive'); removeSceneRootGroup(name); // module-owned viewport content goes with the module }); }, /** List a scene-root group under the object list's System filter @param {string} name */ registerSystemGroup(name) { registerSystemGroup(name); + noteModuleGroup(name, { id: moduleId, name: moduleName }, 'system'); // 30 P3 onDispose(() => { arrayRemove(systemGroupNames, name); + forgetModuleGroup(name, 'system'); removeSceneRootGroup(name); }); }, + /** + * 30 P3: list a scene-root group in the object list's "Module content" section under + * a label a person can read (the group's own name is usually an id). Groups passed to + * registerInteractiveGroup / registerSystemGroup are listed anyway; this names them, + * or lists one that is neither. Read-only there: a click selects a PROXY and frames + * it, and the Inspector points at your module's toolbox and nodes. + * @param {string} name the scene-root group's object name + * @param {{label?: string, icon?: string}} [options] + */ + registerListedGroup(name, options = {}) { + noteModuleGroup(name, { id: moduleId, name: moduleName }, 'listed', options ?? {}); + onDispose(() => forgetModuleGroup(name, 'listed')); + }, /** * Runs when the scene is cleared (locally or by a peer) — remove your * viewport content and reset module state here. diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index d1b92219..a6e3ab43 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -36,6 +36,8 @@ import { canEditObject, warnViewerReadOnly } from './objectPermissions'; import { stripEditOverlays, isEditOverlay } from './editOverlays'; // B7: the transient marker (a LEAF — two stores only, so no cycle back through history) import { markTransient } from './transientObjects'; +// 30 P3: a LEAF (THREE + stores), so a static import here closes no cycle +import { clearModuleSelection } from './moduleContent'; // D2: a LEAF (svelte stores + THREE), so a static import here closes no cycle import { shareDuplicatedMaterials, linkMaterials } from './materialSharing'; import { @@ -165,6 +167,7 @@ export function applySelectionSet(uuids, openProperties = false) { !locked.find((lockedUuid) => lockedUuid[1] === uuid) ); applyMemberTints(group, clean); + if (clean.length) clearModuleSelection(); // 30 P3: an object selection replaces the proxy const previous = get(selectedObjects); selectedObjects.set(clean); if (clean.length) lastSelection = { uuids: [...clean], origin: null }; // 24-B1 @@ -307,6 +310,7 @@ export function deselectObject() { applyMemberTints(get(objectsGroup), []); broadcastSelectionRelease(get(selectedObjects)); selectedObjects.set([]); + clearModuleSelection(); // 30 P3: the Module content proxy goes with any deselect if (controls && !get(isVRMode)) controls.detach(); // selectedObject keeps the last object on purpose — the open inspector binds // to $selectedObject.position/material and would crash on an empty value diff --git a/tests/e2e/object-list-modules.test.cjs b/tests/e2e/object-list-modules.test.cjs new file mode 100644 index 00000000..12776cdd --- /dev/null +++ b/tests/e2e/object-list-modules.test.cjs @@ -0,0 +1,263 @@ +// 30 P3 — EVERY MODULE'S CONTENT IS LISTED. The object list's "Module content" section. +// +// Measured before this (roadmap 30, "Selection and the object list", cause 3): scene-root +// module content — the untangle board, the dungeon, the piano, the sabers — lives +// outside objectsGroup by golden rule 5, so it was never picked and never LISTED (only +// the advanced System filter polled it). Now every group a module registers gets one +// READ-ONLY row: the module's name as a badge, expandable to its named children, a click +// that selects a PROXY and frames it, and an Inspector card saying whose it is. +// +// The proxy is the thing to distrust, so it is checked from every side: it never enters +// objectsGroup, never reaches a peer, never appears in a saved payload, and a deselect +// removes it. Skip-never-fail when the untangle scene or zip cannot be reached (the +// game-untangle sources: UNTANGLE_TPSCENE / UNTANGLE_ZIP / the feed / a sibling checkout). +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES_BASE = (process.env.UNTANGLE_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2').replace(/\/$/, ''); +const MODULES_BASE = (process.env.UNTANGLE_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); +const ROOT = path.resolve(__dirname, '../../..'); +const TEMPLATE_LEVEL = 2; + +async function fetchBytes(url) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(20000) }); + if (!res.ok) { + console.log(' (source) ' + url + ' -> HTTP ' + res.status); + return null; + } + return Buffer.from(await res.arrayBuffer()); + } catch (error) { + console.log(' (source) ' + url + ' -> ' + error.message); + return null; + } +} +async function sceneBytes() { + if (process.env.UNTANGLE_TPSCENE) { + const p = process.env.UNTANGLE_TPSCENE; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const feed = await fetchBytes(SCENES_BASE + '/games/untangle/scene.tpscene'); + if (feed) return { bytes: feed, from: SCENES_BASE }; + for (const dir of ['theprototype.app-scenes', 'scenes']) { + const p = path.join(ROOT, dir, 'games/untangle/scene.tpscene'); + if (fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + } + return null; +} +async function zipBytes() { + if (process.env.UNTANGLE_ZIP) { + const p = process.env.UNTANGLE_ZIP; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const local = []; + if (process.env.MODULES_REPO) local.push(path.join(process.env.MODULES_REPO, 'untangle.zip')); + local.push(h.moduleZipPath('untangle')); + for (const dir of fs.readdirSync(ROOT)) if (/^(theprototype\.app-)?modules/.test(dir)) local.push(path.join(ROOT, dir, 'untangle.zip')); + for (const p of local) if (p && fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + const cdn = await fetchBytes(MODULES_BASE + '/untangle.zip'); + return cdn ? { bytes: cdn, from: MODULES_BASE } : null; +} +async function installZip(peer, bytes, label) { + await peer.page.evaluate(() => window.__stores.modulesOpen.set(true)); + await peer.page.waitForTimeout(400); + await peer.page.getByRole('tab', { name: /^User/ }).click(); + await peer.page.waitForTimeout(200); + await peer.page.locator('#install-module-zip').setInputFiles({ name: 'untangle.zip', mimeType: 'application/zip', buffer: bytes }); + await h.eventually( + () => peer.page.evaluate(() => window.__stores.moduleSDK.loadedModules.map((m) => m.id)), + (ids) => ids.includes('untangle'), + label + ': the untangle module installed from the real zip', + 20000 + ); + await peer.page.evaluate(() => window.__stores.modulesOpen.set(false)); + await peer.page.waitForTimeout(300); +} + + +const GROUP = 'untangle-module'; +const objectCount = (page) => + page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + let n = 0; + g.traverse(() => n++); + return n; + }); +const debug = (page) => page.evaluate(() => window.__stores.moduleSDK.moduleContentDebug()); + +h.run(async () => { + const scene = await sceneBytes(); + if (!scene) { + console.log('SKIP: games/untangle/scene.tpscene unreachable'); + return; + } + const zip = await zipBytes(); + if (!zip) { + console.log('SKIP: untangle.zip unreachable'); + return; + } + console.log(' scene from ' + scene.from + ' / module from ' + zip.from); + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + const page = A.page; + await installZip(A, zip.bytes, 'A'); + await installZip(B, zip.bytes, 'B'); + await page.evaluate(async (arr) => { + const s = window.__stores; + await s.sessions.applySession(await s.sessions.readSessionZip(new Uint8Array(arr).buffer), { backup: false }); + }, Array.from(scene.bytes)); + await h.eventually( + () => page.evaluate((g) => { + let sc; + window.__stores.globalScene.subscribe((v) => (sc = v))(); + return sc?.getObjectByName(g)?.children.filter((c) => c.name.startsWith('untangle-dot-')).length ?? 0; + }, GROUP), + (n) => n > 0, + '(premise) the untangle board is built at the scene root' + ); + await h.connect(A, B); + await page.waitForTimeout(1500); + + // ---------------------------------------------------------------- 1 + console.log('\n=== 1. the section lists the board, read-only ==='); + await page.evaluate(() => window.__stores.objectListClose.set(false)); + await page.waitForTimeout(1500); + const row = page.locator(`#module-content .module-content-row[data-group="${GROUP}"]`); + h.check((await row.count()) === 1, '1.1 the object list has a Module content row for the untangle board'); + const label = ((await row.locator('.module-content-name').textContent()) ?? '').trim(); + h.check(/untangle/i.test(label), `1.2 ...labelled for a person (${label})`); + const badge = ((await row.locator('.module-content-badge').textContent()) ?? '').trim(); + h.check(/untangle/i.test(badge), `1.3 ...with the module's name as its badge (${badge})`); + await row.locator('button[aria-label="Show children"]').click(); + await page.waitForTimeout(300); + const kids = await row.locator('.module-content-child').allTextContents(); + h.check(kids.some((t) => t.trim().startsWith('untangle-dot-')), `1.4 expanding it lists the dots (${kids.length} named parts)`); + // the tree above is untouched: the board is NOT a scene row + const sceneRows = await page.evaluate((g) => [...document.querySelectorAll('#object-tree [role="treeitem"]')].some((el) => el.textContent.includes(g)), GROUP); + h.check(!sceneRows, '1.5 the board is not smuggled into the scene tree'); + await row.click({ button: 'right' }); + await page.waitForTimeout(300); + const menu = await page.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim())); + h.check(menu.includes('Frame it') && !menu.some((t) => /delete|rename|duplicate/i.test(t)), `1.6 its menu is read-only (${menu.join(' | ')})`); + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + + // ---------------------------------------------------------------- 2 + console.log('\n=== 2. a click selects a PROXY and frames it ==='); + const countsA = await objectCount(page); + const countsB = await objectCount(B.page); + const camBefore = await page.evaluate(() => { + let c; + window.__stores.globalCamera.subscribe((v) => (c = v))(); + return c.position.toArray(); + }); + await row.locator('.module-content-name').click(); + await page.waitForTimeout(900); + const d = await debug(page); + h.check(d.selection?.name === GROUP, `2.1 the proxy selection names the board (${d.selection?.name})`); + h.check(d.proxyInScene, '2.2 the proxy box is in the scene'); + const inside = await page.evaluate((g) => { + const s = window.__stores; + let sc; + s.globalScene.subscribe((v) => (sc = v))(); + const box = new s.THREE.Box3().setFromObject(sc.getObjectByName(g)); + const proxy = sc.getObjectByName('module-content-proxy'); + return !!proxy && proxy.parent === sc && proxy.box.containsBox(box.expandByScalar(-1e-4)); + }, GROUP); + h.check(inside, '2.3 ...at the SCENE ROOT, sized to the board\'s bounds'); + const camAfter = await page.evaluate(() => { + let c; + window.__stores.globalCamera.subscribe((v) => (c = v))(); + return c.position.toArray(); + }); + h.check(Math.hypot(...camAfter.map((v, i) => v - camBefore[i])) > 0.2, '2.4 the camera framed it'); + const card = ((await page.locator('#module-content-card').textContent().catch(() => '')) ?? '').trim(); + h.check(/untangle/i.test(card) && /module/i.test(card), `2.5 the Inspector shows whose it is (${card.slice(0, 80)})`); + const sel = await page.evaluate(() => { + let v; + window.__stores.selectedObjects.subscribe((x) => (v = x))(); + return v.length; + }); + h.check(sel === 0, '2.6 the object SELECTION stays empty (the proxy never joins the set)'); + + // ---------------------------------------------------------------- 3 + console.log('\n=== 3. the proxy never replicates and never saves ==='); + h.check((await objectCount(page)) === countsA, `3.1 objectsGroup unchanged on A (${countsA})`); + await page.waitForTimeout(1200); + h.check((await objectCount(B.page)) === countsB, `3.2 ...and on the peer (${countsB})`); + const saved = await page.evaluate(() => JSON.stringify(window.__stores.sessions.buildSessionPayload('probe'))); + h.check(!saved.includes('module-content-proxy'), '3.3 a saved payload does not contain the proxy'); + const onB = await B.page.evaluate(() => { + let sc; + window.__stores.globalScene.subscribe((v) => (sc = v))(); + return !!sc.getObjectByName('module-content-proxy'); + }); + h.check(!onB, '3.4 the peer has no proxy'); + + // ---------------------------------------------------------------- 4 + console.log('\n=== 4. a deselect puts it away ==='); + await page.evaluate(() => window.__stores.objectActions.deselectObject()); + await page.waitForTimeout(300); + const gone = await debug(page); + h.check(!gone.proxyInScene && !gone.selection, '4.1 deselect clears the proxy and the selection'); + + // ---------------------------------------------------------------- 5 + console.log('\n=== 5. in the VIEWPORT: an Edit click on the board selects its proxy; Interact plays ==='); + await page.evaluate(() => window.__stores.inspectorClose.set(true)); + await page.evaluate((g) => { + const s = window.__stores; + let sc; + s.globalScene.subscribe((v) => (sc = v))(); + const box = new s.THREE.Box3().setFromObject(sc.getObjectByName(g)); + const c = box.getCenter(new s.THREE.Vector3()); + s.objectActions.flyTo([c.x, c.y + 0.4, c.z + 3.2], c.toArray(), 0); + }, GROUP); + await page.waitForTimeout(900); + const dot = await page.evaluate((g) => { + const s = window.__stores; + let sc, camera, renderer; + s.globalScene.subscribe((v) => (sc = v))(); + s.globalCamera.subscribe((v) => (camera = v))(); + s.globalRenderer.subscribe((v) => (renderer = v))(); + const root = sc.getObjectByName(g); + const target = root.children.find((c) => c.name.startsWith('untangle-dot-')); + const p = target.getWorldPosition(new s.THREE.Vector3()); + const v = p.clone().project(camera); + const rect = renderer.domElement.getBoundingClientRect(); + const x = rect.left + ((v.x + 1) / 2) * rect.width; + const y = rect.top + ((1 - v.y) / 2) * rect.height; + const ray = new s.THREE.Raycaster(); + ray.setFromCamera(new s.THREE.Vector2(v.x, v.y), camera); + const hit = ray.intersectObject(root, true)[0]; + let n = hit?.object; + while (n && n.parent !== root) n = n.parent; + return { x, y, hitsBoard: !!hit, name: n?.name ?? null, onCanvas: document.elementFromPoint(x, y) === renderer.domElement }; + }, GROUP); + h.check(dot.hitsBoard && dot.onCanvas, `5.1 (premise) the pixel lands on the board (${dot.name})`); + await page.evaluate(() => { + window.__stores.objectActions.deselectObject(); + window.__stores.objectActions.setEditorMode('edit'); + }); + await page.waitForTimeout(1700); + await page.mouse.click(dot.x, dot.y); + await page.waitForTimeout(500); + const edit = await debug(page); + h.check(edit.selection?.name === GROUP, `5.2 an EDIT click on the board selects its proxy (${edit.selection?.name ?? 'nothing'})`); + await page.evaluate(() => { + window.__stores.objectActions.deselectObject(); + window.__stores.objectActions.setEditorMode('interact'); + }); + await page.waitForTimeout(1700); + await page.mouse.click(dot.x, dot.y); + await page.waitForTimeout(500); + const inter = await debug(page); + h.check(!inter.selection, '5.3 in INTERACT the same click selects nothing (it belongs to the module)'); + await page.mouse.click(dot.x + 200, dot.y + 150); // drop whatever the module picked up + await page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + + h.check(h.pageErrors(A).length === 0, `the page threw nothing (${h.pageErrors(A).join(' / ')})`); + await h.finish(browser); +}); From 0da7522d098d439a4cedb348f09d334adec63b2a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 01:20:30 +0300 Subject: [PATCH 08/46] [feat] 30 P1: the editor stops masquerading as the running game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hudDocs: `isGameHud(docs)` + the `hudIsGame` store. THE RULE: a scene is a game when any HUD document holds a screen bound to a game state (`showWhile` set) - that binding is the one thing that makes the editor masquerade (the shared state picks the screen, so the menu paints itself over the editor with nobody in Play). Every Games-tab template has one. A hudbutton->setgamestate wiring alone was considered and NOT taken: with no state-bound screen nothing masquerades, and reading the graph would put a flow edge into a leaf that keeps none. A HUD with no state-bound screen keeps its old behaviour. - HudLayer: outside Play a game's screens (scene and camera docs) are not drawn. The HUD editor's preview eye (`hudPreviewInViewport`) still draws them, with every element INERT: HudElement `editor` (the artboard's swallow, out of the tab order) AND the whole preview click-through (`.hud-inert`), so a previewed menu can never fire the game nor eat a viewport click (the roadmap's selection finding 4). An inert preview claims no keys. - GameChip.svelte (new, mounted in App.svelte beside HudLayer): "Game · " + an eye that toggles the same preview store. Editor-only: hidden in Play, VR and embed mode. Top-left under the logo (tucks under a docked Connect bar via --connect-bottom), z 38 - the authoring HUD band, under every window. Test play joins it in P2. - Suites broken by the change, updated in place: game-towers ("the menu renders" moved to after the play press), game-loop (the 21-D scenario presses Start IN PLAY now - both peers play, the menu screen frees the pointer, A approves the late joiner from the editor while B keeps the round alive), game-waves/game-untangle/game-dungeon-realms (the editor-side menu check becomes "the chip stands in for it", the menu check moves into play). Suite game-editor-flow (new) 17/17 on the real Towers .tpscene + collectible zip. Counterfactual: HudLayer restored to base -> 8 red, incl. "clicking where Start used to be leaves the game on its menu (playing)" - the reported bug, reproduced. game-loop 31/31 · game-untangle 47/47 · game-dungeon-realms 65/65 · game-waves 85/91 with the SAME six ledger/run-log reds on base source (A/B'd: pre-existing, module-side: a run logged twice 1 ms apart, an overkill pulse count). Held: game-towers 20 · hud-actions 65 (base 20/65). svelte-check 336/47, no new message. Build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 5 + src/components/hud/GameChip.svelte | 86 +++++++++++++++ src/components/hud/HudLayer.svelte | 28 ++++- src/lib/hudDocs.js | 24 ++++- tests/e2e/game-dungeon-realms.test.cjs | 3 +- tests/e2e/game-editor-flow.test.cjs | 142 +++++++++++++++++++++++++ tests/e2e/game-loop.test.cjs | 18 +++- tests/e2e/game-towers.test.cjs | 4 +- tests/e2e/game-untangle.test.cjs | 3 +- tests/e2e/game-waves.test.cjs | 3 +- 10 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/components/hud/GameChip.svelte create mode 100644 tests/e2e/game-editor-flow.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 4da8044a..315b9a83 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -106,6 +106,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { loadProjectManifest } from '$lib/projectManifest' import { startSharedLibrary } from '$lib/sharedLibrary' import { startSceneIdentity } from '$lib/sceneIdentity' + import GameChip from './components/hud/GameChip.svelte' import HudLayer from './components/hud/HudLayer.svelte' import HudEditor from './components/editors/HudEditor.svelte' import { importFile, load } from '$lib/fileHandler.svelte' @@ -572,6 +573,10 @@ import { startMusicToolbox } from './lib/musicToolbox' above (a game HUD that dies when you press play is no HUD at all). --z-hud, no new tier: it beats the camera PiP and loses to modal/toast/menu. --> + + +## 1.17.0 — Games that look finished 🎨 + +### 🖐️ Dragging, clicking and selecting that do what you meant + +- 🖐️ **Dragging in games no longer throws on Quest.** Every press in play logged a + `setPointerCapture` InvalidStateError (about a hundred in one Untangle session) and leaving play + could end in "Something went wrong": the editor's orbit controls stayed mounted in play and + captured the pointer under a pointer lock. They stand down while you play now, and the capture + is guarded. +- 🔀 **Edit and Interact.** A new cell at the end of the bottom bar (or the **I** key) switches + the editor between Edit — every click selects, so every object can be moved — and Interact, + where clicks and drags work the way they do in play (piano keys play, crates carry) without + starting the game. Module click handlers say which modes they run in; by default a game piece + answers in Interact and Play and an Edit click selects it. +- 👻 **Click through glass.** A see-through wall or a shell marked "Click-through in the viewport" + (Inspector ▸ Object) no longer steals the click from what is behind it, and clicking the same + spot again (after the double-click window) cycles down through everything under the cursor. +- 🧩 **Every module's content is in the object list.** A new "Module content" section lists the + pieces a module builds outside the scene (the Untangle board, a piano, a dungeon) by name; + click a row to frame it, hide it locally, or open the module's toolbox. + +### 🎮 Games start when you press Start + +- ▶️ **Test play.** A game no longer paints its menu over the editor with live buttons. A small + "Game · menu" chip says the scene is a game, and ▶ Test play resets it to its menu, enters + Play and puts the Start screen in front of you (also on the play button's right-click menu). + The HUD editor's eye still previews the screens, inert. +- 🔁 **The round resets when the last player leaves.** Alone, leaving Play returns the game to its + menu at once; with others, when the last player has been out for ten seconds. +- 🖱️ **Free-cursor games.** A scene can play with the real mouse cursor and no pointer lock + (Untangle and the Jam Room do): the cursor aims, clicks fire On Click, press-drag carries. The + editor grid no longer shows in Play. +- 💾 **Games remember things.** `api.storage` for modules and the new **Store Value** / **Stored + Value** nodes keep a best score or your unlocked levels on this device (never shared, never + saved into the scene). The HUD action "Save best score" wires one up. +- 🔇 **A quieter console.** The PCFSoftShadowMap deprecation, the router pushState warning and + rapier's init warning are gone. + +### 🎨 Seven games that look finished + +- 🏗️ **Towers remembers your best height** — and looks finished: a sky, a tiled floor, wooden + crates, glowing height rings, turning stars; Round over shows this round and your best. +- 🌌 **Stars Room is a space room** — a starfield behind glass, crystal stars, a start screen + with Start round or Free play, a two-minute round, your best round saved. +- 🎸 **Jam Room is a game** — a small studio; press Start, then ▶ on the Transport, and keep the + band going for eight bars. Played with the mouse, your best tempo saved. +- 🏟️ **Football** is a floodlit glass stadium with a live scoreboard. +- 🏰 **Dungeon Realms** is torch-lit stone under a vault, with glowing gems, animated portals and + a Start menu you can click. +- 🌅 **Waves** is a sunset arena with a crystal tower that dims with your health, portals, + readable enemies and hit flashes. +- 🪢 **Untangle, finished:** a real drag (press-drag-release, or click then click), a redrawn + board, 30 levels per mode that unlock as you solve them (your progress stays on this device, + with a Reset), and a new 3D mode — untangle the graph around a globe. +- Every game has a Start screen, Pause on **P**, and a restart. + +### 🧰 For authors + +- 🧱 **Game and template defs can say more:** rounded boxes, capsules, rings, planes, + ico/dodecahedra; spot, directional (shadows fitted to the scene) and hemisphere lights; glass, + clearcoat, sheen and toon materials; animation and particle presets by name; click-through + shells; custom skies with a gradient, fog and a solid ground. Games-tab cards are rendered with + each game's own lighting. +- 🔤 **Centred HUD text is centred.** A single-line HUD text sat flush left inside its box + whatever its alignment said. +- 🧩 SDK: `registerClickHandler(fn, {modes})`, `api.registerListedGroup(name, {label})`, + `api.storage`, `api.editorMode()`, and `api.pointerRay()` is the crosshair ray under a pointer lock and the + cursor's ray in a free-cursor game. + ## 1.16.0 — Waves, and one world to keep 🌊 ### 🎮 A new game, and a Games tab that can move again diff --git a/CLAUDE.md b/CLAUDE.md index 9de987e5..4aa5a66e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2156,6 +2156,101 @@ loadable play content. Everything a user does must be visible to connected peers `#embed-play` (▶ while not playing, so an Esc inside the frame has a way back in). Absent = the old app. The community Worker's `/e/` frames the app at `/?s=&play=1&embed=1`; no separate viewer build. Suite `embed-boot` (20). +- **ROADMAP 30 — EDIT / INTERACT, AND THE SELECTION THAT FINDS THINGS** (1.17.0; plan + as-built: + cloud `plans/core/roadmap-30-games-that-look-finished.md`). `sceneStore.editorMode` + ('edit' | 'interact', LOCAL — never replicated, never saved) + `objectActions.setEditorMode`; the + toggle is the `mode` cell at the RIGHT END of the default Controls bar (`#editor-mode-toggle`, a + ` + {#if $scenePlay.spawn} + + {/if} +

Shared: everyone entering play mode in this scene gets these.

diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte index faa9a53a..a88e1e91 100644 --- a/src/components/play/PointerLockControls.svelte +++ b/src/components/play/PointerLockControls.svelte @@ -7,6 +7,7 @@ import { userdata, peers } from '../../stores/appStore' import { dungeonData, slideMove, spawnPointFor } from '$lib/dungeonPlay' import { resolvePlaySettings } from '$lib/playSettings' + import { scenePlay } from '$lib/scenePhysics' // 30 P3: free-cursor games never take the pointer — the real cursor aims import { playCursorFree } from '$lib/playCursor' import { inputClaims, getGamepadAxes } from '$lib/inputRuntime' @@ -205,6 +206,15 @@ rig.position.y = 0.8 rig.position.z = spawn.z } + } else if (rig) { + // 30c: the scene's own SPAWN (scenePhysics play.spawn: feet position + yaw). + // Absent = the fixed start, untouched. The rig lives in a group lifted 0.9, so + // an eye 0.8 above that is 1.7 above the feet (the dungeon line above). + const at = get(scenePlay)?.spawn + if (at) { + rig.position.set(at.pos[0], at.pos[1] + 0.8, at.pos[2]) + rig.quaternion.setFromEuler(new Euler(0, at.yaw ?? 0, 0, 'YXZ')) + } } }) } else { diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index f98f7d4c..a40bac20 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -17,6 +17,7 @@ import { hasAnimatedImport, sendAnimatedImport, setAnimationState, dropAllAnimat import { dropAllAnimations } from '$lib/animationPreview' import { parkAnimatedAtBase } from '$lib/flowRuntime' import { stripEditOverlays } from '$lib/editOverlays' +import { isPristinePackRef, stubElementOf } from '$lib/packRefs' import { runSceneClearHandlers } from '$lib/moduleSDK' import { annotations } from '$lib/annotationsHandler' import { isViewer, warnViewerReadOnly } from '$lib/objectPermissions' @@ -943,6 +944,12 @@ async function applyCreateObject(object, uuid, override, groupuuid, pos, rot, sc */ export function sendObjects(peerId, element, opts = {}) { let groupid; + if (peerId === null && isPristinePackRef(element)) { + // 30c: a placed KIT PIECE reaches every peer as its stub; each refills it from + // the pack (no GLTF of a 1 MB piece per placement — see packRefs.js) + peer.send({ type: 'object', element: stubElementOf(element), groupuuid: element.parent?.uuid, ...(opts.override ? { override: true } : {}) }); + return; + } if (peerId === null) { groupid = element.uuid; peer.send({type: 'group', name: element.name, uuid: element.uuid, groupparent: null, @@ -1039,6 +1046,19 @@ export function sendObject(conn, element, groupuuid, opts = {}) { objects.forEach(element => { // viewer perms: never sync a viewer's local-only objects to a peer if (element.userData && element.userData.__localOnly) return; + if (isPristinePackRef(element)) { + // 30c: a pristine KIT PIECE travels as a stub (one small message) and the + // receiver refills it from its pack — packRefs.js carries the measurement (one + // wall is 7.9 MB as toJSON). ObjectLoader path: the element's matrix is LOCAL, + // and the receiver adds it under `groupuuid`. + conn.send({ + type: 'object', + element: stubElementOf(element), + groupuuid: element.parent?.uuid, + ...heal + }); + return; + } if (hasAnimatedImport(element.uuid)) { // rigs travel as their original file bytes, one message sendAnimatedImport(conn, element, opts); @@ -1171,7 +1191,8 @@ function countObjects(element, sink) { objects = sceneObjects.children; } objects.forEach(element => { - if (element.type == "Group" && !hasAnimatedImport(element.uuid)) { + // 30c: a kit-piece stub is ONE message; its children are refilled, never sent + if (element.type == "Group" && !hasAnimatedImport(element.uuid) && !isPristinePackRef(element)) { countObjects(element, sink); } sink.push(element.uuid) diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js index 8eba91af..b3de320d 100644 --- a/src/lib/explorerDrop.js +++ b/src/lib/explorerDrop.js @@ -6,6 +6,7 @@ import { peers, showToast, toastStore, stackOnDrop } from '../stores/appStore'; import { explorerItems, itemBlob } from './explorer'; import { prefabs, instantiatePrefab } from './prefabs'; import { importFile } from './fileHandler.svelte'; +import { packRefFromUrl } from './packRefs'; import { setObjectTexture } from './materialsHandler'; import { topLevelObjectOf } from './objectActions'; import { sceneHits, hitWorldNormal } from './scenePick'; @@ -205,7 +206,11 @@ async function placeExplorerPayload(payload, target) { dismiss(); return showToast('Could not fetch the pack item'); } - importFile(new File([await res.blob()], name + '.glb'), name, undefined, target.point ?? undefined); + // 30c: the placed piece carries its pack reference, so a save and the wire write it + // as a small stub instead of the whole model (packRefs.js) + importFile(new File([await res.blob()], name + '.glb'), name, undefined, target.point ?? undefined, undefined, { + packRef: packRefFromUrl(payload.url, { item: name }) + }); dismiss(); } catch { dismiss(); diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js index a79248e6..2a085ab8 100644 --- a/src/lib/fileHandler.svelte.js +++ b/src/lib/fileHandler.svelte.js @@ -23,6 +23,8 @@ import { createGltfLoader, registerAnimatedImport, recordAnimatedImport, sendAni import { environment } from './environment'; import { parkAnimatedAtBase } from '$lib/flowRuntime'; import { stripEditOverlays } from '$lib/editOverlays'; +import { stampPackRef } from '$lib/packRefs'; +import { hashBytes } from '$lib/explorer'; import { saveFileBase } from '$lib/saveName'; import { peers, fixLight, loadingFile, showToast, showInfoToast, dismissToastById } from '../stores/appStore'; import { safeStorage } from './safeStorage'; @@ -666,7 +668,10 @@ function defaultImportName(extension, name) { * @param {any} file @param {string=} name @param {string=} ext - explicit extension when the blob has no name (Library) * @param {number[]=} position - world drop point (Explorer drag-out, 96) * @param {any[]=} extras - companion files picked/dropped alongside (.mtl + its textures) - * @param {{reduce?: boolean | import('./importBudget').ReductionPlan}} [opts] 26-F: import REDUCED + * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null}} [opts] + * 26-F: `reduce` imports REDUCED. 30c: `packRef` names the PACK ITEM this file is — the + * placed root then carries the reference (packRefs.js), so a save and the wire write it + * as a small stub. Ignored for an animated or a reduced import (neither IS the file). * @returns {Promise} the placed root's uuid, or null when nothing was placed */ export async function importFile(file, name, ext, position, extras, opts = {}) { @@ -703,6 +708,9 @@ export async function importFile(file, name, ext, position, extras, opts = {}) { return null; } try { + // 30c: stamp the pack reference BEFORE addImported, which is what replicates it + if (opts.packRef && !parsed.animated && typeof file?.arrayBuffer === 'function') + stampPackRef(parsed.root, opts.packRef, await hashBytes(await file.arrayBuffer())); if (parsed.animated) addAnimatedImport(parsed.animated.result, parsed.animated.buffer, label, parsed.animated.kind); else addImported(parsed.root, label, position); for (const note of parsed.notes) showToast(note); diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js new file mode 100644 index 00000000..b252b9b9 --- /dev/null +++ b/src/lib/packRefs.js @@ -0,0 +1,546 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +// @ts-ignore - three addons ship no declarations here (project-wide) +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +// @ts-ignore +import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; +// @ts-ignore +import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; +import { writable, get } from 'svelte/store'; +import { objectsGroup, pokeScene } from '../stores/sceneStore'; +import { showToast } from '../stores/appStore'; +import { PACKS_BASE } from './packs'; +import { hashBytes } from './explorer'; + +// 30c — KIT REFERENCES: a pack piece in a scene is a REFERENCE, not a copy. +// +// THE MEASUREMENT this module exists for: one placed `WallStone` (a 0.84 MB GLB from the +// architecture kit) serialized through `buildSessionPayload` as a 7.9 MB toJSON element — +// its three 1024² JPEGs come back as PNG data URLs (3.3 + 2.3 + 1.9 MB) — and 5.9 MB +// zipped. `objects` is one toJSON() PER top-level child, so two copies of one wall share +// nothing, and a castle of ~100 kit pieces would be a ~600 MB file. The wire has the same +// shape (a session load sends each element; a placement sends each mesh as a GLTF). +// +// So a piece placed from a pack carries WHERE IT CAME FROM: +// userData.packRef = {pack, item, path, hash, kids} +// `path` is relative to PACKS_BASE (an absolute URL passes through — the Khronos rows), +// `hash` is the content hash of the bytes it was built from, `kids` the uuids of its +// descendants in traverse order (so every peer rebuilds the same child uuids). A save, a +// session-load broadcast and a late joiner's sync write a PRISTINE piece as a STUB: an +// empty Group with the root's uuid, transform and userData plus `packStub: true`. Every +// peer (and the loader of a file) REFILLS a stub from the pack URL — fetched and parsed +// once per piece, and every copy shares its textures, so twelve walls upload three +// textures instead of thirty-six. +// +// PRISTINE is measured, never assumed: a piece whose descendants differ from the file it +// came from (a mesh edit, a new colour, a dropped texture, a moved child) is written in +// full exactly as before — `fingerprintOf` over the descendants' structure, geometry +// checksums, material parameters and an 8×8 sample of every texture, compared against the +// same fingerprint of the parsed file. Only the ROOT's own transform, name and userData +// are free, and those ride the stub. +// +// AN OLDER BUILD reading a stub gets an empty group where the piece was (nothing throws); +// a peer that cannot reach the pack keeps the stub, says so once, and SAVES it back as a +// stub — a reference is never silently turned into nothing. +// +// A LEAF: three + its loaders, svelte/store, two stores, packs (for PACKS_BASE) and +// explorer (for the one content hash) — both leaves themselves. sessions and +// commandsHandler import this; nothing here reaches back. + +/** + * @typedef {{pack: string, item: string, path: string, hash?: string, kids?: string[]}} PackRef + */ + +/** How many refills are in flight — the author script and the suite wait on it. + * @type {import('svelte/store').Writable} */ +export const packRefsPending = writable(0); + +/** url -> the parsed file ({hash, scene}); a failure is forgotten so a retry can win + * @type {Map>} */ +const templates = new Map(); +/** content hash -> the fingerprint of a pristine copy + * @type {Map} */ +const fingerprints = new Map(); +/** image-bytes key -> the ONE texture every piece painted with those bytes uses + * @type {Map} */ +const sharedTextures = new Map(); +/** root uuid -> its refill, so two scans never fill one root twice + * @type {Map>} */ +const filling = new Map(); +/** urls already reported as unreachable (one toast per piece, not per copy) */ +const reported = new Set(); + +/** @param {any} object @returns {PackRef | null} */ +export function packRefOf(object) { + const ref = object?.userData?.packRef; + return ref && typeof ref.path === 'string' && ref.path ? ref : null; +} + +/** Where a reference's bytes live. @param {PackRef} ref */ +export function packRefUrl(ref) { + if (/^https?:\/\//.test(ref.path)) return ref.path; + return String(PACKS_BASE).replace(/\/+$/, '') + '/' + ref.path.replace(/^\/+/, ''); +} + +/** + * The reference for a pack item fetched from `url`: relative to PACKS_BASE when it lives + * there (so a scene follows the pack to whatever ref the build serves), absolute otherwise. + * @param {string} url @param {{pack?: string, item?: string}} [names] + * @returns {PackRef | null} + */ +export function packRefFromUrl(url, names = {}) { + if (!url || typeof url !== 'string') return null; + const base = String(PACKS_BASE).replace(/\/+$/, '') + '/'; + let path = url; + if (url.startsWith(base)) path = url.slice(base.length); + else if (!/^https?:\/\//.test(url)) return null; + const pack = names.pack || (path === url ? '' : path.split('/')[0]) || 'pack'; + const item = names.item || (path.split('/').pop() ?? '').replace(/\.\w+$/, '') || 'item'; + return { pack, item, path }; +} + +function createLoader() { + const loader = new GLTFLoader(); + const draco = new DRACOLoader(); + draco.setDecoderPath('/draco/'); + loader.setDRACOLoader(draco); + loader.setMeshoptDecoder(MeshoptDecoder); + return loader; +} + +/** + * The bytes of every IMAGE in a GLB, by glTF image index — read from the file's own JSON + * chunk, because the parsed texture no longer knows them. Empty for a .gltf or anything + * unexpected (sharing is an optimisation; nothing depends on it). + * @param {ArrayBuffer} buffer @returns {Uint8Array[]} + */ +function glbImages(buffer) { + try { + const view = new DataView(buffer); + if (view.getUint32(0, true) !== 0x46546c67) return []; + const jsonLength = view.getUint32(12, true); + const json = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 20, jsonLength))); + const binStart = 20 + jsonLength + 8; + return (json.images ?? []).map((/** @type {any} */ image) => { + const bv = json.bufferViews?.[image.bufferView]; + return bv ? new Uint8Array(buffer, binStart + (bv.byteOffset ?? 0), bv.byteLength) : new Uint8Array(); + }); + } catch { + return []; + } +} + +const MAP_SLOTS = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap', 'alphaMap', 'bumpMap', 'displacementMap', 'lightMap']; + +/** + * One texture per distinct IMAGE across every kit piece: the architecture kit's six + * sandstone walls carry byte-identical JPEGs, so without this each wall variant uploaded + * its own three. Keyed by the image bytes' hash plus everything that makes two textures + * of one image differ (colour space, sampler, channel, flip). + * @param {any} gltf @param {ArrayBuffer} buffer + */ +async function shareTextures(gltf, buffer) { + const images = glbImages(buffer); + if (!images.length) return; + const json = gltf.parser?.json; + /** @type {string[]} */ + const imageKeys = await Promise.all( + images.map(async (bytes) => (bytes.byteLength ? hashBytes(bytes.slice().buffer) : '')) + ); + gltf.scene.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + for (const material of Array.isArray(node.material) ? node.material : [node.material]) { + for (const slot of MAP_SLOTS) { + const texture = material?.[slot]; + if (!texture) continue; + const index = gltf.parser?.associations?.get(texture)?.textures; + const source = index == null ? undefined : json?.textures?.[index]?.source; + const imageKey = source == null ? '' : imageKeys[source]; + if (!imageKey) continue; + const key = [imageKey, texture.colorSpace, texture.wrapS, texture.wrapT, texture.magFilter, texture.minFilter, texture.channel, texture.flipY].join('|'); + const shared = sharedTextures.get(key); + if (shared && shared !== texture) { + material[slot] = shared; + texture.dispose(); + } else if (!shared) sharedTextures.set(key, texture); + } + } + }); +} + +/** + * Fetch + parse a pack file ONCE per url, and register the fingerprint of a pristine copy + * under the hash of the bytes actually fetched. + * @param {string} url @returns {Promise<{hash: string, scene: any}>} + */ +export function loadPackTemplate(url) { + let job = templates.get(url); + if (!job) { + job = (async () => { + const res = await fetch(url); + if (!res.ok) throw new Error('HTTP ' + res.status); + const buffer = await res.arrayBuffer(); + const hash = await hashBytes(buffer); + /** @type {any} */ + const gltf = await new Promise((resolve, reject) => createLoader().parse(buffer, '', resolve, reject)); + // an animated rig replicates as its BYTES (animatedImports) — never a reference + if (gltf.animations?.length) throw new Error('animated models are not referenced'); + await shareTextures(gltf, buffer); + const scene = gltf.scene; + scene.updateMatrixWorld(true); + fingerprints.set(hash, fingerprintOf(scene)); + return { hash, scene }; + })(); + templates.set(url, job); + job.catch(() => templates.delete(url)); + } + return job; +} + +// ---- the fingerprint --------------------------------------------------------- + +/** Seven significant digits, and anything under 1e-6 is zero — a GLTF round trip turns a + * rotation matrix's -1.14e-9 into -1.42e-9, which is the same matrix. + * @param {number} v */ +const num = (v) => + typeof v === 'number' && Number.isFinite(v) ? (Math.abs(v) < 1e-6 ? 0 : Number(v.toPrecision(7))) : String(v); + +/** Two weighted sums over a typed array — equal arrays give equal sums, and a moved + * vertex moves the second one even when it keeps the first. + * @param {ArrayLike} array */ +function checksum(array) { + let a = 0; + let b = 0; + for (let i = 0; i < array.length; i++) { + a += array[i]; + b += array[i] * ((i % 251) + 1); + } + return num(a) + '/' + num(b); +} + +/** @param {any} geometry */ +function geometryKey(geometry) { + if (!geometry?.attributes) return '-'; + const names = Object.keys(geometry.attributes).sort(); + const parts = names.map((name) => { + const attr = geometry.attributes[name]; + return name + ':' + attr.itemSize + ':' + attr.count + ':' + (attr.normalized ? 1 : 0) + ':' + checksum(attr.array); + }); + const index = geometry.index ? 'i' + geometry.index.count + ':' + checksum(geometry.index.array) : 'noindex'; + const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); + return parts.join(',') + '|' + index + '|' + groups; +} + +/** 8×8 RGBA of a texture's image, cached per image. Null when it cannot be read (no + * canvas, a tainted or odd image) — then the texture is compared by its shape alone. + * @type {WeakMap} */ +const samples = new WeakMap(); +/** @param {any} texture @returns {number[] | null} */ +function textureSample(texture) { + const image = texture?.image; + if (!image || typeof image !== 'object') return null; + if (samples.has(image)) return samples.get(image) ?? null; + /** @type {number[] | null} */ + let out = null; + try { + if (typeof document !== 'undefined' && (image.width ?? 0) > 0) { + const canvas = document.createElement('canvas'); + canvas.width = 8; + canvas.height = 8; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (ctx) { + ctx.drawImage(image, 0, 0, 8, 8); + out = Array.from(ctx.getImageData(0, 0, 8, 8).data); + } + } + } catch { + out = null; + } + samples.set(image, out); + return out; +} + +/** @param {any} texture */ +function textureKey(texture) { + if (!texture) return '-'; + const image = texture.image ?? {}; + return [ + (image.width ?? '?') + 'x' + (image.height ?? '?'), + texture.flipY ? 1 : 0, + texture.wrapS, + texture.wrapT, + num(texture.repeat?.x) + ',' + num(texture.repeat?.y), + num(texture.offset?.x) + ',' + num(texture.offset?.y), + num(texture.rotation), + texture.channel ?? 0 + ].join(':'); +} + +/** @param {any} color */ +const colorKey = (color) => (color?.getHexString ? color.getHexString() : '-'); + +/** @param {any} material @param {(number[] | null)[]} sink */ +function materialKey(material, sink) { + if (!material) return '-'; + const head = [ + material.type, + colorKey(material.color), + num(material.roughness), + num(material.metalness), + colorKey(material.emissive), + num(material.emissiveIntensity), + num(material.opacity), + material.transparent ? 1 : 0, + material.side, + num(material.alphaTest), + material.vertexColors ? 1 : 0, + material.flatShading ? 1 : 0, + material.wireframe ? 1 : 0, + num(material.normalScale?.x) + ',' + num(material.normalScale?.y), + num(material.envMapIntensity) + ].join(','); + const maps = MAP_SLOTS.map((slot) => { + const texture = material[slot]; + if (texture) sink.push(textureSample(texture)); + return slot + '=' + textureKey(texture); + }).join(','); + return head + '|' + maps; +} + +/** + * What must match for a copy to be the file it came from: every DESCENDANT's type, name, + * visibility and local matrix, each mesh's geometry and material(s), plus texture samples + * (compared with a tolerance, because an autosave round trip re-encodes the JPEGs). The + * root itself is excluded — its transform, name and userData are the placement and ride + * the stub. + * @param {any} root @returns {{key: string, samples: (number[] | null)[]}} + */ +export function fingerprintOf(root) { + /** @type {(number[] | null)[]} */ + const sink = []; + /** @type {string[]} */ + const lines = []; + root.traverse((/** @type {any} */ node) => { + if (node === root) return; + let line = node.type + ':' + node.name + ':' + (node.visible ? 1 : 0) + ':' + node.matrix.elements.map(num).join(','); + if (node.isMesh || node.isLine || node.isPoints) { + line += '|' + geometryKey(node.geometry); + const materials = Array.isArray(node.material) ? node.material : [node.material]; + line += '|' + materials.map((/** @type {any} */ m) => materialKey(m, sink)).join('||'); + } + lines.push(line); + }); + return { key: lines.join('\n'), samples: sink }; +} + +/** Mean absolute difference per channel under which two samples are one image. */ +const SAMPLE_TOLERANCE = 10; + +/** @param {{key: string, samples: (number[] | null)[]}} a @param {{key: string, samples: (number[] | null)[]}} b */ +export function sameFingerprint(a, b) { + if (!a || !b || a.key !== b.key || a.samples.length !== b.samples.length) return false; + for (let i = 0; i < a.samples.length; i++) { + const x = a.samples[i]; + const y = b.samples[i]; + if (!x || !y) continue; // unreadable on one side: the shape key already matched + if (x.length !== y.length) return false; + let diff = 0; + for (let k = 0; k < x.length; k++) diff += Math.abs(x[k] - y[k]); + if (diff / x.length > SAMPLE_TOLERANCE) return false; + } + return true; +} + +/** The uuids of a root's descendants in traverse order. @param {any} root */ +function descendantUuids(root) { + /** @type {string[]} */ + const out = []; + root.traverse((/** @type {any} */ node) => { + if (node !== root) out.push(node.uuid); + }); + return out; +} + +/** + * Stamp a freshly imported pack piece BEFORE it replicates: its reference, its children's + * uuids, and — since it IS the file right now — the pristine fingerprint for its hash. + * @param {any} root @param {PackRef} ref @param {string} hash the bytes it was parsed from + */ +export function stampPackRef(root, ref, hash) { + if (!root || !ref) return; + root.updateMatrixWorld(true); + root.userData = { ...(root.userData ?? {}), packRef: { pack: ref.pack, item: ref.item, path: ref.path, hash, kids: descendantUuids(root) } }; + if (hash && !fingerprints.has(hash)) fingerprints.set(hash, fingerprintOf(root)); +} + +/** + * Is this object a reference that can travel as a stub? A hollow stub (not refilled yet, + * or its pack was unreachable) always is; a filled one only when it still IS the file. + * @param {any} object @returns {boolean} + */ +export function isPristinePackRef(object) { + const ref = packRefOf(object); + if (!ref) return false; + if (object.userData.packStub) return object.children.length === 0; + const fingerprint = ref.hash ? fingerprints.get(ref.hash) : null; + if (!fingerprint) return false; + object.updateMatrixWorld(true); + return sameFingerprint(fingerprint, fingerprintOf(object)); +} + +/** + * The stub: an empty Group carrying the root's identity, transform and userData, as a + * toJSON element — the shape `objects` and the `object` message already carry, so an + * older reader parses it into an empty group rather than failing. + * @param {any} root @returns {any} + */ +export function stubElementOf(root) { + const ref = /** @type {PackRef} */ (packRefOf(root)); + const hollow = new THREE.Group(); + hollow.uuid = root.uuid; + hollow.name = root.name; + hollow.position.copy(root.position); + hollow.quaternion.copy(root.quaternion); + hollow.scale.copy(root.scale); + hollow.visible = root.visible; + hollow.renderOrder = root.renderOrder; + hollow.layers.mask = root.layers.mask; + const kids = root.userData.packStub ? ref.kids ?? [] : descendantUuids(root); + hollow.userData = JSON.parse(JSON.stringify({ ...root.userData, packRef: { ...ref, kids }, packStub: true })); + hollow.updateMatrix(); + return hollow.toJSON(); +} + +/** How many nodes a stub stands for (the root + what it refills to), for object budgets. + * @param {any} node a serialized node (element.object) */ +export function stubNodeCount(node) { + const kids = node?.userData?.packStub ? node?.userData?.packRef?.kids : null; + return Array.isArray(kids) ? kids.length : 0; +} + +/** A child uuid every peer computes alike: the root's uuid with its last 12 hex digits + * replaced by the child's index. @param {string} rootUuid @param {number} index */ +function derivedUuid(rootUuid, index) { + return String(rootUuid).slice(0, 24) + (index + 1).toString(16).padStart(12, '0'); +} + +/** A private copy of a parsed piece: geometry and material cloned (the edit tools mutate + * both IN PLACE, and a copy must never move its siblings), textures shared. + * @param {any} scene */ +function instanceOf(scene) { + const copy = scene.clone(true); + copy.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + node.geometry = node.geometry.clone(); + node.material = Array.isArray(node.material) ? node.material.map((/** @type {any} */ m) => m.clone()) : node.material.clone(); + }); + return copy; +} + +/** + * Refill a stub from its pack. Resolves true when it filled, false when it could not (or + * is no longer in the scene, or was filled meanwhile). The children take the recorded + * uuids, so every peer converges on the same ones. + * @param {any} root @returns {Promise} + */ +export function fillPackRef(root) { + if (!root?.userData?.packStub) return Promise.resolve(false); + const ref = packRefOf(root); + if (!ref) return Promise.resolve(false); + const inflight = filling.get(root.uuid); + if (inflight) return inflight; + const url = packRefUrl(ref); + packRefsPending.update((n) => n + 1); + const job = (async () => { + try { + const { hash, scene } = await loadPackTemplate(url); + // replaced (a clear, a reload of the same file) or filled while we fetched + if (!root.userData.packStub || root.children.length) return false; + if (get(objectsGroup)?.getObjectByProperty('uuid', root.uuid) !== root) return false; + const copy = instanceOf(scene); + /** @type {any[]} */ + const nodes = []; + copy.traverse((/** @type {any} */ node) => { + if (node !== copy) nodes.push(node); + }); + const kids = Array.isArray(ref.kids) ? ref.kids : []; + // a stub that lost its kids (a merge import re-uuids the tree) still has to give + // every peer the SAME child uuids: derive them from the root's + nodes.forEach((node, i) => { + node.uuid = typeof kids[i] === 'string' && kids[i] ? kids[i] : derivedUuid(root.uuid, i); + }); + const hideShadow = root.userData.shadow === false; + for (const child of [...copy.children]) root.add(child); + if (hideShadow) + root.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + node.castShadow = false; + node.receiveShadow = false; + node.userData.shadow = false; + }); + delete root.userData.packStub; + root.userData.packRef = { ...ref, hash, kids: nodes.map((node) => node.uuid) }; + pokeScene(); + return true; + } catch (error) { + if (!reported.has(url)) { + reported.add(url); + console.log('kit piece could not be loaded: ' + url, error); + showToast('Could not load the kit piece "' + (ref.item || ref.path) + '" — its pack is unreachable. It stays in the scene as a placeholder.'); + } + return false; + } finally { + filling.delete(root.uuid); + packRefsPending.update((n) => n - 1); + } + })(); + filling.set(root.uuid, job); + return job; +} + +/** Resolves once no refill is in flight (the author script and the suites wait on it). */ +export function packRefsSettled() { + return new Promise((resolve) => { + /** @type {any} */ + let off = null; + off = packRefsPending.subscribe((n) => { + if (n > 0) return; + queueMicrotask(() => off?.()); + resolve(true); + }); + }); +} + +/** Refill every stub in the scene, and learn the pristine fingerprint of every piece whose + * file we have not parsed yet (a scene restored from the autosave), so a later save can + * write it as a stub again. */ +function scan() { + const group = get(objectsGroup); + if (!group) return; + group.traverse((/** @type {any} */ node) => { + const ref = packRefOf(node); + if (!ref) return; + if (node.userData.packStub) { + if (!node.children.length) fillPackRef(node); + } else if (ref.hash && !fingerprints.has(ref.hash)) { + loadPackTemplate(packRefUrl(ref)).catch(() => {}); + } + }); +} + +let started = false; +/** @type {any} */ +let scanTimer = null; + +/** Watch the scene for stubs (a loaded file, a peer's broadcast, a late joiner's sync). */ +export function startPackRefs() { + if (started || typeof window === 'undefined') return; + started = true; + objectsGroup.subscribe(() => { + if (scanTimer) return; + scanTimer = setTimeout(() => { + scanTimer = null; + scan(); + }, 30); + }); +} diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js index 0bc8c1d5..9136d8f1 100644 --- a/src/lib/scenePhysics.js +++ b/src/lib/scenePhysics.js @@ -65,6 +65,22 @@ function nullableNum(v, lo, hi) { return Math.max(lo, Math.min(hi, n)); } +/** + * 30c: the play SPAWN — where desktop play mode puts the player's feet, and which way they + * face (`yaw`, radians about +Y; 0 looks down -Z, the fixed start's direction). Null when + * absent or malformed, and then the block OMITS the key, so a scene that never set one + * saves byte-identically and play starts where it always has. + * @param {any} raw @returns {{pos: number[], yaw: number} | null} + */ +function spawnOf(raw) { + const pos = raw?.pos; + if (!Array.isArray(pos) || pos.length !== 3) return null; + const out = pos.map((v) => Number(v)); + if (!out.every((v) => Number.isFinite(v) && Math.abs(v) <= 100000)) return null; + const yaw = Number(raw.yaw ?? 0); + return { pos: out, yaw: Number.isFinite(yaw) ? yaw : 0 }; +} + /** @param {any} v @param {boolean} fallback */ function bool(v, fallback) { return typeof v === 'boolean' ? v : fallback; @@ -148,9 +164,11 @@ export function normalizeScenePhysics(raw) { grounded: bool(playRaw.grounded, d.play.grounded), simOnPlay: bool(playRaw.simOnPlay, d.play.simOnPlay), // 30 P3: present only when free (see PLAY_CURSORS) - ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}) + ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}), + // 30c: present only when set (see spawnOf) + ...(spawnOf(playRaw.spawn) ? { spawn: spawnOf(playRaw.spawn) } : {}) }, - ['interaction', 'grounded', 'simOnPlay', 'cursor'] + ['interaction', 'grounded', 'simOnPlay', 'cursor', 'spawn'] ), // A1: the 20 ceiling is throwVelocity's MAX_LINVEL, restated rather than imported — // this module is store-only and the response clamps through clampThrow anyway @@ -195,7 +213,7 @@ scenePhysicsState_.subscribe((s) => sceneGravity.set(s.gravity)); export const scenePhysicsGround = derived(scenePhysicsState_, (s) => s.ground); /** out-of-bounds config. NOT named `sceneBounds` — that is sceneBounds.js */ export const scenePhysicsBounds = derived(scenePhysicsState_, (s) => s.bounds); -/** play-mode block ({interaction, grounded, simOnPlay, cursor?: 'free'}) */ +/** play-mode block ({interaction, grounded, simOnPlay, cursor?: 'free', spawn?: {pos, yaw}}) */ export const scenePlay = derived(scenePhysicsState_, (s) => s.play); /** A1: the knock block ({enabled, gain, maxSpeed, minSpeed, radius, spin, predict}) */ export const sceneKnock = derived(scenePhysicsState_, (s) => s.knock); diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 4dfc1f4c..10fa8db0 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -6,6 +6,7 @@ import { serializeGraphs, copyGraphFrom } from './flowGraphs'; import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { stripEditOverlays } from './editOverlays'; +import { isPristinePackRef, stubElementOf, stubNodeCount } from './packRefs'; // B7: a spawner's copies exist only while the world runs — never in a scene file import { isTransient } from './transientObjects'; import { @@ -263,7 +264,9 @@ export function buildSessionPayload(name) { // scene file as permanent content objects: (group?.children ?? []) .filter((/** @type {any} */ child) => !animatedUuids.includes(child.uuid) && !isTransient(child)) - .map((/** @type {any} */ child) => child.toJSON()), + // 30c: a pristine KIT PIECE is written as a stub its pack refills (packRefs.js + // has the measurement: one wall is 7.9 MB as toJSON) + .map((/** @type {any} */ child) => (isPristinePackRef(child) ? stubElementOf(child) : child.toJSON())), animated: animatedImportsSnapshot(group), // authored movement tracks (the Animation window) were never saved animations: animationsSnapshot(), @@ -356,7 +359,7 @@ export function buildSelectionPayload(name, uuids) { const objects = roots .map((uuid) => group?.getObjectByProperty('uuid', uuid)) .filter((/** @type {any} */ object) => object && !animatedUuids.includes(object.uuid) && !isTransient(object)) - .map((/** @type {any} */ object) => object.toJSON()); + .map((/** @type {any} */ object) => (isPristinePackRef(object) ? stubElementOf(object) : object.toJSON())); const clips = animationsSnapshot(); /** @type {any} */ const animations = {}; @@ -1201,6 +1204,9 @@ export function importObjects(payload, indices) { uuidMap.set(node.uuid, fresh); node.uuid = fresh; }); + // 30c: a kit-piece stub refills its children under the RECORDED uuids — which are + // the file's, and this import is a merge; let the refill derive fresh ones + if (object.userData?.packStub && object.userData.packRef) object.userData.packRef.kids = []; group.add(object); recordObjectPresence('create', object); if (peer) peer.send({ type: 'object', element: object.toJSON() }); @@ -1301,6 +1307,18 @@ export async function applySession(payload, opts = {}) { const restored = await restoreSessionLibrary(payload); if (restored) showToast('Restored ' + restored + ' library file' + (restored === 1 ? '' : 's')); } + // 30c: a scene REPLACE ends the run. The physics world holds bodies for the objects + // about to be wiped and none for the ones arriving, and sim-on-play skips a start while + // `simulating` is still true — so after playing one level, the next one loaded was + // walked THROUGH (measured: the walker crossed the tavern's walls and stairs as if they + // were not there). Stopped the quiet way a yielded run stops (no settling moves, no + // undo entry for a layout that is being thrown away); the next Play builds a new world. + try { + const physics = await import('./physics'); + if (get(physics.simulating)) physics.stopSimulation({ yielded: true }); + } catch { + /* physics failing to load must never block a scene load */ + } if (replicate) sceneCommand('/clear all'); // replicated clear (objects + module content) else clearSceneLocal(); /** @type {any} */ @@ -1436,6 +1454,8 @@ export function countPayloadObjects(payload) { const walk = (node) => { if (!node) return; n++; + // 30c: a kit-piece stub stands for the nodes it refills to + n += stubNodeCount(node); for (const kid of node.children ?? []) walk(kid); }; for (const element of payload?.objects ?? []) { diff --git a/tests/e2e/pack-refs.test.cjs b/tests/e2e/pack-refs.test.cjs new file mode 100644 index 00000000..19cee2c7 --- /dev/null +++ b/tests/e2e/pack-refs.test.cjs @@ -0,0 +1,291 @@ +// 30c — KIT REFERENCES (src/lib/packRefs.js): a piece placed from a pack carries where it +// came from, and a save / the wire write a PRISTINE one as a small stub that the loader and +// every peer refill from the pack URL. +// +// The fixture is the bundled Duck (static/library/default/Duck — always served by the dev +// server, nested nodes + a texture) addressed by an ABSOLUTE url, so the suite needs no pack +// server; when the dev server's PACKS_BASE serves the architecture kit (VITE_PACKS_BASE), a +// last section measures a real kit wall too, and SKIPS (never fails) without it. +// +// APP_URL=https://theprototype.app:5255/ node tests/e2e/pack-refs.test.cjs +const h = require('./helpers.cjs'); + +const DUCK = 'library/default/Duck/glTF-Binary/Duck.glb'; + +/** Poll until `predicate` holds and RETURN the last value (h.eventually returns nothing). */ +async function until(fn, predicate, label, timeout = 10000) { + const start = Date.now(); + let last; + while (Date.now() - start < timeout) { + last = await fn(); + if (predicate(last)) { + h.check(true, label); + return last; + } + await new Promise((r) => setTimeout(r, 400)); + } + console.log(' last: ' + JSON.stringify(last)?.slice(0, 600)); + h.check(false, label); + return last; +} + +/** Place the fixture through the REAL drop path (explorerDrop.dropExplorerItem with a pack + * payload), at the canvas centre. Resolves the placed root's uuid. */ +async function place(peer, name = 'Duck') { + return peer.page.evaluate( + async ({ path, name }) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const before = new Set(g.children.map((/** @type {any} */ c) => c.uuid)); + /** @type {any} */ let renderer; + s.globalRenderer.subscribe((v) => (renderer = v))(); + const r = renderer.domElement.getBoundingClientRect(); + await s.explorerDrop.dropExplorerItem({ kind: 'object', name, url: location.origin + '/' + path }, r.left + r.width / 2, r.top + r.height / 2); + for (let i = 0; i < 100; i++) { + const fresh = g.children.find((/** @type {any} */ c) => !before.has(c.uuid)); + if (fresh) return fresh.uuid; + await new Promise((res) => setTimeout(res, 100)); + } + return null; + }, + { path: DUCK, name } + ); +} + +/** Facts about one root in a page. */ +function facts(peer, uuid) { + return peer.page.evaluate((uuid) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + if (!o) return null; + let tris = 0; + const kids = []; + o.traverse((/** @type {any} */ n) => { + if (n !== o) kids.push(n.uuid); + if (n.isMesh) tris += (n.geometry.index ? n.geometry.index.count : n.geometry.attributes.position.count) / 3; + }); + return { + ref: o.userData.packRef ?? null, + stub: !!o.userData.packStub, + children: o.children.length, + kids, + tris, + pos: o.position.toArray().map((v) => Math.round(v * 1000) / 1000), + pristine: s.packRefs.isPristinePackRef(o) + }; + }, uuid); +} + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- 1. a placement carries its reference ------------------------------------ + const duck = await place(A); + h.check(!!duck, 'the pack payload placed an object through the real drop path'); + const f1 = await facts(A, duck); + h.check(f1?.ref?.path?.endsWith(DUCK) && /^[0-9a-f]{64}$/.test(f1?.ref?.hash ?? ''), `the root carries packRef {path, hash} (${f1?.ref?.path} ${f1?.ref?.hash?.slice(0, 8)})`); + h.check(Array.isArray(f1?.ref?.kids) && f1.ref.kids.join() === f1.kids.join() && f1.kids.length > 0, `packRef.kids records the descendants in traverse order (${f1?.kids?.length})`); + h.check(f1?.pristine === true, 'a fresh placement is pristine'); + + // ---- 2. the save writes a stub, a fraction of the full element ---------------- + const saved = await A.page.evaluate((uuid) => { + const s = window.__stores; + const payload = s.sessions.buildSessionPayload('refs'); + const el = payload.objects.find((/** @type {any} */ e) => e.object?.uuid === uuid); + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const full = JSON.stringify(g.getObjectByProperty('uuid', uuid).toJSON()).length; + return { stub: !!el?.object?.userData?.packStub, kids: el?.object?.children?.length ?? 0, bytes: JSON.stringify(el).length, full }; + }, duck); + h.check(saved.stub && saved.kids === 0, 'buildSessionPayload writes the pristine piece as a hollow stub'); + h.check(saved.bytes < 4000 && saved.full > saved.bytes * 20, `the stub is ${saved.bytes} B against a ${saved.full} B full element`); + + // ---- 3. an EDITED piece is written in full ------------------------------------ + const edited = await A.page.evaluate(async (uuid) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + let mesh = null; + o.traverse((/** @type {any} */ n) => { + if (n.isMesh && !mesh) mesh = n; + }); + const out = {}; + // a colour change + const was = mesh.material.color.getHex(); + mesh.material.color.setHex(0x00ff00); + out.color = s.packRefs.isPristinePackRef(o); + mesh.material.color.setHex(was); + out.colorBack = s.packRefs.isPristinePackRef(o); + // a vertex moved in place (the vertex-drag / sculpt path) + const pa = mesh.geometry.attributes.position; + const y = pa.getY(0); + pa.setY(0, y + 0.25); + out.vertex = s.packRefs.isPristinePackRef(o); + const payload = s.sessions.buildSessionPayload('edited'); + const el = payload.objects.find((/** @type {any} */ e) => e.object?.uuid === uuid); + out.savedFull = !el.object.userData?.packStub && (el.object.children?.length ?? 0) > 0; + pa.setY(0, y); + out.vertexBack = s.packRefs.isPristinePackRef(o); + // a child moved inside the piece + mesh.position.x += 0.5; + mesh.updateMatrix(); + out.child = s.packRefs.isPristinePackRef(o); + mesh.position.x -= 0.5; + mesh.updateMatrix(); + // the ROOT moved — that is the placement, and rides the stub + o.position.x += 3; + o.updateMatrix(); + out.root = s.packRefs.isPristinePackRef(o); + return out; + }, duck); + h.check(edited.color === false && edited.colorBack === true, `a colour change makes it NOT pristine, and undoing it restores it (${edited.color}/${edited.colorBack})`); + h.check(edited.vertex === false && edited.savedFull === true && edited.vertexBack === true, `an in-place vertex edit is NOT pristine and saves IN FULL (${edited.vertex}/${edited.savedFull}/${edited.vertexBack})`); + h.check(edited.child === false, 'a moved child inside the piece is NOT pristine'); + h.check(edited.root === true, 'moving the root is the placement: still pristine'); + + // ---- 4. a load refills stubs; copies share their textures --------------------- + const second = await place(A, 'Duck 2'); + const loaded = await A.page.evaluate(async () => { + const s = window.__stores; + const payload = s.sessions.buildSessionPayload('two'); + const stubs = payload.objects.filter((/** @type {any} */ e) => e.object?.userData?.packStub).length; + await s.sessions.applySession(payload, { backup: false }); + await new Promise((r) => setTimeout(r, 200)); + await s.packRefs.packRefsSettled(); + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const maps = new Set(); + const geos = new Set(); + const mats = new Set(); + let filled = 0; + for (const c of g.children) { + if (!c.userData.packRef) continue; + if (!c.userData.packStub && c.children.length) filled++; + c.traverse((/** @type {any} */ n) => { + if (!n.isMesh) return; + geos.add(n.geometry); + mats.add(n.material); + if (n.material.map) maps.add(n.material.map); + }); + } + return { stubs, filled, maps: maps.size, geos: geos.size, mats: mats.size, names: payload.objects.map((/** @type {any} */ e) => e.object.name) }; + }); + h.check(loaded.stubs === 2, `both copies saved as stubs (${loaded.stubs})`); + h.check(loaded.filled === 2, `applySession refilled both (${loaded.filled})`); + h.check(loaded.maps === 1, `the two copies SHARE one texture (${loaded.maps} distinct maps)`); + h.check(loaded.geos === 2 && loaded.mats === 2, `...but each owns its geometry and material, so an edit to one cannot move the other (${loaded.geos}/${loaded.mats})`); + const f4 = await facts(A, duck); + h.check(f4 && f4.kids.join() === f1.kids.join(), 'the refilled piece takes the RECORDED child uuids'); + h.check(f4 && Math.abs(f4.pos[0] - (f1.pos[0] + 3)) < 1e-3, `the stub kept the root's placement (x ${f4?.pos?.[0]})`); + h.check(f4 && f4.tris === f1.tris && f4.pristine === true, `the refill is the file again: ${f4?.tris} tris, pristine ${f4?.pristine}`); + + // ---- 5. an autosave-style GLTF round trip re-learns pristine ------------------ + const round = await A.page.evaluate(async (uuid) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + const Exporter = s.GLTFExporterModule.GLTFExporter; + const glb = await new Promise((res, rej) => new Exporter().parse(o, res, rej, { binary: true, onlyVisible: false })); + const gltf = await new Promise((res, rej) => s.animatedImports.createGltfLoader().parse(glb, '', res, rej)); + const back = gltf.scene.children[0]; + const la = s.packRefs.fingerprintOf(o).key.split('\n'); + const lb = s.packRefs.fingerprintOf(back).key.split('\n'); + const diff = la.map((line, i) => (line === lb[i] ? null : [line.slice(0, 300), (lb[i] ?? '').slice(0, 300)])).filter(Boolean); + return { hasRef: !!back.userData?.packRef, pristine: s.packRefs.isPristinePackRef(back), diff: diff.slice(0, 2), n: [la.length, lb.length] }; + }, duck); + h.check(round.hasRef, 'packRef rides GLTF extras (the autosave path)'); + h.check(round.pristine === true, `after a GLTF round trip (re-encoded textures) the piece still reads pristine (${round.pristine})`); + if (!round.pristine) console.log(' diff ' + JSON.stringify(round.diff) + ' lines ' + round.n); + + // ---- 6. an unreachable pack keeps the reference ------------------------------- + const lost = await A.page.evaluate(async () => { + const s = window.__stores; + const T = s.THREE; + const hollow = new T.Group(); + hollow.name = 'Lost piece'; + hollow.userData = { packRef: { pack: 'nowhere', item: 'Ghost', path: location.origin + '/no/such/piece.glb', hash: 'x', kids: [] }, packStub: true }; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + g.add(hollow); + s.pokeScene(); + await new Promise((r) => setTimeout(r, 300)); + await s.packRefs.packRefsSettled(); + const payload = s.sessions.buildSessionPayload('lost'); + const el = payload.objects.find((/** @type {any} */ e) => e.object?.uuid === hollow.uuid); + return { children: hollow.children.length, stillStub: !!hollow.userData.packStub, savedStub: !!el?.object?.userData?.packStub, path: el?.object?.userData?.packRef?.path, uuid: hollow.uuid }; + }); + h.check(lost.children === 0 && lost.stillStub, 'an unreachable piece stays a hollow stub'); + h.check(lost.savedStub && /no\/such\/piece\.glb$/.test(lost.path ?? ''), 'and SAVES back as the same reference — never silently dropped'); + await A.page.evaluate((uuid) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + o?.parent?.remove(o); + s.pokeScene(); + }, lost.uuid); + + // ---- 7. two peers: a late joiner and a live placement ------------------------- + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + const joined = await until( + () => Promise.all([facts(B, duck), facts(B, second)]), + (r) => r.every((f) => f && !f.stub && f.children > 0), + 'the late joiner refilled both pieces from their pack', + 20000 + ); + h.check(joined[0]?.kids.join() === f1.kids.join(), 'the joiner has the same child uuids'); + h.check(joined[0]?.tris === f1.tris, `the joiner has the same geometry (${joined[0]?.tris} tris)`); + const wire = await A.page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ const p = (() => { let v; s.peers.subscribe((x) => (v = x))(); return v; })(); + /** @type {any[]} */ const sent = []; + const orig = p.send.bind(p); + p.send = (msg) => { + sent.push({ type: msg.type, bytes: JSON.stringify(msg).length, stub: !!msg.element?.object?.userData?.packStub }); + return orig(msg); + }; + window.__refSent = sent; + return true; + }); + h.check(wire, 'a wire spy wraps A.peers.send'); + const third = await place(A, 'Duck 3'); + const live = await until(() => facts(B, third), (f) => f && !f.stub && f.children > 0, 'peer B refilled a LIVE placement', 20000); + h.check(live?.ref?.hash === f1.ref.hash, 'B built it from the same bytes (hash)'); + const spied = await A.page.evaluate(() => window.__refSent.filter((m) => m.type === 'object')); + h.check(spied.length === 1 && spied[0].stub && spied[0].bytes < 4000, `the placement crossed the wire as ONE stub message (${JSON.stringify(spied)})`); + + // ---- 8. a real kit wall, when this server's PACKS_BASE serves the architecture kit + const kit = await A.page.evaluate(async () => { + const s = window.__stores; + const url = String(s.packs.PACKS_BASE).replace(/\/+$/, '') + '/architecture-kit/WallStone/glTF-Binary/wall-stone.glb'; + const res = await fetch(url).catch(() => null); + if (!res?.ok) return null; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const before = new Set(g.children.map((/** @type {any} */ c) => c.uuid)); + await s.explorerDrop.dropExplorerItem({ kind: 'object', name: 'WallStone', url }, innerWidth / 2, innerHeight / 2); + let o = null; + for (let i = 0; i < 100 && !o; i++) { + o = g.children.find((/** @type {any} */ c) => !before.has(c.uuid)) ?? null; + if (!o) await new Promise((r) => setTimeout(r, 100)); + } + if (!o) return { placed: false }; + const payload = s.sessions.buildSessionPayload('kit'); + const el = payload.objects.find((/** @type {any} */ e) => e.object?.uuid === o.uuid); + return { placed: true, path: o.userData.packRef?.path, stub: JSON.stringify(el).length, full: JSON.stringify(o.toJSON()).length }; + }); + if (!kit) console.log('SKIP the architecture kit is not served by this build (set VITE_PACKS_BASE)'); + else { + h.check(kit.placed && kit.path === 'architecture-kit/WallStone/glTF-Binary/wall-stone.glb', `a kit wall carries a PACKS_BASE-relative path (${kit.path})`); + h.check(kit.stub < 4000 && kit.full > 1_000_000, `a kit wall saves as ${kit.stub} B instead of ${kit.full} B`); + } + + await h.finish(browser); +}); diff --git a/tests/unit/scenePhysicsSpawn.test.js b/tests/unit/scenePhysicsSpawn.test.js new file mode 100644 index 00000000..e38882e1 --- /dev/null +++ b/tests/unit/scenePhysicsSpawn.test.js @@ -0,0 +1,26 @@ +// 30c: the play SPAWN is scene data on scenePhysics' play block — present only when set, so a +// scene that never set one normalizes (and saves) exactly as before. +import { describe, it, expect } from 'vitest'; +import { normalizeScenePhysics } from '../../src/lib/scenePhysics.js'; + +describe('scenePhysics play.spawn', () => { + it('is absent by default (a scene without one is byte-identical)', () => { + const state = normalizeScenePhysics({}); + expect('spawn' in state.play).toBe(false); + expect(JSON.stringify(normalizeScenePhysics({ play: { simOnPlay: true } }).play)).toBe( + JSON.stringify({ interaction: 'grab', grounded: false, simOnPlay: true }) + ); + }); + + it('keeps a valid spawn: feet position and yaw', () => { + const state = normalizeScenePhysics({ play: { spawn: { pos: [1, 0.3, 4.6], yaw: -0.2 } } }); + expect(state.play.spawn).toEqual({ pos: [1, 0.3, 4.6], yaw: -0.2 }); + // yaw defaults to 0 (looking down -Z, the fixed start's direction) + expect(normalizeScenePhysics({ play: { spawn: { pos: [0, 0, 0] } } }).play.spawn).toEqual({ pos: [0, 0, 0], yaw: 0 }); + }); + + it('drops a malformed or cleared spawn instead of carrying it', () => { + for (const spawn of [null, {}, { pos: [1, 2] }, { pos: [1, 'x', 3] }, { pos: [NaN, 0, 0] }, { pos: [1e9, 0, 0] }]) + expect('spawn' in normalizeScenePhysics({ play: { spawn } }).play).toBe(false); + }); +}); From 43f95d589bdd0ad12074c25c5fc8dacfcdeff2ff Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 21:26:13 +0300 Subject: [PATCH 41/46] [feat] 30c-levels p2: three walkable general-tab levels built from the kits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/level-templates.cjs: Castle Courtyard (curtain walls, a gate between pillars, two three-storey round towers, a rampart terrace reached by stairs through an archway, a paved avenue to a well, a market stall, trees; a lit afternoon sky), Forest Clearing (a stone path, a footbridge over a pond neck kitbashed with sandstone ramps, a 4 x 4 m plaster hut from the architecture kit, a walkable mound, a cliff, two rings of trees; late-morning sky, fog that closes in past the tree line), Tavern Interior (a double-height hall with a balcony, a kitchen behind a partition doorway with a loft above it, oak stairs, a bar, furniture; warm lamps + a warm hemisphere). Every level: a spawn point, sim-on-play, ONE walk-mode Character Controller node, dynamic props, and colliders that match the pieces — custom compound shapes for the archway/doorways (jambs + lintel), stairs and ramps (wedges), trees (trunks, MEASURED off-centre), the footbridge (deck + rails) and the mound (a measured hull that runs out past its 0.37 m lip); grass, flowers, rugs, water are sensors. - author-templates.cjs: a `kit` object type (a pack item as a REFERENCE stub, resolved through the pack's own default.json on PACKS_BASE, refilled and awaited before the card renders — the run refuses a level whose pack is unreachable), `seed: false` (kit levels stay out of the offline seed), and a kit level's card CLONES the scene instead of the toJSON round trip (which is the 7.9 MB-per-wall cost). Every other def is unchanged. - Measurements that shaped the layouts: the Arch opening is +/-0.408 m (the collider's jambs sit 6 cm inside the stone: a grazing walk stopped 1 run in 2); the Footbridge walks along its LOCAL Z with a flat 0.85 m deck and no approach. Suites: level-templates (NEW) ALL PASS 42 (all three levels: loads from the Templates modal, every piece refilled, spawn + sim-on-play, centre luminance castle 0.47 / forest 0.36 / tavern 0.33 >= 0.25, no black quarter, walls hold, archway/stairs/rampart, footbridge, hut doorway, balcony stairs, kitchen doorway pass); castle x2 green after the arch fix. Unit levelTemplates 8/8. Counterfactuals: CF=arch (the arch's custom collider removed): the archway walk red; CF=spawn (the spawn cleared): the spawn check red. svelte-check 335/47 = base; build green. Staged content: /home/deck/.code/theprototype-app/cloud-lane-30-staging/30c-levels. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 66 +++- scripts/level-templates.cjs | 598 +++++++++++++++++++++++++++++ tests/e2e/level-templates.test.cjs | 258 +++++++++++++ tests/unit/levelTemplates.test.js | 113 ++++++ 4 files changed, 1032 insertions(+), 3 deletions(-) create mode 100644 scripts/level-templates.cjs create mode 100644 tests/e2e/level-templates.test.cjs create mode 100644 tests/unit/levelTemplates.test.js diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 97bcf029..fcf47b31 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -46,6 +46,7 @@ // // DEF (the file / the card): // kind* 'template' | 'example' | 'game' | 'contest' — decides the folder + index section +// seed false keeps a template out of the bundled offline seed (30c: kit levels need the pack CDN) // slug* title* description identity + card text; author, license ('CC0-1.0'), tags [] // modules [{id, version}] — the card's module list (games only; must match installModules) // installModules [''] — zips installed from MODULES_REPO before the build (the game shows) @@ -86,6 +87,11 @@ // spline points [{pos, radius}], closed, color // group / empty children [objects] (names resolve inside groups too) // mirror of (a named object/group), opacity (0.15), prefix — reflected across x = 0 +// kit pack*, item* (a pack item NAME, e.g. 'architecture-kit' / 'WallStone'), scale? +// ([x,y,z] or a number) — a PACK PIECE as a reference (30c packRefs.js): written +// as a stub the app refills from PACKS_BASE, so a level of 100 pieces stays small. +// Kept TOP-LEVEL (physics only reads top-level objects); `physics` sets its +// collider (custom compound boxes/wedges for doorways, stairs, trees) // MATERIAL (every mesh type): color, roughness (0.85), metalness (0), emissive + // emissiveIntensity (1), opacity (< 1 → transparent), flatShading, side ('double' | 'back'), // toon (MeshToonMaterial), physical (MeshPhysicalMaterial — also implied by any of: @@ -2014,7 +2020,10 @@ const DEFS = [ MIRROR_DEF, BEAT_DEF, // the def is the module's (see moduleDef); a checkout without it cannot author it - ...MODULE_DEFS.map((id) => moduleDef(id) ?? { slug: id, missingModuleDef: true }) + ...MODULE_DEFS.map((id) => moduleDef(id) ?? { slug: id, missingModuleDef: true }), + // 30c level design: three walkable GENERAL templates built from the kits (seed: false — + // kit references need the pack CDN); the layouts live in level-templates.cjs + ...require('./level-templates.cjs').LEVEL_DEFS ]; (async () => { @@ -2116,6 +2125,28 @@ const DEFS = [ const s = window.__stores; const T = s.THREE; s.commandsHandler.sceneCommand('/clear all'); + // 30c: KIT pieces name a pack item; resolve each to its file path through the pack's + // own item list (default.json) on PACKS_BASE, once per pack, before anything builds + /** @type {Record>} */ + const kitFiles = {}; + /** @param {any[]} list @param {Set} sink */ + const kitPacks = (list, sink) => { + for (const o of list ?? []) { + if (o.type === 'kit') sink.add(o.pack); + if (o.children) kitPacks(o.children, sink); + } + return sink; + }; + for (const pack of kitPacks(d.objects, new Set())) { + const base = String(s.packs.PACKS_BASE).replace(/\/+$/, ''); + const res = await fetch(base + '/' + pack + '/default.json'); + if (!res.ok) throw new Error('kit: pack "' + pack + '" is not served at ' + base + ' (HTTP ' + res.status + ')'); + kitFiles[pack] = {}; + for (const row of await res.json()) { + const file = row?.variants?.['glTF-Binary']; + if (row?.name && file) kitFiles[pack][row.name] = pack + '/' + row.name + '/glTF-Binary/' + file; + } + } /** @type {any} */ let group; s.objectsGroup.subscribe((g) => (group = g))(); @@ -2221,6 +2252,18 @@ const DEFS = [ if (o.type === 'group' || o.type === 'empty') { object = new T.Group(); for (const child of o.children ?? []) object.add(build(child, opts)); + } else if (o.type === 'kit') { + // 30c: a hollow STUB — the app's packRefs watcher refills it from the pack + // (awaited below, before the card renders and the file is written) + const path = kitFiles[o.pack]?.[o.item]; + if (!path) throw new Error('kit "' + o.name + '": pack ' + o.pack + ' has no item "' + o.item + '"'); + object = new T.Group(); + object.userData.packRef = { pack: o.pack, item: o.item, path, kids: [] }; + object.userData.packStub = true; + if (o.scale != null) { + const k = Array.isArray(o.scale) ? o.scale : [o.scale, o.scale, o.scale]; + object.scale.set(k[0], k[1], k[2]); + } } else if (o.type === 'light' && o.kind && o.kind !== 'point') { // 30 author-kit: spot / directional / hemisphere. Spot and directional follow // createLight's convention (cast shadows by default, the V-1 bias pair) and aim @@ -2400,6 +2443,18 @@ const DEFS = [ } group.add(build(o)); } + // 30c: refill every kit stub from its pack BEFORE anything measures the scene (the + // shadow fit below, the card) — and refuse to write a level whose pack is unreachable + if (kitPacks(d.objects, new Set()).size) { + s.objectsGroup.update((v) => v); + await new Promise((r) => setTimeout(r, 200)); + await s.packRefs.packRefsSettled(); + const hollow = []; + group.traverse((/** @type {any} */ n) => { + if (n.userData?.packStub) hollow.push(n.name); + }); + if (hollow.length) throw new Error('kit pieces did not load: ' + hollow.slice(0, 5).join(', ')); + } // 30 author-kit: FIT each shadow-casting directional light's ortho frustum to the // meshes just built — the 8 corners of their world box carried into the light's own // frame (it looks down -Z, and the shadow camera is posed from the light toward its @@ -2731,7 +2786,10 @@ const DEFS = [ if (liveScene?.fog) scene.fog = liveScene.fog.clone(); const envRoot = liveScene?.getObjectByName('environment-root'); if (envRoot) scene.add(envRoot.clone(true)); - const clone = new T.ObjectLoader().parse(group.toJSON()); + // 30c: a level of kit pieces is CLONED, not round-tripped — toJSON writes every + // piece's textures as PNG data URLs (7.9 MB for one wall), which is the very cost + // the kit references exist to avoid. Every other def renders exactly as before. + const clone = kitPacks(d.objects, new Set()).size ? group.clone(true) : new T.ObjectLoader().parse(group.toJSON()); scene.add(clone); // 21-C C6-b: a module's WORLD lives at the scene root (golden rule 5), so a // card rendered from objectsGroup alone shows a dungeon template as a lone @@ -2897,7 +2955,9 @@ const DEFS = [ // with a partial one. if (!ONLY) { fs.mkdirSync(STATIC_OUT, { recursive: true }); - const seedTemplates = defs.filter((d) => d.kind === 'template').map((d) => ({ + // 30c: `seed: false` keeps a template OUT of the offline seed — a level built from kit + // references needs the pack CDN, so bundling it offline would promise an empty world + const seedTemplates = defs.filter((d) => d.kind === 'template' && d.seed !== false).map((d) => ({ ...writeDef(STATIC_OUT, d), scene: `/templates/${d.slug}/scene.tpscene`, thumb: built[d.slug].thumb ? `/templates/${d.slug}/thumb.webp` : '' diff --git a/scripts/level-templates.cjs b/scripts/level-templates.cjs new file mode 100644 index 00000000..c280e7ac --- /dev/null +++ b/scripts/level-templates.cjs @@ -0,0 +1,598 @@ +// 30c level design — three GENERAL-tab templates built from the round-3 kits (the +// Modular Architecture Kit, Nature & Terrain, Props & Interiors), authored by +// author-templates.cjs through its `kit` object type (a pack piece as a REFERENCE — core +// src/lib/packRefs.js — so a level of ~150 pieces is a ~100 KB file whose bytes come from +// the pack CDN). +// +// Every level is a PLAYABLE walk: a Character Controller node in walk mode, the simulation +// starting on Play (the walker only collides while a world exists — charController.js), a +// spawn point (scenePhysics play.spawn), and colliders that match the pieces: walls and +// props keep their measured box, and the pieces a box gets wrong take CUSTOM compound +// shapes — a doorway is two jambs and a lintel, a staircase a wedge, a tree its trunk, a +// footbridge its deck and rails. Grass, flowers, rugs and water are sensors (pass-through). +// +// THE GRID (kit.md): walls stand ON grid lines, centred on them, pivot bottom-centre, 2 m +// long; floor tiles fill the 2 × 2 cells; storeys are 3 m. Every position below is on that +// grid unless it is a prop. +// +// Numbers the colliders use were MEASURED from the shipped GLBs (see the handover's +// probe): the arch opening, the footbridge deck profile, the tree trunks, the mound. + +const A = 'architecture-kit'; +const N = 'nature-kit'; +const P = 'props-kit'; +const PI = Math.PI; +const H = PI / 2; + +// ---- colliders ------------------------------------------------------------------------ + +/** A custom compound collider from convex pieces, each a flat vertex list (object-local, + * the root's pivot). @param {number[][][]} pieces each a list of [x,y,z] */ +function compound(pieces, extra = {}) { + /** @type {number[]} */ + const verts = []; + /** @type {number[][]} */ + const ranges = []; + for (const piece of pieces) { + ranges.push([verts.length, piece.length * 3]); + for (const v of piece) verts.push(...v.map((n) => Math.round(n * 1000) / 1000)); + } + if (verts.length > 1200) throw new Error('collider over the 1200-float cap: ' + verts.length); + return { mode: 'static', collider: 'custom', colliderVerts: verts, colliderPieces: ranges, ...extra }; +} + +/** the 8 corners of an axis-aligned box @param {number[]} min @param {number[]} max */ +function box(min, max) { + const out = []; + for (const x of [min[0], max[0]]) for (const y of [min[1], max[1]]) for (const z of [min[2], max[2]]) out.push([x, y, z]); + return out; +} + +/** a ramp rising toward -Z from `z0` (low, y0) to `z1` (high, y1), `w` wide + * @param {number} w @param {number} z0 @param {number} z1 @param {number} y0 @param {number} y1 */ +function wedge(w, z0, z1, y0, y1) { + const x = w / 2; + return [ + [-x, y0, z0], + [x, y0, z0], + [-x, y0, z1], + [x, y0, z1], + [-x, y1, z1], + [x, y1, z1] + ]; +} + +const COLLIDERS = { + // the Arch: 2 × 3 × 0.5, its opening MEASURED at ±0.408 m and 2.05 m high — two jambs + // and a lintel, so the walker passes through the opening and not the stone. The jambs sit + // 6 cm INSIDE the stone: the 0.82 m opening leaves a 0.3 m capsule 11 cm a side, and a walk + // that grazed a jamb corner slid off it and stopped (measured, 1 run in 2); the eye walks the + // centre line, so nothing visibly clips + arch: compound([box([-1, 0, -0.25], [-0.47, 3, 0.25]), box([0.47, 0, -0.25], [1, 3, 0.25]), box([-1, 2.05, -0.25], [1, 3, 0.25])]), + // a doorway wall: kit.md's exact 1.0 × 2.2 m opening, centred, in a 0.25 m wall + doorway: compound([box([-1, 0, -0.125], [-0.5, 3, 0.125]), box([0.5, 0, -0.125], [1, 3, 0.125]), box([-1, 2.2, -0.125], [1, 3, 0.125])]), + // Stairs (2 × 3 × 4, bottom step at +Z): a wedge 36.9° — under the walker's 50° climb + stairs: compound([wedge(2, 2, -2, 0, 3)]), + /** a trunk: a square column of half-width r about (cx, cz), h tall — the canopy stays + * walk-under @param {number} r @param {number} [cx] @param {number} [cz] @param {number} [h] */ + trunk: (r, cx = 0, cz = 0, h = 3) => compound([box([cx - r, 0, cz - r], [cx + r, h, cz + r])]), + /** a sandstone Ramp (2 × 1 × 2, low edge at +Z) squashed to `rise` metres */ + ramp: (/** @type {number} */ rise) => compound([wedge(2, 1, -1, 0, rise)]), + sensor: { sensor: true } +}; + +// the footbridge, MEASURED (the GLB's own vertices): it is walked along its LOCAL Z (2 m), +// its deck a flat 0.85 m up across |x| < 1.45, and its posts and rails fill 1.45 < |x| < 2 +// up to 2.06 m. The deck has no approach of its own (a 0.85 m step either end), so the level +// kitbashes a squashed sandstone Ramp onto each end. +const BRIDGE_DECK = { deckY: 0.85, halfW: 1.45, railY: 2.06 }; +function bridgeCollider() { + const d = BRIDGE_DECK; + return compound([ + box([-d.halfW, 0, -1], [d.halfW, d.deckY, 1]), + box([-2, 0, -1], [-d.halfW, d.railY, 1]), + box([d.halfW, 0, -1], [2, d.railY, 1]) + ]); +} + +// the grassy mound (8 × 1.2 × 8): its convex hull, from MEASURED rings of (radius, height) +// (its mesh ends in a 0.37 m lip, over the walker's 0.3 m step, so the hull's foot runs out +// to 4.3 m at ground level: a 31° slope where the lip is) +const HILL_RINGS = [ + [0, 1.2], + [1.5, 1.15], + [2, 1.01], + [2.5, 0.85], + [3, 0.68], + [3.5, 0.49] +]; +function hillCollider() { + /** @type {number[][]} */ + const verts = []; + for (const [r, y] of HILL_RINGS) { + if (r === 0) { + verts.push([0, y, 0]); + continue; + } + for (let i = 0; i < 12; i++) verts.push([Math.cos((i / 12) * 2 * PI) * r, y, Math.sin((i / 12) * 2 * PI) * r]); + } + verts.push(...[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => [Math.cos((i / 12) * 2 * PI) * 4.3, 0, Math.sin((i / 12) * 2 * PI) * 4.3])); + return compound([verts]); +} + +// the trunks, MEASURED between 0.3 and 1.2 m up (radius about the trunk's own centre, which +// is off the pivot on the pine, the birch and the dead tree); the oak's reading includes its +// root flare, so it takes the trunk proper +/** @type {Record} */ +const TRUNKS = { + Oak: COLLIDERS.trunk(0.6, -0.05, 0.04), + OakAutumn: COLLIDERS.trunk(0.6, -0.05, 0.04), + Pine: COLLIDERS.trunk(0.4, -0.24, -0.14), + Birch: COLLIDERS.trunk(0.25, 0.01, -0.24), + DeadTree: COLLIDERS.trunk(0.4, 0.36, 0.5) +}; + +// ---- helpers -------------------------------------------------------------------------- + +/** a counter per name prefix, so every object has a unique readable name */ +function namer() { + /** @type {Record} */ + const seen = {}; + return (/** @type {string} */ base) => { + seen[base] = (seen[base] ?? 0) + 1; + return base + ' ' + seen[base]; + }; +} + +/** a deterministic PRNG (mulberry32), so a re-author is byte-stable @param {number} seed */ +function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const r3 = (/** @type {number} */ n) => Math.round(n * 1000) / 1000; + +/** one kit piece @param {string} pack @param {string} item @param {string} name + * @param {number[]} pos @param {number} [yaw] @param {any} [extra] */ +function piece(pack, item, name, pos, yaw = 0, extra = {}) { + return { type: 'kit', pack, item, name, pos: pos.map(r3), ...(yaw ? { rot: [0, r3(yaw), 0] } : {}), ...extra }; +} + +/** the walk: ONE Character Controller node in walk mode — the whole of a level's logic */ +function walkGraph() { + return { + nodes: [ + { + id: 'walk', + type: 'charcontroller', + position: { x: 40, y: 40 }, + data: { label: 'Character Controller', mode: 'walk', speed: 0.08, jumpHeight: 1.1, eyeHeight: 1.7, gravity: true }, + class: 'w-[150px]' + } + ], + edges: [] + }; +} + +/** the shared play physics: ground on, respawn below, grab props, the sim starts on Play so + * the walker has a world to collide with; `spawn` = where desktop play starts */ +function playPhysics(/** @type {number[]} */ spawn, /** @type {number} */ yaw) { + return { + ground: { enabled: true, height: 0, friction: 0.8, restitution: 0 }, + bounds: { limit: -20, action: 'respawn' }, + damping: { linear: 0.1, angular: 0.4 }, + play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { pos: spawn, yaw } } + }; +} + +/** the house look: AO, AgX, a soft bloom (flames, lanterns), SMAA */ +function lookPost(bloom = 0.6) { + return { + enabled: true, + effects: [ + { id: 'ao', kind: 'ao', enabled: true, params: {} }, + { id: 'tone', kind: 'tonemapping', enabled: true, params: { mode: 'AGX' } }, + { id: 'bloom', kind: 'bloom', enabled: true, params: { intensity: bloom, luminanceThreshold: 0.85 } }, + { id: 'aa', kind: 'smaa', enabled: true, params: {} } + ], + changedAt: 0 + }; +} + +const DYNAMIC = (/** @type {number} */ mass) => ({ mode: 'dynamic', mass }); + +// ---- 1. Castle Courtyard --------------------------------------------------------------- +// +// A 16 × 20 m walled courtyard: curtain walls on the grid lines x = ±8, z = 6 and z = -14, +// round towers three storeys tall on the two north corners, a rampart terrace (a stone +// platform 3 m up behind the north wall, battlements for a parapet) reached by a staircase +// through an archway, a paved avenue from the gate to the well, a market stall with its +// crates and sacks, and trees on the earth either side. The gate (oak double doors between +// two pillars) is behind you when you spawn; you look north down the avenue. +function castleObjects() { + const n = namer(); + /** @type {any[]} */ + const o = []; + // curtain walls ------------------------------------------------------------------ + for (const x of [-5, -3, -1, 1, 3, 5]) o.push(piece(A, 'WallStone', n('North wall'), [x, 0, -14])); + for (const z of [-11, -9, -7, -5, -3, -1, 1, 3, 5]) { + o.push(piece(A, 'WallStone', n('West wall'), [-8, 0, z], H)); + // the east wall has one DOOR (to a storeroom — the Door leaf is shut) + if (z === -7) { + o.push(piece(A, 'WallStoneDoor', 'Storeroom doorway', [8, 0, z], -H, { physics: COLLIDERS.doorway })); + o.push(piece(A, 'Door', 'Storeroom door', [8, 0, z], -H)); + } else o.push(piece(A, 'WallStone', n('East wall'), [8, 0, z], H)); + } + for (const x of [-7, -5, -3, -1, 3, 5, 7]) o.push(piece(A, 'WallStone', n('South wall'), [x, 0, 6])); + // the gate: oak double doors in a 2 m gap, a pillar either side, a battlement over it + o.push(piece(A, 'Gate', 'Castle gate', [1, 0, 6])); + o.push(piece(A, 'Pillar', n('Gate pillar'), [0, 0, 6])); + o.push(piece(A, 'Pillar', n('Gate pillar'), [2, 0, 6])); + o.push(piece(A, 'CornerPostStone', n('Corner post'), [-8, 0, 6])); + o.push(piece(A, 'CornerPostStone', n('Corner post'), [8, 0, 6])); + // battlements along every wall top (the north ones are the rampart's parapet) + for (const x of [-5, -3, -1, 1, 3, 5]) o.push(piece(A, 'Battlement', n('Battlement'), [x, 3, -14])); + for (const z of [-11, -9, -7, -5, -3, -1, 1, 3, 5]) { + o.push(piece(A, 'Battlement', n('Battlement'), [-8, 3, z], H)); + o.push(piece(A, 'Battlement', n('Battlement'), [8, 3, z], H)); + } + for (const x of [-7, -5, -3, -1, 1, 3, 5, 7]) o.push(piece(A, 'Battlement', n('Battlement'), [x, 3, 6])); + // two round towers, three storeys, on the north corners + for (const x of [-8, 8]) for (const y of [0, 3, 6]) o.push(piece(A, 'Tower', n(x < 0 ? 'West tower' : 'East tower'), [x, y, -14])); + // the rampart: a solid platform 3 m up behind the north wall (three courses of blocks) + for (const x of [-5, -3, -1, 1, 3, 5]) for (const y of [0, 1, 2]) o.push(piece(A, 'Block', n('Rampart block'), [x, y, -13])); + // its railing along the courtyard edge — open at x = -5, where the stairs arrive + for (const x of [-3, -1, 1, 3, 5]) o.push(piece(A, 'Railing', n('Rampart railing'), [x, 3, -12.08])); + // the staircase up to it, entered through an archway + o.push(piece(A, 'Stairs', 'Rampart stairs', [-5, 0, -10], 0, { physics: COLLIDERS.stairs })); + o.push(piece(A, 'Arch', 'Stair archway', [-5, 0, -7], 0, { physics: COLLIDERS.arch })); + // the avenue: flagstones from the gate to the rampart + for (const x of [-3, -1, 1, 3]) for (const z of [5, 3, 1, -1, -3, -5, -7, -9, -11]) o.push(piece(A, 'FloorStone', n('Flagstone'), [x, 0, z])); + // the well in the middle of the avenue + o.push(piece(P, 'Well', 'Well', [0, 0.1, -4])); + // the market on the east side, facing the avenue + o.push(piece(P, 'MarketStall', 'Market stall', [6, 0, -2], -H)); + o.push(piece(P, 'CrateStack', 'Crate stack', [6.4, 0, 1.4], -H)); + o.push(piece(P, 'Crate', n('Crate'), [4.9, 0, 0.9], 0.3, { physics: DYNAMIC(15) })); + o.push(piece(P, 'CrateTeal', n('Crate'), [5.1, 0, -4.8], -0.2, { physics: DYNAMIC(12) })); + o.push(piece(P, 'Barrel', n('Barrel'), [6.9, 0, -4.6], 0, { physics: DYNAMIC(30) })); + o.push(piece(P, 'BarrelSmall', n('Barrel'), [6.3, 0, -5.4], 0, { physics: DYNAMIC(12) })); + o.push(piece(P, 'Sacks', 'Grain sacks', [6.7, 0, 3.5], -2.4)); + o.push(piece(P, 'Lantern', n('Lantern'), [6.4, 1.05, 1.1], 0, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Signpost', 'Signpost', [3.3, 0, 4.4], -0.5)); + o.push(piece(P, 'Chest', 'Treasure chest', [5, 0, -11], -0.3)); + // wall torches on the inner faces (a wall face is 0.125 off its grid line) + for (const z of [-9, 3]) o.push(piece(P, 'WallTorch', n('Wall torch'), [7.875, 1.5, z], -H, { physics: COLLIDERS.sensor })); + for (const z of [-1, -9]) o.push(piece(P, 'WallTorch', n('Wall torch'), [-7.875, 1.5, z], H, { physics: COLLIDERS.sensor })); + // the west garden: trees, a bench, bushes, flowers on the earth + o.push(piece(N, 'Oak', 'Courtyard oak', [-6, 0, 3], 0.7, { physics: TRUNKS.Oak })); + o.push(piece(N, 'Birch', 'Courtyard birch', [-6.2, 0, -3.5], 2.1, { physics: TRUNKS.Birch })); + o.push(piece(P, 'Bench', 'Garden bench', [-5, 0, 0.2], H)); + for (const [x, z, yaw] of [[-7.1, 0.6, 0.3], [-7.2, -5.6, 1.9], [4.9, 4.9, 0.8]]) o.push(piece(N, 'Bush', n('Bush'), [x, 0, z], yaw)); + for (const [x, z] of [[-4.9, 4.6], [-6.9, -1.4], [4.8, -8.6]]) o.push(piece(N, 'FlowerPatch', n('Flowers'), [x, 0, z], x, { physics: COLLIDERS.sensor })); + const rand = rng(1701); + for (let i = 0; i < 14; i++) { + const west = i % 2 === 0; + const x = west ? -7.3 + rand() * 2.8 : 4.6 + rand() * 2.8; + const z = west ? -6 + rand() * 10.5 : -9 + rand() * 13.5; + o.push(piece(N, i % 3 ? 'GrassTuft' : 'GrassTuftDry', n('Grass'), [x, 0, z], rand() * 6, { physics: COLLIDERS.sensor })); + } + // pines outside the walls, so the skyline is not empty + for (const [x, z, s] of [[-12, -18, 1.1], [-14, -9, 1], [-13, 2, 0.9], [12.5, -19, 1.05], [14, -6, 1.1], [13, 4, 0.95], [-3, -20, 1.2], [4, -21, 1]]) + o.push(piece(N, 'Pine', n('Pine'), [x, 0, z], x * 0.37, { scale: s, physics: TRUNKS.Pine })); + return o; +} + +const CASTLE_DEF = { + kind: 'template', + seed: false, + slug: 'castle-courtyard', + title: 'Castle Courtyard', + description: + 'A walled courtyard built from the kits: towers, a gate, a rampart up a stair through an archway, a market stall and a well. Press Play to walk it.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['level design', 'kits', 'castle', 'walkable'], + objects: castleObjects(), + // a lit afternoon: a high warm sun from the south-west, a blue sky fading to haze, the + // packed-earth ground, and a far fog so the horizon softens + env: { + preset: 'daylight', + exposure: 1.05, + background: { top: '#4f86c6', bottom: '#dfe8ee' }, + fog: { color: '#dfe8ee', near: 35, far: 130 }, + ground: { color: '#7a6446', roughness: 0.95 }, + sun: { color: '#fff1d6', intensity: 2.6, dir: [-0.55, 0.62, 0.55] }, + hemi: { sky: '#d3e6ff', ground: '#7a6446', intensity: 1.25 } + }, + // just inside the gate, looking up the avenue at the well and the rampart + physics: playPhysics([1, 0.3, 4.6], 0), + post: lookPost(0.5), + graphs: { scene: walkGraph() }, + view: { pos: [9.5, 9, 13], target: [0, 2, -5] }, + thumb: {} +}; + +// ---- 2. Forest Clearing ---------------------------------------------------------------- +// +// A clearing in a ring of trees under a cliff: a stone path runs north from where you stand, +// turns east over a footbridge where a pond narrows, and ends at a small plaster hut built +// from the architecture kit (four walls, a doorway, a window, a slate roof). A mound to the +// west is walkable; logs, a stump, mushrooms, ferns and flowers dress the floor. +function forestObjects() { + const n = namer(); + /** @type {any[]} */ + const o = []; + // the path north, then east over the bridge to the hut + for (const z of [4, 2, 0, -2, -4, -6]) o.push(piece(N, 'PathTile', n('Path'), [0, 0, z], 0)); + // the crossing, west to east: a ramp up, the footbridge (turned so its walk runs along X), + // a ramp down — the Ramp is architecture-kit sandstone squashed to the deck's 0.85 m + o.push(piece(A, 'Ramp', n('Bridge ramp'), [2, 0, -6], -H, { scale: [1, 0.85, 1], physics: COLLIDERS.ramp(1) })); + o.push(piece(N, 'Bridge', 'Footbridge', [4, 0, -6], H, { physics: bridgeCollider() })); + o.push(piece(A, 'Ramp', n('Bridge ramp'), [6, 0, -6], H, { scale: [1, 0.85, 1], physics: COLLIDERS.ramp(1) })); + o.push(piece(N, 'PathTile', n('Path'), [8, 0, -6], 0)); + // the pond: its body to the north-east, a narrow neck running under the bridge + o.push(piece(N, 'PondWater', 'Pond', [4.8, 0.02, -10.8], 0.35, { scale: 1.7, physics: COLLIDERS.sensor })); + o.push(piece(N, 'PondWater', 'Pond neck', [4, 0.025, -6], H, { scale: [1.5, 1, 0.85], physics: COLLIDERS.sensor })); + for (const [x, z, yaw] of [[1.3, -8.4, 0.2], [7.6, -11.6, 1.2], [2.2, -11.9, 2.4], [6.8, -7.9, 0.9]]) o.push(piece(N, 'Reeds', n('Reeds'), [x, 0, z], yaw, { physics: COLLIDERS.sensor })); + for (const [x, z, yaw] of [[1.5, -10.5, 0.4], [7.9, -9.6, 2.2], [4.6, -12.7, 1.1]]) o.push(piece(N, 'FlatRock', n('Pond rock'), [x, 0, z], yaw)); + for (const [x, z] of [[1.9, -7.7], [5.4, -12.6], [7.3, -8.3]]) o.push(piece(N, 'RockSmall', n('Pebble'), [x, 0, z], x)); + // the hut: a 4 × 4 m plaster cottage (kit.md's recipe, moved to x 10..14, z -9..-5), + // its doorway facing the path, a window to the south + const hx = 10; + const hz = -9; + for (const [dx, dz] of [[1, 1], [3, 1], [1, 3], [3, 3]]) o.push(piece(A, 'FloorWood', n('Hut floor'), [hx + dx, 0, hz + dz])); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 1, 0, hz])); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 3, 0, hz])); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 1, 0, hz + 4])); + o.push(piece(A, 'WallPlasterWindow', 'Hut window wall', [hx + 3, 0, hz + 4])); + o.push(piece(A, 'Window', 'Hut window', [hx + 3, 0, hz + 4])); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx, 0, hz + 1], H)); + o.push(piece(A, 'WallPlasterDoor', 'Hut doorway', [hx, 0, hz + 3], H, { physics: COLLIDERS.doorway })); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 4, 0, hz + 1], H)); + o.push(piece(A, 'WallPlaster', n('Hut wall'), [hx + 4, 0, hz + 3], H)); + for (const [dx, dz] of [[0, 0], [4, 0], [0, 4], [4, 4]]) o.push(piece(A, 'CornerPostStone', n('Hut corner'), [hx + dx, 0, hz + dz])); + for (const dx of [1, 3]) { + o.push(piece(A, 'RoofSlope', n('Hut roof'), [hx + dx, 3, hz + 3])); + o.push(piece(A, 'RoofSlope', n('Hut roof'), [hx + dx, 3, hz + 1], PI)); + } + for (const dx of [0, 4]) { + o.push(piece(A, 'WallPlasterGable', n('Hut gable'), [hx + dx, 3, hz + 3], H)); + o.push(piece(A, 'WallPlasterGable', n('Hut gable'), [hx + dx, 3, hz + 1], -H)); + } + o.push(piece(A, 'Chimney', 'Hut chimney', [hx + 3, 3.7, hz + 1])); + // inside the hut + o.push(piece(P, 'Bed', 'Hut bed', [hx + 2.8, 0.1, hz + 1.25], H)); + o.push(piece(P, 'Table', 'Hut table', [hx + 2.9, 0.1, hz + 3], 0, { scale: [0.7, 1, 0.8] })); + o.push(piece(P, 'Chair', 'Hut chair', [hx + 2.9, 0.1, hz + 2.2], PI)); + o.push(piece(P, 'Rug', 'Hut rug', [hx + 1.6, 0.1, hz + 2.1], 0, { scale: 0.8, physics: COLLIDERS.sensor })); + o.push(piece(P, 'Lantern', n('Lantern'), [hx + 2.9, 0.88, hz + 3], 0, { physics: COLLIDERS.sensor })); + o.push({ type: 'light', name: 'Hut lamp', pos: [hx + 2, 2.2, hz + 2], color: 0xffb468, intensity: 6, distance: 8, decay: 2 }); + o.push(piece(P, 'Barrel', n('Barrel'), [hx - 0.8, 0, hz + 1.6], 0, { physics: DYNAMIC(25) })); + o.push(piece(P, 'Crate', n('Crate'), [hx - 0.9, 0, hz + 0.6], 0.4, { physics: DYNAMIC(15) })); + // the cliff behind everything, and the walkable mound to the west + for (const x of [-10, -6, -2, 2, 6, 10]) o.push(piece(N, 'Cliff', n('Cliff'), [x, 0, -19], x > 0 ? PI * 0.02 : 0)); + o.push(piece(N, 'Hill', 'Grassy mound', [-9, 0, -3], 0.4, { physics: hillCollider() })); + // trees round the clearing + const trees = [ + ['Oak', -6, -9, 1.0], ['OakAutumn', -11, 4, 1.05], ['Pine', -14, -12, 1.1], ['Pine', -4, -15, 1.15], ['Birch', -3.5, -10.5, 1], + ['Pine', 11, -15, 1.1], ['Birch', 8.5, 2.8, 1], ['Oak', 15.5, -3, 1.1], ['DeadTree', -12.5, -6, 1], ['Pine', 16, -10, 1.2], + ['Birch', -8, 7.5, 0.95], ['Pine', 6, 10, 1.1], ['OakAutumn', 13, 5.5, 0.9], ['Pine', -15, 3, 1.05], ['Oak', 2, -15.5, 1.05] + ]; + // an outer ring of pines closes the clearing in (and hides the edge of the world) + const ring = rng(99); + for (let i = 0; i < 16; i++) { + const a = (i / 16) * 2 * PI + ring() * 0.25; + const r = 20 + ring() * 5; + trees.push([i % 5 === 2 ? 'Oak' : 'Pine', r3(2 + Math.cos(a) * r), r3(-3 + Math.sin(a) * r), r3(1 + ring() * 0.3)]); + } + for (const [item, x, z, s] of trees) + o.push(piece(N, /** @type {string} */ (item), n(/** @type {string} */ (item)), [/** @type {number} */ (x), 0, /** @type {number} */ (z)], /** @type {number} */ (x) * 0.61, { scale: s, physics: TRUNKS[/** @type {string} */ (item)] })); + // the floor of the clearing + o.push(piece(N, 'Log', 'Fallen log', [-3.4, 0, -1.2], 0.45)); + o.push(piece(N, 'Stump', 'Stump', [-4.2, 0, -4.6], 0.8)); + o.push(piece(N, 'Mushrooms', n('Mushrooms'), [-3.3, 0, -5.3], 1.3, { physics: COLLIDERS.sensor })); + o.push(piece(N, 'Mushrooms', n('Mushrooms'), [-6.8, 0, -7.8], 2.6, { physics: COLLIDERS.sensor })); + o.push(piece(N, 'BoulderCluster', 'Boulders', [-9.5, 0, -13.5], 0.9)); + o.push(piece(N, 'RockLarge', 'Big rock', [9.5, 0, 0.5], 2.2)); + o.push(piece(N, 'RockMedium', n('Rock'), [-1.8, 0, 7.5], 0.5)); + o.push(piece(P, 'Signpost', 'Signpost', [1.6, 0, -3.4], -0.6)); + o.push(piece(N, 'Stump', 'Seat stump', [-1.9, 0, 1.6], 2.0, { scale: 0.7 })); + const rand = rng(4242); + const clear = (/** @type {number} */ x, /** @type {number} */ z) => Math.abs(x) < 1.6 && z > -7.5 && z < 6.5; // the path + for (let i = 0; i < 16; i++) { + const a = rand() * 2 * PI; + const r = 4 + rand() * 9; + const x = r3(Math.cos(a) * r); + const z = r3(Math.sin(a) * r * 0.9 - 3); + if (clear(x, z) || (x > 0.8 && x < 14.5 && z > -13 && z < -3.5) || z < -16) continue; // path, bridge, pond, hut, cliff + const kind = i % 4 === 0 ? 'Fern' : i % 4 === 1 ? 'Bush' : i % 4 === 2 ? 'FlowerPatch' : 'BushAutumn'; + o.push(piece(N, kind, n(kind), [x, 0, z], rand() * 6, kind === 'FlowerPatch' || kind === 'Fern' ? { physics: COLLIDERS.sensor } : {})); + } + for (let i = 0; i < 26; i++) { + const x = r3(-12 + rand() * 26); + const z = r3(-14 + rand() * 22); + if (clear(x, z) || (x > 0.8 && x < 14.5 && z > -13 && z < -3.5) || z < -16) continue; + o.push(piece(N, i % 4 ? 'GrassTuft' : 'Flowers', n('Grass'), [x, 0, z], rand() * 6, { physics: COLLIDERS.sensor })); + } + for (const [x, z, yaw] of [[-2.6, -2.8, 0.2], [2.4, 1.5, 1.4], [-5.5, 2.5, 2.9]]) o.push(piece(N, 'FallenLeaves', n('Leaves'), [x, 0.01, z], yaw, { physics: COLLIDERS.sensor })); + return o; +} + +const FOREST_DEF = { + kind: 'template', + seed: false, + slug: 'forest-clearing', + title: 'Forest Clearing', + description: + 'A clearing under a cliff: a stone path, a footbridge over the neck of a pond and a little plaster hut, ringed by oaks, pines and birches. Press Play to walk it.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['level design', 'kits', 'nature', 'walkable'], + objects: forestObjects(), + // late morning in the woods: a clear sky, a green ground, a warm sun through the trees + env: { + preset: 'daylight', + exposure: 1.05, + background: { top: '#5d93cf', bottom: '#dfe8e0' }, + // the fog closes in past the tree line, so the flat ground fades into haze instead of + // ending in a band at the horizon (the Towers finding) + fog: { color: '#dfe8e0', near: 18, far: 62 }, + ground: { color: '#5f7d3f', roughness: 1 }, + sun: { color: '#fff3d8', intensity: 2.5, dir: [0.45, 0.72, 0.5] }, + hemi: { sky: '#d9ecff', ground: '#5f7d3f', intensity: 1.3 } + }, + physics: playPhysics([0, 0.35, 5], 0), + post: lookPost(0.5), + graphs: { scene: walkGraph() }, + view: { pos: [-6, 7.5, 12], target: [3, 1, -6] }, + thumb: {} +}; + +// ---- 3. Tavern Interior ---------------------------------------------------------------- +// +// A two-storey room 12 × 10 m: the HALL (x -6..2) rises the full 6 m with a BALCONY along its +// north wall, the KITCHEN (x 2..6) sits behind a partition with a doorway and has a LOFT above +// it that opens onto the balcony. An oak staircase climbs the west wall to the balcony; a bar +// runs under it; tables, benches, barrels and a hearth fill the rooms, and lanterns and wall +// torches light it warm. The front door is shut: you are inside. +function tavernObjects() { + const n = namer(); + /** @type {any[]} */ + const o = []; + const XS = [-5, -3, -1, 1, 3, 5]; + const ZS = [-5, -3, -1, 1, 3]; + // ground floor, ceiling, balcony and loft ------------------------------------------ + for (const x of XS) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Floor'), [x, 0, z])); + for (const x of XS) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Ceiling'), [x, 6, z])); + for (const x of [-5, -3, -1, 1]) o.push(piece(A, 'FloorWood', n('Balcony'), [x, 3, -5])); + for (const x of [3, 5]) for (const z of ZS) o.push(piece(A, 'FloorWood', n('Loft'), [x, 3, z])); + // outer walls, two storeys: sandstone below, plaster above -------------------------- + /** @param {number} y @param {string} solid @param {string} win @param {string} winWall */ + const shell = (y, solid, win, winWall) => { + // a window's shutters are its FRONT (+Z): every wall of the shell is turned so its + // front faces OUT (kit.md: the Window shares its wall's position and rotation) + for (const x of XS) { + // north wall z = -6, turned to face -Z + if (y === 3 && x === -1) { + o.push(piece(A, winWall, n('Window wall'), [x, y, -6], PI)); + o.push(piece(A, 'Window', n('Window'), [x, y, -6], PI)); + } else o.push(piece(A, solid, n('Wall'), [x, y, -6], PI)); + // south wall z = 4 (the front: the door, and windows) + if (y === 0 && x === -3) { + o.push(piece(A, 'WallStoneDoor', 'Front doorway', [x, y, 4])); + o.push(piece(A, 'Door', 'Front door', [x, y, 4])); + } else if ((y === 0 && (x === 1 || x === 5)) || (y === 3 && x === -3)) { + o.push(piece(A, winWall, n('Window wall'), [x, y, 4])); + o.push(piece(A, 'Window', n('Window'), [x, y, 4])); + } else o.push(piece(A, solid, n('Wall'), [x, y, 4])); + } + for (const z of ZS) { + if (y === 0 && z === 1) { + o.push(piece(A, winWall, n('Window wall'), [-6, y, z], -H)); + o.push(piece(A, 'Window', n('Window'), [-6, y, z], -H)); + } else o.push(piece(A, solid, n('Wall'), [-6, y, z], -H)); + if (y === 0 && z === -1) { + o.push(piece(A, winWall, n('Window wall'), [6, y, z], H)); + o.push(piece(A, 'Window', n('Window'), [6, y, z], H)); + } else o.push(piece(A, solid, n('Wall'), [6, y, z], H)); + } + for (const [x, z] of [[-6, -6], [6, -6], [-6, 4], [6, 4]]) o.push(piece(A, 'CornerPostStone', n('Corner post'), [x, y, z])); + }; + shell(0, 'WallStone', 'Window', 'WallStoneWindow'); + shell(3, 'WallPlaster', 'Window', 'WallPlasterWindow'); + // the partition between hall and kitchen (x = 2), a doorway at z = 1 -------------------- + for (const z of ZS) { + if (z === 1) o.push(piece(A, 'WallPlasterDoor', 'Kitchen doorway', [2, 0, z], H, { physics: COLLIDERS.doorway })); + else o.push(piece(A, 'WallPlaster', n('Partition'), [2, 0, z], H)); + } + // the staircase up the west wall to the balcony, rails along the open edges ---------- + o.push(piece(A, 'StairsWood', 'Balcony stairs', [-5, 0.1, -2], 0, { physics: COLLIDERS.stairs })); + for (const x of [-3, -1, 1]) o.push(piece(A, 'Railing', n('Balcony railing'), [x, 3.1, -3.92])); + for (const z of [-3, -1, 1, 3]) o.push(piece(A, 'Railing', n('Loft railing'), [2.08, 3.1, z], H)); + o.push(piece(A, 'Column', n('Column'), [2, 0.1, -4])); + o.push(piece(A, 'Column', n('Column'), [-2, 0.1, -4])); + for (const x of [-3, -1, 1]) o.push(piece(A, 'Beam', n('Balcony beam'), [x, 2.7, -4])); + for (const x of [-5, -3, -1, 1]) o.push(piece(A, 'Beam', n('Ceiling beam'), [x, 5.7, -0.5])); + // the bar, under the balcony: half walls (scaled to counter height) with a trim top -- + for (const x of [-2.2, -0.2]) { + o.push(piece(A, 'WallStoneHalf', n('Bar'), [x, 0.1, -2.6], 0, { scale: [1, 0.72, 1] })); + o.push(piece(A, 'Trim', n('Bar top'), [x, 1.18, -2.6])); + } + o.push(piece(P, 'Bookcase', n('Shelf'), [-1.2, 0.1, -5.5])); + o.push(piece(P, 'Bookcase', n('Shelf'), [0.4, 0.1, -5.5])); + for (const [x, z] of [[-3.4, -5.2], [-3.6, -4.4], [1.4, -5.2]]) o.push(piece(P, 'Barrel', n('Barrel'), [x, 0.1, z], x, { physics: DYNAMIC(30) })); + o.push(piece(P, 'Candles', n('Candles'), [-1.6, 1.38, -2.6], 0, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Lantern', n('Lantern'), [-0.2, 1.38, -2.6], 0, { physics: COLLIDERS.sensor })); + for (const x of [-2.4, -1.2, 0]) o.push(piece(P, 'Chair', n('Bar stool'), [x, 0.1, -1.7], PI)); + // the hall: two long tables with benches, a rug, a plant -------------------------- + for (const [x, z] of [[-2.6, 1.2], [-0.2, 0.6]]) { + o.push(piece(P, 'Table', n('Table'), [x, 0.1, z], H)); + o.push(piece(P, 'Bench', n('Bench'), [x - 0.75, 0.1, z], H)); + o.push(piece(P, 'Bench', n('Bench'), [x + 0.75, 0.1, z], H)); + o.push(piece(P, 'Candles', n('Candles'), [x, 0.88, z], 0, { physics: COLLIDERS.sensor })); + } + o.push(piece(P, 'Rug', 'Hall rug', [-1.2, 0.1, 1.0], H, { scale: 1.2, physics: COLLIDERS.sensor })); + o.push(piece(P, 'PottedPlant', n('Plant'), [1.4, 0.1, 3.3])); + o.push(piece(P, 'PottedPlant', n('Plant'), [-5.3, 0.1, 3.3])); + o.push(piece(P, 'CrateTeal', n('Crate'), [-4.9, 0.1, 1.9], 0.2, { physics: DYNAMIC(12) })); + // the kitchen: a hearth, a cauldron, a workbench, stores ----------------------------- + o.push(piece(A, 'Chimney', 'Hearth', [5.4, 0.1, -5.4])); + o.push(piece(P, 'Cauldron', 'Cauldron', [4.4, 0.1, -5], 0.4)); + o.push(piece(P, 'Workbench', 'Workbench', [5.45, 0.1, -1.5], -H)); + o.push(piece(P, 'Candles', n('Candles'), [5.45, 1.02, -1.2], 0, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'CrateStack', 'Kitchen crates', [4.6, 0.1, 3.2])); + o.push(piece(P, 'Sacks', 'Kitchen sacks', [5.2, 0.1, 1.4], 1.9)); + o.push(piece(P, 'BarrelSmall', n('Barrel'), [3.1, 0.1, -5.3], 0, { physics: DYNAMIC(10) })); + // the loft: a bed, a chest, a rug, a tapestry, a plant -------------------------------- + o.push(piece(P, 'Bed', 'Loft bed', [5, 3.1, -4.4])); + o.push(piece(P, 'Chest', 'Loft chest', [5.2, 3.1, 2.8], -H)); + o.push(piece(P, 'Rug', 'Loft rug', [4.1, 3.1, -0.5], H, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Tapestry', n('Tapestry'), [5.875, 3.9, 0.5], -H, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'Tapestry', n('Tapestry'), [-3, 3.9, -5.875], 0, { physics: COLLIDERS.sensor })); + o.push(piece(P, 'PottedPlant', n('Plant'), [1.3, 3.1, -5.3])); + // wall torches and lights ---------------------------------------------------------- + for (const [x, z, yaw] of [[-5.875, 0.7, H], [-5.875, 2.8, H], [1.875, -1.8, -H], [-0.8, 3.875, PI], [5.875, 2, -H]]) + o.push(piece(P, 'WallTorch', n('Wall torch'), [x, 1.55, z], yaw, { physics: COLLIDERS.sensor })); + for (const [x, y, z, i] of [[-3, 2.5, 1.2, 9], [0.2, 2.5, 1, 9], [-1, 2.4, -3.2, 7], [4, 2.4, -2.5, 8], [4, 5.2, -1, 8], [-2, 5.2, -5, 6], [-4.2, 2.3, -1, 5]]) + o.push({ type: 'light', name: n('Lamp'), pos: [x, y, z], color: 0xffb060, intensity: i, distance: 11, decay: 2 }); + return o; +} + +const TAVERN_DEF = { + kind: 'template', + seed: false, + slug: 'tavern-interior', + title: 'Tavern Interior', + description: + 'A two-storey tavern: a hall with a bar under the balcony, a kitchen with a hearth, and a loft up the oak stairs, lit warm by lanterns and torches. Press Play to walk it.', + author: 'theprototype', + license: 'CC0-1.0', + tags: ['level design', 'kits', 'interior', 'walkable'], + objects: tavernObjects(), + // an early evening outside the windows; inside, the warm lamps do the work and a warm + // hemisphere keeps the corners from going black + env: { + preset: 'sunset', + exposure: 1.1, + background: { top: '#3f4a78', bottom: '#f0a766' }, + fog: null, + ground: { color: '#5b5040', roughness: 1 }, + sun: { color: '#ffb06a', intensity: 1.6, dir: [0.7, 0.35, 0.6] }, + hemi: { sky: '#ffd9ae', ground: '#6b5236', intensity: 1.2 } + }, + // by the front door, the bar and the stairs ahead + physics: playPhysics([-1.4, 0.3, 3.3], -0.2), + post: lookPost(0.7), + graphs: { scene: walkGraph() }, + view: { pos: [0.5, 2.1, 3.3], target: [-1.5, 1.6, -3] }, + thumb: {} +}; + +module.exports = { LEVEL_DEFS: [CASTLE_DEF, FOREST_DEF, TAVERN_DEF], COLLIDERS, TRUNKS, BRIDGE_DECK, HILL_RINGS }; diff --git a/tests/e2e/level-templates.test.cjs b/tests/e2e/level-templates.test.cjs new file mode 100644 index 00000000..d95083d6 --- /dev/null +++ b/tests/e2e/level-templates.test.cjs @@ -0,0 +1,258 @@ +// 30c level design — the three GENERAL-tab levels built from the kits (Castle Courtyard, +// Forest Clearing, Tavern Interior; defs in scripts/level-templates.cjs). For each one: +// 1. it loads from the Templates modal (the real card click) and every kit piece refills +// from its pack (packRefs.js), with the file a fraction of what the pieces weigh; +// 2. Play puts you on the level's SPAWN (scenePhysics play.spawn) with the sim running; +// 3. the play frame is readable — centre luminance >= 0.25, round 1's bar (fork 11) — +// and no quarter of the frame is black (no void, no black corner); +// 4. the walker CANNOT pass a wall, and CAN pass the openings built for it (an archway +// and a staircase to a rampart, a doorway into a hut and a footbridge, a staircase to +// a balcony) — the custom colliders are the reason both halves hold. +// +// The staged scenes are served to the Templates modal by page.route (the templates-modal +// suite's idiom) from LEVELS_DIR (default: the lane's staging folder), and the kits — until +// the pack PRs are merged and format-1 is retagged — from PACKS_DIR when the build's own +// PACKS_BASE is the CDN. SKIPS (never fails) when LEVELS_DIR has no index. +// +// APP_URL=https://theprototype.app:5255/ node tests/e2e/level-templates.test.cjs +// LEVELS=castle-courtyard (a subset) +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const LEVELS_DIR = process.env.LEVELS_DIR || '/home/deck/.code/theprototype-app/cloud-lane-30-staging/30c-levels'; +const PACKS_DIR = process.env.PACKS_DIR || '/home/deck/.code/lanes-30/levels-packs'; +const ONLY = (process.env.LEVELS || '').split(',').filter(Boolean); + +// what each level is walked through: [label, start {x, z, yaw}, key, ms, predicate(end), detail] +// yaw: 0 looks -Z, +PI/2 looks -X, PI looks +Z, -PI/2 looks +X (the spawn convention) +const PI = Math.PI; +// an object only that level has — what the load waits for, so a check never reads the +// PREVIOUS level while the new one is still arriving +const MARKER = { 'castle-courtyard': 'Castle gate', 'forest-clearing': 'Footbridge', 'tavern-interior': 'Balcony stairs' }; +const WALKS = { + 'castle-courtyard': [ + { label: 'the walker cannot walk through the south wall by the gate', at: [0, 3], yaw: PI, ms: 2500, pass: (e) => e.z < 5.8, moved: (b, e) => e.z - b.z > 1.2 }, + { label: 'the walker cannot walk through the west curtain wall', at: [-4.8, 1.5], yaw: PI / 2, ms: 2500, pass: (e) => e.x > -7.9, moved: (b, e) => b.x - e.x > 1.5 }, + { label: 'the walker passes the ARCHWAY and climbs the stairs onto the rampart', at: [-5, -4.6], yaw: 0, ms: 4500, pass: (e) => e.feet > 2.6 && e.z < -11.2, moved: (b, e) => b.z - e.z > 5 }, + { label: 'on the rampart the battlements hold the walker in', at: [0, -12.8], y: 3, yaw: 0, ms: 2000, pass: (e) => e.z > -13.95 && e.feet > 2.6, moved: () => true } + ], + 'forest-clearing': [ + { label: 'the walker cannot walk through the hut wall', at: [16, -7], yaw: PI / 2, ms: 2500, pass: (e) => e.x > 14.1, moved: (b, e) => b.x - e.x > 1 }, + { label: 'the walker crosses the FOOTBRIDGE over the pond neck', at: [0, -6], yaw: -PI / 2, ms: 3000, pass: (e) => e.x > 5.3, moved: (b, e) => e.x - b.x > 5 }, + { label: 'the walker enters the hut through its DOORWAY', at: [8, -6], yaw: -PI / 2, ms: 2200, pass: (e) => e.x > 10.6, moved: (b, e) => e.x - b.x > 2.5 } + ], + 'tavern-interior': [ + { label: 'the walker cannot walk through the front wall', at: [0, 3], yaw: PI, ms: 2000, pass: (e) => e.z < 3.9, moved: (b, e) => e.z - b.z > 0.3 }, + { label: 'the walker cannot walk through the east wall of the kitchen', at: [3.4, -2.6], yaw: -PI / 2, ms: 2500, pass: (e) => e.x < 5.9, moved: (b, e) => e.x - b.x > 1 }, + { label: 'the walker climbs the oak STAIRS to the balcony', at: [-5, 1.3], yaw: 0, ms: 4500, pass: (e) => e.feet > 2.7 && e.z < -3.2, moved: (b, e) => b.z - e.z > 3.5 }, + { label: 'the walker goes through the kitchen DOORWAY', at: [1.1, 1], yaw: -PI / 2, ms: 2200, pass: (e) => e.x > 2.6, moved: (b, e) => e.x - b.x > 1.5 } + ] +}; + +/** the rig's world position (feet = eye - 1.7) */ +const rig = (page) => + page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let cam; + s.playerCam.subscribe((c) => (cam = c))(); + if (!cam) return null; + const w = cam.getWorldPosition(new s.THREE.Vector3()); + return { x: w.x, y: w.y, z: w.z, feet: w.y - 1.7 }; + }); + +/** put the rig's FEET at (x, y, z), level, facing `yaw` */ +const placeRig = (page, x, y, z, yaw) => + page.evaluate( + ({ x, y, z, yaw }) => { + const s = window.__stores; + /** @type {any} */ let cam; + s.playerCam.subscribe((c) => (cam = c))(); + const v = new s.THREE.Vector3(x, y + 1.7, z); + cam.parent?.worldToLocal(v); + cam.position.copy(v); + cam.quaternion.setFromEuler(new s.THREE.Euler(0, yaw, 0, 'YXZ')); + cam.updateMatrixWorld(true); + return true; + }, + { x, y, z, yaw } + ); + +/** centre-clip luminance + the darkest quadrant of a real screenshot */ +async function frameLight(page) { + return page.evaluate(async (b64) => { + const bmp = await createImageBitmap(await (await fetch('data:image/png;base64,' + b64)).blob()); + const c = document.createElement('canvas'); + c.width = bmp.width; + c.height = bmp.height; + const x = c.getContext('2d'); + x.drawImage(bmp, 0, 0); + const lum = (/** @type {number} */ x0, /** @type {number} */ y0, /** @type {number} */ w, /** @type {number} */ hgt) => { + const d = x.getImageData(x0, y0, w, hgt).data; + let sum = 0; + for (let i = 0; i < d.length; i += 4) sum += 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]; + return sum / (d.length / 4) / 255; + }; + const W = bmp.width; + const Hh = bmp.height; + const centre = lum(Math.round(W / 2 - 180), Math.round(Hh / 2 - 180), 360, 360); + const quads = [lum(0, 0, W / 2, Hh / 2), lum(W / 2, 0, W / 2, Hh / 2), lum(0, Hh / 2, W / 2, Hh / 2), lum(W / 2, Hh / 2, W / 2, Hh / 2)]; + return { centre, darkest: Math.min(...quads), quads }; + }, (await page.screenshot()).toString('base64')); +} + +h.run(async () => { + const indexFile = path.join(LEVELS_DIR, 'index.json'); + if (!fs.existsSync(indexFile)) { + console.log('SKIP no staged levels at ' + LEVELS_DIR + ' (author them: see the handover)'); + console.log('ALL PASS'); + return; + } + const index = JSON.parse(fs.readFileSync(indexFile, 'utf8')); + const levels = (index.templates ?? []).filter((t) => WALKS[t.slug] && (!ONLY.length || ONLY.includes(t.slug))); + h.check(levels.length > 0, `the staged index lists the levels (${levels.map((l) => l.slug).join(', ')})`); + + const browser = await h.launch({ args: h.GPU_ARGS }); + // warm the lazy rapier import (the physics-suite ritual): Play must have a world at once + { + const warm = await h.setupPage(browser, 'warm'); + await warm.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await warm.page.waitForTimeout(3000); + await warm.ctx.close(); + } + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1540, height: 774 } } }); + const page = A.page; + // the Templates modal's feed -> the staged folder; the kits -> PACKS_DIR when this build + // reads them from the CDN (a relative pack path resolves against PACKS_BASE) + await page.route('**/cdn.jsdelivr.net/**', (route) => { + const url = route.request().url(); + const scenes = url.match(/\/theprototype-app\/scenes@[^/]+\/(.*)$/); + if (scenes) { + const file = path.join(LEVELS_DIR, decodeURIComponent(scenes[1])); + if (scenes[1] === 'index.json') return route.fulfill({ json: index }); + if (fs.existsSync(file)) return route.fulfill({ body: fs.readFileSync(file) }); + return route.fulfill({ status: 404 }); + } + const packs = url.match(/\/theprototype-app\/packs@[^/]+\/(.*)$/); + if (packs && fs.existsSync(PACKS_DIR)) { + const file = path.join(PACKS_DIR, decodeURIComponent(packs[1])); + if (fs.existsSync(file)) return route.fulfill({ body: fs.readFileSync(file) }); + } + return route.continue(); + }); + + for (const level of levels) { + console.log('\n--- ' + level.slug); + const size = fs.statSync(path.join(LEVELS_DIR, level.scene)).size; + h.check(size < 400_000, `${level.title}: the .tpscene is ${Math.round(size / 1024)} KB (kit pieces ride as references)`); + + // 1. load it from the Templates modal --------------------------------------- + await page.evaluate(() => window.__stores.isLocked.set(null)); + await page.locator('#logo-menu').click(); + await page.waitForTimeout(300); + await page.locator('#open-templates').click(); + await page.waitForTimeout(500); + await page.locator('#templates-tab-general').click(); + const card = page.locator(`[data-scene-slug="${level.slug}"]`); + h.check(await card.isVisible().catch(() => false), `${level.title}: the card is on the General tab`); + await card.click(); + // a replace of a non-empty scene answers nothing here (no peers); a large-scene ask + // would be a finding, so it is NOT auto-answered + await h.eventually( + () => + page.evaluate(async (marker) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + let refs = 0; + let hollow = 0; + let meshes = 0; + g.traverse((/** @type {any} */ n) => { + if (n.userData?.packRef) refs++; + if (n.userData?.packStub) hollow++; + if (n.isMesh) meshes++; + }); + /** @type {any} */ let pending; + s.packRefs.packRefsPending.subscribe((v) => (pending = v))(); + return { refs, hollow, meshes, pending, marker: !!g.getObjectByName(marker) }; + }, MARKER[level.slug]), + (r) => r.marker && r.refs > 50 && r.hollow === 0 && r.pending === 0, + `${level.title}: the level loads and every kit piece refills from its pack`, + 60000 + ); + const facts = await page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + let refs = 0; + let meshes = 0; + const maps = new Set(); + g.traverse((/** @type {any} */ n) => { + if (n.userData?.packRef) refs++; + if (n.isMesh) { + meshes++; + if (n.material?.map) maps.add(n.material.map); + } + }); + /** @type {any} */ let play; + s.scenePhysics.scenePlay.subscribe((v) => (play = v))(); + return { refs, meshes, maps: maps.size, spawn: play?.spawn ?? null, simOnPlay: play?.simOnPlay }; + }); + h.check(facts.maps < facts.refs / 2, `${level.title}: ${facts.refs} kit pieces share ${facts.maps} base textures`); + h.check(!!facts.spawn && facts.simOnPlay === true, `${level.title}: a spawn point and sim-on-play are scene data (${JSON.stringify(facts.spawn)})`); + + // COUNTERFACTUALS (CF=arch | CF=spawn): break one guard in the page and watch its + // check go red — the archway loses its custom collider (the box of the whole arch + // then blocks the opening), or the scene loses its spawn (Play starts at the fixed + // (0, 2, 3)) + if (process.env.CF) + await page.evaluate((cf) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + if (cf === 'arch') delete g.getObjectByName('Stair archway')?.userData?.physics; + if (cf === 'spawn') s.scenePhysics.setScenePhysics({ play: { spawn: null } }); + }, process.env.CF); + + // 2. Play: the spawn, the sim -------------------------------------------------- + await page.evaluate(() => window.__stores.isLocked.set(true)); + await h.eventually( + () => page.evaluate(() => { let v; window.__stores.physics.simulating.subscribe((x) => (v = x))(); return v; }), + (v) => v === true, + `${level.title}: Play starts the simulation (sim-on-play)`, + 15000 + ); + await page.waitForTimeout(1200); + const at = await rig(page); + h.check( + at && Math.hypot(at.x - facts.spawn.pos[0], at.z - facts.spawn.pos[2]) < 0.6 && Math.abs(at.feet - facts.spawn.pos[1]) < 0.6, + `${level.title}: Play starts at the spawn point (${at && [at.x, at.feet, at.z].map((v) => v.toFixed(2))})` + ); + const walker = await page.evaluate(() => { let v; window.__stores.charController.walkerState.subscribe((x) => (v = x))(); return v; }); + h.check(walker.source === 'rapier' && walker.grounded, `${level.title}: the walker stands on the level through the physics world (${walker.source}, grounded ${walker.grounded})`); + + // 3. the frame reads, and no quarter is a void ---------------------------------- + await page.waitForTimeout(800); + const light = await frameLight(page); + await page.screenshot({ path: `/home/deck/.code/lanes-30/after-30c/30c-level-design/play-${level.slug}.png` }); + h.check(light.centre >= 0.25, `${level.title}: the play frame reads — centre luminance ${light.centre.toFixed(3)} >= 0.25`); + h.check(light.darkest >= 0.08, `${level.title}: no black quarter (darkest quadrant ${light.darkest.toFixed(3)}; ${light.quads.map((q) => q.toFixed(2))})`); + + // 4. walls hold, openings pass ---------------------------------------------------- + for (const walk of WALKS[level.slug]) { + await placeRig(page, walk.at[0], walk.y ?? 0.3, walk.at[1], walk.yaw); + await page.waitForTimeout(700); + const before = await rig(page); + await page.keyboard.down('KeyW'); + await page.waitForTimeout(walk.ms); + await page.keyboard.up('KeyW'); + await page.waitForTimeout(300); + const end = await rig(page); + const ok = before && end && walk.moved(before, end) && walk.pass(end); + h.check(!!ok, `${level.title}: ${walk.label} (from ${before && [before.x, before.feet, before.z].map((v) => v.toFixed(2))} to ${end && [end.x, end.feet, end.z].map((v) => v.toFixed(2))})`); + } + await page.evaluate(() => window.__stores.isLocked.set(false)); + await page.waitForTimeout(2500); + } + await h.finish(browser); +}); diff --git a/tests/unit/levelTemplates.test.js b/tests/unit/levelTemplates.test.js new file mode 100644 index 00000000..46a81c00 --- /dev/null +++ b/tests/unit/levelTemplates.test.js @@ -0,0 +1,113 @@ +// 30c level design — the three kit levels' DEFS (scripts/level-templates.cjs), checked with +// no browser: every piece names a real kit item, sits on the kit's grid, every custom +// collider fits the 1200-float cap, and each level has a spawn and a walk controller. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const { LEVEL_DEFS, COLLIDERS } = require('../../scripts/level-templates.cjs'); + +// the three kits' item lists, when a local snapshot of the packs is around (the pack PRs +// are separate repos; the list check SKIPS rather than fails without them) +const PACKS_DIR = process.env.PACKS_DIR || '/home/deck/.code/lanes-30/levels-packs'; +/** @param {string} pack */ +function itemsOf(pack) { + const file = path.join(PACKS_DIR, pack, 'default.json'); + return fs.existsSync(file) ? new Set(JSON.parse(fs.readFileSync(file, 'utf8')).map((/** @type {any} */ r) => r.name)) : null; +} + +/** @param {any} def */ +const kits = (def) => def.objects.filter((/** @type {any} */ o) => o.type === 'kit'); + +describe('level templates', () => { + it('are three general-tab templates kept out of the offline seed', () => { + expect(LEVEL_DEFS.map((/** @type {any} */ d) => d.slug)).toEqual(['castle-courtyard', 'forest-clearing', 'tavern-interior']); + for (const def of LEVEL_DEFS) { + expect(def.kind).toBe('template'); + expect(def.seed).toBe(false); + expect(def.description.length).toBeGreaterThan(40); + } + }); + + it('name every object uniquely', () => { + for (const def of LEVEL_DEFS) { + const names = def.objects.map((/** @type {any} */ o) => o.name); + expect(new Set(names).size).toBe(names.length); + } + }); + + it('build from the kits, and only from items the kits ship', () => { + for (const def of LEVEL_DEFS) { + expect(kits(def).length).toBeGreaterThan(90); + for (const o of kits(def)) { + const items = itemsOf(o.pack); + if (items) expect(items.has(o.item), def.slug + ': ' + o.pack + '/' + o.item).toBe(true); + } + } + }); + + it('stand walls and floors on the 1 m grid, turned in right angles', () => { + const grid = /^(Wall(Stone|Plaster)|Floor|Railing|Battlement|Block|Tower|Pillar|CornerPost|Stairs|Gate|RoofSlope|Arch|Door|Window)/; + for (const def of LEVEL_DEFS) + for (const o of kits(def)) { + // the tavern's bar is half walls used as FURNITURE (scaled, off the grid on purpose) + if (!grid.test(o.item) || /^Bar/.test(o.name)) continue; + const [x, , z] = o.pos; + // walls sit on grid lines, cells on odd metres; rails ride 0.08 inside an edge + expect(Math.abs(x * 2 - Math.round(x * 2)) < 0.17 || o.item === 'Railing', def.slug + ' ' + o.name + ' x ' + x).toBe(true); + expect(Math.abs(z * 2 - Math.round(z * 2)) < 0.17 || o.item === 'Railing', def.slug + ' ' + o.name + ' z ' + z).toBe(true); + const yaw = o.rot?.[1] ?? 0; + expect(Math.abs(yaw / (Math.PI / 2) - Math.round(yaw / (Math.PI / 2))) < 1e-3, def.slug + ' ' + o.name + ' yaw').toBe(true); + } + }); + + it('give every custom collider valid pieces under the 1200-float cap', () => { + for (const def of LEVEL_DEFS) + for (const o of def.objects) { + const p = o.physics; + if (p?.collider !== 'custom') continue; + expect(p.colliderVerts.length).toBeLessThanOrEqual(1200); + expect(p.colliderVerts.length % 3).toBe(0); + for (const [start, count] of p.colliderPieces) { + expect(count).toBeGreaterThanOrEqual(9); + expect(start + count).toBeLessThanOrEqual(p.colliderVerts.length); + } + } + }); + + it('leave the doorway and the archway walkable for the 0.3 m capsule', () => { + /** @param {any} c */ + const gap = (c) => { + const v = c.colliderVerts; + const [a, b] = c.colliderPieces; + const leftMax = Math.max(...v.slice(a[0], a[0] + a[1]).filter((/** @type {number} */ _, /** @type {number} */ i) => i % 3 === 0)); + const rightMin = Math.min(...v.slice(b[0], b[0] + b[1]).filter((/** @type {number} */ _, /** @type {number} */ i) => i % 3 === 0)); + return rightMin - leftMax; + }; + expect(gap(COLLIDERS.doorway)).toBeGreaterThan(0.6); + expect(gap(COLLIDERS.arch)).toBeGreaterThan(0.6); + // a staircase is a ramp the walker can climb (50° is its limit) + const slope = Math.atan2(3, 4) * (180 / Math.PI); + expect(slope).toBeLessThan(50); + }); + + it('carry a spawn, sim-on-play and ONE walk-mode character controller', () => { + for (const def of LEVEL_DEFS) { + expect(def.physics.play.simOnPlay).toBe(true); + expect(def.physics.play.spawn.pos).toHaveLength(3); + const nodes = def.graphs.scene.nodes; + expect(nodes.filter((/** @type {any} */ n) => n.type === 'charcontroller')).toHaveLength(1); + expect(nodes[0].data.mode).toBe('walk'); + // the sim needs something dynamic, or startSimulation declines to run at all + expect(def.objects.some((/** @type {any} */ o) => o.physics?.mode === 'dynamic')).toBe(true); + } + }); + + it('are deterministic (a re-author writes the same layout)', () => { + delete require.cache[require.resolve('../../scripts/level-templates.cjs')]; + const again = require('../../scripts/level-templates.cjs').LEVEL_DEFS; + expect(JSON.stringify(again)).toBe(JSON.stringify(LEVEL_DEFS)); + }); +}); From 32bab8458934a8a6985c1d15d515518f308c53ed Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 22:53:17 +0300 Subject: [PATCH 42/46] [fix] integrate: a move whose rotation is Euler.toArray() lands on the peer - 27-A's wire validator took a rotation of 3 or 4 FINITE NUMBERS only; three 0.185's Euler.toArray() is [x, y, z, 'XYZ'] - the shape the gizmo drag, the Explorer drop, the Inspector, Align to ground and ~a dozen senders use - so every such move was refused on arrival (invalid:move) and the peer kept the old pose. Found by the 30c pack lanes (a snapped kit drag never reached peer B, 29 -> 42 invalid:move). - isQuatOrEuler also accepts three numbers + an XYZ-permutation order string (the appliers read [0..2] only); a NaN or a junk order stays refused. - NEW suite move-wire-shape (two peers, the REAL alignToGround sender, a pass-through spy proving the toArray shape left A): 7/7. - vitest wireValidate +4 expectations. - Counterfactual: isEulerWithOrder removed from isQuatOrEuler -> move-wire-shape 2 red ("B follows a move whose rotation is [x, y, z, order]", invalid:move 1 -> 2). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/wireValidate.js | 12 ++++- tests/e2e/move-wire-shape.test.cjs | 83 ++++++++++++++++++++++++++++++ tests/unit/wireValidate.test.js | 5 ++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/move-wire-shape.test.cjs diff --git a/src/lib/wireValidate.js b/src/lib/wireValidate.js index 134cacc6..9ad3e21f 100644 --- a/src/lib/wireValidate.js +++ b/src/lib/wireValidate.js @@ -38,7 +38,17 @@ export function isVec3(v) { /** A rotation on the wire is an Euler triple or a quaternion. @param {unknown} v */ export function isQuatOrEuler(v) { - return isFiniteArray(v, 3) || isFiniteArray(v, 4); + return isFiniteArray(v, 3) || isFiniteArray(v, 4) || isEulerWithOrder(v); +} + +/** + * three's `Euler.toArray()` is `[x, y, z, order]` — the shape the gizmo, the Explorer drop, + * the Inspector and a dozen other senders put on the wire. The appliers read [0..2] only, so + * refusing it dropped every one of those moves on the receiving peer (`invalid:move`). + * @param {unknown} v + */ +export function isEulerWithOrder(v) { + return Array.isArray(v) && v.length === 4 && isFiniteArray(v.slice(0, 3), 3) && typeof v[3] === 'string' && /^[XYZ]{3}$/.test(v[3]); } /** @param {unknown} v */ diff --git a/tests/e2e/move-wire-shape.test.cjs b/tests/e2e/move-wire-shape.test.cjs new file mode 100644 index 00000000..aaf038f0 --- /dev/null +++ b/tests/e2e/move-wire-shape.test.cjs @@ -0,0 +1,83 @@ +// A `move` sent with three's `Euler.toArray()` — `[x, y, z, 'XYZ']` — must land on the peer. +// +// 27-A's wire validator accepted a rotation of 3 or 4 FINITE NUMBERS only, and three 0.185's +// `Euler.toArray()` carries the ORDER STRING as its fourth entry. That is the shape the gizmo +// drag, the Explorer drop-to-surface, the Inspector, Align to ground and a dozen other senders +// put on the wire, so every one of those moves was refused on arrival (`invalid:move`) and the +// peer kept the old pose. Found by the 30c pack lanes (a snapped kit drag never reached peer B). +// +// The path driven here is a REAL sender (objectActions.alignToGround), with a pass-through +// spy proving the message left in the toArray shape, so the check cannot pass on a sender +// that happens to send three numbers. +const h = require('./helpers.cjs'); + +const poseOf = (page, uuid) => + page.evaluate(async (id) => { + const w = window.__stores; + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const o = g.getObjectByProperty('uuid', id); + return o ? { y: o.position.y, x: o.position.x, ry: o.rotation.y } : null; + }, uuid); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const uuid = await A.page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + return g.children[g.children.length - 1].uuid; + }); + await h.eventually(() => poseOf(B.page, uuid), (p) => !!p, 'premise: B holds the box', 20000); + // let /create's own re-seat settle before moving it (the documented /create trap) + await A.page.waitForTimeout(1500); + + // lift + turn it on A and replicate that pose with a plain 3-number move first, so B + // starts from a known pose that differs from where Align to ground will put it + await A.page.evaluate((id) => { + const w = window.__stores; + let g; w.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', id); + o.userData.physics = { mode: 'static' }; + o.position.set(1.5, 6, -2); + o.rotation.set(0, 0.7, 0); + let p; w.peers.subscribe((v) => (p = v))(); + p.send({ type: 'move', uuid: id, pos: [1.5, 6, -2], rot: [0, 0.7, 0], scale: [1, 1, 1] }); + }, uuid); + await h.eventually(() => poseOf(B.page, uuid), (p) => p && Math.abs(p.y - 6) < 0.01, 'premise: B has the lifted pose (y 6)', 15000); + + // spy on what A sends, passing it through + await A.page.evaluate(() => { + const w = window; + w.__moves = []; + let p; w.__stores.peers.subscribe((v) => (p = v))(); + const orig = p.send.bind(p); + p.send = (d) => { if (d && d.type === 'move') w.__moves.push(d.rot); return orig(d); }; + }); + const errsBefore = await B.page.evaluate(() => window.__stores.wireErrors.wireErrors().filter((e) => e.type === 'invalid:move').reduce((n, e) => n + e.count, 0)); + + await A.page.evaluate((id) => window.__stores.objectActions.alignToGround(id), uuid); + const aPose = await poseOf(A.page, uuid); + const sent = await A.page.evaluate(() => window.__moves); + h.check(aPose && aPose.y < 5, `premise: Align to ground moved the box on A (y ${aPose && aPose.y.toFixed(2)})`); + h.check( + sent.length > 0 && sent.every((r) => Array.isArray(r) && r.length === 4 && typeof r[3] === 'string'), + `premise: the real sender put the toArray shape on the wire (${JSON.stringify(sent[0])})` + ); + + await h.eventually( + () => poseOf(B.page, uuid), + (p) => p && Math.abs(p.y - aPose.y) < 0.01, + 'THE BUG: B follows a move whose rotation is [x, y, z, order]', + 8000 + ); + const bPose = await poseOf(B.page, uuid); + h.check(bPose && Math.abs(bPose.ry - 0.7) < 0.01 && Math.abs(bPose.x - 1.5) < 0.01, `B keeps the rest of the pose (x ${bPose && bPose.x.toFixed(2)}, ry ${bPose && bPose.ry.toFixed(2)})`); + const errsAfter = await B.page.evaluate(() => window.__stores.wireErrors.wireErrors().filter((e) => e.type === 'invalid:move').reduce((n, e) => n + e.count, 0)); + h.check(errsAfter === errsBefore, `B recorded no invalid:move (${errsBefore} -> ${errsAfter})`); + + await h.finish(browser); +}); diff --git a/tests/unit/wireValidate.test.js b/tests/unit/wireValidate.test.js index 1bc684aa..3e0054ed 100644 --- a/tests/unit/wireValidate.test.js +++ b/tests/unit/wireValidate.test.js @@ -36,6 +36,11 @@ describe('shape predicates', () => { expect(isQuatOrEuler([0, 0, 0])).toBe(true); expect(isQuatOrEuler([0, 0, 0, 1])).toBe(true); expect(isQuatOrEuler([0, 0])).toBe(false); + // three's Euler.toArray() carries the order: every gizmo/drop move is sent this way + expect(isQuatOrEuler([0.1, 0.2, 0.3, 'XYZ'])).toBe(true); + expect(isQuatOrEuler([0.1, 0.2, NaN, 'XYZ'])).toBe(false); + expect(isQuatOrEuler([0.1, 0.2, 0.3, 'nope'])).toBe(false); + expect(validateWireMessage({ type: 'move', uuid: '6c4c8a2e-1111-4a2b-9c3d-0123456789ab', pos: [0, 1, 0], rot: [0, 0.5, 0, 'XYZ'], scale: [1, 1, 1] })).toBe(true); }); }); From af0e61935e1938b71d27617530013b8acea5f370 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 22:58:55 +0300 Subject: [PATCH 43/46] [fix] integrate: placing a kit piece is undoable, by reference - A piece placed from a pack was kept in history as its full toJSON: a 0.8 MB architecture-kit wall is ~7.9 MB that way (PNG data URLs), over history's 5 MB snapshot limit, so every placement toasted "Object is too large for undo history" and building a level from the kits had no undo (orchestrator review note, after-30c/30c-pack-arch). - history.registerReferenceSnapshot(fn): captureObjectSnapshot asks the provider first and keeps what it returns; packRefs registers "a PRISTINE piece -> its stub" from startPackRefs through a DYNAMIC import (no static edge from history's subtree to the Explorer). Restore needs nothing new: the stub added back is refilled by the scene's own stub scan, with the recorded child uuids. An edited piece still records in full. - NEW suite pack-undo: ten pieces through the real drop path, each entry a ~620 B stub, undo x10 / redo x10 twice, same uuids + triangle counts; with KIT_URL a real WallStone (jsDelivr, the arch PR branch): 620 B entry, no toast, undo + redo. 15/15. - Held: pack-refs 28/28. - Counterfactual: provider ignored (reference = null) -> pack-undo 4 red (entries 491456 B per Duck; the wall: no entry, the toast, undo cannot remove it). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/history.js | 21 +++++- src/lib/packRefs.js | 5 ++ tests/e2e/pack-undo.test.cjs | 138 +++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/pack-undo.test.cjs diff --git a/src/lib/history.js b/src/lib/history.js index 6f0d3734..26e57bd4 100644 --- a/src/lib/history.js +++ b/src/lib/history.js @@ -185,6 +185,24 @@ export function recordTransformSet(items) { // --- create/delete: object presence, restored from a serialized snapshot --- +/** @type {((object: any) => any) | null} */ +let referenceSnapshot = null; + +/** + * 30b integrate: content that can be REBUILT from where it came from is kept in history as + * that reference, not as its serialized bytes. A kit piece placed from a pack is ~0.8 MB of + * GLB and ~7.9 MB as toJSON (its textures come back as PNG data URLs), so every placement + * toasted "too large for undo history" and level building with the kits had no undo. The + * provider returns a toJSON element (packRefs' stub) or null for "not a reference"; the + * restore needs nothing new, because a stub added back to the scene is refilled by the + * same scan that refills a loaded file's or a peer's. Registered, never imported: packRefs + * reaches the Explorer, and nothing in history's import subtree may grow that edge. + * @param {((object: any) => any) | null} fn + */ +export function registerReferenceSnapshot(fn) { + referenceSnapshot = fn; +} + /** * Serialize an object (ObjectLoader JSON round-trip, same format the * `object` peer message uses for lights/parents) for create/delete entries. @@ -199,7 +217,8 @@ export function captureObjectSnapshot(object, quiet = false) { // re-broadcast hands one to every peer (editOverlays.js) const unpark = parkEditOverlays(object); try { - const element = object.toJSON(); + const reference = referenceSnapshot ? referenceSnapshot(object) : null; + const element = reference ?? object.toJSON(); if (JSON.stringify(element).length > SNAPSHOT_LIMIT) { if (!quiet) showToast('Object is too large for undo history — this step will not be undoable'); return null; diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js index b252b9b9..a9a8f7d7 100644 --- a/src/lib/packRefs.js +++ b/src/lib/packRefs.js @@ -536,6 +536,11 @@ let scanTimer = null; export function startPackRefs() { if (started || typeof window === 'undefined') return; started = true; + // 30b integrate: undo keeps a pristine piece as its stub (history.registerReferenceSnapshot). + // Dynamic: history's import subtree must not gain an edge to the Explorer through here. + import('./history').then((history) => + history.registerReferenceSnapshot((/** @type {any} */ object) => (isPristinePackRef(object) ? stubElementOf(object) : null)) + ); objectsGroup.subscribe(() => { if (scanTimer) return; scanTimer = setTimeout(() => { diff --git a/tests/e2e/pack-undo.test.cjs b/tests/e2e/pack-undo.test.cjs new file mode 100644 index 00000000..52e4cdba --- /dev/null +++ b/tests/e2e/pack-undo.test.cjs @@ -0,0 +1,138 @@ +// 30b integrate — placing kit pieces is UNDOABLE, by reference. +// +// A piece placed from a pack was recorded in history as its full toJSON — a 0.8 MB +// architecture-kit wall is ~7.9 MB that way (its JPEGs come back as PNG data URLs), past +// history's 5 MB snapshot limit, so every placement toasted "Object is too large for undo +// history" and a level built from the kits had no undo at all. history now asks a +// registered provider first (packRefs: a PRISTINE piece -> its stub) and keeps THAT; undo +// removes the piece, redo adds the stub back and the scene's stub scan refills it from the +// pack, with the recorded child uuids. +// +// Fixture: the bundled Duck (always served, addressed by an absolute url — the pack-refs +// suite's fixture), placed TEN times through the real drop path. A last section places a +// real architecture-kit wall when KIT_URL names one (e.g. a jsDelivr url of the packs repo) +// and SKIPS without it. +// +// APP_URL=https://theprototype.app:5239/ [KIT_URL=https://…/WallStone.glb] node tests/e2e/pack-undo.test.cjs +const h = require('./helpers.cjs'); + +const DUCK = 'library/default/Duck/glTF-Binary/Duck.glb'; + +async function place(peer, url, name) { + return peer.page.evaluate( + async ({ url, name }) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const before = new Set(g.children.map((c) => c.uuid)); + /** @type {any} */ let renderer; + s.globalRenderer.subscribe((v) => (renderer = v))(); + const r = renderer.domElement.getBoundingClientRect(); + await s.explorerDrop.dropExplorerItem({ kind: 'object', name, url }, r.left + r.width / 2, r.top + r.height / 2); + for (let i = 0; i < 200; i++) { + const fresh = g.children.find((c) => !before.has(c.uuid)); + if (fresh) return fresh.uuid; + await new Promise((res) => setTimeout(res, 100)); + } + return null; + }, + { url, name } + ); +} + +/** the scene's roots that carry a pack reference, with their refill state */ +const pieces = (peer) => + peer.page.evaluate(() => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + return g.children + .filter((o) => o.userData.packRef) + .map((o) => { + let tris = 0; + o.traverse((n) => { + if (n.isMesh) tris += (n.geometry.index ? n.geometry.index.count : n.geometry.attributes.position.count) / 3; + }); + return { uuid: o.uuid, stub: !!o.userData.packStub, tris, kids: o.children.length }; + }); + }); + +/** the create entries on the undo stack, with the size of what each one keeps */ +const createEntries = (peer) => + peer.page.evaluate(() => { + const s = window.__stores; + /** @type {any[]} */ let stack = []; + s.history.undoStack.subscribe((v) => (stack = v))(); + return stack + .filter((e) => e.kind === 'create') + .map((e) => ({ uuid: e.uuid, bytes: JSON.stringify(e.snapshot.element).length, stub: !!e.snapshot.element?.object?.userData?.packStub })); + }); + +const toasts = (peer) => + peer.page.evaluate(() => { + const s = window.__stores; + /** @type {any[]} */ let list = []; + (s.notifications ?? s.toastStore).subscribe((v) => (list = v))(); + return (list || []).map((t) => String(t.message ?? t.text ?? t)).filter((m) => /too large for undo/i.test(m)).length; + }); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const url = await A.page.evaluate((p) => location.origin + '/' + p, DUCK); + + // 1. TEN placements, each a create entry that keeps the STUB ------------------------- + const placed = []; + for (let i = 0; i < 10; i++) placed.push(await place(A, url, 'Duck')); + h.check(placed.every(Boolean) && new Set(placed).size === 10, `premise: ten kit pieces placed through the real drop path (${placed.filter(Boolean).length})`); + const filled = await pieces(A); + const tris0 = filled[0]?.tris ?? 0; + h.check(filled.length === 10 && filled.every((p) => !p.stub && p.tris > 0), `premise: all ten are filled pieces (${filled.map((p) => p.tris).join(',')} tris)`); + const entries = await createEntries(A); + const mine = entries.filter((e) => placed.includes(e.uuid)); + h.check(mine.length === 10, `each placement recorded ONE create entry (${mine.length})`); + h.check( + mine.every((e) => e.stub && e.bytes < 4000), + `THE FIX: each entry keeps the piece as its STUB, not its bytes (${mine.map((e) => e.bytes).join(',')} B)` + ); + + // 2. undo all ten ------------------------------------------------------------------- + for (let i = 0; i < 10; i++) await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => pieces(A), (p) => p.length === 0, 'undo x10 removes every piece', 8000); + + // 3. redo all ten: back as stubs, refilled from the pack under the SAME uuids --------- + for (let i = 0; i < 10; i++) await A.page.evaluate(() => window.__stores.history.redo()); + await h.eventually( + () => pieces(A), + (p) => p.length === 10 && p.every((x) => !x.stub && x.tris === tris0), + 'redo x10 restores every piece, refilled from the pack (same triangle count)', + 20000 + ); + const back = await pieces(A); + h.check(placed.every((u) => back.some((p) => p.uuid === u)), 'the restored pieces keep their uuids'); + + // 4. and once more round the loop (redo recaptured each snapshot on its way out) ------- + for (let i = 0; i < 10; i++) await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => pieces(A), (p) => p.length === 0, 'a second undo x10 still removes them all', 8000); + for (let i = 0; i < 10; i++) await A.page.evaluate(() => window.__stores.history.redo()); + await h.eventually(() => pieces(A), (p) => p.length === 10 && p.every((x) => !x.stub), 'a second redo x10 still restores them all', 20000); + + // 5. a REAL kit wall (7.9 MB as toJSON): no "too large" toast, and it undoes ---------- + const kit = process.env.KIT_URL; + if (!kit) { + console.log('SKIP a real kit wall (set KIT_URL to an architecture-kit GLB)'); + } else { + const t0 = await toasts(A); + const wall = await place(A, kit, 'WallStone'); + h.check(!!wall, 'premise: a real architecture-kit wall placed'); + const e = (await createEntries(A)).find((x) => x.uuid === wall); + h.check(!!e && e.stub && e.bytes < 4000, `the wall's undo entry is its stub (${e && e.bytes} B)`); + h.check((await toasts(A)) === t0, 'no "too large for undo history" toast for the wall'); + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => pieces(A), (p) => !p.some((x) => x.uuid === wall), 'undo removes the wall', 8000); + await A.page.evaluate(() => window.__stores.history.redo()); + await h.eventually(() => pieces(A), (p) => p.some((x) => x.uuid === wall && !x.stub && x.tris > 500), 'redo brings the wall back, refilled', 30000); + } + + await h.finish(browser); +}); From 7b9f827de6bbcebe9e3126a559e911fdff8cb2ba Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 24 Sep 2026 00:24:01 +0300 Subject: [PATCH 44/46] [fix] integrate: the suites follow the round-2 games; the level defs keep their camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game-waves REWRITTEN for Waves 2.x (the 29-F suite asserted 1.x: 3 waves, knock kills): the card, the file (17 objects, health + waves 2.x, the custom sky, the play block with the 30b spawn + no teleport/fly), B over the handshake, Play -> the shell + the HUD, THE REPORT (the enemies walk; B places a walker where A does), a kill through the module's own engine.hit, a late joiner; A steps out of Play before C joins (a peer cannot approve from Play). The game itself is the modules flight waves-shooter's (118). 40/0. - game-football 6.9: the over screen says Rematch + Menu (30b), 'New match' -> 'Menu'. 136/0. - vr-sweep 10.4/10.5: one held sweep across three mixer MUTE buttons flips each channel, one over two pedals' FOOTSWITCHES stomps each (music-fx 0.2.0 on the union Jam Room) — the Quest ask "on the mixer enable/disable". 25/0. Counterfactual: music-fx 0.1.0 has no handler on either part (its flight section 12 goes 10 red with the handler removed). - object-sync launches on the GPU backend: on the union a third SOFTWARE-GL page boots past setupPage's 30 s (measured 27-40 s vs 7 s on round 1) while the boot programs (16 = 16), draw calls (14/frame = 14) and boot script (0.13 s = 0.13 s) are identical; on the GPU all three boot in < 2 s. 12/0. - level-templates: LIVE=1 runs it against a build's own feed + pack CDN (the preview proof). - scripts/level-templates.cjs: the camera views are `view: {pos}` again (the merge's spawn rename caught them and the authoring died in the page); author-templates: the Jam Room row names music-fx 0.2.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 2 +- scripts/level-templates.cjs | 6 +- tests/e2e/game-football.test.cjs | 6 +- tests/e2e/game-waves.test.cjs | 246 ++++++++--------------------- tests/e2e/level-templates.test.cjs | 4 +- tests/e2e/object-sync.test.cjs | 5 +- tests/e2e/vr-sweep.test.cjs | 53 +++++++ 7 files changed, 135 insertions(+), 187 deletions(-) diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index ad8ce91c..6f6be2cd 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -1839,7 +1839,7 @@ const JAM_DEF = { author: 'theprototype', tags: ['music', 'vr'], installModules: ['music-lab', 'music-fx'], - modules: [{ id: 'music-lab', version: '0.2.1' }, { id: 'music-fx', version: '0.1.0' }], + modules: [{ id: 'music-lab', version: '0.2.1' }, { id: 'music-fx', version: '0.2.0' }], // warm, indoor, readable: a dark wood gradient behind the open front, a warm hemisphere, // a soft key light, and the lamps doing the rest env: { diff --git a/scripts/level-templates.cjs b/scripts/level-templates.cjs index 1a3f526f..036bd415 100644 --- a/scripts/level-templates.cjs +++ b/scripts/level-templates.cjs @@ -315,7 +315,7 @@ const CASTLE_DEF = { physics: playPhysics([1, 0.3, 4.6], 0), post: lookPost(0.5), graphs: { scene: walkGraph() }, - view: { position: [9.5, 9, 13], target: [0, 2, -5] }, + view: { pos: [9.5, 9, 13], target: [0, 2, -5] }, thumb: {} }; @@ -451,7 +451,7 @@ const FOREST_DEF = { physics: playPhysics([0, 0.35, 5], 0), post: lookPost(0.5), graphs: { scene: walkGraph() }, - view: { position: [-6, 7.5, 12], target: [3, 1, -6] }, + view: { pos: [-6, 7.5, 12], target: [3, 1, -6] }, thumb: {} }; @@ -591,7 +591,7 @@ const TAVERN_DEF = { physics: playPhysics([-1.4, 0.3, 3.3], -0.2), post: lookPost(0.7), graphs: { scene: walkGraph() }, - view: { position: [0.5, 2.1, 3.3], target: [-1.5, 1.6, -3] }, + view: { pos: [0.5, 2.1, 3.3], target: [-1.5, 1.6, -3] }, thumb: {} }; diff --git a/tests/e2e/game-football.test.cjs b/tests/e2e/game-football.test.cjs index f788960e..9c63eee3 100644 --- a/tests/e2e/game-football.test.cjs +++ b/tests/e2e/game-football.test.cjs @@ -460,10 +460,10 @@ h.run(async () => { await h.eventually(() => screenOf(A.page), (v) => v === 'over', '6.7 A sees the over screen', 6000); h.check(/MATCH OVER/.test(await hudText(A.page)), '6.8 the over screen renders'); - // New match on the over screen: back to the menu, the sheet survives + // Menu on the over screen (30b: Rematch + Menu; it was New match): back to the menu, the sheet survives const newBefore = await stampOf(B.page, 'evnew'); - await hudButton(A.page, 'New match').click(); - await h.eventually(() => snap(B.page), (s) => s?.score.blue === 0 && s.started === false && s.outcome === null, '6.9 B: New match — score 0'); + await hudButton(A.page, 'Menu').click(); + await h.eventually(() => snap(B.page), (s) => s?.score.blue === 0 && s.started === false && s.outcome === null, '6.9 B: Menu on the over screen (30b: Rematch + Menu) — score 0'); await h.eventually(() => stampOf(B.page, 'evnew'), (t) => t !== null && t !== newBefore, '6.10 B: the "On new match" stamp landed in the trigger log'); await h.eventually(() => gameStateOf(C.page), (v) => v === 'menu', '6.11 C: the shell is back in menu'); await h.eventually(() => screenOf(A.page), (v) => v === 'menu', '6.12 A sees the menu again', 6000); diff --git a/tests/e2e/game-waves.test.cjs b/tests/e2e/game-waves.test.cjs index 01764e0a..d982a0ce 100644 --- a/tests/e2e/game-waves.test.cjs +++ b/tests/e2e/game-waves.test.cjs @@ -1,6 +1,7 @@ -// 29-F ACCEPTANCE — the Waves game template: wave survival composed from TWO modules -// (`health` holds the hit points, `waves` derives the wave from the enemies' counters and -// walks the living ones at the goal) on the REAL artefacts and nothing authored in-test: +// 29-F ACCEPTANCE, rewritten for Waves 2.x (roadmap 30b: "Waves becomes a VR shooter") — the +// Waves game template composed from TWO modules (`health` holds the hit points, `waves` runs +// the levels, walks the enemies and owns the guns) on the REAL artefacts, nothing authored +// in-test: // the scene — games/waves/scene.tpscene from the scenes FEED (core's own SCENES_BASE // fallback, read out of src/lib/sceneTemplates.js so a ref move follows by // construction — #230), or WAVES_TPSCENE=, or a sibling scenes checkout @@ -8,28 +9,19 @@ // a packed sibling modules checkout, or the modules CDN // installed on TWO peers plus a LATE JOINER. Skip-never-fail when a source is missing. // -// What it proves: -// 1 THE GAMES TAB: the card is picked in the real Templates modal (the feed's index.json -// and the scene are served through a route so the row exists before its release) and -// loadRemoteScene replaces the world — 10 objects, both modules in the requirement -// list, the sunset env, the play block (grab, grounded, sim on play), the knock block -// ON, the menu screen, and the modules' derivations: 4 enemies on a 3-wave curve, a -// goal, three spawn points, five health rows +// The GAME itself (guns, abilities, the five levels, breaches, the VR start board, the +// Meshy figures) is proven by the modules repo's own flight `waves-shooter` (118 checks); +// this suite is core's side of the template: the card, the file, the handshake, the shell. +// 1 THE GAMES TAB: the card is picked in the real Templates modal and replaces the world — +// 17 objects (10 enemies in their three kinds), both modules in the requirement list, the +// custom sky, the play block (click, grounded, sim on play, a SPAWN in front of the +// crystal, no teleport and no fly — 30b C1), the menu shell, and the module's derivation // 2 B receives it over the handshake: same uuids, the same derivation, the HUD document -// 3 play: Start -> the shell is playing on both -> the HUD screen; the run is ON (wave 1 -// uses two of the four enemies), the first enemy WALKS toward the goal (+z) and B -// places it where A does; the parked enemies stand where the file put them; the HUD -// Text elements read the Waves Value nodes and the bar reads the player's health -// 4 kills are KNOCKS (the template's damage source is `hit`, scaled by speed): a slow -// sweep is one pulse (3 -> 2), a hard one three (dead) — core's feedProbe, the code a -// hand runs — the hit lands in B's hit log and B's ledger agrees (its own local pulses -// off the replicated `hit`); B's knock (a non-initiator) completes wave 1; the kills -// rows are per peer; after the interval wave 2 heals the survivors (3 alive) -// 5 the late joiner: C reads WAVE 2 from the counters in the triggers handshake, the same -// ledger, and the game shell -// 6 the last waves fall on A's knocks -> DONE on all three -> `over` -// -> Set Game State (outcome won) -> the over screen; ONE run logged on every peer; -// Again -> a new round with every enemy back at full health +// 3 play: Play -> the shell is playing on both, the HUD screen, the enemies WALK and B +// places them where A does, the HUD reads Wave 1 +// 4 a shot's worth of hits (the module's own engine.hit, the path its guns take) kills an +// enemy on A and B agrees (the replicated ledger) +// 5 the late joiner: C reads the same run and the game shell const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); @@ -48,10 +40,8 @@ function coreScenesBase() { const SCENES_BASE = (process.env.WAVES_SCENES_BASE || coreScenesBase() || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@main').replace(/\/$/, ''); const MODULES_BASE = (process.env.WAVES_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); const ROOT = path.resolve(__dirname, '../../..'); -const ENEMIES = ['Enemy 1', 'Enemy 2', 'Enemy 3', 'Enemy 4']; -const OBJECTS = ['Ground', 'Goal', 'Spawn 1', 'Spawn 2', 'Spawn 3', ...ENEMIES, 'Home']; -const ENEMY_HP = 3; -const INTERVAL_S = 3; +const ENEMIES = ['Enemy 01', 'Enemy 02', 'Enemy 03', 'Enemy 04', 'Enemy 05 Runner', 'Enemy 06 Runner', 'Enemy 07', 'Enemy 08 Tank', 'Enemy 09 Runner', 'Enemy 10 Tank']; +const OBJECTS = ['Ground', 'Goal', 'Spawn 1', 'Spawn 2', 'Spawn 3', ...ENEMIES, 'Home', 'Arena']; /** bytes from a URL, or null with the reason logged (never throws) */ async function fetchBytes(url) { @@ -157,48 +147,7 @@ const simOf = (page) => }); const hudButton = (page, name) => page.getByRole('button', { name, exact: true }); const myVar = (page, name) => page.evaluate((n) => window.__stores.peerVars.myPeerVar(n, null), name); -const lastHitOf = (page, uuid) => page.evaluate((u) => window.__stores.knock.hitLogSnapshot().last[u]?.at ?? null, uuid); -/** a knock probe swept along +x through the enemy's CURRENT position at `speed` m/s — - * core's feedProbe, the same code a hand (or the desktop head probe) runs. The sweep is - * one synchronous loop, so the enemy the module walks every frame holds still under it. */ -const knock = (page, uuid, speed) => - page.evaluate( - ({ uuid, speed }) => { - let g; - window.__stores.objectsGroup.subscribe((v) => (g = v))(); - const o = g?.getObjectByProperty('uuid', uuid); - if (!o) return { hits: 0, armed: false }; - const k = window.__stores.knock; - const id = 'gw-' + Math.random().toString(36).slice(2, 7); - k.dropProbe(id); - const [bx, by, bz] = o.position.toArray(); - let hits = 0; - let armed = true; - let t = 1000; - const step = (speed * 16) / 1000; - for (let x = bx - 1.2; x <= bx + 0.05; x += step) { - const r = k.feedProbe(id, [x, by, bz], t); - hits += r.hits; - armed = armed && r.armed; - t += 16; - } - k.dropProbe(id); - return { hits, armed }; - }, - { uuid, speed } - ); -/** a knock that cannot miss: up to three sweeps, the first that lands wins */ -async function knockUntil(page, uuid, speed, label) { - let last = null; - for (let attempt = 0; attempt < 3; attempt++) { - last = await knock(page, uuid, speed); - if (last.hits >= 1) break; - await page.waitForTimeout(250); - } - h.check(!!last && last.hits >= 1 && last.armed, label + ' (hits ' + (last?.hits ?? 0) + ', armed ' + (last?.armed ?? false) + ')'); - return last; -} const hpOf = async (page, uuid) => (await healthSnap(page)).find((s) => s.uuid === uuid)?.hp ?? null; h.run(async () => { @@ -247,7 +196,7 @@ h.run(async () => { tags: ['vr', 'co-op', 'survival'], modules: [ { id: 'health', version: '1.0.0' }, - { id: 'waves', version: '1.0.0' } + { id: 'waves', version: '2.1.0' } ], bytes: scene.bytes.length, scene: 'games/waves/scene.tpscene', @@ -276,151 +225,92 @@ h.run(async () => { const needs = (await A.page.locator('#templates-modal .tpl-needs[data-needs="waves"]').textContent().catch(() => '')) ?? ''; h.check(/health/i.test(needs) && /waves/i.test(needs), '1.2 the card names both modules it needs (' + needs.trim() + ')'); await card.click(); - await h.eventually(() => namesOf(A.page).then((n) => Object.keys(n)), (n) => n.length === 11 && !n.includes('Box'), '1.3 picking the card loads the scene: the box is gone, 11 objects stand (10 + the Arena group)', 30000); + await h.eventually(() => namesOf(A.page).then((n) => Object.keys(n)), (n) => n.length === OBJECTS.length && !n.includes('Box'), '1.3 picking the card loads the scene: the box is gone, ' + OBJECTS.length + ' objects stand', 30000); await A.page.waitForTimeout(1500); const names = await namesOf(A.page); - h.check(OBJECTS.every((n) => !!names[n]), '1.4 the ground, the goal, three spawn pads, four enemies and the home pad (' + Object.keys(names).join(', ') + ')'); + h.check(OBJECTS.every((n) => !!names[n]), '1.4 the ground, the goal, three spawn pads, ten enemies (grunts, runners, tanks), the home pad and the arena (' + Object.keys(names).join(', ') + ')'); const modules = await A.page.evaluate(() => { let m; window.__stores.moduleRequirements.sceneModules.subscribe((v) => (m = v))(); - return (m ?? []).map((e) => e.id); + return (m ?? []).map((e) => e.id + '@' + e.version); }); - h.check(modules.includes('health') && modules.includes('waves'), '1.5 the file\'s requirement list names BOTH modules (' + modules.join(',') + ')'); + h.check(modules.some((m) => m.startsWith('health@')) && modules.some((m) => /^waves@2\./.test(m)), '1.5 the file\'s requirement list names health and waves 2.x (' + modules.join(',') + ')'); const env = await A.page.evaluate(() => { let e; window.__stores.environment.environment.subscribe((v) => (e = v))(); return e?.preset ?? null; }); - h.check(env === 'custom', '1.6 the custom sunset sky (' + env + ')'); + h.check(env === 'custom', '1.6 the custom sky (' + env + ')'); const phys = await A.page.evaluate(() => window.__stores.scenePhysics.scenePhysicsDebug()); - h.check(phys.play?.interaction === 'grab' && phys.play?.grounded === true && phys.play?.simOnPlay === true, '1.7 play block: grab, grounded, sim on play (' + JSON.stringify(phys.play) + ')'); - h.check(phys.knock?.enabled === true && phys.knock?.maxSpeed === 10, '1.8 the knock block is ON (a hand knocks an enemy) (' + JSON.stringify(phys.knock) + ')'); + h.check(phys.play?.interaction === 'click' && phys.play?.grounded === true && phys.play?.simOnPlay === true, '1.7 play block: click, grounded, sim on play (' + JSON.stringify(phys.play) + ')'); + h.check( + JSON.stringify(phys.play?.spawn?.position) === '[0,0,9]' && phys.play?.locomotion?.teleport === false && phys.play?.locomotion?.fly === false, + '1.8 30b C1 in the file: a spawn in front of the crystal, no teleport, no fly (' + JSON.stringify({ spawn: phys.play?.spawn, loco: phys.play?.locomotion }) + ')' + ); h.check((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.9 the game shell starts in menu with the menu screen'); await h.eventually(() => A.page.evaluate(() => ({ chip: !!document.querySelector('#game-chip'), buttons: document.querySelectorAll('#hud-layer button').length })), (v) => v.chip && v.buttons === 0, '1.10 in the editor the game chip stands in for the menu (30 P1: no live menu over the editor)', 6000); - await h.eventually(() => snap(A.page), (s) => !!s && s.enemies.length === 4 && s.wave === 1 && !s.running && !!s.goal && s.spawns === 3 && s.waves === 3, '1.11 waves derives from the file: 4 enemies, wave 1 of 3, idle, a goal, three spawn points', 10000); + await h.eventually(() => snap(A.page), (s) => !!s && s.enemies.length === ENEMIES.length && !s.running && !!s.goal && s.spawns === 3, '1.11 waves derives from the file: ten enemies, idle, a goal, three spawn points', 10000); const a1 = await snap(A.page); - h.check(a1.enemies.map((e) => e.label).join() === ENEMIES.join(), '1.12 enemy order is by name (' + a1.enemies.map((e) => e.label).join(', ') + ')'); - await h.eventually(() => healthSnap(A.page), (all) => all.length === 5 && all.filter((s) => s.scope === 'object').every((s) => s.hp === ENEMY_HP && !s.dead), '1.13 health derives five rows: four enemies at ' + ENEMY_HP + ' hp and the player'); const order = a1.enemies.map((e) => e.uuid); - h.check(order.every((u, i) => names[ENEMIES[i]] === u), '1.14 the health targets are the four Enemy objects by uuid'); + h.check(order.every((u) => Object.values(names).includes(u)), '1.12 the run\'s enemies are the file\'s Enemy objects by uuid'); + await h.eventually(() => healthSnap(A.page), (all) => all.filter((s) => s.scope === 'object').length >= ENEMIES.length && all.filter((s) => s.scope === 'object').every((s) => s.hp > 0 && !s.dead), '1.13 health derives a row per enemy, every one alive'); // ---- 2. B receives it over the handshake ------------------------------------------------------ await h.connect(A, B); - await h.eventually(() => namesOf(B.page).then((n) => ({ uuids: Object.values(n), count: Object.keys(n).length })), (v) => v.count === 11 && order.every((u) => v.uuids.includes(u)), '2.1 B received the 11 objects with the same enemy uuids', 30000); - await h.eventually(() => snap(B.page), (s) => !!s && s.enemies.length === 4 && s.wave === 1 && !s.running && s.spawns === 3 && s.enemies.map((e) => e.uuid).join() === order.join(), '2.2 B: the graph replicated and its Waves node derives the SAME arena', 20000); - await h.eventually(() => healthSnap(B.page), (all) => all.length === 5, '2.3 B: five health rows'); - await h.eventually(() => screenOf(B.page), (v) => v === 'menu', '2.4 B: the HUD document arrived (menu screen)', 10000); + await h.eventually(() => namesOf(B.page).then((n) => ({ uuids: Object.values(n), count: Object.keys(n).length })), (v) => v.count === OBJECTS.length && order.every((u) => v.uuids.includes(u)), '2.1 B received the ' + OBJECTS.length + ' objects with the same enemy uuids', 30000); + await h.eventually(() => snap(B.page), (s) => !!s && s.enemies.length === ENEMIES.length && !s.running && s.enemies.map((e) => e.uuid).join() === order.join(), '2.2 B: the graph replicated and its Waves node derives the SAME arena', 20000); + await h.eventually(() => screenOf(B.page), (v) => v === 'menu', '2.3 B: the HUD document arrived (menu screen)', 10000); - // ---- 3. play: Start -> the shell -> the run is on, the enemies walk --------------------------- + // ---- 3. play: the shell, the HUD, the enemies walk --------------------------------------------- await A.page.locator('#play-button').click(); await h.eventually(() => simOf(A.page), (v) => v.own === true, '3.1 A simulates on entering play (simOnPlay)', 15000); await B.page.locator('#play-button').click(); await A.page.waitForTimeout(500); - await h.eventually(async () => (await hudText(A.page)) ?? '', (t) => /WAVES/.test(t) && /Start/.test(t), '3.1b in play the menu renders its title and Start'); - await hudButton(A.page, 'Start').click(); - await h.eventually(() => gameStateOf(A.page), (v) => v === 'playing', '3.2 Start flips the shell to playing on A'); + await h.eventually(async () => (await hudText(A.page)) ?? '', (t) => /WAVES/i.test(t) && /Play/.test(t) && /How to play/.test(t), '3.1b in play the menu renders its title, Play and How to play'); + await A.page.locator('#hud-layer button', { hasText: /^\W*Play$/ }).first().click(); + await h.eventually(() => gameStateOf(A.page), (v) => v === 'playing', '3.2 Play flips the shell to playing on A'); await h.eventually(() => gameStateOf(B.page), (v) => v === 'playing', '3.3 ...and on B (the replicated state)'); await h.eventually(() => screenOf(A.page), (v) => v === 'hud', '3.4 A sees the HUD screen', 6000); - await h.eventually(() => snap(A.page), (s) => s?.running && s.started && s.wave === 1 && s.size === 2 && s.alive === 2, '3.5 the run is ON: wave 1 uses two of the four enemies, both alive', 10000); - await h.eventually(() => snap(B.page), (s) => s?.running && s.started && s.wave === 1 && s.alive === 2, '3.6 B agrees', 10000); - const p0 = await posOf(A.page, order[0]); - await A.page.waitForTimeout(1500); - const p1 = await posOf(A.page, order[0]); - h.check(!!p0 && !!p1 && p1[2] > p0[2] + 1, '3.7 the first enemy walks toward the goal (+z): ' + p0?.[2].toFixed(1) + ' -> ' + p1?.[2].toFixed(1)); - const pA = await posOf(A.page, order[0]); - const pB = await posOf(B.page, order[0]); - h.check(!!pA && !!pB && Math.abs(pA[2] - pB[2]) < 1.0, '3.8 B places it where A does (same stamp, same clock): ' + pA?.[2].toFixed(1) + ' vs ' + pB?.[2].toFixed(1)); - const p3 = await posOf(A.page, order[3]); - h.check(!!p3 && Math.abs(p3[2] - -8) < 0.3, '3.9 the fourth enemy is not in wave 1: parked where the file put it (z ' + p3?.[2].toFixed(1) + ')'); - await h.eventually(() => hudRuntime(A.page), (r) => r['wv-wave']?.text === 'Wave 1' && r['wv-left']?.text === '2 left', '3.10 HUD Text reads the Waves Value nodes: Wave 1, 2 left'); - await h.eventually(() => hudRuntime(A.page), (r) => Math.abs((r['wv-hp']?.value ?? 0) - 1) < 0.01, '3.11 the HUD bar reads the player\'s health (full)'); + await h.eventually(() => snap(A.page), (s) => s?.running && s.started && s.alive >= 1, '3.5 the run is ON with enemies alive', 15000); + const walking = async (page) => { + const s = await snap(page); + const out = {}; + for (const e of s?.enemies ?? []) out[e.uuid] = await posOf(page, e.uuid); + return out; + }; + const w0 = await walking(A.page); + await A.page.waitForTimeout(2500); + const w1 = await walking(A.page); + const moved = order.filter((u) => w0[u] && w1[u] && Math.hypot(w1[u][0] - w0[u][0], w1[u][2] - w0[u][2]) > 1); + h.check(moved.length >= 1, '3.6 THE REPORT: the enemies WALK (' + moved.length + ' moved > 1 m in 2.5 s)'); + const u0 = moved[0]; + const pA = u0 ? await posOf(A.page, u0) : null; + const pB = u0 ? await posOf(B.page, u0) : null; + h.check(!!pA && !!pB && Math.hypot(pA[0] - pB[0], pA[2] - pB[2]) < 1.5, '3.7 B places a walker where A does (same stamp, same clock): ' + JSON.stringify([pA?.map((v) => +v.toFixed(1)), pB?.map((v) => +v.toFixed(1))])); + await h.eventually(() => hudRuntime(A.page), (r) => /Wave 1/.test(r['wv-wave']?.text ?? ''), '3.8 HUD Text reads the Waves Value node: Wave 1', 8000); - // ---- 4. kills are knocks: speed-scaled pulses, both peers, the wave advances ------------------- - const hitBefore = await lastHitOf(B.page, order[0]); - await knockUntil(A.page, order[0], 1, '4.1 A sweeps a SLOW probe through Enemy 1'); - await h.eventually(() => hpOf(A.page, order[0]), (v) => v === ENEMY_HP - 1, '4.2 a slow knock is ONE pulse: ' + ENEMY_HP + ' -> ' + (ENEMY_HP - 1)); - await h.eventually(() => lastHitOf(B.page, order[0]), (t) => t !== null && t !== hitBefore, '4.3 the hit landed in B\'s hit log (the replicated `hit`)'); - await h.eventually(() => hpOf(B.page, order[0]), (v) => v === ENEMY_HP - 1, '4.4 B\'s ledger agrees (its own local pulse off the same hit)'); - await knockUntil(A.page, order[0], 9, '4.5 A sweeps a HARD probe through Enemy 1'); - await h.eventually(() => snap(A.page), (s) => s?.alive === 1 && s.enemies[0].kills === 1, '4.6 a hard knock is three pulses: Enemy 1 is dead, one left in wave 1'); - await h.eventually(() => myVar(A.page, 'kills'), (v) => v === 1, '4.7 A\'s own kills row reads 1 (the killing blow was its hand)'); - await h.eventually(() => hudRuntime(A.page), (r) => r['wv-left']?.text === '1 left', '4.8 HUD Text: 1 left'); - await knockUntil(B.page, order[1], 9, '4.9 B (not the initiator) sweeps a hard probe through Enemy 2'); - await h.eventually(() => snap(A.page), (s) => s?.completed === 1, '4.10 wave 1 is complete on A (B\'s hit replicated, A fired its own pulses)', 10000); - await h.eventually(() => myVar(B.page, 'kills'), (v) => v === 1, '4.11 B\'s own kills row reads 1'); - h.check((await myVar(A.page, 'kills')) === 1, '4.12 ...and A\'s is still 1: one writer per row'); - await h.eventually(() => snap(A.page), (s) => s?.wave === 2 && s.started && s.alive === 3, '4.13 after the interval: wave 2, three alive (the survivors healed with local pulses)', (INTERVAL_S + 6) * 1000); - await h.eventually(() => snap(B.page), (s) => s?.wave === 2 && s.started && s.alive === 3, '4.14 B: wave 2 too', 10000); - // the slow knock's one pulse plus the hard knock's three is FOUR hits; wave 2 heals one - // death's worth (3), so the survivor comes back at 2 — heals bank per death, they do not - // top up (the health module's documented rule) - await h.eventually(() => snap(A.page), (s) => s && s.enemies[0].hits === ENEMY_HP + 1 && s.enemies[0].heals === ENEMY_HP && s.enemies[0].hp === ENEMY_HP - 1 && s.enemies[3].hits === 0, '4.15 the ledger: Enemy 1 has ' + (ENEMY_HP + 1) + ' hits and ' + ENEMY_HP + ' heals (hp ' + (ENEMY_HP - 1) + ', the overkill pulse counted), Enemy 4 untouched'); - await h.eventually(() => hudRuntime(B.page), (r) => r['wv-wave']?.text === 'Wave 2' && r['wv-left']?.text === '3 left', '4.16 B\'s HUD Text: Wave 2, 3 left'); + // ---- 4. a kill: the gun's own path (engine.hit), both peers agree ------------------------------- + const prey = moved[0] ?? order[0]; + const pre = (await snap(A.page)).enemies.find((e) => e.uuid === prey); + h.check(!!pre, '4.0 (premise) a walking enemy to shoot (' + (pre?.label ?? '?') + ', hp ' + (pre?.hp ?? '?') + ')'); + await A.page.evaluate((u) => window.__waves.engine.hit(u, 12), prey); + await h.eventually(() => snap(A.page), (s) => (s?.enemies.find((e) => e.uuid === prey)?.kills ?? 0) >= 1, '4.1 enough hits kill it on A (kills 1)', 8000); + await h.eventually(() => snap(B.page), (s) => (s?.enemies.find((e) => e.uuid === prey)?.kills ?? 0) >= 1, '4.2 and on B (the replicated ledger)', 8000); + await h.eventually(() => myVar(A.page, 'kills'), (v) => v >= 1, '4.3 A\'s own kills row counts it'); // ---- 5. the late joiner ------------------------------------------------------------------------ + // A approves the join, and a peer cannot approve from inside Play (the documented gotcha): + // A steps out; B still plays, so the shell stays playing (the 10 s window) await A.page.keyboard.press('Escape'); await A.page.waitForTimeout(500); const C = await h.setupPage(browser, 'C'); await installZip(C, 'health', healthZip.bytes, 'C'); await installZip(C, 'waves', wavesZip.bytes, 'C'); await h.connect(C, A); - await A.page.locator('#play-button').click(); - await h.eventually(() => namesOf(C.page).then((n) => Object.keys(n).length), (n) => n === 11, '5.1 C received the arena', 30000); - await h.eventually(() => snap(C.page), (s) => !!s && s.wave === 2 && s.enemies.length === 4 && s.enemies[0].hits === ENEMY_HP + 1 && s.enemies[0].heals === ENEMY_HP, '5.2 C reads WAVE 2 with the same ledger — the counts arrived in the triggers handshake', 30000); + await h.eventually(() => namesOf(C.page).then((n) => Object.keys(n).length), (n) => n === OBJECTS.length, '5.1 C received the arena', 30000); + await h.eventually(() => snap(C.page), (s) => !!s && s.enemies.length === ENEMIES.length && (s.enemies.find((e) => e.uuid === prey)?.kills ?? 0) >= 1, '5.2 C reads the same ledger (the kill arrived in the triggers handshake)', 30000); await h.eventually(() => gameStateOf(C.page), (v) => v === 'playing', '5.3 C: the game shell reads playing'); h.check((await myVar(C.page, 'kills')) === null, '5.4 the joiner has no kills row yet'); - await C.page.locator('#play-button').click(); - await h.eventually(() => hudRuntime(C.page), (r) => r['wv-wave']?.text === 'Wave 2', '5.5 C\'s HUD Text reads Wave 2', 10000); - - // ---- 6. the last waves, over, the log, Again ------------------------------------------------------ - // (the template wires no pause: P is the editor's sim toggle and does nothing to the run) - // wave 2 uses three enemies; wave 3 all four. A's hand clears both — ONE enemy at a - // time, as early in the wave as it can (the walkers converge on the goal and, held - // kinematic, stand co-located there, where one sweep hits every body it overlaps — - // run 2 measured hits 3 on a sweep aimed at one), waiting for each to fall and skipping - // any a shared sweep already took, and never swinging again until the next wave is ON - // (a knock in the interval lands on a body the next wave is about to heal). - const clearWave = async (n, indices, label) => { - // the heals into a new wave land a tick or two after it starts (local pulses, counters - // republished ~6/s): swing only once every enemy the wave uses reads healed, or a body - // still at 0 from the last wave is skipped as "down" - await h.eventually(() => snap(A.page), (s) => s?.wave === n && s.started && indices.every((i) => s.enemies[i].hp > 0), label + ' (premise) wave ' + n + ' is on and its enemies are healed', 12000); - for (const i of indices) { - const before = await snap(A.page); - if (before.completed >= n) break; - if (before.enemies[i].hp <= 0) continue; - const kills = before.enemies[i].kills; - await knockUntil(A.page, order[i], 9, label + ' A knocks down ' + ENEMIES[i] + ' (wave ' + n + ')'); - await h.eventually(() => snap(A.page), (s) => s.enemies[i].kills > kills || s.completed >= n, label + ' ...' + ENEMIES[i] + ' is down', 8000); - } - await h.eventually(() => snap(A.page), (s) => s?.completed >= n, label + ' wave ' + n + ' is complete', 8000); - }; - await clearWave(2, [0, 1, 2], '6.1'); - await h.eventually(() => snap(A.page), (s) => s?.wave === 3 && s.started && s.alive === 4, '6.2 wave 3: all four alive', (INTERVAL_S + 8) * 1000); - await h.eventually(() => snap(C.page), (s) => s?.wave === 3 && s.alive === 4, '6.3 C: wave 3, four alive', 10000); - await clearWave(3, [0, 1, 2, 3], '6.4'); - await h.eventually(() => snap(A.page), (s) => s?.done === true && s.alive === 0, '6.5 the last enemy falls: DONE on A', 10000); - await h.eventually(() => snap(B.page), (s) => s?.done === true, '6.6 done on B', 8000); - await h.eventually(() => snap(C.page), (s) => s?.done === true, '6.7 done on C', 8000); - await h.eventually(() => gameOf(A.page), (g) => g.state === 'over' && g.outcome === 'won', '6.8 `over` -> Set Game State: the shell is OVER (won) on A', 8000); - await h.eventually(() => gameOf(B.page), (g) => g.state === 'over' && g.outcome === 'won', '6.9 ...and on B'); - await h.eventually(() => gameOf(C.page), (g) => g.state === 'over' && g.outcome === 'won', '6.10 ...and on C'); - await h.eventually(() => screenOf(A.page), (v) => v === 'over', '6.11 A sees the over screen', 6000); - h.check(/ARENA CLEARED/.test(await hudText(A.page)) && /Again/.test(await hudText(A.page)), '6.12 the over screen renders its title and Again'); - await h.eventually(() => snap(A.page), (s) => s?.log.length === 1 && s.log[0].cleared && s.log[0].waves === 3 && s.log[0].reached === 3, '6.13 ONE run logged in gameState.vars: cleared, 3 waves', 8000); - const logA = (await snap(A.page)).log[0]; - const kills = [await myVar(A.page, 'kills'), await myVar(B.page, 'kills'), await myVar(C.page, 'kills')].map((v) => (v === null ? 'none' : String(v))); - // nine kills in a round (2 + 3 + 4); B's hand took one, C's none, so A's took the rest — - // however many bodies one of A's sweeps caught at the goal, every kill is credited to a hand - h.check(kills.join() === '8,1,none', '6.14 kills rows: A 8, B 1, C none — each peer credited only its own blows (' + kills.join() + ')'); - await h.eventually(() => snap(B.page), (s) => s?.log.length === 1 && JSON.stringify(s.log[0]) === JSON.stringify(logA), '6.15 B holds the SAME run entry (idempotent by its stamp)', 8000); - await h.eventually(() => snap(C.page), (s) => s?.log.length === 1 && s.log[0].at === logA.at, '6.16 and so does C'); - await h.eventually(() => hudRuntime(A.page), (r) => (r['wv-kills,wv-kills-over']?.rows ?? []).length >= 2, '6.17 the leaderboard rows list the scorers (one node feeds both lists)'); - await hudButton(A.page, 'Again').click(); - await h.eventually(() => gameStateOf(B.page), (v) => v === 'playing', '6.18 Again -> a new round on B', 8000); - await h.eventually(() => snap(A.page), (s) => s?.running && s.wave === 1 && s.completed === 0 && s.enemies.every((e) => e.hp === ENEMY_HP), '6.19 A: wave 1 again, every enemy back at full health (the reset chains)', 10000); - await h.eventually(() => snap(C.page), (s) => s?.running && s.wave === 1 && s.enemies.every((e) => e.hp === ENEMY_HP), '6.20 C agrees', 10000); - h.check((await snap(A.page)).log.length === 1, '6.21 the log keeps the finished run'); for (const p of [A, B, C]) await p.page.evaluate(() => window.__stores.isLocked.set(false)).catch(() => {}); await h.finish(browser); diff --git a/tests/e2e/level-templates.test.cjs b/tests/e2e/level-templates.test.cjs index a69f1d9a..6878cd79 100644 --- a/tests/e2e/level-templates.test.cjs +++ b/tests/e2e/level-templates.test.cjs @@ -16,6 +16,7 @@ // // APP_URL=https://theprototype.app:5255/ node tests/e2e/level-templates.test.cjs // LEVELS=castle-courtyard (a subset) +// LIVE=1 (no routes: the build's own feed + pack CDN — the preview proof; LEVELS_DIR still lists the slugs) const fs = require('fs'); const path = require('path'); const h = require('./helpers.cjs'); @@ -124,7 +125,8 @@ h.run(async () => { const page = A.page; // the Templates modal's feed -> the staged folder; the kits -> PACKS_DIR when this build // reads them from the CDN (a relative pack path resolves against PACKS_BASE) - await page.route('**/cdn.jsdelivr.net/**', (route) => { + // LIVE=1: no routing at all — the build's own scenes feed and pack CDN (a preview proof) + if (!process.env.LIVE) await page.route('**/cdn.jsdelivr.net/**', (route) => { const url = route.request().url(); const scenes = url.match(/\/theprototype-app\/scenes@[^/]+\/(.*)$/); if (scenes) { diff --git a/tests/e2e/object-sync.test.cjs b/tests/e2e/object-sync.test.cjs index 9173dd08..14c4b8f2 100644 --- a/tests/e2e/object-sync.test.cjs +++ b/tests/e2e/object-sync.test.cjs @@ -66,7 +66,10 @@ const spyConns = (peer) => const diag = (peer) => peer.page.evaluate(() => window.__diag || null); h.run(async () => { - const browser = await h.launch(); + // GPU backend: three SOFTWARE-GL pages boot the third one past setupPage's 30 s on the 1.17 + // union (the first two saturate the shared SwiftShader process; the programs, draw calls and + // boot script are identical to 1.17 round 1 — measured, 30b-integrate) — the newer suites' rule + const browser = await h.launch({ args: h.GPU_ARGS }); const A = await h.setupPage(browser, 'A'); await instrument(A, 'A'); diff --git a/tests/e2e/vr-sweep.test.cjs b/tests/e2e/vr-sweep.test.cjs index 057353e6..7558f9cd 100644 --- a/tests/e2e/vr-sweep.test.cjs +++ b/tests/e2e/vr-sweep.test.cjs @@ -449,6 +449,59 @@ h.run(async () => { '10.2 one held sweep across three piano keys plays each, once (' + JSON.stringify(jam.keyClicks) + ')' ); h.check(jam.stepClicks.length === 3 && jam.diff >= 3, '10.3 a sweep across three drum steps flips each (' + JSON.stringify({ cells: jam.cellNames, clicks: jam.stepClicks.length, diff: jam.diff }) + ')'); + // the Quest ask: "on the mixer enable/disable" — music-fx 0.2.0's mute BUTTONS and the + // pedals' FOOTSWITCHES answer a click, so one held sweep flips each it passes + const fx = await page.evaluate(async () => { + const s = window.__stores; + const THREE = s.THREE; + const T = window.__T; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + g.updateMatrixWorld(true); + const topOf = (o) => { + const b = new THREE.Box3().setFromObject(o); + return new THREE.Vector3((b.min.x + b.max.x) / 2, b.max.y + 0.005, (b.min.z + b.max.z) / 2); + }; + const devices = []; + g.traverse((o) => o.userData?.device?.kind?.startsWith?.('mod-music-fx-') && devices.push(o)); + const mixer = devices.find((d) => d.userData.device.kind === 'mod-music-fx-mixer'); + const pedals = devices.filter((d) => d !== mixer).sort((a, b) => a.getWorldPosition(new THREE.Vector3()).z - b.getWorldPosition(new THREE.Vector3()).z); + const params = (d) => ({ ...(s.audioDevices.deviceOf(d)?.params ?? d.userData.device.params ?? {}) }); + const mutes = ['mute-1', 'mute-2', 'mute-3'].map((n) => mixer?.getObjectByName(n)).filter(Boolean); + const muteBefore = mixer ? params(mixer) : {}; + if (mutes.length === 3) { + T.aim(1, topOf(mutes[0]).add(new THREE.Vector3(0, 0.05, 0.2)), topOf(mutes[0])); + T.press(1); + for (const m of mutes) { + T.tipFrame(1, topOf(m)); + T.tipFrame(1, topOf(m)); + } + T.release(1); + } + const switches = pedals.slice(0, 2).map((p) => p.getObjectByName('footswitch')).filter(Boolean); + const bypassBefore = pedals.slice(0, 2).map((p) => !!params(p).bypass); + if (switches.length === 2) { + T.aim(1, topOf(switches[0]).add(new THREE.Vector3(0, 0.05, 0.2)), topOf(switches[0])); + T.press(1); + for (const sw of switches) { + T.tipFrame(1, topOf(sw)); + T.tipFrame(1, topOf(sw)); + } + T.release(1); + } + await new Promise((r) => setTimeout(r, 600)); + const muteAfter = mixer ? params(mixer) : {}; + return { + mixer: !!mixer, + mutes: mutes.length, + muted: [1, 2, 3].map((i) => !!muteAfter['mute' + i] !== !!muteBefore['mute' + i]), + pedals: pedals.length, + switches: switches.length, + stomped: pedals.slice(0, 2).map((p, i) => !!params(p).bypass !== bypassBefore[i]) + }; + }); + h.check(fx.mixer && fx.mutes === 3 && fx.muted.every(Boolean), '10.4 one held sweep across three mixer MUTE buttons flips each channel (' + JSON.stringify(fx) + ')'); + h.check(fx.switches === 2 && fx.stomped.every(Boolean), '10.5 a sweep over two pedals\' FOOTSWITCHES stomps each (music-fx 0.2.0)'); } await h.finish(browser); From c086a03ff2a4c44fe9304238fdbf5217f9c7996f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 24 Sep 2026 01:24:37 +0300 Subject: [PATCH 45/46] [fix] integrate: the character capsule is a query shape, never an obstacle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The KCC capsule is world.createCollider() with no body: a FIXED, solid collider that stays in the world where the player last was and is teleported with the camera every frame. 21-E6 only made one in walk mode; 30b-vr-modes' collideRigStep made EVERY desktop Play session with a running simulation create and drive it — and on the union a crate in the health flight was flung ~8 m (x 0.78 -> -8.28) between Play + sim start and the first knock, so the knock's closing speed read 9.3 m/s instead of 6 and killed it (3 points, not 2). It also stood as an invisible pillar for every dynamic body. - `.setSolverGroups(0)`: no contact forces with anything; computeColliderMovement still sweeps the shape, so walls and steps stop the walker exactly as before. - Proof: modules flight module-health (GPU) 99/0; counterfactual (the solver groups removed) -> 3 red ("A: Crate2 took 2 points per knock -> 1/3", B, C). Base 2ea91c3: the crate sits still at (0.78, 0.5, 1.24) when the knock comes (probe). - Held: vr-walk-interact 20/0, char-controller 55/0, level-templates 42/0 (walls, arch, stairs), vr-mode-spawn 24/0, game-towers 52/0, physics-ground-bounds 31/0, throw-peer 28/0, play-interact 46/0 (x2; one 6.1 flake before). - check-baseline.json: 335 -> 333/47 (the merged branches removed two; the message list is identical to the merge's). Co-Authored-By: Claude Opus 5.5 (1M context) --- check-baseline.json | 2 +- src/lib/charController.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/check-baseline.json b/check-baseline.json index 8c091f3e..12fe5e30 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 335, + "errors": 333, "warnings": 47, "measured": "2026-09-23" } diff --git a/src/lib/charController.js b/src/lib/charController.js index 438297f5..2b18c9ce 100644 --- a/src/lib/charController.js +++ b/src/lib/charController.js @@ -267,7 +267,12 @@ function ensureCapsule(rt, eyeHeight) { if (capsule && key !== capsuleKey) dropCapsule(); if (!capsule) { try { - capsule = world.createCollider(RAPIER.ColliderDesc.capsule(half, CAPSULE_RADIUS)); + // a QUERY shape for the controller, never an obstacle: a collider with no body is + // FIXED, and teleported with the camera every frame it depenetrated whatever it + // was moved into (30b-integrate: desktop Play + a sim flung a crate 8 m before the + // first knock) and stood as an invisible pillar where the player last was. Solver + // groups 0 = no contact forces with anything; computeColliderMovement still sweeps it. + capsule = world.createCollider(RAPIER.ColliderDesc.capsule(half, CAPSULE_RADIUS).setSolverGroups(0)); controller = world.createCharacterController(0.02); controller.enableAutostep?.(0.3, 0.2, true); controller.enableSnapToGround?.(0.3); From 4226a5d088822cf624a8aef693a67526b6075cd6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 24 Sep 2026 01:38:02 +0300 Subject: [PATCH 46/46] [docs] 1.17.0 round 2 + 3: changelog, CLAUDE.md, MODULES.md - CHANGELOG 1.17.0 gains three sections: Games in a headset (Interact in VR, left Y, walking with walls, spawn, helpers in both eyes / none in Interact, the VR game menu, hold-and-sweep, module content follows the world), Games that sound and feel like games (sounds, music, banners, bursts, haptics, the six Game nodes, per-game lines incl. Music FX 0.2.0), Kits and levels (four packs, three General levels, kit-piece undo, the move fix). - CLAUDE.md: architecture entries for the Quest round (vrGrip / locomotionPolicy / playSpawn / moduleWorld / helperLayer, the gameKit leaves, gameFeelActions) and the Meshy round (packRefs + history.registerReferenceSnapshot, the packs, the levels); gotchas (HELPER_LAYER 1/2 = the eyes, Euler.toArray on the wire, a blanket shape rename, a body-less rapier collider, the third software-GL page, additive bursts, round-end banners, no DOM HUD in a headset, the 30c known-not-fixed pair); R30b SDK additions; the status entry. - MODULES.md: api.setSpawn / api.respawnPlayer. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 56 ++++++++++++++++++++++++++ CLAUDE.md | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++ MODULES.md | 6 +++ 3 files changed, 172 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1bb7bb1..9c5ba88d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,62 @@ with a Reset), and a new 3D mode — untangle the graph around a globe. - Every game has a Start screen, Pause on **P**, and a restart. +### 🥽 Games in a headset + +- 🎮 **Play in VR puts you in Interact.** A game opens in Interact in a headset: your grips grab + and knock things instead of the world, the stick WALKS you (walls stop you, gravity holds you, + small steps climb), and there is no flying or teleporting unless the scene allows it. The left + **Y** button switches to Edit and back (a tick in your hand and a small wrist label say which), + and the game can put you on its start spot. In Edit, grips move and scale the world again — + in game scenes too. +- 👁️ **No more light helper in one eye.** Editor helpers drew into the left eye only in a + headset. Now they are in both eyes in Edit and gone in Interact and Play — together with the + grid, collider wireframes and selection outlines. +- 📋 **The game menu in VR.** A game's menu, pause and results screens float in front of you and + answer your laser and trigger (or a poke); the score and timer sit on your left wrist and, + if you like, in a strip at the top of your view. +- 🎹 **Hold the trigger and sweep.** Hold the trigger and brush across piano keys, drum steps, + pads or buttons — each one you pass fires once. +- 🌍 **Module content follows the world.** Grab and turn the world and the Untangle board (and + every module's content) turns with it. + +### 🔊 Games that sound and feel like games + +- 🔔 20 built-in game sounds, 7 music loops (quiet under the sound effects, only while you + play), a big centred banner for moments like "GOAL!" or "Floor 3", sparkle / confetti / smoke / + spark bursts, and controller vibration patterns (in Interact and Play only). Settings ▸ Sound + has a "Game sounds" and a "Music" volume. +- 🧩 New Game nodes: **Announce**, **Game Sound**, **Effect Burst**, **Controller Buzz**, + **Game Music** and the **On Grab** trigger. +- 🏗️ **Towers** tells you when your tower reaches each ring (a banner, sparkles at the ring, + a chime), and the gold ring at the top is a fanfare with confetti. **Stars Room** sparkles where + you hit a star and pays a coin when it lights. **The Jam Room is a VR cockpit**: stand in the + middle and every instrument is in reach; a sweep also stomps the pedals and flips the mixer + mutes (Music FX 0.2.0). +- ⚽ **Football plays like football**: your first touch kicks a match off, 3-2-1, GOAL! with + confetti and a crowd, the conceding team kicks off, first to 5 or 3:00 with a golden goal; + swing a controller through the ball to kick it; the consoles stand outside the court. +- 🏰 **Dungeon Realms is lit everywhere**, with real wall torches, footsteps, gem chimes, a + "Floor N" banner and dungeon music; you can no longer walk through pillars, crates or chests. +- 🔫 **Waves is a VR shooter**: a gun in your hand (Blaster, Scatter, Beam), an ability on your + grip (Shield, Slow-mo, Pulse), five levels of rigged grunts, runners and tanks walking at the + crystal, a menu with How to play / Loadout / Options, and a Start board you shoot in a headset. +- 🪢 **Untangle** drags with a controller's trigger (laser or tip), lets you hold and turn the + globe in one hand, and lost its drone: one sound per event and a quiet puzzle loop. + +### 🧱 Kits and levels + +- 🧱 Four new packs in Explorer ▸ Packs — **Modular Architecture**, **Nature & Terrain**, + **Props & Interiors** and **Sci-fi & Modern** — over 120 pieces that snap to the 1 m grid (CC0, + made with Meshy.ai). +- 🗺️ Three walkable example levels in Templates ▸ General: **Castle Courtyard**, **Forest + Clearing** and **Tavern Interior**. Kit pieces are saved as references to their pack, so a + level of 150 pieces is about 25 KB. Configure Scene ▸ Physics ▸ Play mode ▸ Spawn point. +- ↩️ Placing a kit piece is undoable (it was "too large for undo history"). +- 🛠️ **Fixed:** moves made with the gizmo, Align to ground or an Explorer drop were refused by + peers (a rotation sent with its order letters); loading a scene while the simulation runs no + longer leaves the old physics world behind. + ### 🧰 For authors - 🧱 **Game and template defs can say more:** rounded boxes, capsules, rings, planes, diff --git a/CLAUDE.md b/CLAUDE.md index 4aa5a66e..c3d92adf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2252,6 +2252,58 @@ loadable play content. Everything a user does must be visible to connected peers game gets a dark card. `HudElement.svelte`: a text/timer box takes `justify-content` from `style.align`, so a single centred line is centred (modules DEVX #28). +- **ROADMAP 30b — THE QUEST ROUND (games in a headset)** (1.17.0; the user's Quest 3 feedback on the + round-1 preview is the acceptance test: cloud `plans/core/lanes/30b/user-feedback-2026-09-23.md`). + C1 MODES (vr-modes): `vrGrip.js` (pure: what a grip may hold — in Edit anything >= 4 m on an axis + or containing your head is SCENERY and the grip grabs the WORLD, which is what brought the Edit + world grab back in game scenes; Interact grabs only dynamic bodies, rigid, no select, no undo, + never the world), `locomotionPolicy.js` (pure: `play.locomotion {teleport?, fly?}` absent = + false in Interact/Play, Edit always flies/teleports; `normalizeSpawn` = `{position, yaw[, + vrOnly]}` and ALSO reads 30c's first `{pos, yaw}`), `charController.resolveWalk` shared by desktop + and VR (a capsule, gravity, 0.3 m autostep; the dungeon raster also clamps the rapier tier), + `playSpawn.js` (`desktopSpawn`/`spawnDesktopPlayer`; VR moves the rig so the FEET land on the + spawn facing yaw), `onVRSessionStart` puts a GAME (a showWhile screen, a spawn, or a + `userData.play` publisher) in Interact and LEFT Y toggles (right B when the radial menu is on + the left) with a forced haptic tick + the `vr-mode-label` wrist label, `moduleWorld.js` + re-homes every registered module group under `module-world-root` INSIDE the world rig so module + content follows world grabs (a `scene.remove` shim; playPublishers + moduleContent scan it), and + `helperLayer.helpersHiddenFor` hides helpers in Play OR Interact (see the HELPER_LAYER gotcha). + Suites vr-helper-eyes, interact-clean-view, vr-grips-by-mode, vr-walk-interact, vr-mode-spawn, + module-world-root (+ vitest vrGrip/locomotionPolicy); `tests/e2e/fakeXR.cjs` = a fake XR + session driving the REAL per-frame path (install/pose/button/stick/installSpace/head). + · C2-C6 GAME FEEL (vr-play), one debug name `__stores.gameKit`: `gameFeel.js` (the ONE + Interact/Play predicate), `gameSfx.js` (20 procedural sounds on the sfx bus, 24-voice cap, an + unknown name is a no-op), `gameMusic.js` + `gameMusicPresets.js` (7 presets on the SESSION + clock, REFUSED from Edit, stopped on leaving Interact/Play), Settings ▸ Interface ▸ Sound (two + safeStorage volumes), `hapticPatterns.js` + vrControls.hapticPattern/hapticKnock (hapticPulse is + SILENT in Edit through one funnel; a 4th `force` arg is the mode tick's), `vrGameInput.js` (hover + tap / press bump; the one "clickable" resolver), `effectsBurst.js` (a pooled 12×96 Points system + at scene root, local, `warmUp` compiles the material at build or the first burst of a session + is lost to a shader compile), `gameAnnounce.js` + HudLayer's `#game-announce` (desktop) / a + head-locked banner (VR), `vrGamePanel.js` (a menu-type screen — input 'menu', an enabled + control, or showWhile menu|paused|over — is drawn on a canvas BOARD 1.2 m ahead with lazy yaw + follow; every other screen's lines ride the LEFT-WRIST card and an optional head strip, + `vr:gameStrip`), and the hold-and-sweep (the tip fires the NEAREST touching clickable once, the + laser while held; click ctx `{source, mode}`, `{sweep:false}` opts out; Scene's VR trigger + fallthrough in Interact/Play is `interactClick`, never a select). `gameFeelActions.js` + (core-games) = the Announce / Game Sound / Effect Burst / Controller Buzz / Game Music / On Grab + flow nodes, LOCAL on every peer from the replicated stamp inside the actionSeenAt family; music + is a DECLARATION. Suites game-audio, game-haptics, game-effects, vr-game-panel, vr-sweep, + game-feel-nodes. +- **ROADMAP 30c — THE MESHY ASSET ROUND** (1.17.0): four packs on packs `main` (architecture-kit, + nature-kit, props-kit, scifi-kit; `tools/meshy` spends the credits through one ledger) and + `src/lib/packRefs.js` (level-design): a piece placed from a pack carries `userData.packRef = + {pack, item, path, hash, kids}` and a PRISTINE one (a fingerprint over the descendants' geometry, + materials and an 8×8 texture sample, root transform free) travels and saves as a STUB that every + peer and every loader REFILLS from the pack url (fetched and parsed once per piece, textures + shared) — one WallStone is 7.9 MB as toJSON and 555 B as a stub. The four paths: save + wire use + stubs, autosave GLTF keeps the full tree, and undo keeps the stub through + `history.registerReferenceSnapshot` (registered from `startPackRefs` by a dynamic import; + restore needs nothing new — the stub scan refills it). Three walkable General templates + (`scripts/level-templates.cjs` via author-templates' `kit` type, `seed: false`); a scene load + now ends a running simulation first. Suites pack-refs, pack-undo, level-templates (`LIVE=1` + runs it against a build's own feed + pack CDN). + ## Replication golden rules 1. Every mutation = apply locally + `$peers.send({type, ...})`; receivers apply WITHOUT @@ -2346,6 +2398,38 @@ loadable play content. Everything a user does must be visible to connected peers ## Hard-won gotchas (do not rediscover) +- **HELPER_LAYER MUST NEVER BE 1 OR 2: three's WebXRManager gives the LEFT eye layer 1 and the + RIGHT eye layer 2.** Helpers on layer 1 drew into ONE eye in a headset — the Quest report "in the + middle of the world I see the light source helper, and only with one eye" (Towers, Stars Room, + Football). It is 30 now. +- **three's `Euler.toArray()` IS `[x, y, z, 'XYZ']`**, and 27-A's wire validator accepted a + rotation of 3 or 4 FINITE NUMBERS only — so every `move` from the gizmo, Align to ground, an + Explorer drop and a dozen other senders was refused on arrival (`invalid:move`) and the peer kept + the old pose. `isQuatOrEuler` takes the order string now (suite move-wire-shape). +- **A BLANKET SHAPE RENAME HITS EVERY KEY OF THAT SHAPE.** Unifying `play.spawn {pos}` -> + `{position}` with a textual `{ pos: [` replace also rewrote the level defs' camera `view: {pos}`, + and the authoring script died in the page (`reading '0'`) — the next suite would have been the + first to notice. Rename by the owning key (`spawn: { pos:`), and author once after any rename. +- **A rapier collider with no body is FIXED AND SOLID.** The character controller's capsule was + `world.createCollider(capsule)`, teleported with the camera each frame: it depenetrated whatever + it was moved into and stood as an invisible pillar where the player last was. Harmless while + only walk mode made one; 30b's collideRigStep made every desktop Play + sim session drive it, + and a crate was flung 8 m before the first knock. A query shape takes `.setSolverGroups(0)`. +- **A THIRD SOFTWARE-GL PAGE BOOTS PAST setupPage's 30 s ON THE 1.17 UNION** (7 s on round 1, + first bad from the vr-play merge in a noisy bisect) while its boot programs, draw calls and boot + script are identical — the page waits in shader links behind two pages saturating the shared + SwiftShader process. Three-page suites launch on the GPU backend (`h.GPU_ARGS`; modules: + `E2E_GPU=1`), where all three boot in < 2 s. Root cause still owed. +- **An ADDITIVE burst vanishes against a bright sky** — pair it with a normal-blended one + (Towers' ring puff). **A banner at a round's END doubles the results panel** — the panel carries + the words. +- **A module effect/click on a Quest cannot rely on the DOM HUD**: core draws no DOM HUD in a + headset, so a Start button that only exists there left Waves' round unstarted and "the enemies + do not move" (30b-waves P0). vrGamePanel is core's answer; a module may still add its own board. +- **Known, not fixed (30c pack lanes):** a nested import (a Group with children) doubles its + offset on peers (the receiver parents it under the import root and adds the root position again + per level — kits ship FLAT to stay clear), and a `move` landing while the peer still shows the + loading placeholder is lost. - **`TubeGeometry` HANDED A 2D `Shape` BUILDS EVERY VERTEX AS NaN** — its Frenet frames come from Vector2 tangents. Untangle 2.1.0's amber rim never drew, and worse, the board group's `Box3.setFromObject` read NaN, so the object list's "Frame it" flew the camera into NaN and the @@ -4922,6 +5006,18 @@ override for e2e — never share 5173 (the user's main-checkout server). locked (replicate the INDEX per-item opt-in; ONE mesh with scenes as tags; scene-is-primary renaming), and the vocabulary settled: **session = the mesh, room = who is in a scene, PocketBase rooms stay DISCOVERY** — that naming blocks R4. +- Status (2026-09-24): **1.17.0 round 2 + 3 — THE QUEST ROUND AND THE MESHY ASSETS, integrated + on `feat/1.17` (lane `30b-integrate`)**, held at the user's release gate with a new preview + (https://preview-1-17.theprototype.pages.dev). Core merged #244 vr-modes, #245 vr-play, #246 + core-games, #247 level-templates (the two `play.spawn` shapes unified on `{position, yaw}`) + + integrate fixes: move-with-order-string, kit placement undo by reference, the character capsule + made a query shape (it flung bodies once every desktop Play + sim drove it), game-waves rewritten + for Waves 2.x, game-football 6.9, vr-sweep flips mixer mutes + footswitches, check 333/47. Modules dev merged #24 untangle, #21 football, #23 waves, #25 + waves assets, #22 dungeon + integrate: DEVX renumber (#36/#38/#39/#40), build:waves on a fresh + clone, music-fx 0.2.0 (footswitch + mute buttons the sweep flips), the Kit's torches are the + props-kit WallTorch. Packs main: the four kits + the Meshy tool. Scenes `preview-1-17`: the seven + games + three General levels re-authored on the union. The per-feedback table, the suite table + and the owed-on-device list are in the lane handover. - Status (2026-09-23): **1.17.0 "Games that look finished" — ROADMAP 30, integrated on `feat/1.17` (lane `30-integrate`).** Merged core #240 author-kit → #241 game-flow → #239 editor-modes (one conflict, `playInteract.js`: the Interact cursor carry + the play aim, both kept) → #242 @@ -6464,3 +6560,17 @@ the free cursor by publishing `userData.play.cursor = 'free'` on its scene-root → 'edit' | 'interact' (LOCAL, read-only; DEVX #35, added by the 30 integrate): a module whose own pointer listeners run outside core's click routing (untangle's window-capture drag) stands down while it reads 'edit' and nothing is playing, so an Edit click selects its content. + +**R30b additions (1.17.0, the Quest round):** `api.setSpawn([x, y, z], yaw, {teleport?})` (y = the +FEET, yaw = three's rotation.y, a CHECKPOINT unless `teleport`; a module's overrides the scene's +`play.spawn`) + `api.respawnPlayer()`; `api.playSound(name, pos?)` knows 20 procedural names +(click pop whoosh success fail hit kick shoot laser explosion coin levelup goal whistle cheer step +ring sparkle hurt portal; an unknown name is a no-op); `api.music.play(preset, {volume?})` / +`stop()` / `current()` / `presets()` (arcade ambient dungeon stadium space puzzle studio; refused +from Edit, stops on leaving Interact/Play); `api.hapticPattern(name, hand?)` (tap bump hit success +fail rumble heartbeat; every haptic is silent in Edit); `api.effects.burst([x, y, z], {kind?, +color?, count?})` (sparkle confetti smoke sparks, LOCAL); `api.announce(text, {sub?, ms?, +color?})`; click handlers get `{source: 'click' | 'sweep', mode}` and +`registerClickHandler(fn, {sweep: false})` opts out of the VR sweep. Feature-detect every one +(`api.x?.()`). A scene's play block may say `play.locomotion {teleport?, fly?}` and +`play.spawn {position, yaw, vrOnly?}`. diff --git a/MODULES.md b/MODULES.md index 58c0b736..dd2c3444 100644 --- a/MODULES.md +++ b/MODULES.md @@ -527,6 +527,12 @@ register(api) { "Level 3"): the desktop HUD, and in a headset a banner fixed in front of the player. A new one replaces the one showing; `ms` 300..15000 (default 1800). +- **`api.setSpawn([x, y, z], yaw, {teleport?})`** — where the player starts: `y` is the + FEET, `yaw` is three's `rotation.y` (0 faces −Z). Entering Interact or Play puts the player + there (a headset's rig lands its feet on it, a desktop its camera); without `teleport` it is a + checkpoint and nobody moves until then. A module's spawn overrides the scene's + `play.spawn`. `api.respawnPlayer()` sends the player back to it now. + **In VR, your game's HUD is in the player's hands.** A screen with `input: 'menu'`, a control on it, or bound to the `menu` / `paused` / `over` game state is drawn on a board ~1.2 m in front of the player that follows their head lazily; its buttons