From 688dc407712e45ac6bc65a513d9ab870f76218ad Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:38:06 +0300 Subject: [PATCH 1/5] [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 0da7522d098d439a4cedb348f09d334adec63b2a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 01:20:30 +0300 Subject: [PATCH 2/5] [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. --> + +