From 40e07fb4043968a576eea6e87322858b2389d303 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 00:23:18 +0300 Subject: [PATCH 01/33] [feat] K3 per-game settings leaf (gameSettings.js) - src/lib/gameSettings.js: the ONE settings store every game shares - the core rows (music/volume, sfx/volume, haptics, show FPS, VR turning + snap angle, comfort vignette, quality preset) plus game rows declared by api.game.addSetting or a flow node (registerGameSetting(row, owner) -> off). LOCAL per device via safeStorage, per game id: core rows in tp:game::shell, a game row in tp:game::. - the game id = slug of the last scene FILE opened (sessions.applySession notes the payload name, so a Games-tab load of Towers is 'towers'), else the saved scene name, else 'untitled'. - live resolvers core reads: sfxLevel/musicLevel/hapticsAllowed/resolveTurning. - vitest gameSettings (14): per-game isolation (SFX off in Towers, on in Waves), row coercion, reserved ids, a game row surviving re-registration. Counterfactual: drop the gid from shellKey -> "SFX off in game A does not reach game B" goes red. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/gameSettings.js | 363 ++++++++++++++++++++++++++++++++ src/lib/sessions.js | 5 + tests/unit/gameSettings.test.js | 147 +++++++++++++ 3 files changed, 515 insertions(+) create mode 100644 src/lib/gameSettings.js create mode 100644 tests/unit/gameSettings.test.js diff --git a/src/lib/gameSettings.js b/src/lib/gameSettings.js new file mode 100644 index 00000000..45fa3a48 --- /dev/null +++ b/src/lib/gameSettings.js @@ -0,0 +1,363 @@ +// 31 (K3) — PER-GAME SETTINGS. The user, on a Quest: "I should be able to disable the +// sound/sfx/music on each game. Adjust different other settings; you pick the best ones +// that need to be usually placed in games, although all of them should be able to show +// FPS when I want to debug." +// +// Two kinds of row share ONE store and one panel: +// · the CORE rows every game gets (CORE_SETTINGS): music on + volume, sound effects on + +// volume, haptics, show FPS, VR turning (snap/smooth + angle), a comfort vignette and a +// quality preset. Core's music/SFX/haptics/turning/vignette/quality read them LIVE. +// · GAME rows a game declares itself (`api.game.addSetting`, or a flow node): "Point to +// move stars", "Board: globe / 2D". They render under the core rows. +// +// PER GAME: a game is identified by `gameId` — the slug of the last scene FILE opened +// (a Games-tab load is a .tpscene whose session name is the game's title, so Towers is +// 'towers' however the scene is named afterwards), else the saved scene's name, else +// 'untitled'. Settings are LOCAL to this device (safeStorage), never replicated and never +// saved into a scene: turning the music off in a game is about MY ears. +// +// Keys: the core rows sit in ONE JSON key `tp:game::shell`; a game row is its own key +// `tp:game::` (so a flow node, a module and the panel all address the same +// thing). Core ids are reserved, and so is `shell`. +// +// A LEAF: svelte/store + safeStorage and nothing else — flowRuntime, moduleSDK, the audio +// leaves, vrControls and the HUD all read it, from every side of the history-cycle family. +import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; + +/** + * @typedef {{id: string, label: string, type: 'toggle' | 'choice' | 'range', options?: string[], + * optionLabels?: string[], min?: number, max?: number, step?: number, default: any, + * help?: string, vrOnly?: boolean, onChange?: (value: any) => void, owner?: string}} SettingRow + */ + +/** The rows every game gets, in panel order. `vrOnly` rows are drawn only in a headset + * (and in the desktop panel under a "VR" heading). @type {SettingRow[]} */ +export const CORE_SETTINGS = [ + { id: 'music', label: 'Music', type: 'toggle', default: true }, + { id: 'musicVolume', label: 'Music volume', type: 'range', min: 0, max: 100, step: 10, default: 100 }, + { id: 'sfx', label: 'Sound effects', type: 'toggle', default: true }, + { id: 'sfxVolume', label: 'Effects volume', type: 'range', min: 0, max: 100, step: 10, default: 100 }, + { id: 'haptics', label: 'Controller vibration', type: 'toggle', default: true, vrOnly: true }, + { id: 'showFps', label: 'Show FPS', type: 'toggle', default: false }, + { + id: 'turning', + label: 'Turning', + type: 'choice', + options: ['default', 'snap', 'smooth', 'off'], + optionLabels: ['Device default', 'Snap', 'Smooth', 'Off'], + default: 'default', + vrOnly: true + }, + { + id: 'turnAngle', + label: 'Snap angle', + type: 'choice', + options: ['default', '15', '30', '45', '90'], + optionLabels: ['Device default', '15°', '30°', '45°', '90°'], + default: 'default', + vrOnly: true + }, + { id: 'vignette', label: 'Comfort vignette', type: 'toggle', default: false, vrOnly: true }, + { + id: 'quality', + label: 'Quality', + type: 'choice', + options: ['auto', 'low', 'medium', 'high'], + optionLabels: ['Auto', 'Low', 'Medium', 'High'], + default: 'auto' + } +]; + +/** ids a game row may not take (the core rows + the JSON key's own name) */ +export const RESERVED_SETTING_IDS = new Set([...CORE_SETTINGS.map((r) => r.id), 'shell']); +const CORE_BY_ID = new Map(CORE_SETTINGS.map((r) => [r.id, r])); + +/* ------------------------------------------------------------------ pure helpers --- */ + +/** + * A scene/game name as an id: lower case, runs of anything else collapsed to '-'. + * @param {any} name @returns {string} + */ +export function slugGameId(name) { + const s = String(name ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + return s || 'untitled'; +} + +/** @param {string} gid */ +export function shellKey(gid) { + return 'tp:game:' + slugGameId(gid) + ':shell'; +} +/** @param {string} gid @param {string} id */ +export function rowKey(gid, id) { + return 'tp:game:' + slugGameId(gid) + ':' + String(id); +} + +/** + * A declared row, made safe: a known type, an id, a default of the row's own kind. + * Returns null for a row that cannot be one (no id, a reserved id, an unknown type). + * @param {any} row @returns {SettingRow | null} + */ +export function normalizeSettingRow(row) { + if (!row || typeof row !== 'object') return null; + const id = String(row.id ?? '').trim(); + if (!id || id.length > 64) return null; + const type = row.type === 'choice' || row.type === 'range' ? row.type : row.type === 'toggle' || row.type === undefined ? 'toggle' : null; + if (!type) return null; + /** @type {SettingRow} */ + const out = { id, label: String(row.label ?? id).slice(0, 80), type, default: undefined }; + if (type === 'choice') { + const options = Array.isArray(row.options) ? row.options.map((/** @type {any} */ o) => String(o)).filter(Boolean).slice(0, 12) : []; + if (!options.length) return null; + out.options = options; + if (Array.isArray(row.optionLabels)) out.optionLabels = options.map((o, i) => String(row.optionLabels[i] ?? o)); + } + if (type === 'range') { + const min = Number.isFinite(Number(row.min)) ? Number(row.min) : 0; + const max = Number.isFinite(Number(row.max)) ? Number(row.max) : 100; + out.min = Math.min(min, max); + out.max = Math.max(min, max); + const step = Number(row.step); + out.step = Number.isFinite(step) && step > 0 ? step : (out.max - out.min) / 10 || 1; + } + out.default = coerceSettingValue(out, row.default, true); + if (typeof row.onChange === 'function') out.onChange = row.onChange; + if (row.help) out.help = String(row.help).slice(0, 200); + if (row.vrOnly) out.vrOnly = true; + return out; +} + +/** + * A value of the row's own kind: a toggle is a boolean, a choice one of its options, a + * range a number clamped into [min, max]. Anything else answers the default (or, while + * the default itself is being coerced, the kind's own zero). + * @param {SettingRow} row @param {any} value @param {boolean} [forDefault] + */ +export function coerceSettingValue(row, value, forDefault = false) { + const fallback = forDefault + ? row.type === 'toggle' + ? false + : row.type === 'choice' + ? (row.options ?? [])[0] + : Number(row.min ?? 0) + : row.default; + if (row.type === 'toggle') { + if (typeof value === 'boolean') return value; + if (value === 'true' || value === 1) return true; + if (value === 'false' || value === 0) return false; + return fallback; + } + if (row.type === 'choice') { + const s = value === undefined || value === null ? '' : String(value); + return (row.options ?? []).includes(s) ? s : fallback; + } + const n = typeof value === 'number' ? value : value === '' || value === null || value === undefined ? NaN : Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(Number(row.max ?? 100), Math.max(Number(row.min ?? 0), n)); +} + +/** The core rows' values for a stored JSON object, every key coerced. @param {any} raw */ +export function coreValuesFrom(raw) { + /** @type {Record} */ + const out = {}; + for (const row of CORE_SETTINGS) out[row.id] = coerceSettingValue(row, raw?.[row.id]); + return out; +} + +/* ------------------------------------------------------------------ the game id ---- */ + +/** the last scene file opened (its session name) and the saved scene's name */ +const idSources = { file: /** @type {string | null} */ (null), level: /** @type {string | null} */ (null), forced: /** @type {string | null} */ (null) }; + +/** Which game this is. @type {import('svelte/store').Writable} */ +export const gameId = writable('untitled'); + +function resolveId() { + const next = slugGameId(idSources.forced || idSources.file || idSources.level || ''); + if (next !== get(gameId)) { + gameId.set(next); + reload(); + } +} + +/** sessions.applySession: a scene FILE was opened under this name @param {any} name */ +export function noteSceneFileName(name) { + idSources.file = name ? String(name) : null; + resolveId(); +} +/** the saved scene's name changed (levels.currentLevel) @param {any} name */ +export function noteSceneLevelName(name) { + idSources.level = name ? String(name) : null; + resolveId(); +} +/** a test (or a module that knows better) pins the id; null releases @param {string | null} id */ +export function forceGameId(id) { + idSources.forced = id ? String(id) : null; + resolveId(); +} +/** @returns {string} */ +export function currentGameId() { + return get(gameId); +} + +/* ------------------------------------------------------------- rows and values ---- */ + +/** The game rows declared right now (api.game.addSetting, flow nodes). @type {import('svelte/store').Writable} */ +export const gameSettingRows = writable([]); + +/** Every value for the current game, core + game rows. A store a $derived can depend on. + * @type {import('svelte/store').Writable>} */ +export const gameSettingValues = writable(coreValuesFrom(null)); + +/** @param {string} raw */ +function parseJson(raw) { + try { + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +/** read one game row's stored value @param {SettingRow} row */ +function readRow(row) { + const raw = safeStorage.getItem(rowKey(get(gameId), row.id)); + return coerceSettingValue(row, raw === null ? undefined : parseJson(raw)); +} + +/** re-read every value (the game changed, or a row appeared) */ +function reload() { + const gid = get(gameId); + const values = coreValuesFrom(parseJson(safeStorage.getItem(shellKey(gid)) ?? '')); + for (const row of get(gameSettingRows)) values[row.id] = readRow(row); + gameSettingValues.set(values); +} + +/** the row for an id (core first, then game rows) @param {string} id @returns {SettingRow | null} */ +export function settingRow(id) { + return CORE_BY_ID.get(id) ?? get(gameSettingRows).find((r) => r.id === id) ?? null; +} + +/** + * The current value of a row — core or game — falling back to its default, or undefined + * for an id nobody declared. + * @param {string} id + */ +export function gameSettingValue(id) { + const values = get(gameSettingValues); + if (id in values) return values[id]; + const row = settingRow(id); + return row ? row.default : undefined; +} + +/** + * Write a row's value for the current game (coerced, persisted, published; the row's own + * onChange hears it). Returns the value stored, or undefined for an unknown id. + * @param {string} id @param {any} value + */ +export function setGameSetting(id, value) { + const row = settingRow(id); + if (!row) return undefined; + const next = coerceSettingValue(row, value); + const gid = get(gameId); + const values = { ...get(gameSettingValues), [id]: next }; + if (CORE_BY_ID.has(id)) { + /** @type {Record} */ + const core = {}; + for (const r of CORE_SETTINGS) core[r.id] = values[r.id]; + safeStorage.setItem(shellKey(gid), JSON.stringify(core)); + } else safeStorage.setItem(rowKey(gid, id), JSON.stringify(next)); + gameSettingValues.set(values); + if (row.onChange) { + try { + row.onChange(next); + } catch { + /* a game's callback throwing must not break the panel */ + } + } + return next; +} + +/** Put every core row back to its default for this game. */ +export function resetCoreSettings() { + safeStorage.removeItem(shellKey(get(gameId))); + reload(); +} + +/** + * Declare a game row. A second declaration of the same id REPLACES the first (a module + * re-registering after a reload, a node edited) — and `off()` only removes the row it + * added. Returns off, or null when the row is refused (reserved id, bad shape). + * @param {any} row @param {string} [owner] + * @returns {(() => void) | null} + */ +export function registerGameSetting(row, owner = '') { + const clean = normalizeSettingRow(row); + if (!clean || RESERVED_SETTING_IDS.has(clean.id)) return null; + clean.owner = String(owner || ''); + gameSettingRows.update((rows) => [...rows.filter((r) => r.id !== clean.id), clean]); + gameSettingValues.update((values) => ({ ...values, [clean.id]: readRow(clean) })); + return () => { + gameSettingRows.update((rows) => rows.filter((r) => r !== clean)); + gameSettingValues.update((values) => { + if (get(gameSettingRows).some((r) => r.id === clean.id)) return values; + const { [clean.id]: _gone, ...rest } = values; + return rest; + }); + }; +} + +/** Drop every row an owner declared (a module disabled, a graph replaced). @param {string} owner */ +export function unregisterGameSettingsOf(owner) { + const gone = get(gameSettingRows).filter((r) => r.owner === owner).map((r) => r.id); + if (!gone.length) return; + gameSettingRows.update((rows) => rows.filter((r) => r.owner !== owner)); + reload(); +} + +/* ------------------------------------------------------- what core reads, live ---- */ + +/** 0..1 — the per-game multiplier on the device's "Game sounds" volume (0 when off) */ +export function sfxLevel() { + const v = get(gameSettingValues); + return v.sfx === false ? 0 : Math.min(1, Math.max(0, Number(v.sfxVolume ?? 100) / 100)); +} +/** 0..1 — the per-game multiplier on the device's "Music" volume (0 when off) */ +export function musicLevel() { + const v = get(gameSettingValues); + return v.music === false ? 0 : Math.min(1, Math.max(0, Number(v.musicVolume ?? 100) / 100)); +} +/** controller vibration allowed in this game? */ +export function hapticsAllowed() { + return get(gameSettingValues).haptics !== false; +} + +/** + * The VR turning this game asks for, resolved against the device's own snap angle + * (`deviceAngle`, degrees, 0 = snap turning off): {mode: 'snap'|'smooth'|'off', angle}. + * Pure over (values, deviceAngle); exported for the suites. + * @param {Record} values @param {number} deviceAngle + */ +export function resolveTurning(values, deviceAngle) { + const dev = Number(deviceAngle) || 0; + const choice = String(values?.turning ?? 'default'); + const angle = values?.turnAngle && values.turnAngle !== 'default' ? Number(values.turnAngle) : dev || 45; + if (choice === 'off') return { mode: 'off', angle: 0 }; + if (choice === 'smooth') return { mode: 'smooth', angle }; + if (choice === 'snap') return { mode: 'snap', angle }; + // device default: the device's own snap setting (0 = off), unless the game picked an angle + if (!dev && (!values?.turnAngle || values.turnAngle === 'default')) return { mode: 'off', angle: 0 }; + return { mode: 'snap', angle }; +} + +/** Test seam: forget every row and source, back to 'untitled' defaults. */ +export function debugResetGameSettings() { + idSources.file = idSources.level = idSources.forced = null; + gameSettingRows.set([]); + gameId.set('untitled'); + reload(); +} diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 10fa8db0..b43a8fad 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -64,6 +64,8 @@ import { disabledModules } from './moduleSDK'; import { findNodeSpec } from './nodeCatalog'; import { hudDocsSnapshot, hudDocsRestore } from './hudDocs'; import { gameStateSnapshot, gameStateRestore } from './gameState'; +// 31 K3: which GAME a scene is decides whose per-game settings apply (a leaf) +import { noteSceneFileName } from './gameSettings'; import { sceneCommand, sendObjects, clearSceneLocal } from './commandsHandler.svelte'; import { nameOf } from './lockControl'; import { idbGet, idbPut, idbDelete, idbKeys } from './idb'; @@ -1296,6 +1298,9 @@ function reportUnknownNodes(payload) { */ export async function applySession(payload, opts = {}) { const { backup = true, replicate = true, game = true, workspace = true } = opts; + // 31 K3: the file's own name is the game's identity for its per-game settings (a + // Games-tab load is unnamed afterwards, but its session name is the game's title) + noteSceneFileName(payload?.name); const group = get(objectsGroup); if (backup && group?.children.length) await saveSession('Backup before "' + payload.name + '"'); // R22-R8: a session saved by "Save into session" carries the whole Explorer library diff --git a/tests/unit/gameSettings.test.js b/tests/unit/gameSettings.test.js new file mode 100644 index 00000000..1bdf310c --- /dev/null +++ b/tests/unit/gameSettings.test.js @@ -0,0 +1,147 @@ +// 31 (K3): the per-game settings leaf with no browser — row normalisation, coercion, the +// per-game key scheme, game rows beside the core rows, and the resolvers core reads live. +import { describe, it, expect, beforeEach } from 'vitest'; +import { + CORE_SETTINGS, + slugGameId, + shellKey, + rowKey, + normalizeSettingRow, + coerceSettingValue, + noteSceneFileName, + noteSceneLevelName, + forceGameId, + currentGameId, + gameSettingValue, + setGameSetting, + registerGameSetting, + unregisterGameSettingsOf, + resetCoreSettings, + sfxLevel, + musicLevel, + hapticsAllowed, + resolveTurning, + debugResetGameSettings +} from '../../src/lib/gameSettings.js'; +import { safeStorage, debugResetStorage } from '../../src/lib/safeStorage.js'; + +beforeEach(() => { + for (const key of safeStorage.keys()) safeStorage.removeItem(key); + debugResetStorage(); + debugResetGameSettings(); +}); + +describe('ids and keys', () => { + it('slugs a game name', () => { + expect(slugGameId('Stars Room')).toBe('stars-room'); + expect(slugGameId(' Dungeon Realms!! ')).toBe('dungeon-realms'); + expect(slugGameId('')).toBe('untitled'); + expect(slugGameId(null)).toBe('untitled'); + }); + it('keys per game', () => { + expect(shellKey('Towers')).toBe('tp:game:towers:shell'); + expect(rowKey('Stars Room', 'stars-clap')).toBe('tp:game:stars-room:stars-clap'); + }); + it('the file name wins over the saved scene name, then untitled', () => { + expect(currentGameId()).toBe('untitled'); + noteSceneLevelName('My level'); + expect(currentGameId()).toBe('my-level'); + noteSceneFileName('Towers'); + expect(currentGameId()).toBe('towers'); + noteSceneFileName(null); + expect(currentGameId()).toBe('my-level'); + forceGameId('fixture'); + expect(currentGameId()).toBe('fixture'); + }); +}); + +describe('rows', () => { + it('normalises a toggle / choice / range', () => { + expect(normalizeSettingRow({ id: 'a', label: 'A', type: 'toggle', default: 'true' })?.default).toBe(true); + const c = normalizeSettingRow({ id: 'b', type: 'choice', options: ['globe', '2d'], default: 'cube' }); + expect(c?.default).toBe('globe'); + const r = normalizeSettingRow({ id: 'r', type: 'range', min: 10, max: 0, default: 99 }); + expect(r?.min).toBe(0); + expect(r?.max).toBe(10); + expect(r?.default).toBe(10); + }); + it('refuses what cannot be a row', () => { + expect(normalizeSettingRow(null)).toBe(null); + expect(normalizeSettingRow({ label: 'no id' })).toBe(null); + expect(normalizeSettingRow({ id: 'x', type: 'colour' })).toBe(null); + expect(normalizeSettingRow({ id: 'x', type: 'choice', options: [] })).toBe(null); + }); + it('coerces values to the row kind', () => { + const t = /** @type {any} */ (normalizeSettingRow({ id: 't', default: true })); + expect(coerceSettingValue(t, 'false')).toBe(false); + expect(coerceSettingValue(t, 'maybe')).toBe(true); + const r = /** @type {any} */ (normalizeSettingRow({ id: 'r', type: 'range', min: 0, max: 5, default: 2 })); + expect(coerceSettingValue(r, 0)).toBe(0); // zero is a real value, never "missing" + expect(coerceSettingValue(r, 9)).toBe(5); + expect(coerceSettingValue(r, 'x')).toBe(2); + }); + it('a game row may not take a core id', () => { + expect(registerGameSetting({ id: 'music', type: 'toggle', default: true })).toBe(null); + expect(registerGameSetting({ id: 'shell', type: 'toggle', default: true })).toBe(null); + }); +}); + +describe('values per game', () => { + it('core defaults', () => { + for (const row of CORE_SETTINGS) expect(gameSettingValue(row.id)).toEqual(row.default); + }); + it('SFX off in game A does not reach game B', () => { + noteSceneFileName('Towers'); + setGameSetting('sfx', false); + expect(sfxLevel()).toBe(0); + noteSceneFileName('Waves'); + expect(gameSettingValue('sfx')).toBe(true); + expect(sfxLevel()).toBe(1); + noteSceneFileName('Towers'); + expect(gameSettingValue('sfx')).toBe(false); + }); + it('volumes multiply, off means zero', () => { + setGameSetting('musicVolume', 40); + expect(musicLevel()).toBeCloseTo(0.4); + setGameSetting('music', false); + expect(musicLevel()).toBe(0); + setGameSetting('haptics', false); + expect(hapticsAllowed()).toBe(false); + resetCoreSettings(); + expect(musicLevel()).toBe(1); + expect(hapticsAllowed()).toBe(true); + }); + it('a game row persists under its own key and reads back after re-registering', () => { + noteSceneFileName('Stars Room'); + /** @type {any[]} */ + const heard = []; + const off = registerGameSetting({ id: 'stars-clap', label: 'Make stars with a clap', type: 'toggle', default: true, onChange: (v) => heard.push(v) }, 'node:n1'); + expect(typeof off).toBe('function'); + expect(gameSettingValue('stars-clap')).toBe(true); + setGameSetting('stars-clap', false); + expect(heard).toEqual([false]); + expect(safeStorage.getItem('tp:game:stars-room:stars-clap')).toBe('false'); + off?.(); + expect(gameSettingValue('stars-clap')).toBe(undefined); + registerGameSetting({ id: 'stars-clap', type: 'toggle', default: true }, 'node:n1'); + expect(gameSettingValue('stars-clap')).toBe(false); + unregisterGameSettingsOf('node:n1'); + expect(gameSettingValue('stars-clap')).toBe(undefined); + }); + it('an unknown id writes nothing', () => { + expect(setGameSetting('nope', 1)).toBe(undefined); + }); +}); + +describe('turning', () => { + it('device default follows the device angle, 0 = off', () => { + expect(resolveTurning({ turning: 'default', turnAngle: 'default' }, 30)).toEqual({ mode: 'snap', angle: 30 }); + expect(resolveTurning({ turning: 'default', turnAngle: 'default' }, 0)).toEqual({ mode: 'off', angle: 0 }); + }); + it('a game choice wins', () => { + expect(resolveTurning({ turning: 'smooth', turnAngle: 'default' }, 30)).toEqual({ mode: 'smooth', angle: 30 }); + expect(resolveTurning({ turning: 'snap', turnAngle: '90' }, 0)).toEqual({ mode: 'snap', angle: 90 }); + expect(resolveTurning({ turning: 'off', turnAngle: '90' }, 45)).toEqual({ mode: 'off', angle: 0 }); + expect(resolveTurning({ turning: 'default', turnAngle: '15' }, 0)).toEqual({ mode: 'snap', angle: 15 }); + }); +}); From 35a8bbba50687bd0a68599e1d18566428fbd2f9a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 00:24:04 +0300 Subject: [PATCH 02/33] [feat] perf probe: the roadmap-31 performance table for the seven games - scripts/perf-games.cjs: per game, a fresh page installs the game's modules from their real zips, loads its real .tpscene through the Games-tab path (importSessionZip + requestLoadSession) from the scenes repo at a git ref (PERF_SCENES_REF, default preview-1-17) or a directory, presses Play and the game's own HUD Play/Start, then measures 10 s at CPU throttle x4: draw calls + triangles per display frame (every render() pass summed), geometries/textures, a texture-MB estimate, lights and shadow-casting lights/meshes, meshes/instanced/unculled, p50/p95/p99 frame ms and the heap delta. Markdown + JSON into after-31/31-perf/. - --vr walks a fake XR session's left stick forward the whole window (fakeXR.cjs); --profile adds CDP CPU self-time and allocation sampling (collected objects included) per function, top 30 each. - Baseline on 1.17 (Radeon 890M): Waves p50 700 ms, Dungeon Realms ~10 MB/s heap churn, Football 336 calls + 7 lights, Jam Room 910 calls. No behaviour change (a script), so no counterfactual; the table is the artefact. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/perf-games.cjs | 419 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 scripts/perf-games.cjs diff --git a/scripts/perf-games.cjs b/scripts/perf-games.cjs new file mode 100644 index 00000000..01300c51 --- /dev/null +++ b/scripts/perf-games.cjs @@ -0,0 +1,419 @@ +#!/usr/bin/env node +// 31-perf P0 — THE PERF PROBE: the roadmap-31 Performance-protocol table for the seven +// Games-tab games, measured the same way every time so a before/after means something. +// +// node scripts/perf-games.cjs [--label baseline-1.17] [--only waves,football] [--vr] +// [--seconds 10] [--throttle 4] [--out ] +// +// Per game, on a FRESH page against a running dev server (APP_URL, default this lane's +// https://theprototype.app:5263/): +// 1 install the game's modules from their REAL zips (the sibling modules checkout, or +// PERF_MODULES_DIR), through the Modules manager's own zip input +// 2 load the game's REAL .tpscene through the Games-tab path (`importSessionZip` + +// `requestLoadSession`, what `sceneTemplates.loadRemoteScene` runs) — read from the +// scenes repo at a git REF (PERF_SCENES_REF, default `preview-1-17`: the games as 1.17 +// ships them) or a directory (PERF_SCENES_DIR) +// 3 press the real Play button, then the game's own HUD Play/Start button when it has one +// (else the shell is set to `playing`), and with `--vr` a fake XR session walks the left +// stick forward the whole time (tests/e2e/fakeXR.cjs — the real per-frame VR path) +// 4 settle 2 s, then CPU-throttle x4 (CDP `Emulation.setCPUThrottlingRate`) for the +// measured window and record: every render() call's draw calls + triangles (summed per +// display frame), every rAF frame's duration, and the JS heap before/after +// 5 read the scene: geometries/textures (renderer.info.memory), a texture-MB estimate +// (unique textures, w*h*4 x 4/3 for mips), lights, shadow-casting lights, shadow-casting +// meshes, meshes / instanced meshes / meshes with frustumCulled off, shader programs +// +// Writes `perf-