diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte
index 9ddf8a1d..a7f71a2a 100644
--- a/src/components/editors/Nodes.svelte
+++ b/src/components/editors/Nodes.svelte
@@ -211,6 +211,9 @@
ongamestate: AnimationNode,
setvariable: AnimationNode,
getvariable: AnimationNode,
+ // 30 P4: the device-local pair (the second registry — the catalog is the first)
+ storevalue: AnimationNode,
+ storedvalue: AnimationNode,
gametime: AnimationNode,
// 21-F3's `collectcount` card MOVED to the collectible module (R3a) — an old
// scene's node renders as UnknownNode until the module is installed, honestly
diff --git a/src/components/hud/GameChip.svelte b/src/components/hud/GameChip.svelte
new file mode 100644
index 00000000..b967327d
--- /dev/null
+++ b/src/components/hud/GameChip.svelte
@@ -0,0 +1,111 @@
+
+
+{#if visible}
+
+ Game · {stateLabel}
+
+
+
+{/if}
+
+
diff --git a/src/components/hud/HudLayer.svelte b/src/components/hud/HudLayer.svelte
index 260619ca..c1659958 100644
--- a/src/components/hud/HudLayer.svelte
+++ b/src/components/hud/HudLayer.svelte
@@ -12,7 +12,7 @@
// phase (the AnnotationPins / AnnotationMarkers split).
import { onMount } from 'svelte';
import HudElement from './HudElement.svelte';
- import { hudDocs, hudRuntime, hudScreenOverride, hudPreviewInViewport, visibleScreen, activeHudKeys, setHudValue, hudValueOf } from '$lib/hudDocs';
+ import { hudDocs, hudRuntime, hudScreenOverride, hudPreviewInViewport, hudIsGame, visibleScreen, activeHudKeys, setHudValue, hudValueOf } from '$lib/hudDocs';
import { isVRMode, isLocked, playPointerFree } from '../../stores/sceneStore';
import { hudEditorClose } from '../../stores/appStore.js';
import { viewportOverrides, renderLayer } from '$lib/viewportOverrides';
@@ -62,6 +62,17 @@
// authoring. `=== false` would read the transient value and flip back a moment later.
const playing = $derived($isLocked === true);
+ // 30 P1: THE EDITOR STOPS MASQUERADING AS THE GAME. Outside Play a GAME's screens (the
+ // `hudIsGame` rule: a screen bound to a game state) are not drawn at all — the menu of
+ // a Games-tab game used to sit over the scene being built, with a LIVE Start that began
+ // the round for every peer with nobody in Play. The game chip (GameChip.svelte) stands
+ // in for them. The HUD editor's preview eye still draws them for authoring, and then
+ // every control is INERT (`editor` on HudElement — the artboard's own swallow), so
+ // laying out a menu can never fire the game. A HUD with no state-bound screen is a
+ // plain overlay and keeps its old behaviour exactly.
+ const gameInEditor = $derived(!playing && $hudIsGame);
+ const gameHidden = $derived(gameInEditor && !$hudPreviewInViewport);
+
// BOTH stores are read as dependencies. `visibleScreen` reaches the override through
// `get()`, and a `get()` inside a $derived registers NOTHING — so with only $hudDocs
// here, showing a screen wrote the store and the layer never re-rendered. It looked
@@ -103,7 +114,7 @@
// dropdowns, and under pointer lock the ring is the only hand a player has. A
// disabled control is skipped the way it ignores presses.
const focusables = $derived(elements.filter((el) => isInteractiveKind(el.kind) && el.enabled !== false));
- const anyVisible = $derived(elements.length > 0 && !$isVRMode && layerAllowed && !authoringHidden);
+ const anyVisible = $derived(elements.length > 0 && !$isVRMode && layerAllowed && !authoringHidden && !gameHidden);
// ---- 21-E3: the MENU SUBSTATE - this component is the SINGLE WRITER ------------
// Visibility IS the state: any visible screen marked input:'menu' while playing
@@ -286,7 +297,8 @@
// Claim only while a screen with focusables is actually up, and release the moment it
// is not — a claim left standing pauses editor fly for good.
$effect(() => {
- const wants = anyVisible && focusables.length > 0;
+ // 30 P1: an INERT preview has nothing to drive, so it must not pause editor flight
+ const wants = anyVisible && focusables.length > 0 && !gameInEditor;
if (!wants) return;
claimInput('keys');
return () => releaseInput('keys');
@@ -332,7 +344,7 @@
{#if anyVisible}
-
+
{#each elements as el (el.__key + ':' + el.id)}
-
+
{/each}
@@ -368,6 +380,12 @@
.hud-slot {
position: absolute;
}
+ /* 30 P1: a previewed GAME screen is a picture of the menu, not the menu — every click
+ goes through it to the viewport (the elements opt back INTO pointer events one by
+ one, so the override has to reach them) */
+ .hud-inert :global(*) {
+ pointer-events: none !important;
+ }
/* the keyboard ring, so a player under pointer lock can see where they are */
.hud-focused {
outline: 2px solid var(--accent, #ef562f);
diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte
index 9adc16ed..5f21b136 100644
--- a/src/components/menu/Controls.svelte
+++ b/src/components/menu/Controls.svelte
@@ -39,6 +39,9 @@
import { visibleDockKey, dockOccupants, bottomInset, FLOW_FAMILY, armDockMode, DOCK_TITLES } from '$lib/bottomDock';
import { togglePanel } from '$lib/panelToggles';
import { requestPlay, willEnterXR, willEnterAR, vrSupported, arSupported, xrSessionFailed } from '$lib/playMode';
+ // 30 P2: Test play — the play button's right-click row beside the game chip's button
+ import { testPlay } from '$lib/gamePresence';
+ import { hudIsGame } from '$lib/hudDocs';
import { DOCK_VIEWS } from '$lib/dockMenu';
import { safeStorage } from '$lib/safeStorage';
import { VRButton, XRButton } from '@threlte/xr'
@@ -916,6 +919,18 @@
requestPlay();
}
},
+ // 30 P2: TEST PLAY — back to the menu, into Play, the Start screen. Offered only
+ // where it means something: a scene with no game shell has no menu to start from.
+ ...($hudIsGame
+ ? [
+ { section: 'Game' },
+ {
+ label: 'Test play (start from the menu)',
+ tooltip: 'Reset the game to its menu, enter Play, and start from the Start screen',
+ action: () => testPlay()
+ }
+ ]
+ : []),
// the FAB is a toolbar cell like any other, so it carries the same tail —
// minus "Hide button" (there is no toolbar without a way to press play)
...toolbarTail(null)
diff --git a/src/components/play/PlayReticle.svelte b/src/components/play/PlayReticle.svelte
index 993276a6..e8db4c9f 100644
--- a/src/components/play/PlayReticle.svelte
+++ b/src/components/play/PlayReticle.svelte
@@ -5,6 +5,7 @@
// playInteract.js.
import { isLocked, isVRMode } from '../../stores/sceneStore';
import { playInteractState } from '$lib/playInteract';
+ import { playCursorFree } from '$lib/playCursor';
import { safeStorage } from '$lib/safeStorage';
// the scroll hint is worth exactly one showing, so it is a LOCAL pref and
@@ -14,7 +15,10 @@
);
const reticle = $derived($playInteractState);
- const visible = $derived($isLocked && !$isVRMode && reticle.mode !== 'off');
+ // 30 P3: a free-cursor game aims with the real cursor, so there is no crosshair to draw.
+ // $playInteractState is the dependency that re-reads it (it moves on every aim change,
+ // and playCursorFree reads its stores through get()).
+ const visible = $derived($isLocked && !$isVRMode && reticle.mode !== 'off' && !playCursorFree());
const carrying = $derived(reticle.mode === 'carrying');
$effect(() => {
diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte
index 018c6484..faa9a53a 100644
--- a/src/components/play/PointerLockControls.svelte
+++ b/src/components/play/PointerLockControls.svelte
@@ -7,6 +7,8 @@
import { userdata, peers } from '../../stores/appStore'
import { dungeonData, slideMove, spawnPointFor } from '$lib/dungeonPlay'
import { resolvePlaySettings } from '$lib/playSettings'
+ // 30 P3: free-cursor games never take the pointer — the real cursor aims
+ import { playCursorFree } from '$lib/playCursor'
import { inputClaims, getGamepadAxes } from '$lib/inputRuntime'
import { gamepadPrefs } from '$lib/gamepadPrefs'
import { coarsePointer } from '$lib/inputDevice'
@@ -111,7 +113,7 @@
if (document.pointerLockElement === domElement) document.exitPointerLock()
} else if (!free && wasFree) {
wasFree = false
- if (!noPointerLock && $isLocked === true && document.pointerLockElement !== domElement) {
+ if (!noPointerLock && !playCursorFree() && $isLocked === true && document.pointerLockElement !== domElement) {
const again: any = domElement.requestPointerLock({ unadjustedMovement: true })
again?.catch?.(() => {})
}
@@ -160,6 +162,10 @@
*/
function requestLock(first: boolean) {
if (noPointerLock) return cancelLockRetry() // W4: nothing to lock on a touch device
+ // 30 P3: a FREE-CURSOR game (`play.cursor: 'free'`) enters play without a lock: the
+ // cursor stays visible and aims (playCursor.playAimNdc), movement keys still walk or
+ // fly, and Escape still leaves — PLC's Escape branch already handles "no lock held"
+ if (playCursorFree()) return cancelLockRetry()
if (get(isLocked) !== true) return cancelLockRetry()
if (get(playPointerFree)) return cancelLockRetry() // 21-E3: the menu owns the pointer
if (document.pointerLockElement === domElement) return cancelLockRetry()
@@ -527,6 +533,7 @@
// a programmatic exit, so this stays idle.)
function onCanvasPointerDown() {
if (noPointerLock) return // W4: a touch tap is the look/interact gesture, not a re-lock
+ if (playCursorFree()) return // 30 P3: a click in a free-cursor game is the game's
if ($isLocked === true && !$playPointerFree && document.pointerLockElement !== domElement) {
const again: any = domElement.requestPointerLock({ unadjustedMovement: true })
again?.catch?.(() => {})
diff --git a/src/extensions/Grid.svelte b/src/extensions/Grid.svelte
index 8fb6b9af..c07a0725 100644
--- a/src/extensions/Grid.svelte
+++ b/src/extensions/Grid.svelte
@@ -1,7 +1,11 @@
- {#if showGrid}
+ {#if showGrid && ($isLocked !== true || $helpersInPlay)}
flowRuntime ->
@@ -1115,6 +1117,40 @@ function updateLeaderboardNodes(time, ctx) {
/** @type {Map} */
const hudSetActed = new Map();
+/** 30 P4: a Store Value / Stored Value key in THIS scene's namespace
+ * (`tp:scene::`). The name is the scene's own (levels'
+ * `currentLevel`), so a best score belongs to its game. @param {string} key */
+function storedKey(key) {
+ let name = null;
+ try {
+ name = levelsRef ? get(levelsRef.currentLevel)?.name ?? null : null;
+ } catch {}
+ return sceneStorageKey(name, key);
+}
+
+/** 30 P4: the Store Value write. `set` keeps whatever arrived (a number or text); `max`,
+ * `min` and `add` are numeric against what this device already holds, and an unchanged
+ * best writes nothing. @param {any} data */
+function storeValue(data) {
+ const key = String(data.key ?? '').trim();
+ if (!key) return;
+ const full = storedKey(key);
+ const mode = data.mode ?? 'set';
+ if (mode === 'set') {
+ writeStored(full, typeof data.value === 'string' ? data.value : num(data.value ?? 0));
+ return;
+ }
+ const value = num(data.value ?? 0);
+ const held = Number(readStored(full, undefined));
+ const has = Number.isFinite(held);
+ const next =
+ mode === 'max' ? (has ? Math.max(held, value) : value)
+ : mode === 'min' ? (has ? Math.min(held, value) : value)
+ : mode === 'add' ? (has ? held : 0) + value
+ : value;
+ if (!has || next !== held) writeStored(full, next);
+}
+
/** The element behind an id, across every HUD document. An input's OPTIONS and its
* `shared` flag live on the element, and a node names only the id. @param {string} id */
function findHudElement(id) {
@@ -1154,7 +1190,7 @@ function updateGameNodes(time, ctx) {
// 1. the ACTIONS, on a fresh trigger stamp only
for (const node of nodes) {
const type = node.type;
- if (type !== 'setgamestate' && type !== 'setcamera' && type !== 'setvariable' && type !== 'setlook' && type !== 'travel')
+ if (type !== 'setgamestate' && type !== 'setcamera' && type !== 'setvariable' && type !== 'setlook' && type !== 'travel' && type !== 'storevalue')
continue;
seeActionNode(node, time);
const stamp = triggerStampFor(node.id, ctx);
@@ -1197,6 +1233,12 @@ function updateGameNodes(time, ctx) {
const hash = typeof data.level === 'string' ? data.level : '';
if (sceneName && levelsRef?.travelToScene) levelsRef.travelToScene(sceneName);
else if (hash && levelsRef) levelsRef.travelToLevel(hash, String(data.levelName ?? ''));
+ } else if (type === 'storevalue') {
+ // 30 P4: a LOCAL write on the stamp edge, inside the actionSeenAt family above (a
+ // fresh node adopting an old stamp must not overwrite a best on connect). It
+ // sends NOTHING: every peer that sees the trigger acts for its own device, which
+ // is the whole semantics of "saved on this device".
+ storeValue(data);
} else if (type === 'setcamera') {
const uuid = typeof data.camera === 'string' ? data.camera : '';
if (uuid) lookThroughCamera(uuid);
@@ -1828,6 +1870,7 @@ export const valueTypes = [
'hudinput', // 21-D4: the HUD as a SOURCE - what the player set on a slider/toggle/etc
// 21-D6 the game shell
'ongamestate', 'getvariable', 'gametime',
+ 'storedvalue', // 30 P4: what Store Value saved on this device
// 24-A A4: `peervariable` was MISSING here since 21-G4, and the omission was silent in
// every direction that is easy to look at — it has an OUTPUT type in flowSockets, an
// evaluator case below, and the editor draws its source handle — but `resolveInputs`
@@ -2368,6 +2411,16 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) {
// LOCAL read of REPLICATED state, so every peer computes the same number and
// nothing about the read goes on the wire
return num(gameVar(String(d.name ?? ''), d.fallback ?? 0));
+ // --- 30 P4: what THIS device saved (never replicated, legitimately per-peer) ---
+ case 'storedvalue': {
+ const key = String(d.key ?? '').trim();
+ const held = key ? readStored(storedKey(key), undefined) : undefined;
+ if (d.output === 'text') {
+ if (held === undefined) return String(d.fallback ?? '');
+ return typeof held === 'string' ? held : JSON.stringify(held);
+ }
+ return held === undefined ? num(d.fallback ?? 0) : num(held);
+ }
// --- 21-G4: the PER-PLAYER half of the same idea ---
case 'peervariable': {
// Also a LOCAL read of REPLICATED state — every peer holds every peer's row —
diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js
index 96a5eaab..b2fb8e33 100644
--- a/src/lib/flowSockets.js
+++ b/src/lib/flowSockets.js
@@ -61,6 +61,9 @@ const OUTPUT = {
ongamestate: 'event',
getvariable: 'number',
gametime: 'number',
+ // 30 P4: the readable half of Store Value (a number socket; `output: text` carries a
+ // string down the same channel, the hudtext precedent)
+ storedvalue: 'number',
// 21-F3's `collectcount` MOVED to the collectible module (R3a); a module value
// node's socket type comes from its registerValueNode `vtype`, so no entry here.
// 21-G4: one player's own number (mine / a named peer / the sum / the max)
@@ -168,6 +171,7 @@ const INPUT = {
setcamera: { trigger: 'event', camera: 'object' },
setlook: { trigger: 'event', camera: 'object', on: 'boolean' },
setvariable: { trigger: 'event', value: 'number' },
+ storevalue: { trigger: 'event', value: 'number' }, // 30 P4
gamestart: { camera: 'object' },
// 21-F4: travel fires on its trigger edge; allplayers takes each player's own
// boolean answer (a Latch, a Gate, a Compare — anything true/false)
diff --git a/src/lib/gamePresence.js b/src/lib/gamePresence.js
index 3f38f0ca..3771d284 100644
--- a/src/lib/gamePresence.js
+++ b/src/lib/gamePresence.js
@@ -25,11 +25,15 @@
// history's import subtree, and nothing here registers a history kind.
import { writable, get } from 'svelte/store';
-import { peers } from '../stores/appStore';
+import { peers, showToast } from '../stores/appStore';
import { isLocked } from '../stores/sceneStore';
import { rolesInfo } from './cloudHooks';
import { sessionHost } from './connectionState';
import { gameState, resetGame } from './gameState';
+// 30 P2: both leaves (hudDocs: svelte/store + gameState + hudKinds + safeStorage; playMode:
+// sceneStore + appStore), so Test play lives here beside the reset permission it obeys
+import { hudIsGame, hudScreenOverride } from './hudDocs';
+import { requestPlay } from './playMode';
/** The two modes. A third would be a protocol change, so the reader treats anything it
* does not recognise as `editor` (the normalize-at-the-boundary rule). */
@@ -275,8 +279,11 @@ export function isSessionWriter() {
*/
export function tickAbandonWatch(now = Date.now()) {
const game = get(gameState);
- if (game.state !== 'playing') {
- // nothing to abandon; forget the arming so the NEXT round starts clean
+ // 30 P2 (roadmap 30 fork 5): EVERY state but `menu` is a round a player can leave — a
+ // paused round and a finished one (`over`, the victory screen) used to linger on the
+ // editor forever, because only `playing` was watched. `menu` is the one state with
+ // nothing to abandon, and reaching it forgets the arming so the NEXT round starts clean.
+ if (game.state === 'menu') {
armedRound = null;
return 'idle';
}
@@ -287,7 +294,11 @@ export function tickAbandonWatch(now = Date.now()) {
}
// a round nobody has entered yet is not an abandoned round
if (armedRound !== game.round) return 'idle';
- if (now - lastPlayingAt < ABANDON_MS) return 'waiting';
+ // 30 P2: ALONE, leaving IS abandoning — there is nobody whose reload or connect dance
+ // the ten-second window protects, and the round must not sit `playing` with its in-game
+ // HUD waiting for a player who is back in the editor. With peers the window stands.
+ const grace = livePeers().length ? ABANDON_MS : 0;
+ if (now - lastPlayingAt < grace) return 'waiting';
if (!isSessionWriter()) return 'notwriter';
// re-read through the single write path: `resetGame` is what the admin button calls,
// so the two ways a game ends up back at its menu are literally one function
@@ -342,6 +353,41 @@ export function requestResetGame() {
/** @type {(()=>void)[]} */
let disposers = [];
+/** was this peer in play at the last isLocked notification (the exit EDGE, not a state) */
+let wasPlaying = false;
+/** @type {any} */ let pokeTimer = null;
+
+/** One deferred watch pass; several notifications in one task coalesce into it. */
+function pokeWatch() {
+ if (pokeTimer || typeof setTimeout === 'undefined') return;
+ pokeTimer = setTimeout(() => {
+ pokeTimer = null;
+ tickAbandonWatch();
+ }, 0);
+}
+
+/**
+ * 30 P2 — ▶ TEST PLAY (roadmap 30 fork 4). What an author means by "try my game": back to
+ * the menu, into Play, the Start screen in front of them. Reached from the game chip and
+ * from the play button's right-click menu.
+ *
+ * The reset goes through `requestResetGame`, so it obeys the same rule as the Users
+ * popover's admin entry: the host (or anyone alone) may, an admin under a roles plugin
+ * may, and anybody else JOINS the game as it is — told so, never silently. This peer's
+ * own screen overrides are cleared either way (LOCAL), so the state-bound screen decides
+ * what shows. Play itself is the ordinary `requestPlay`, synchronously inside the click, so
+ * an immersive session is still requested within the gesture that asked for it.
+ * @returns {{ok: boolean, reason?: string}} the reset's verdict
+ */
+export function testPlay() {
+ const game = get(gameState);
+ const pristine = game.state === 'menu' && !game.startedAt;
+ const verdict = pristine ? { ok: true } : requestResetGame();
+ if (!verdict.ok) showToast((verdict.reason ?? 'The game cannot be reset.') + ' Joining the game as it is.');
+ hudScreenOverride.set({});
+ requestPlay();
+ return verdict;
+}
/** Install the presence broadcast + the abandon watch. Idempotent. */
export function startGamePresence() {
@@ -349,7 +395,26 @@ export function startGamePresence() {
// the subscribe lives HERE and not at module scope: a module-level subscribe runs
// its callback SYNCHRONOUSLY at module eval, and anything it reads that is declared
// below TDZ-crashes the SSR prerender (the documented meshEdit/faceEdit trap)
- disposers.push(isLocked.subscribe(() => publishPlayMode()));
+ disposers.push(
+ isLocked.subscribe((v) => {
+ publishPlayMode();
+ // 30 P2: a screen THIS peer chose during play (the pause menu a P press opened, a
+ // hudscreen node's override) is play state — it must not follow the player back
+ // into the editor, or the next Play lands on a stale pause screen over a round
+ // that has since returned to its menu. Games only: a plain HUD's overrides are
+ // authored behaviour and keep their old lifetime.
+ if (v !== true && wasPlaying && get(hudIsGame)) hudScreenOverride.set({});
+ wasPlaying = v === true;
+ pokeWatch();
+ })
+ );
+ // 30 P2: the watch also runs on the EDGES, not only on its 1s beat: entering play arms
+ // the round even if the player leaves inside the first second, and the last player
+ // leaving resets a solo round on the spot. Deferred a macrotask, because a tick may
+ // WRITE the game state and these are store notifications (never write a store from
+ // inside a subscriber).
+ disposers.push(gameState.subscribe(() => pokeWatch()));
+ disposers.push(peerPlayModes.subscribe(() => pokeWatch()));
watchTimer = setInterval(() => tickAbandonWatch(), WATCH_MS);
disposers.push(() => {
clearInterval(watchTimer);
@@ -361,6 +426,9 @@ export function startGamePresence() {
export function stopGamePresence() {
for (const dispose of disposers) dispose();
disposers = [];
+ if (pokeTimer) clearTimeout(pokeTimer);
+ pokeTimer = null;
+ wasPlaying = false;
}
/** Test/debug view — `abandonWrites` is the only way to tell WHICH peer wrote a
diff --git a/src/lib/gameStorage.js b/src/lib/gameStorage.js
new file mode 100644
index 00000000..e68d6544
--- /dev/null
+++ b/src/lib/gameStorage.js
@@ -0,0 +1,197 @@
+// 30 P4 — WHAT A GAME REMEMBERS ON THIS DEVICE (roadmap 30 fork 7).
+//
+// Two consumers, one rule: a module's `api.storage` and the Store Value / Stored Value flow
+// nodes. Both are LOCAL per device by design — a best score, unlocked levels, a "seen the
+// tutorial" flag — so nothing here replicates, nothing enters a scene file and nothing is
+// undone. A peer's best is theirs; the card says so ("Saved on this device only").
+//
+// THE KEYS ARE A CONTRACT, byte for byte, because a module that must run on an older core
+// (untangle's fallback for a 1.16 client) writes the SAME key itself so its data survives
+// the upgrade:
+//
+// tp:mod:: a module's own namespace (api.storage)
+// tp:scene:: a flow graph's namespace, per scene ('untitled'
+// when the scene has no name)
+//
+// VALUES ARE JSON. `set(key, value)` stores `JSON.stringify(value)`; `get` parses it back
+// and hands the fallback over on a missing key OR a value that does not parse (a hand-edited
+// entry, a truncated write), because a broken save must never crash a game's boot.
+//
+// THE CAP is per MODULE (256 KB), measured as the JSON text of every entry in the namespace
+// plus its key — what the browser actually spends. A write that would cross it returns
+// false and nothing is written; the caller hears it once per session through `onOverCap`
+// (moduleSDK turns that into ONE toast), never once per frame.
+//
+// A DELIBERATE LEAF: it imports safeStorage (itself a leaf) and nothing else, so moduleSDK,
+// flowRuntime and a unit test can all reach it. safeStorage is what makes the promise
+// honest: in Safari private mode, a sandboxed frame or a full quota the write falls back to
+// memory for the session instead of throwing.
+
+import { safeStorage } from './safeStorage';
+
+/** bytes (UTF-16 code units, i.e. JSON text length) one module may keep */
+export const MODULE_STORAGE_CAP = 256 * 1024;
+
+/** @param {string} moduleId */
+export function modulePrefix(moduleId) {
+ return 'tp:mod:' + String(moduleId) + ':';
+}
+
+/** @param {string|null|undefined} sceneName */
+export function scenePrefix(sceneName) {
+ const name = typeof sceneName === 'string' && sceneName.trim() ? sceneName.trim() : 'untitled';
+ return 'tp:scene:' + name + ':';
+}
+
+/** The full key a module writes. @param {string} moduleId @param {string} key */
+export function moduleStorageKey(moduleId, key) {
+ return modulePrefix(moduleId) + String(key);
+}
+
+/** The full key a Store Value node writes. @param {string|null|undefined} sceneName @param {string} key */
+export function sceneStorageKey(sceneName, key) {
+ return scenePrefix(sceneName) + String(key);
+}
+
+/** Parsed-value cache keyed by the FULL key. Every write through this module keeps it
+ * current, so a Stored Value node read every frame costs a Map lookup, not a
+ * localStorage read and a JSON.parse. @type {Map} */
+const cache = new Map();
+
+/** @param {string} full @returns {{text: string|null, value: any}} */
+function read(full) {
+ const hit = cache.get(full);
+ if (hit) return hit;
+ const text = safeStorage.getItem(full);
+ /** @type {any} */
+ let value;
+ let ok = text !== null;
+ if (ok) {
+ try {
+ value = JSON.parse(/** @type {string} */ (text));
+ } catch {
+ ok = false;
+ }
+ }
+ const entry = { text: ok ? text : null, value: ok ? value : undefined };
+ cache.set(full, entry);
+ return entry;
+}
+
+/** Read a full key: the parsed value, or `fallback` when it is missing or unreadable.
+ * @param {string} full @param {any} [fallback] */
+export function readStored(full, fallback = undefined) {
+ const entry = read(full);
+ return entry.text === null ? fallback : entry.value;
+}
+
+/** Does a full key hold a readable value? @param {string} full */
+export function hasStored(full) {
+ return read(full).text !== null;
+}
+
+/** Write a full key (JSON). `undefined` removes it. Returns the JSON text written, or
+ * null when the value cannot be serialized (a cycle, a BigInt).
+ * @param {string} full @param {any} value @returns {string|null} */
+export function writeStored(full, value) {
+ if (value === undefined) {
+ removeStored(full);
+ return '';
+ }
+ /** @type {string} */
+ let text;
+ try {
+ const out = JSON.stringify(value);
+ if (typeof out !== 'string') return null; // a function, a symbol
+ text = out;
+ } catch {
+ return null;
+ }
+ safeStorage.setItem(full, text);
+ cache.set(full, { text, value: JSON.parse(text) });
+ return text;
+}
+
+/** @param {string} full */
+export function removeStored(full) {
+ safeStorage.removeItem(full);
+ cache.delete(full);
+}
+
+/** Every full key under a prefix. @param {string} prefix @returns {string[]} */
+function fullKeysUnder(prefix) {
+ return safeStorage.keys().filter((key) => key.startsWith(prefix));
+}
+
+/** The JSON bytes a namespace holds, optionally pretending one key held `replace` instead.
+ * @param {string} prefix @param {string} [except] a full key to leave out */
+function usedBytes(prefix, except) {
+ let total = 0;
+ for (const full of fullKeysUnder(prefix)) {
+ if (full === except) continue;
+ const text = safeStorage.getItem(full);
+ if (text !== null) total += full.length + text.length;
+ }
+ return total;
+}
+
+/**
+ * The `api.storage` object for one module.
+ * @param {string} moduleId
+ * @param {{cap?: number, onOverCap?: (info: {key: string, bytes: number, cap: number}) => void}} [options]
+ */
+export function makeModuleStorage(moduleId, options = {}) {
+ const prefix = modulePrefix(moduleId);
+ const cap = options.cap ?? MODULE_STORAGE_CAP;
+ return {
+ /** @param {string} key @param {any} [fallback] */
+ get(key, fallback = undefined) {
+ return readStored(prefix + String(key), fallback);
+ },
+ /** @param {string} key @param {any} value @returns {boolean} written */
+ set(key, value) {
+ const full = prefix + String(key);
+ if (value === undefined) {
+ removeStored(full);
+ return true;
+ }
+ /** @type {string|undefined} */
+ let text;
+ try {
+ text = JSON.stringify(value);
+ } catch {
+ return false;
+ }
+ if (typeof text !== 'string') return false;
+ const bytes = usedBytes(prefix, full) + full.length + text.length;
+ if (bytes > cap) {
+ options.onOverCap?.({ key: String(key), bytes, cap });
+ return false;
+ }
+ return writeStored(full, value) !== null;
+ },
+ /** @param {string} key */
+ remove(key) {
+ removeStored(prefix + String(key));
+ },
+ /** this module's keys, without the prefix, sorted @returns {string[]} */
+ keys() {
+ return fullKeysUnder(prefix)
+ .map((full) => full.slice(prefix.length))
+ .sort();
+ },
+ /** forget everything this module stored — the module's own reset */
+ clear() {
+ for (const full of fullKeysUnder(prefix)) removeStored(full);
+ },
+ /** how much of the cap is spent (JSON text of every entry + its key) */
+ bytes() {
+ return usedBytes(prefix);
+ }
+ };
+}
+
+/** TEST SEAM: forget the parsed-value cache (a suite that writes localStorage directly). */
+export function debugResetGameStorage() {
+ cache.clear();
+}
diff --git a/src/lib/hudActions.js b/src/lib/hudActions.js
index 5579f1c1..efb51344 100644
--- a/src/lib/hudActions.js
+++ b/src/lib/hudActions.js
@@ -115,6 +115,10 @@ export const HUD_ACTIONS = [
// entry calls, so the two ways a game is reset are one function.
{ key: 'resetgame', label: 'Reset the game', group: 'Game', role: 'press', node: 'setgamestate', data: { state: 'menu', reset: true }, handle: 'trigger', hint: 'Back to the menu AND the round clock to zero — collectibles read un-collected again.' },
{ key: 'setvar', label: 'Set a variable', group: 'Game', role: 'press', node: 'setvariable', data: { name: 'score', op: 'add', value: 1 }, handle: 'trigger', hint: 'Add to, subtract from or set a shared number.' },
+ // 30 P4: keep the best on THIS device. The value is fed from the `score` variable by a
+ // `via` source (additive to the press role: absent everywhere else, so every other
+ // press action builds exactly what it built before).
+ { key: 'savebest', label: 'Save best score', group: 'Game', role: 'press', node: 'storevalue', data: { key: 'best', mode: 'max' }, handle: 'trigger', via: { node: 'getvariable', data: { name: 'score' }, handle: 'value' }, hint: 'Keeps the highest “score” seen on this device (Store Value, max) — each player keeps their own.' },
// 21-F4: LEVEL COMPLETE — travel to a level. The destination is the AUTHOR'S PICK
// on the Travel card, deliberately not a "next by folder order": the Explorer
// library is LOCAL, so two peers can hold different orders and a computed "next"
@@ -309,6 +313,10 @@ export function describeNode(node, handle = null) {
return 'Only once';
case 'getvariable':
return 'Variable “' + (d.name ?? '') + '”';
+ case 'storevalue':
+ return 'Save “' + (d.key ?? '') + '” on this device (' + (d.mode ?? 'set') + ')';
+ case 'storedvalue':
+ return 'Saved “' + (d.key ?? '') + '” (this device)';
case 'gametime':
return 'Round time (' + (d.read ?? 'elapsed') + ')';
case 'hudtext':
@@ -521,6 +529,13 @@ export function addBinding(elementId, actionKey) {
const actionNode = makeNode(action.node, baseX + 220, baseY, action.data);
created.push(actionNode);
createdEdges.push(makeEdge(press, actionNode, action.handle));
+ // 30 P4: a press action may name the VALUE it acts on too (Save best score reads
+ // the score) — the drives role's `via`, one socket over
+ if (action.via) {
+ const source = makeNode(action.via.node, baseX, baseY + 120, action.via.data);
+ created.push(source);
+ createdEdges.push(makeEdge(source, actionNode, action.via.handle));
+ }
}
} else {
const displayType = /** @type {any} */ (DISPLAY_NODE)[String(action.role === 'drives' ? currentKindOf(elementId) : '')] ?? 'hudtext';
diff --git a/src/lib/hudDocs.js b/src/lib/hudDocs.js
index 8b38b473..81c83f03 100644
--- a/src/lib/hudDocs.js
+++ b/src/lib/hudDocs.js
@@ -21,7 +21,7 @@
// computes the same string with no message of its own. Screen visibility is per-peer ON
// PURPOSE — one player on the start menu while another plays.
-import { writable, get } from 'svelte/store';
+import { writable, derived, get } from 'svelte/store';
import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares
// 21-D1: the kind REGISTRY. hudKinds imports nothing, so this stays a leaf.
import { HUD_KINDS as REGISTERED_KINDS, defaultsForKind, styleDefaultsForKind, kindDef } from './hudKinds';
@@ -75,6 +75,28 @@ export const activeHudDoc = writable(null);
* @type {import('svelte/store').Writable>} */
export const hudScreenOverride = writable({});
+/**
+ * 30 P1: IS THIS SCENE A GAME? The rule, and why it is this one: a scene is a game when ANY
+ * of its HUD documents holds a screen bound to a game state (`showWhile` set). That binding
+ * is the one thing that makes the editor MASQUERADE as the running game — the shared state
+ * picks the screen, so a menu whose state is `menu` paints itself over the editor with
+ * nobody in Play. Every Games-tab template has one; a hand-made HUD with no state-bound
+ * screen is a plain overlay and keeps its old behaviour (drawn in the editor, buttons live).
+ * A `hudbutton -> setgamestate` wiring alone was considered and NOT taken: with no
+ * state-bound screen nothing masquerades, and reading the graph here would put a flow edge
+ * into a leaf that keeps none.
+ * @param {Record} docs @returns {boolean}
+ */
+export function isGameHud(docs) {
+ for (const doc of Object.values(docs ?? {}))
+ for (const screen of Array.isArray(doc?.screens) ? doc.screens : [])
+ if (typeof screen?.showWhile === 'string' && screen.showWhile) return true;
+ return false;
+}
+
+/** 30 P1: the rule above as a store — HudLayer and the game chip both read it. */
+export const hudIsGame = derived(hudDocs, (docs) => isGameHud(docs));
+
/** The editor's current selection, per document key -> element ids. LOCAL.
* @type {import('svelte/store').Writable>} */
export const hudSelection = writable({});
diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js
index c81f3631..24ae0f2e 100644
--- a/src/lib/moduleSDK.js
+++ b/src/lib/moduleSDK.js
@@ -43,6 +43,12 @@ import { ndcFromClient } from './canvasRect';
// 27-B: recovery paths report through the diagnostics ring (hardening audit H4)
import { log } from './diagnostics';
import { safeStorage } from './safeStorage';
+// 30 P4: api.storage — a LEAF (safeStorage only), shared with the Store Value flow node
+import { makeModuleStorage } from './gameStorage';
+
+/** modules already told they hit the storage cap this session (ONE toast each, never
+ * one per write — a game saving every frame would otherwise bury the screen) */
+const storageCapWarned = new Set();
// Module SDK v1 — in-repo modules under src/modules// register through
// the api object passed to their register(api). See MODULES.md for the guide.
@@ -1014,6 +1020,24 @@ function makeApi(moduleId, moduleName = moduleId) {
* immune to the shared-scope add race. Rows replicate on the presence channel,
* late joiners converge, a row drops with its owner's disconnect.
*/
+ /**
+ * 30 P4 (roadmap 30 fork 7): what this module remembers ON THIS DEVICE — a best
+ * score, unlocked levels, a settings choice. JSON values under
+ * `tp:mod::` through safeStorage (never throws: a private window or a
+ * full quota falls back to memory for the session), 256 KB per module (a `set` over
+ * it returns false and says so ONCE), LOCAL: never replicated, never in a scene
+ * file, never undone — and deliberately NOT cleared when the module is disabled or
+ * removed (a reinstall keeps your progress; `clear()` is the module's own reset).
+ * `get(key, fallback)` · `set(key, value) -> bool` · `remove(key)` · `keys()` ·
+ * `clear()` · `bytes()`.
+ */
+ storage: makeModuleStorage(moduleId, {
+ onOverCap: () => {
+ if (storageCapWarned.has(moduleId)) return;
+ storageCapWarned.add(moduleId);
+ showToast(`"${moduleName}" hit its 256 KB storage limit on this device — that value was not saved.`);
+ }
+ }),
peerVars: {
/** Write MY OWN row. @param {string} name @param {number} value */
setMine(name, value) {
diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js
index a06ac847..ab712820 100644
--- a/src/lib/nodeCatalog.js
+++ b/src/lib/nodeCatalog.js
@@ -214,6 +214,33 @@ export const nodeCatalog = [
defaults: { name: 'score', fallback: 0 },
params: [{ key: 'name', kind: 'text', placeholder: 'score', maxLength: 40 }]
},
+ // 30 P4 (roadmap 30 fork 7): what a game remembers ON THIS DEVICE — a best score,
+ // a level reached. Keyed `tp:scene::` so a best belongs to its
+ // game. LOCAL by design: Store Value writes this peer's browser and sends
+ // nothing, so a REPLICATED trigger (an ordinary On Click) makes every peer save
+ // its own copy, while a per-player trigger saves only for the one who earned it.
+ {
+ type: 'storevalue',
+ label: 'Store Value',
+ defaults: { key: 'best', mode: 'max', value: 0 },
+ inputs: ['trigger', 'value'],
+ inputLabels: { value: 'value — wire a Counter, a variable, a score' },
+ params: [
+ { key: 'key', kind: 'text', placeholder: 'best', maxLength: 60 },
+ { key: 'mode', kind: 'select', options: ['set', 'max', 'min', 'add'] }
+ ],
+ note: 'Saved on this device only — never sent to other players, never in the scene file.'
+ },
+ {
+ type: 'storedvalue',
+ label: 'Stored Value',
+ defaults: { key: 'best', output: 'number', fallback: 0 },
+ params: [
+ { key: 'key', kind: 'text', placeholder: 'best', maxLength: 60 },
+ { key: 'output', kind: 'select', options: ['number', 'text'] }
+ ],
+ note: 'Reads what THIS device saved — each player sees their own.'
+ },
// the round clock, derived from the shared startedAt stamp — no clock of its own
{
type: 'gametime',
diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js
index df0afa02..d93cf64d 100644
--- a/src/lib/nodeDocs.js
+++ b/src/lib/nodeDocs.js
@@ -35,6 +35,8 @@ export const NODE_DOCS = {
allplayers: "Iterates the connected players so a per-player value can be read, or a HUD row shown, for each of them.",
setvariable: "Stores a named value in the replicated scene variables when a pulse arrives - shared state without an object.",
getvariable: "Reads a named scene variable as a value - the read side of Set Variable, live on every peer.",
+ storevalue: "Saves a value on THIS device when a pulse arrives (set, keep the max or min, or add) - a best score or a level reached that survives a reload; never sent to other players.",
+ storedvalue: "Reads what Store Value saved on this device, as a number or text - the read side of a best score; each player sees their own.",
gametime: "Seconds since the game (or the round) started, the same on every peer - a clock that pauses with the game.",
peervariable: "A value kept PER PLAYER (score, lives, team) - each peer reads its own row, or a named player's.",
charcontroller: "Turns the connected object into a walking, jumping character driven by the player's movement input.",
@@ -70,6 +72,7 @@ export const NODE_DOCS = {
onclick: "Fires a short pulse when its object is clicked - the bridge from user input into the graph.",
keypress: "Fires a pulse while a keyboard key is pressed - the bridge from your keyboard into the graph.",
onimpact: "Fires a pulse when a physics simulation lands the connected object on the ground or another object.",
+ onhit: "Fires a pulse when a hand or a walking player knocks the object - with how hard it was hit (speed) and whether you did it (byMe).",
onenter: "Fires a pulse when something enters a trigger volume - the checkpoint, doorway and pressure-plate node.",
onexit: "Fires a pulse when something leaves a trigger volume - the other half of On Enter.",
onrest: "Fires a pulse when a physics body has finished moving - the counterpart to On Impact, which fires when it starts.",
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/playCursor.js b/src/lib/playCursor.js
new file mode 100644
index 00000000..533c69f7
--- /dev/null
+++ b/src/lib/playCursor.js
@@ -0,0 +1,77 @@
+// 30 P3 — FREE-CURSOR GAMES (roadmap 30 fork 6): WHERE PLAY MODE AIMS.
+//
+// Play mode had exactly one aim: NDC (0, 0), the crosshair, because under pointer lock the
+// cursor is pinned to the centre of the canvas and does not exist for the player. A board
+// game, a puzzle or an instrument wants the opposite — the real cursor, visible, aiming
+// wherever it is — and `scenePhysics.play.cursor: 'free'` asks for that (absent = 'locked',
+// today, byte for byte). A module may publish it too, field by field, through the
+// `userData.play` contract (`resolvePlaySettings`).
+//
+// THIS LEAF IS THE ONE ANSWER to "where is the player aiming right now", so the pieces that
+// ask cannot disagree:
+// · playInteract's tap and grab (the crosshair ray before this),
+// · PointerLockControls, which must not ask for a lock in free mode,
+// · PlayReticle, which has no crosshair to draw in free mode,
+// · the module SDK's `pointerRay()` (lane 30-core-modes reads `playAimNdc()` in play:
+// the cursor when free, the crosshair under a lock — which is also the fix for the
+// stale-mouse ray it returned under a lock).
+//
+// The cursor is recorded in CLIENT pixels and converted to NDC against the canvas at read
+// time (the W9 rule `pointerRay` already keeps: the viewport can change with the pointer
+// perfectly still). LOCAL and unreplicated — an aim is a fact about this screen.
+//
+// Imports stores and leaves only (sceneStore, scenePhysics via playSettings, canvasRect),
+// so playInteract, PointerLockControls and moduleSDK can all reach it without a cycle.
+
+import { get } from 'svelte/store';
+import { isLocked, isVRMode, globalScene } from '../stores/sceneStore';
+import { resolvePlaySettings } from './playSettings';
+import { ndcFromClient } from './canvasRect';
+
+/** the last pointer position over the page, CLIENT pixels */
+const cursor = { x: 0, y: 0, seen: false };
+
+if (typeof window !== 'undefined') {
+ // capture phase + passive: we only read coordinates, and a panel that stops the event
+ // on its way up (the documented delegated-handler trap) must not hide the cursor from us
+ const note = (/** @type {PointerEvent} */ event) => {
+ cursor.x = event.clientX;
+ cursor.y = event.clientY;
+ cursor.seen = true;
+ };
+ window.addEventListener('pointermove', note, { capture: true, passive: true });
+ window.addEventListener('pointerdown', note, { capture: true, passive: true });
+}
+
+/** 'free' | 'locked' — the scene's play cursor, publishers included. Readable in the
+ * editor too (it is authored data), so the Inspector can show it. @returns {'free'|'locked'} */
+export function playCursorSetting() {
+ return resolvePlaySettings(get(globalScene)).cursor === 'free' ? 'free' : 'locked';
+}
+
+/** Is play running with a FREE cursor right now? Desktop play only: a headset has no
+ * cursor to free, and outside play there is no aim at all. */
+export function playCursorFree() {
+ if (get(isLocked) !== true || get(isVRMode)) return false;
+ return playCursorSetting() === 'free';
+}
+
+/** The last cursor position in client pixels, or null before the first pointer event. */
+export function cursorClient() {
+ return cursor.seen ? { x: cursor.x, y: cursor.y } : null;
+}
+
+/**
+ * WHERE PLAY MODE AIMS, in NDC: the cursor in free-cursor play (the centre until the first
+ * pointer event), the crosshair (0, 0) otherwise.
+ * @returns {{x: number, y: number}}
+ */
+export function playAimNdc() {
+ if (!playCursorFree() || !cursor.seen) return { x: 0, y: 0 };
+ return ndcFromClient(cursor.x, cursor.y);
+}
+
+/** test/debug view */
+export function playCursorDebug() {
+ return { setting: playCursorSetting(), free: playCursorFree(), cursor: cursorClient(), ndc: playAimNdc() };
+}
diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js
index 88102534..1ace69f9 100644
--- a/src/lib/playInteract.js
+++ b/src/lib/playInteract.js
@@ -18,6 +18,8 @@ import { velocityFromSamples } from './throwVelocity';
import { resolvePlaySettings } from './playSettings';
import { nameOf } from './lockControl';
import { moduleClickHandlers, moduleInteractiveGroups, fireClickMiss } from './moduleSDK';
+// 30 P3: where play mode aims — the crosshair under a lock, the cursor in a free-cursor game
+import { playAimNdc, playCursorFree } from './playCursor';
// 21-B B3: play mode becomes INTERACT mode — a crosshair grab at distance,
// scroll to push and pull, and a release that throws with the velocity you
@@ -113,8 +115,13 @@ function dynamicUuids() {
/** @param {any} camera */
function aimFrom(camera) {
camera.getWorldPosition(camPos);
- camera.getWorldDirection(camDir);
+ // 30 P3: the aim is the crosshair (NDC 0,0) under a lock and the CURSOR in a
+ // free-cursor game; `camDir` then follows the ray rather than the view axis, which is
+ // what makes a carried object follow the cursor
+ const aim = playAimNdc();
+ centre.set(aim.x, aim.y);
raycaster.setFromCamera(centre, camera);
+ camDir.copy(raycaster.ray.direction);
return sceneHits(raycaster, {}); // no tinyProxies: a proxy carries no `face`
// and is a SELECTION affordance — grabbing an invisible speck is not a feature
}
@@ -218,6 +225,11 @@ function sendThrow(object, velocity, throwIt) {
/** @param {PointerEvent} event */
function onPointerDown(event) {
if (event.button !== 0) return;
+ // 30 P3: with a FREE cursor a press anywhere on the page reaches this window listener —
+ // a HUD button, a toast, the ✕ — so only a press on the VIEWPORT is a world gesture.
+ // Under a lock the target is the locked canvas anyway, which is why this is scoped to
+ // free mode (a synthesized window-level press keeps working there, as it always has).
+ if (playCursorFree() && !isViewportTarget(event)) return;
const mode = interactionMode();
if (mode === 'off' || !activeCamera) return;
const hits = aimFrom(activeCamera);
@@ -245,6 +257,13 @@ function onPointerDown(event) {
beginGrab(target, activeCamera);
}
+/** Is this event aimed at the 3D viewport (the renderer's canvas)? @param {Event} event */
+function isViewportTarget(event) {
+ /** @type {any} */
+ const target = event.target;
+ return !!target && target.tagName === 'CANVAS' && !!target.closest?.('.viewport');
+}
+
/** @param {PointerEvent} event */
function onPointerUp(event) {
if (event.button !== 0) return;
@@ -338,7 +357,11 @@ export function tickPlayInteract(delta, camera) {
return;
}
camera.getWorldPosition(camPos);
- camera.getWorldDirection(camDir);
+ // 30 P3: along the AIM ray, so a free-cursor carry follows the cursor
+ const aim = playAimNdc();
+ centre.set(aim.x, aim.y);
+ raycaster.setFromCamera(centre, camera);
+ camDir.copy(raycaster.ray.direction);
targetPos.copy(camPos).addScaledVector(camDir, carryDistance);
// dt-based, so a throttled tab does not change the feel
const k = Math.min(SPRING_K_MAX, Math.max(SPRING_K_MIN, SPRING_K / Math.sqrt(Math.max(grab.mass, 1))));
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/src/lib/playSettings.js b/src/lib/playSettings.js
index 57784b0d..2f739586 100644
--- a/src/lib/playSettings.js
+++ b/src/lib/playSettings.js
@@ -41,7 +41,11 @@ export function playPublishers(scene) {
* The effective play settings: the scene's shared `play` block, overridden
* FIELD BY FIELD by each publisher (a module only overrides what it declares).
* @param {any} scene
- * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number}}
+ * 30 P3: `cursor` too — 'free' (the real cursor aims, no pointer lock) or 'locked' (the
+ * crosshair, today). A module publishing `userData.play.cursor` overrides the scene's, the
+ * way `grounded` does, which is how a board-game module asks for it without an authored
+ * scene field.
+ * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number, cursor: 'free'|'locked'}}
*/
export function resolvePlaySettings(scene) {
const base = get(scenePlay);
@@ -49,7 +53,8 @@ export function resolvePlaySettings(scene) {
const out = {
interaction: base.interaction,
grounded: base.grounded,
- eyeHeight: DEFAULT_EYE_HEIGHT
+ eyeHeight: DEFAULT_EYE_HEIGHT,
+ cursor: base.cursor === 'free' ? 'free' : 'locked'
};
const publishers = playPublishers(scene);
if (publishers.length > 1 && !warnedMultiple) {
@@ -67,6 +72,7 @@ export function resolvePlaySettings(scene) {
out.interaction = play.interaction;
if (typeof play.grounded === 'boolean') out.grounded = play.grounded;
if (typeof play.eyeHeight === 'number') out.eyeHeight = play.eyeHeight;
+ if (play.cursor === 'free' || play.cursor === 'locked') out.cursor = play.cursor;
}
return out;
}
diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js
index 8992b6c7..0bc8c1d5 100644
--- a/src/lib/scenePhysics.js
+++ b/src/lib/scenePhysics.js
@@ -43,6 +43,10 @@ export const DEFAULT_SCENE_PHYSICS = Object.freeze({
});
const BOUNDS_ACTIONS = ['freeze', 'respawn', 'delete'];
+// 30 P3: the play cursor. 'locked' (the crosshair under pointer lock) is the default and
+// is NEVER written — a normalized play block carries `cursor` only when it is 'free' — so
+// every scene saved before this, and every scene that does not use it, stays byte-identical.
+const PLAY_CURSORS = ['free', 'locked'];
const INTERACTIONS = ['grab', 'click', 'off'];
/** @param {any} v @param {number} lo @param {number} hi @param {number} fallback */
@@ -142,9 +146,11 @@ export function normalizeScenePhysics(raw) {
{
interaction: pick(playRaw.interaction, INTERACTIONS, d.play.interaction),
grounded: bool(playRaw.grounded, d.play.grounded),
- simOnPlay: bool(playRaw.simOnPlay, d.play.simOnPlay)
+ 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' } : {})
},
- ['interaction', 'grounded', 'simOnPlay']
+ ['interaction', 'grounded', 'simOnPlay', 'cursor']
),
// 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
@@ -189,7 +195,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}) */
+/** play-mode block ({interaction, grounded, simOnPlay, cursor?: 'free'}) */
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/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/game-dungeon-realms.test.cjs b/tests/e2e/game-dungeon-realms.test.cjs
index cbb17627..c5a02156 100644
--- a/tests/e2e/game-dungeon-realms.test.cjs
+++ b/tests/e2e/game-dungeon-realms.test.cjs
@@ -255,7 +255,7 @@ h.run(async () => {
const dungeonNodes = await nodesOf(A.page, 'dkdungeon');
h.check(dungeonNodes.length === 1 && dungeonNodes[0].data.apply === true && dungeonNodes[0].data.seed === SEED, '1.7 ONE Dungeon node owns the recipe (apply on, seed ' + SEED + ')');
h.check((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.8 the game shell starts in menu with the menu screen');
- h.check(/DUNGEON REALMS/.test(await hudText(A.page)), '1.9 the HUD menu screen renders its title');
+ 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.9 in the editor the game chip stands in for the menu (30 P1: no live menu over the editor)', 6000);
h.check(a1.resolvedGrounded === true && a1.grounded === true, '1.10 grounded resolves TRUE from the Kit contract (playSettings)');
// ---- 2. B receives it over the handshake ---------------------------------------------------
@@ -276,6 +276,7 @@ h.run(async () => {
await A.page.locator('#play-button').click();
await B.page.locator('#play-button').click();
await h.eventually(() => snap(A.page), (s) => s.menu, '3.1 A: the module start menu appears in play mode');
+ await h.eventually(async () => (await hudText(A.page)) ?? '', (t) => /DUNGEON REALMS/.test(t), '3.1b in play the HUD menu screen renders its title');
await h.eventually(() => snap(B.page), (s) => s.menu, '3.2 B: the module start menu appears');
h.check(await clickMenu(A.page, 'join-p1'), '3.3 A joins as Player 1 (module menu)');
h.check(await clickMenu(B.page, 'join-p2'), '3.4 B joins as Player 2');
diff --git a/tests/e2e/game-editor-flow.test.cjs b/tests/e2e/game-editor-flow.test.cjs
new file mode 100644
index 00000000..213ae7e1
--- /dev/null
+++ b/tests/e2e/game-editor-flow.test.cjs
@@ -0,0 +1,214 @@
+// 30 P1/P2 — GAMES START ON START: the editor stops masquerading as the running game.
+//
+// Before: loading a Games-tab game painted its MENU over the editor ("TOWERS / Start
+// round" on top of the scene being built) with LIVE buttons — a Start press in the editor
+// started the round for every peer with nobody in Play — and leaving Play left the round
+// `playing` with its in-game HUD on the editor.
+//
+// After: a GAME's screens are not drawn outside Play (the rule, and why it is the rule:
+// hudDocs `isGameHud` — a scene is a game when one of its HUD screens is bound to a game
+// state with `showWhile`), the HUD editor's preview eye still shows them for authoring
+// with every button INERT, and a small chip stands in for them: "Game · " plus
+// ▶ Test play.
+//
+// Loaded from the REAL Towers .tpscene with the REAL collectible zip, skip-never-fail when
+// either is missing (authored content must never turn a bare checkout red).
+const h = require('./helpers.cjs');
+const fs = require('fs');
+const path = require('path');
+
+const SCENES_REPO = [
+ path.resolve(__dirname, '../../../theprototype.app-scenes'),
+ path.resolve(__dirname, '../../../scenes')
+].find((p) => fs.existsSync(p));
+const TPSCENE =
+ process.env.TOWERS_TPSCENE || (SCENES_REPO && path.join(SCENES_REPO, 'games/towers/scene.tpscene'));
+
+/** @param {any} page */
+async function loadTowers(page) {
+ const bytes = Array.from(fs.readFileSync(TPSCENE));
+ await page.evaluate(async (arr) => {
+ const s = window.__stores;
+ const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer);
+ await s.sessions.applySession(payload, { backup: false });
+ }, bytes);
+ await page.waitForTimeout(2000);
+}
+
+/** @param {any} page */
+const snap = (page) =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ /** @param {any} st */
+ const g = (st) => {
+ let v;
+ st.subscribe((/** @type {any} */ x) => (v = x))();
+ return v;
+ };
+ const layer = document.querySelector('#hud-layer');
+ const chip = document.querySelector('#game-chip');
+ return {
+ state: g(s.gameState.gameState)?.state ?? null,
+ locked: g(s.isLocked),
+ isGame: g(s.hudDocs.hudIsGame),
+ screen: s.hudDocs.visibleScreen('scene')?.id ?? null,
+ layer: !!layer,
+ layerText: layer?.textContent ?? '',
+ buttons: layer ? layer.querySelectorAll('button').length : 0,
+ chip: !!chip,
+ chipText: chip?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
+ preview: g(s.hudDocs.hudPreviewInViewport)
+ };
+ });
+
+h.run(async () => {
+ if (!TPSCENE || !fs.existsSync(TPSCENE)) {
+ console.log('SKIP: no sibling scenes checkout with games/towers/scene.tpscene (or TOWERS_TPSCENE)');
+ return;
+ }
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A', {
+ context: { viewport: { width: 1280, height: 720 } },
+ storage: { hudPreviewInViewport: 'false' }
+ });
+ const page = A.page;
+ if (!(await h.installModule(A, 'collectible'))) {
+ console.log('SKIP: no collectible.zip in the sibling modules checkout (npm run pack -- collectible)');
+ await h.finish(browser);
+ return;
+ }
+ await loadTowers(page);
+
+ // =====================================================================
+ // 1. EDIT MODE SHOWS THE CHIP, NOT THE MENU
+ // =====================================================================
+ let st = await snap(page);
+ h.check(st.state === 'menu' && st.screen === 'menu', `premise: the game sits on its menu (${st.state}/${st.screen})`);
+ h.check(st.locked !== true, 'premise: we are in the editor, not in Play');
+ h.check(st.isGame === true, 'Towers reads as a GAME (a showWhile-bound screen)');
+ h.check(st.buttons === 0, `no HUD button exists in the editor (${st.buttons})`);
+ h.check(!/TOWERS/.test(st.layerText), 'and the menu title is not painted over the editor');
+ h.check(st.chip, 'the game chip stands in for it');
+ h.check(/Game\s*·\s*menu/.test(st.chipText), `reading "Game · menu" (${st.chipText})`);
+
+ // A REAL click where the Start button used to be changes nothing. The spot is computed
+ // from the element's own anchored rect, the rule HudLayer places it by.
+ const start = await page.evaluate(() => {
+ const s = window.__stores;
+ const doc = s.hudDocs.hudDocOf('scene');
+ const menu = doc.screens.find((sc) => sc.id === 'menu');
+ const el = menu.elements.find((e) => e.kind === 'button' && /start/i.test(String(e.label ?? e.text ?? '')));
+ if (!el) return null;
+ const r = s.hudDocs.rectInFrame(el, window.innerWidth, window.innerHeight);
+ return { x: r.left + r.w / 2, y: r.top + r.h / 2, id: el.id };
+ });
+ h.check(!!start, `premise: the Start button is in the document (${start?.id})`);
+ if (start) {
+ const under = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? null, start);
+ h.check(under !== 'BUTTON', `nothing button-shaped is under that spot in the editor (${under})`);
+ await page.mouse.click(start.x, start.y);
+ await page.waitForTimeout(900);
+ st = await snap(page);
+ h.check(st.state === 'menu', `clicking where Start used to be leaves the game on its menu (${st.state})`);
+ }
+
+ // =====================================================================
+ // 2. THE PREVIEW EYE: the author can still SEE the screens — inert
+ // =====================================================================
+ await page.locator('#game-chip-preview').click();
+ await h.eventually(() => snap(page), (v) => v.buttons > 0 && /TOWERS/.test(v.layerText), 'the preview eye paints the menu over the editor', 5000);
+ st = await snap(page);
+ h.check(st.preview === true, 'the eye IS the HUD editor\'s preview store (one switch, two surfaces)');
+ const inert = await page.evaluate(() => {
+ const b = [...document.querySelectorAll('#hud-layer button')].find((x) => /start/i.test(x.textContent ?? ''));
+ if (!b) return null;
+ const r = b.getBoundingClientRect();
+ const x = r.left + r.width / 2;
+ const y = r.top + r.height / 2;
+ const under = document.elementFromPoint(x, y);
+ return { tab: b.tabIndex, x, y, clickThrough: !b.contains(under), under: under?.tagName ?? null };
+ });
+ h.check(!!inert && inert.tab === -1, `the previewed Start is out of the tab order (${JSON.stringify(inert)})`);
+ h.check(!!inert && inert.clickThrough, `and the preview is a PICTURE of the menu: a click there reaches the viewport (${inert?.under})`);
+ // a real press on the previewed button's own pixel
+ if (inert) await page.mouse.click(inert.x, inert.y);
+ await page.waitForTimeout(900);
+ st = await snap(page);
+ h.check(st.state === 'menu', `pressing the PREVIEWED Start does nothing — buttons are inert outside Play (${st.state})`);
+ await page.locator('#game-chip-preview').click();
+ await h.eventually(() => snap(page), (v) => v.buttons === 0, 'the eye off hides the screens again', 5000);
+
+ // =====================================================================
+ // 3. ▶ TEST PLAY (solo): back to the menu, into Play, the Start screen
+ // =====================================================================
+ // leave the round somewhere Test play must undo: mid-game, with a local screen override
+ await page.evaluate(() => {
+ const s = window.__stores;
+ s.gameState.setGameState('playing');
+ s.hudDocs.showHudScreen('scene', 'pause');
+ });
+ await h.eventually(() => snap(page), (v) => v.state === 'playing', 'premise: the round is running before Test play', 4000);
+ await page.locator('#game-chip-test').click();
+ await h.eventually(() => snap(page), (v) => v.locked === true, 'Test play enters Play', 6000);
+ st = await snap(page);
+ h.check(st.state === 'menu', `...with the game reset to its menu (${st.state})`);
+ h.check(st.screen === 'menu', `...and the MENU screen showing, not the stale pause override (${st.screen})`);
+ await h.eventually(() => snap(page), (v) => /Start round/.test(v.layerText) && v.buttons > 0, 'the Start screen is in front of the player', 6000);
+ h.check(!st.chip, 'the chip is editor chrome — gone in Play');
+ await page.locator('#hud-layer button', { hasText: 'Start round' }).click();
+ await h.eventually(() => snap(page), (v) => v.state === 'playing' && v.screen === 'hud', 'Start (in Play) starts the round', 8000);
+ await page.keyboard.press('Escape');
+ await h.eventually(() => snap(page), (v) => v.locked !== true, 'Escape returns to the editor', 6000);
+ await h.eventually(() => snap(page), (v) => v.state === 'menu', 'ALONE, leaving play resets the round to its menu at once', 3000);
+ st = await snap(page);
+ h.check(st.buttons === 0 && !/Stack on the glowing pad/.test(st.layerText), `and no in-game HUD is left on the editor (${st.buttons} buttons)`);
+ h.check(/Game\s*·\s*menu/.test(st.chipText), `the chip reads the menu again (${st.chipText})`);
+
+ // the play button's right-click menu carries the same row
+ await page.locator('#play-button').click({ button: 'right' });
+ const row = page.locator('[role=menuitem]', { hasText: 'Test play (start from the menu)' });
+ await h.eventually(() => row.count(), (n) => n === 1, 'the play button right-click menu offers "Test play (start from the menu)"', 4000);
+ await row.first().click();
+ await h.eventually(() => snap(page), (v) => v.locked === true && v.state === 'menu', 'and the row enters Play on the menu', 6000);
+ await page.keyboard.press('Escape');
+ await h.eventually(() => snap(page), (v) => v.locked !== true, 'back to the editor', 6000);
+
+ // =====================================================================
+ // 4. TWO PEERS: the round ends when the LAST player leaves
+ // =====================================================================
+ const B = await h.setupPage(browser, 'B', { context: { viewport: { width: 1280, height: 720 } } });
+ if (!(await h.installModule(B, 'collectible'))) {
+ console.log('SKIP (section 4): no collectible.zip for the second peer');
+ await h.finish(browser);
+ return;
+ }
+ await h.connect(B, A);
+ await h.eventually(() => snap(B.page), (v) => v.isGame === true, 'B received the game (its HUD document)', 20000);
+ const both = async () => ({ a: await snap(page), b: await snap(B.page) });
+ // A (the host) Test-plays and starts; B joins play
+ await page.locator('#game-chip-test').click();
+ await h.eventually(() => snap(page), (v) => v.locked === true, 'A enters Play through Test play', 6000);
+ await page.locator('#hud-layer button', { hasText: 'Start round' }).click();
+ await h.eventually(() => both(), (v) => v.a.state === 'playing' && v.b.state === 'playing', 'Start in A\'s Play starts the round for both', 8000);
+ await B.page.evaluate(() => window.__stores.playMode.requestPlay());
+ await h.eventually(
+ () => page.evaluate(() => window.__stores.gamePresence.gamePresenceDebug().peers),
+ (m) => Object.values(m).includes('playing'),
+ 'A sees B in play',
+ 8000
+ );
+ // A leaves while B plays: the round stays
+ await page.keyboard.press('Escape');
+ await h.eventually(() => snap(page), (v) => v.locked !== true, 'A back in the editor', 6000);
+ await page.waitForTimeout(12000); // past the ten-second window: B is still playing
+ let v2 = await both();
+ h.check(v2.a.state === 'playing' && v2.b.state === 'playing', `A left while B plays: the round is still on for both (${v2.a.state}/${v2.b.state})`);
+ // B leaves too: nobody is in play, so the host commits the menu after the window
+ await B.page.keyboard.press('Escape');
+ await h.eventually(() => snap(B.page), (v) => v.locked !== true, 'B back in the editor', 6000);
+ await h.eventually(() => both(), (v) => v.a.state === 'menu' && v.b.state === 'menu', 'the LAST player leaving resets the round to menu on BOTH (the host writes it)', 20000);
+ const writes = await Promise.all([page, B.page].map((p) => p.evaluate(() => window.__stores.gamePresence.gamePresenceDebug().abandonWrites)));
+ h.check(writes[0] >= 1 && writes[1] === 0, `written by the host alone (A=${writes[0]}, B=${writes[1]})`);
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/game-loop.test.cjs b/tests/e2e/game-loop.test.cjs
index cc023995..a0d14096 100644
--- a/tests/e2e/game-loop.test.cjs
+++ b/tests/e2e/game-loop.test.cjs
@@ -82,7 +82,10 @@ h.run(async () => {
...withStates,
screens: withStates.screens.map((sc) => ({
...sc,
- showWhile: sc.id === hudScreen ? 'playing' : 'menu'
+ showWhile: sc.id === hudScreen ? 'playing' : 'menu',
+ // 30 P1: the menu is pressed IN PLAY now, so it frees the pointer the way every
+ // Games-tab menu does (21-E3's menu substate)
+ ...(sc.id === hudScreen ? {} : { input: 'menu' })
}))
});
await new Promise((r) => setTimeout(r, 600));
@@ -168,6 +171,10 @@ h.run(async () => {
// close the editor so the HUD renders in the viewport (D5's authoring rule)
window.__stores.hudEditorClose.set(true);
});
+ // 30 P1: a GAME's screens are drawn in PLAY, never over the editor (game-editor-flow),
+ // so both players press play first — which is also the only honest version of "the
+ // user's scenario": Start is a button inside the game, not a switch in the editor.
+ for (const p of [A, B]) await p.page.evaluate(() => window.__stores.playMode.requestPlay());
await page.waitForTimeout(1200);
const beforeA = await onScreen(A);
const beforeB = await onScreen(B);
@@ -238,10 +245,17 @@ h.run(async () => {
h.check(scoredB === 'Gems: 7', `and the peer derives the same string with no runtime message (${JSON.stringify(scoredB)})`);
// ---- 8. a LATE JOINER walks into a running game ------------------------
+ // A approves from the EDITOR (a peer cannot approve while in play); B stays in play,
+ // so the round keeps a player and the abandon watch leaves it running
+ await page.evaluate(() => window.__stores.playMode.exitPlay());
+ await page.waitForTimeout(600);
const C = await h.setupPage(browser, 'C');
await C.page.waitForFunction(() => !!window.__stores?.gameState, { timeout: 30000 });
await h.connect(C, A);
await C.page.waitForTimeout(3200);
+ // the joiner looks at the game the way a player does: from inside Play
+ await C.page.evaluate(() => window.__stores.playMode.requestPlay());
+ await C.page.waitForTimeout(1200);
const lateState = await gstate(C);
h.check(lateState.state === 'playing', `a late joiner arrives mid-game (${lateState.state})`);
h.check(lateState.vars?.gems === 7, `with the score already right (${lateState.vars?.gems})`);
@@ -286,6 +300,8 @@ h.run(async () => {
);
// ---- 9. the game ENDS, and everyone follows ----------------------------
+ await page.evaluate(() => window.__stores.playMode.requestPlay());
+ await page.waitForTimeout(600);
const ended = await page.evaluate(async () => {
const s = window.__stores;
s.gameState.setGameState('over', { outcome: 'won' });
diff --git a/tests/e2e/game-towers.test.cjs b/tests/e2e/game-towers.test.cjs
index ae72bd1f..e2a9ee2c 100644
--- a/tests/e2e/game-towers.test.cjs
+++ b/tests/e2e/game-towers.test.cjs
@@ -65,11 +65,13 @@ h.run(async () => {
h.check(crates.length === 9, `9 pre-placed dynamic crates (${crates.length})`);
h.check(st.play?.simOnPlay === true && st.play?.interaction === 'grab', 'play block: grab + simOnPlay');
h.check(st.state === 'menu' && st.screen === 'menu', `starts on the menu screen (${st.state}/${st.screen})`);
- h.check(/TOWERS/.test(await hud()), 'the menu renders (TOWERS)');
// 2 — entering play starts the sim (simOnPlay honoured from the file)
await page.evaluate(() => window.__stores.isLocked.set(true));
await h.eventually(() => snap().then((v) => v.sim), (v) => v === true, 'entering play starts the sim', 10000);
+ // 30 P1: a game's menu is drawn in PLAY, not over the editor (game-editor-flow covers
+ // the editor half), so the menu check lives on this side of the play press now
+ h.check(/TOWERS/.test(await hud()), 'the menu renders in play (TOWERS)');
// 3 — the Start button flips to playing and swaps the menu for the HUD
await clickBtn('Start round');
diff --git a/tests/e2e/game-untangle.test.cjs b/tests/e2e/game-untangle.test.cjs
index e45c2f8c..dc636cd0 100644
--- a/tests/e2e/game-untangle.test.cjs
+++ b/tests/e2e/game-untangle.test.cjs
@@ -203,7 +203,7 @@ h.run(async () => {
let a1 = await snap(A.page);
h.check(a1.dots === 5 + TEMPLATE_LEVEL, '1.7 level ' + TEMPLATE_LEVEL + ' has ' + (5 + TEMPLATE_LEVEL) + ' dots (' + a1.dots + ')');
h.check((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.8 the game shell starts in menu with the menu screen');
- h.check(/UNTANGLE/.test(await hudText(A.page)) && /Start/.test(await hudText(A.page)), '1.9 the menu renders its title and Start');
+ 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.9 in the editor the game chip stands in for the menu (30 P1: no live menu over the editor)', 6000);
// 1c — the SAME file loaded AGAIN (Templates modal, twice): the node's data did not change, so
// only a node re-armed by the scene clear applies its level again. Without that the second load
@@ -236,6 +236,7 @@ h.run(async () => {
await A.page.locator('#play-button').click();
await B.page.locator('#play-button').click();
await A.page.waitForTimeout(500);
+ await h.eventually(async () => (await hudText(A.page)) ?? '', (t) => /UNTANGLE/.test(t) && /Start/.test(t), '4.0 in play the menu renders its title and Start');
await hudButton(A.page, 'Start').click();
await h.eventually(() => gameStateOf(A.page), (v) => v === 'playing', '4.1 Start flips the shell to playing');
await h.eventually(() => screenOf(A.page), (v) => v === 'hud', '4.2 A sees the HUD screen', 6000);
diff --git a/tests/e2e/game-waves.test.cjs b/tests/e2e/game-waves.test.cjs
index 9755d98f..b6a18f6d 100644
--- a/tests/e2e/game-waves.test.cjs
+++ b/tests/e2e/game-waves.test.cjs
@@ -296,7 +296,7 @@ h.run(async () => {
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((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.9 the game shell starts in menu with the menu screen');
- h.check(/WAVES/.test(await hudText(A.page)) && /Start/.test(await hudText(A.page)), '1.10 the menu renders its title and Start');
+ 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);
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(', ') + ')');
@@ -316,6 +316,7 @@ h.run(async () => {
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(() => gameStateOf(B.page), (v) => v === 'playing', '3.3 ...and on B (the replicated state)');
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/module-storage.test.cjs b/tests/e2e/module-storage.test.cjs
new file mode 100644
index 00000000..a2ef2b58
--- /dev/null
+++ b/tests/e2e/module-storage.test.cjs
@@ -0,0 +1,274 @@
+// 30 P4 — GAMES REMEMBER THINGS ON THIS DEVICE (roadmap 30 fork 7).
+//
+// Two surfaces, one leaf (gameStorage.js):
+// · `api.storage` for modules — JSON under `tp:mod::` through safeStorage,
+// 256 KB per module (over it: `false` and ONE toast), LOCAL, surviving a reload.
+// · the Store Value / Stored Value flow nodes — `tp:scene::`,
+// acting on the trigger's stamp edge, sending NOTHING.
+//
+// Modules are driven INLINE through `moduleSDK.initModules` (the post-backends precedent: the
+// real makeApi path, no zip). The key format is asserted byte for byte against raw
+// localStorage, because a module that must also run on an older core (untangle's fallback)
+// writes the very same key itself.
+const h = require('./helpers.cjs');
+
+/** register two inline modules that park their api.storage on window @param {any} page */
+const registerModules = (page) =>
+ page.evaluate(async () => {
+ const sdk = window.__stores.moduleSDK;
+ /** @param {string} id */
+ const make = (id) => ({
+ id,
+ name: 'Storage test ' + id,
+ version: '1.0.0',
+ description: 'inline',
+ register(api) {
+ window['__' + id] = api.storage;
+ }
+ });
+ await sdk.initModules([make('stA'), make('stB')]);
+ return !!window.__stA && !!window.__stB;
+ });
+
+/** @param {any} page */
+const toastsMatching = (page, re) =>
+ page.evaluate((src) => {
+ let list = [];
+ window.__stores.toastStore.subscribe((v) => (list = v ?? []))();
+ const r = new RegExp(src);
+ return list.filter((t) => r.test(String(t?.message ?? t?.text ?? t ?? ''))).length;
+ }, re.source);
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+ const page = A.page;
+
+ // ===================================================================== 1. the api
+ h.check(await registerModules(page), 'two inline modules received api.storage');
+ const basics = await page.evaluate(() => {
+ const a = window.__stA;
+ const out = {};
+ out.setN = a.set('score', 42);
+ out.setO = a.set('progress', { mode2d: { unlocked: 3, solved: [1, 2] } });
+ out.setT = a.set('name', 'Ada');
+ out.getN = a.get('score');
+ out.getO = a.get('progress');
+ out.getT = a.get('name');
+ out.missing = a.get('nothing', 'fallback');
+ out.keys = a.keys();
+ out.raw = localStorage.getItem('tp:mod:stA:progress');
+ a.remove('name');
+ out.afterRemove = a.keys();
+ out.bytes = a.bytes();
+ return out;
+ });
+ h.check(basics.setN && basics.setO && basics.setT, 'set returns true for JSON values');
+ h.check(basics.getN === 42 && basics.getT === 'Ada', `get round-trips a number and text (${basics.getN}, ${basics.getT})`);
+ h.check(JSON.stringify(basics.getO) === '{"mode2d":{"unlocked":3,"solved":[1,2]}}', 'and an object');
+ h.check(basics.missing === 'fallback', 'a missing key hands back the fallback');
+ h.check(JSON.stringify(basics.keys) === '["name","progress","score"]', `keys() lists this module's keys, sorted (${basics.keys})`);
+ h.check(
+ basics.raw === '{"mode2d":{"unlocked":3,"solved":[1,2]}}',
+ `THE KEY CONTRACT: localStorage["tp:mod:stA:progress"] holds the JSON (${basics.raw})`
+ );
+ h.check(JSON.stringify(basics.afterRemove) === '["progress","score"]', 'remove() drops one key');
+ h.check(basics.bytes > 0, `bytes() measures what the module spends (${basics.bytes})`);
+
+ // ===================================================================== 2. two modules never meet
+ const apart = await page.evaluate(() => {
+ const a = window.__stA;
+ const b = window.__stB;
+ b.set('score', 7);
+ const view = { a: a.get('score'), b: b.get('score'), bKeys: b.keys() };
+ b.clear();
+ view.afterClear = { a: a.get('score'), bKeys: b.keys() };
+ return view;
+ });
+ h.check(apart.a === 42 && apart.b === 7, `the same key in two modules holds two values (${apart.a}, ${apart.b})`);
+ h.check(JSON.stringify(apart.bKeys) === '["score"]', "a module's keys() never lists another module's");
+ h.check(apart.afterClear.a === 42 && apart.afterClear.bKeys.length === 0, "clear() empties this module and leaves the other alone");
+
+ // ===================================================================== 3. the cap
+ const cap = await page.evaluate(() => {
+ const a = window.__stA;
+ const big = 'x'.repeat(300 * 1024);
+ const first = a.set('huge', big);
+ const second = a.set('huge2', big);
+ return { first, second, has: a.get('huge', null) !== null, cap: window.__stores.gameStorage.MODULE_STORAGE_CAP };
+ });
+ h.check(cap.first === false && cap.second === false, 'a write over 256 KB returns false');
+ h.check(!cap.has, 'and writes nothing');
+ h.check(cap.cap === 262144, `the cap is 256 KB (${cap.cap})`);
+ await page.waitForTimeout(300);
+ const capToasts = await toastsMatching(page, /storage limit/);
+ h.check(capToasts === 1, `ONE toast per module per session, not one per write (${capToasts})`);
+
+ // ===================================================================== 4. a failing localStorage
+ const failing = await page.evaluate(() => {
+ const a = window.__stA;
+ const original = Storage.prototype.setItem;
+ Storage.prototype.setItem = function () {
+ throw new DOMException('quota', 'QuotaExceededError');
+ };
+ let threw = false;
+ let ok = false;
+ let back = null;
+ try {
+ ok = a.set('offline', { still: 'here' });
+ back = a.get('offline');
+ } catch {
+ threw = true;
+ }
+ Storage.prototype.setItem = original;
+ return { threw, ok, back, raw: localStorage.getItem('tp:mod:stA:offline') };
+ });
+ h.check(!failing.threw, 'a throwing localStorage never throws out of api.storage');
+ h.check(failing.ok && failing.back?.still === 'here', 'the value applies for the session (memory fallback)');
+ h.check(failing.raw === null, 'and truly never reached localStorage');
+ h.check(h.pageErrors(A).length === 0, `no page error (${h.pageErrors(A).length})`);
+
+ // ===================================================================== 5. the flow nodes
+ const B = await h.setupPage(browser, 'B');
+ await h.connect(B, A);
+ const built = await page.evaluate(async () => {
+ const s = window.__stores;
+ s.levels.currentLevel.set({ hash: '', name: 'Arcade', unsaved: true });
+ const nodes = [
+ { id: 'svKey', type: 'keypress', position: { x: 0, y: 0 }, data: { type: 'keypress', code: 'KeyK', edge: 'down' }, class: 'w-[150px]' },
+ { id: 'svCount', type: 'counter', position: { x: 0, y: 200 }, data: { type: 'counter', op: 'up', step: 1 }, class: 'w-[150px]' },
+ { id: 'svStore', type: 'storevalue', position: { x: 300, y: 0 }, data: { type: 'storevalue', key: 'best', mode: 'max', value: 0 }, class: 'w-[150px]' },
+ { id: 'svRead', type: 'storedvalue', position: { x: 600, y: 0 }, data: { type: 'storedvalue', key: 'best', output: 'number', fallback: -1 }, class: 'w-[150px]' }
+ ];
+ const edges = [
+ { id: 'e-svKey-svCount.pulse', source: 'svKey', target: 'svCount', targetHandle: 'pulse' },
+ { id: 'e-svKey-svStore.trigger', source: 'svKey', target: 'svStore', targetHandle: 'trigger' },
+ { id: 'e-svCount-svStore.value', source: 'svCount', target: 'svStore', targetHandle: 'value' }
+ ];
+ s.flowGraphs.update((g) => ({ ...g, scene: { nodes, edges } }));
+ s.flowNodes.set(nodes);
+ s.flowEdges.set(edges);
+ await new Promise((r) => setTimeout(r, 800)); // past the nodes' first-seen (actionSeenAt)
+ return true;
+ });
+ h.check(built, 'a Counter -> Store Value (max) graph');
+ // count the wire while the node writes: Store Value must send NOTHING of its own
+ await page.evaluate(() => {
+ let peer;
+ window.__stores.peers.subscribe((v) => (peer = v))();
+ window.__sentTypes = [];
+ const send = peer.send.bind(peer);
+ peer.send = (msg) => {
+ window.__sentTypes.push(msg?.type);
+ return send(msg);
+ };
+ });
+ for (let i = 0; i < 3; i++) {
+ // a LOCAL pulse (this player's own key press): the counter and the store both act
+ await page.evaluate(() => window.__stores.flowRuntime.applyNodeTrigger('svKey', (Date.now() % 86400000) / 1000, false));
+ await page.waitForTimeout(700);
+ }
+ const flow = await page.evaluate(() => ({
+ raw: localStorage.getItem('tp:scene:Arcade:best'),
+ sent: window.__sentTypes
+ }));
+ h.check(flow.raw === '3', `three presses keep the best (3) under tp:scene:Arcade:best (${flow.raw})`);
+ h.check(!flow.sent.some((t) => /stor/i.test(String(t))), `Store Value put nothing on the wire (${JSON.stringify([...new Set(flow.sent)])})`);
+ // a LOWER value must not overwrite the max
+ await page.evaluate(() => {
+ const s = window.__stores;
+ s.flowGraphs.update((g) => ({
+ ...g,
+ scene: { ...g.scene, nodes: g.scene.nodes.map((n) => (n.id === 'svCount' ? { ...n, data: { ...n.data, step: -5 } } : n)) }
+ }));
+ });
+ await page.evaluate(() => window.__stores.flowRuntime.applyNodeTrigger('svKey', (Date.now() % 86400000) / 1000, false));
+ await page.waitForTimeout(700);
+ h.check((await page.evaluate(() => localStorage.getItem('tp:scene:Arcade:best'))) === '3', 'max mode never lowers the best');
+ const readNow = await page.evaluate(() => {
+ let v = {};
+ window.__stores.flowValues.subscribe((x) => (v = x))();
+ return v.svRead;
+ });
+ h.check(readNow === 3, `Stored Value reads it back (${readNow})`);
+ const onB = await B.page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('tp:scene:')));
+ h.check(onB.length === 0, `the second peer received NOTHING (${JSON.stringify(onB)})`);
+
+ // ===================================================================== 6. a reload keeps both halves
+ await h.freshReload(A);
+ await page.waitForFunction(() => window.__stores && !!window.__stores.moduleSDK, { timeout: 30000 });
+ await registerModules(page);
+ const after = await page.evaluate(async () => {
+ const s = window.__stores;
+ s.levels.currentLevel.set({ hash: '', name: 'Arcade', unsaved: true });
+ const nodes = [
+ { id: 'svRead2', type: 'storedvalue', position: { x: 0, y: 0 }, data: { type: 'storedvalue', key: 'best', output: 'number', fallback: -1 }, class: 'w-[150px]' },
+ { id: 'svText', type: 'storedvalue', position: { x: 0, y: 200 }, data: { type: 'storedvalue', key: 'best', output: 'text', fallback: '' }, class: 'w-[150px]' }
+ ];
+ s.flowGraphs.update((g) => ({ ...g, scene: { nodes, edges: [] } }));
+ s.flowNodes.set(nodes);
+ s.flowEdges.set([]);
+ await new Promise((r) => setTimeout(r, 900));
+ let v = {};
+ s.flowValues.subscribe((x) => (v = x))();
+ return { score: window.__stA.get('score'), num: v.svRead2, text: v.svText };
+ });
+ h.check(after.score === 42, `a module's value survives a reload (${after.score})`);
+ h.check(after.num === 3, `Stored Value reads the saved best after a reload (${after.num})`);
+ h.check(after.text === '3', `and as text (${JSON.stringify(after.text)})`);
+ const otherScene = await page.evaluate(async () => {
+ const s = window.__stores;
+ s.levels.currentLevel.set({ hash: '', name: 'Other game', unsaved: true });
+ await new Promise((r) => setTimeout(r, 500));
+ let v = {};
+ s.flowValues.subscribe((x) => (v = x))();
+ return v.svRead2;
+ });
+ h.check(otherScene === -1, `another scene's namespace does not see it (fallback ${otherScene})`);
+
+ // ===================================================================== 7. "Save best score" on a HUD button
+ const bind = await page.evaluate(async () => {
+ const s = window.__stores;
+ s.hudDocs.setHudDocFor('scene', {
+ active: 'main',
+ screens: [{ id: 'main', name: 'Main', elements: [{ id: 'saveBtn', kind: 'button', text: 'Save', anchor: 'center', x: 0, y: 0, w: 120, h: 40 }] }]
+ });
+ await new Promise((r) => setTimeout(r, 300));
+ const offered = s.hudActions.actionsForKind('button').some((a) => a.key === 'savebest');
+ const res = s.hudActions.addBinding('saveBtn', 'savebest');
+ await new Promise((r) => setTimeout(r, 900)); // past the fresh nodes' first-seen
+ let g;
+ s.flowGraphs.subscribe((v) => (g = v))();
+ const nodes = g.scene.nodes;
+ const edges = g.scene.edges;
+ const store = nodes.find((n) => n.type === 'storevalue');
+ const src = nodes.find((n) => n.type === 'getvariable');
+ const press = nodes.find((n) => n.type === 'hudbutton');
+ return {
+ offered,
+ ok: res.ok,
+ store: store?.data ?? null,
+ src: src?.data?.name ?? null,
+ trig: edges.some((e) => e.source === press?.id && e.target === store?.id && e.targetHandle === 'trigger'),
+ val: edges.some((e) => e.source === src?.id && e.target === store?.id && e.targetHandle === 'value'),
+ labels: s.hudActions.bindingsFor('saveBtn').map((b) => b.label)
+ };
+ });
+ h.check(bind.offered, 'a HUD button is offered "Save best score"');
+ h.check(bind.ok && bind.store?.key === 'best' && bind.store?.mode === 'max', `which builds a Store Value (best, max) (${JSON.stringify(bind.store)})`);
+ h.check(bind.src === 'score' && bind.val, 'fed by the score variable on its value socket');
+ h.check(bind.trig, 'and pressed by the button on its trigger socket');
+ h.check(bind.labels.some((l) => /Save .best. on this device/.test(l)), `the Actions pane says what it does (${JSON.stringify(bind.labels)})`);
+ await page.evaluate(() => {
+ window.__stores.gameState.setGameVar('score', 12);
+ window.__stores.flowRuntime.fireHudButton('saveBtn');
+ });
+ await h.eventually(
+ () => page.evaluate(() => localStorage.getItem('tp:scene:Other game:best')),
+ (v) => v === '12',
+ 'pressing it saves the score as this device\'s best',
+ 4000
+ );
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/play-free-cursor.test.cjs b/tests/e2e/play-free-cursor.test.cjs
new file mode 100644
index 00000000..4cd3b8fd
--- /dev/null
+++ b/tests/e2e/play-free-cursor.test.cjs
@@ -0,0 +1,255 @@
+// 30 P3 — FREE-CURSOR GAMES + NO EDITOR GRID IN PLAY (roadmap 30 fork 6).
+//
+// `scenePhysics.play.cursor: 'free'` enters play WITHOUT a pointer lock: the real cursor
+// stays visible and AIMS — a tap fires On Click at the object under the cursor, a
+// press-drag carries a dynamic body along the cursor ray — while the crosshair has nothing
+// to draw. The field is OMITTED at its default, so a scene that never uses it saves
+// byte-identically. Independently, the editor grid is never drawn in play (either cursor).
+//
+// Real input throughout: page.mouse at an object's PROJECTED pixel, with each target placed
+// OFF the view centre, so the crosshair ray (NDC 0,0) would miss it — a pass here cannot be
+// the old aim getting lucky.
+const h = require('./helpers.cjs');
+
+/** @param {any} page @param {string} body */
+const sp = (page, body) => page.evaluate((b) => new Function('sp', b)(window.__stores.scenePhysics), body);
+
+/** place an object in front of the ACTIVE camera, offset sideways (camera right) and up
+ * @param {any} page @param {string} uuid @param {number} ahead @param {number} right @param {number} [up] */
+const placeInView = (page, uuid, ahead, right, up = 0) =>
+ page.evaluate(
+ ([uuid, ahead, right, up]) => {
+ const THREE = window.__stores.THREE;
+ let camera = null;
+ let group = null;
+ window.__stores.globalCamera.subscribe((v) => (camera = v))();
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ const pos = camera.getWorldPosition(new THREE.Vector3());
+ const dir = camera.getWorldDirection(new THREE.Vector3());
+ const side = new THREE.Vector3().crossVectors(dir, camera.up).normalize();
+ const upv = new THREE.Vector3().crossVectors(side, dir).normalize();
+ const at = pos.addScaledVector(dir, ahead).addScaledVector(side, right).addScaledVector(upv, up);
+ const object = group.getObjectByProperty('uuid', uuid);
+ object.position.copy(at);
+ object.updateMatrixWorld();
+ window.__stores.objectsGroup.update((v) => v);
+ return at.toArray();
+ },
+ [uuid, ahead, right, up]
+ );
+
+/** @param {any} page @param {string} uuid */
+const posOf = (page, uuid) =>
+ page.evaluate((uuid) => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ return group.getObjectByProperty('uuid', uuid)?.position.toArray() ?? null;
+ }, uuid);
+
+/** @param {any} page */
+const counter = (page) =>
+ page.evaluate(() => {
+ let map = {};
+ window.__stores.flowTriggers.subscribe((v) => (map = v))();
+ return map.fcCount?.count ?? 0;
+ });
+
+/** @param {any} page */
+const status = (page) =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ let locked;
+ let scene;
+ s.isLocked.subscribe((v) => (locked = v))();
+ s.globalScene.subscribe((v) => (scene = v))();
+ let grid = null;
+ scene?.traverse((o) => {
+ if (o.name === 'editor-grid') grid = o;
+ });
+ return {
+ locked,
+ lockCalls: window.__lockCalls ?? 0,
+ lockElement: !!document.pointerLockElement,
+ reticle: !!document.querySelector('#play-reticle'),
+ free: s.playCursor.playCursorFree(),
+ grid: !!grid && grid.visible !== false
+ };
+ });
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ {
+ // rapier's wasm is lazy; warm the vite dep cache on a throwaway page first
+ 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: 1280, height: 720 } },
+ storage: { showGrid: 'true', helpersInPlay: 'false' }
+ });
+ const page = A.page;
+ // count every pointer-lock request the app makes, whatever element it asks for
+ await page.evaluate(() => {
+ window.__lockCalls = 0;
+ const original = Element.prototype.requestPointerLock;
+ Element.prototype.requestPointerLock = function (...args) {
+ window.__lockCalls++;
+ return original.apply(this, args);
+ };
+ });
+
+ const ids = await page.evaluate(() => {
+ const cmd = window.__stores.commandsHandler.sceneCommand;
+ let group = null;
+ cmd('/create box');
+ cmd('/create box');
+ cmd('/create box');
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ const [target, crate, floor] = group.children.slice(-3);
+ target.name = 'Target';
+ target.userData.physics = { mode: 'static' };
+ crate.name = 'Crate';
+ crate.userData.physics = { mode: 'dynamic', mass: 1 };
+ floor.name = 'Floor';
+ floor.scale.set(20, 0.2, 20);
+ floor.position.set(0, -0.1, 0);
+ floor.userData.physics = { mode: 'static' };
+ window.__stores.objectsGroup.update((v) => v);
+ return { target: target.uuid, crate: crate.uuid };
+ });
+ await page.waitForTimeout(1500);
+
+ // ===================================================================== 1. the field
+ const saved = await page.evaluate(() => {
+ const sp = window.__stores.scenePhysics;
+ sp.setScenePhysics({ gravity: -9 }); // a non-default scene, so a snapshot exists
+ const plain = sp.scenePhysicsSnapshot();
+ sp.setScenePhysics({ play: { cursor: 'locked' } });
+ const locked = sp.scenePhysicsSnapshot();
+ sp.setScenePhysics({ play: { cursor: 'nonsense' } });
+ const junk = sp.scenePhysicsSnapshot();
+ sp.setScenePhysics({ play: { cursor: 'free' } });
+ const free = sp.scenePhysicsSnapshot();
+ return { plain: plain?.play, locked: locked?.play, junk: junk?.play, free: free?.play };
+ });
+ h.check(!!saved.plain && !('cursor' in saved.plain), `a scene saved without the field has no cursor key (${JSON.stringify(saved.plain)})`);
+ h.check(!('cursor' in (saved.locked ?? {})), "'locked' is the default and is never written");
+ h.check(!('cursor' in (saved.junk ?? {})), 'an unknown value normalizes to the default (not written)');
+ h.check(saved.free?.cursor === 'free', `'free' is written (${JSON.stringify(saved.free)})`);
+ const resolved = await page.evaluate(() => window.__stores.playSettings.resolvePlaySettings(null).cursor);
+ h.check(resolved === 'free', `resolvePlaySettings carries it (${resolved})`);
+
+ // ===================================================================== 2. the grid, editor side
+ let st = await status(page);
+ h.check(st.grid, 'premise: the editor grid is drawn in the editor');
+
+ // ===================================================================== 3. enter play, free
+ await page.evaluate(() => window.__stores.physics.toggleSimulation());
+ await page.waitForTimeout(800);
+ await page.evaluate(() => window.__stores.playMode.requestPlay());
+ await h.eventually(() => status(page), (v) => v.locked === true, 'play mode is on', 5000);
+ await page.waitForTimeout(1200);
+ st = await status(page);
+ h.check(st.free, 'the cursor is free');
+ h.check(st.lockCalls === 0, `entering play asked for NO pointer lock (${st.lockCalls} requests)`);
+ h.check(!st.lockElement, 'and nothing holds one');
+ h.check(!st.reticle, 'the crosshair is not drawn (the cursor aims)');
+ h.check(!st.grid, 'the editor grid is hidden in play');
+
+ // ===================================================================== 4. a real click fires On Click
+ await page.evaluate((uuid) => {
+ const nodes = [
+ { id: 'fcClick', type: 'onclick', position: { x: 0, y: 0 }, data: { type: 'onclick', pulse: 0.3 }, class: 'w-[150px]' },
+ { id: 'fcSel', type: 'objectselector', position: { x: 300, y: 0 }, data: { type: 'objectselector', selected: uuid }, class: 'w-[150px]' },
+ { id: 'fcCount', type: 'counter', position: { x: 0, y: 200 }, data: { type: 'counter', op: 'up', step: 1 }, class: 'w-[150px]' }
+ ];
+ const edges = [
+ { id: 'e-fcClick-fcSel', source: 'fcClick', target: 'fcSel' },
+ { id: 'e-fcClick-fcCount', source: 'fcClick', target: 'fcCount' }
+ ];
+ window.__stores.flowGraphs.update((graphs) => ({ ...graphs, scene: { nodes, edges } }));
+ window.__stores.flowNodes.set(nodes);
+ window.__stores.flowEdges.set(edges);
+ }, ids.target);
+ // off-centre: 1.6 m right of the view axis at 5 m, so the crosshair would miss it
+ const targetAt = await placeInView(page, ids.target, 5, 1.6);
+ await page.waitForTimeout(600);
+ const tp = await h.projectPoint(page, targetAt);
+ const under = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? null, tp);
+ h.check(under === 'CANVAS', `premise: the target's pixel is the viewport (${under})`);
+ h.check(Math.abs(tp.x - 640) > 60, `premise: the target is off the view centre (${Math.round(tp.x)}px)`);
+ const before = await counter(page);
+ await page.mouse.move(tp.x, tp.y);
+ await page.mouse.down();
+ await page.waitForTimeout(90);
+ await page.mouse.up();
+ await h.eventually(() => counter(page), (n) => n > before, `a real click at the target's pixel fires its On Click (${before} -> ?)`, 3000);
+ const debugClick = await page.evaluate(() => window.__stores.playInteract.playInteractDebug().lastUp);
+ h.check(debugClick === 'click', `recorded as a click on an object (${debugClick})`);
+
+ // ===================================================================== 5. a real press-drag carries a body
+ const crateAt = await placeInView(page, ids.crate, 3, -0.9);
+ await page.waitForTimeout(500);
+ const cp = await h.projectPoint(page, crateAt);
+ await page.mouse.move(cp.x, cp.y);
+ await page.mouse.down();
+ await page.waitForTimeout(250);
+ const carrying = await page.evaluate(() => window.__stores.playInteract.playInteractDebug().carrying);
+ h.check(carrying === ids.crate, `a press on the crate's pixel grabs it (${carrying === ids.crate})`);
+ // drag the cursor 260px to the right; the carried crate follows the cursor ray
+ const startPos = await posOf(page, ids.crate);
+ for (let i = 1; i <= 10; i++) {
+ await page.mouse.move(cp.x + i * 26, cp.y);
+ await page.waitForTimeout(40);
+ }
+ await page.waitForTimeout(700);
+ const draggedPos = await posOf(page, ids.crate);
+ const moved = Math.hypot(draggedPos[0] - startPos[0], draggedPos[2] - startPos[2]);
+ const shown = await h.projectPoint(page, draggedPos);
+ h.check(moved > 0.5, `the crate travelled with the cursor (${moved.toFixed(2)} m)`);
+ h.check(Math.abs(shown.x - (cp.x + 260)) < 90, `and sits under it on screen (${Math.round(shown.x)}px vs cursor ${Math.round(cp.x + 260)}px)`);
+ await page.mouse.up();
+ await page.waitForTimeout(300);
+ const released = await page.evaluate(() => window.__stores.playInteract.playInteractDebug().carrying);
+ h.check(released === null, 'releasing lets go');
+
+ // a click on page CHROME is not a world gesture in a free-cursor game
+ const chromeClick = await page.evaluate(() => {
+ const before = window.__stores.playInteract.playInteractDebug().lastUp;
+ const b = document.createElement('button');
+ b.textContent = 'chrome';
+ b.style.cssText = 'position:fixed;left:4px;top:300px;z-index:99999';
+ document.body.appendChild(b);
+ const r = b.getBoundingClientRect();
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, before };
+ });
+ const beforeChrome = await counter(page);
+ await page.mouse.click(chromeClick.x, chromeClick.y);
+ await page.waitForTimeout(400);
+ h.check((await counter(page)) === beforeChrome, 'a press on page chrome is not taken as a world tap');
+
+ // ===================================================================== 6. Escape leaves
+ await page.keyboard.press('Escape');
+ await h.eventually(() => status(page), (v) => v.locked !== true, 'Escape leaves a free-cursor game', 5000);
+ await h.eventually(() => status(page), (v) => v.grid, 'the grid is back in the editor', 3000);
+
+ // ===================================================================== 7. locked: the lock IS asked for, the grid still hides
+ await sp(page, 'sp.setScenePhysics({ play: { cursor: "locked" } })');
+ await page.evaluate(() => {
+ window.__lockCalls = 0;
+ });
+ await page.waitForTimeout(2200); // past the exit settle
+ await page.evaluate(() => window.__stores.playMode.requestPlay());
+ await h.eventually(() => status(page), (v) => v.locked === true, 'play mode again, cursor locked', 5000);
+ await page.waitForTimeout(800);
+ st = await status(page);
+ h.check(st.lockCalls > 0, `a LOCKED game still asks for the pointer lock (${st.lockCalls}) — the spy works`);
+ h.check(!st.free, 'and reads as not free');
+ h.check(!st.grid, 'the editor grid is hidden in play with a locked cursor too');
+ await page.evaluate(() => window.__stores.playMode.exitPlay());
+ await page.waitForTimeout(500);
+
+ await h.finish(browser);
+});
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')
diff --git a/tests/unit/gameStorage.test.js b/tests/unit/gameStorage.test.js
new file mode 100644
index 00000000..1e9bbe8e
--- /dev/null
+++ b/tests/unit/gameStorage.test.js
@@ -0,0 +1,106 @@
+// 30 P4: the storage leaf with no browser — namespacing, JSON round trips, the cap, and a
+// broken entry never throwing. vitest runs in node, where localStorage does not exist, so
+// safeStorage serves every key from its in-memory fallback: the same path Safari private
+// mode takes, which is exactly the one worth covering here.
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ makeModuleStorage,
+ moduleStorageKey,
+ sceneStorageKey,
+ readStored,
+ writeStored,
+ MODULE_STORAGE_CAP,
+ debugResetGameStorage
+} from '../../src/lib/gameStorage.js';
+import { safeStorage, debugResetStorage } from '../../src/lib/safeStorage.js';
+
+beforeEach(() => {
+ for (const key of safeStorage.keys()) safeStorage.removeItem(key);
+ debugResetStorage();
+ debugResetGameStorage();
+});
+
+describe('the key contract', () => {
+ it('is byte-for-byte what a module fallback writes', () => {
+ expect(moduleStorageKey('untangle', 'progress')).toBe('tp:mod:untangle:progress');
+ expect(sceneStorageKey('Towers', 'best')).toBe('tp:scene:Towers:best');
+ expect(sceneStorageKey('', 'best')).toBe('tp:scene:untitled:best');
+ expect(sceneStorageKey(null, 'best')).toBe('tp:scene:untitled:best');
+ });
+});
+
+describe('makeModuleStorage', () => {
+ it('round-trips JSON values', () => {
+ const s = makeModuleStorage('a');
+ expect(s.set('n', 3)).toBe(true);
+ expect(s.set('o', { unlocked: 4, solved: [1, 2] })).toBe(true);
+ expect(s.set('t', 'hi')).toBe(true);
+ expect(s.get('n')).toBe(3);
+ expect(s.get('o')).toEqual({ unlocked: 4, solved: [1, 2] });
+ expect(s.get('t')).toBe('hi');
+ expect(safeStorage.getItem('tp:mod:a:o')).toBe('{"unlocked":4,"solved":[1,2]}');
+ });
+
+ it('hands back the fallback for a missing or unreadable key', () => {
+ const s = makeModuleStorage('a');
+ expect(s.get('missing', 7)).toBe(7);
+ safeStorage.setItem('tp:mod:a:broken', '{not json');
+ expect(s.get('broken', 'fb')).toBe('fb');
+ });
+
+ it('keeps two modules apart', () => {
+ const a = makeModuleStorage('a');
+ const b = makeModuleStorage('b');
+ a.set('score', 1);
+ b.set('score', 2);
+ expect(a.get('score')).toBe(1);
+ expect(b.get('score')).toBe(2);
+ expect(a.keys()).toEqual(['score']);
+ a.clear();
+ expect(a.keys()).toEqual([]);
+ expect(b.get('score')).toBe(2);
+ });
+
+ it('remove and keys', () => {
+ const s = makeModuleStorage('a');
+ s.set('x', 1);
+ s.set('y', 2);
+ expect(s.keys()).toEqual(['x', 'y']);
+ s.remove('x');
+ expect(s.keys()).toEqual(['y']);
+ expect(s.set('y', undefined)).toBe(true);
+ expect(s.keys()).toEqual([]);
+ });
+
+ it('refuses a write over the cap, once per call, and writes nothing', () => {
+ /** @type {any[]} */
+ const over = [];
+ const s = makeModuleStorage('a', { cap: 100, onOverCap: (info) => over.push(info) });
+ expect(s.set('small', 'x'.repeat(40))).toBe(true);
+ expect(s.set('big', 'x'.repeat(80))).toBe(false);
+ expect(over.length).toBe(1);
+ expect(s.get('big', null)).toBe(null);
+ // replacing a key measures the NEW value, not old + new
+ expect(s.set('small', 'y'.repeat(40))).toBe(true);
+ expect(MODULE_STORAGE_CAP).toBe(262144);
+ });
+
+ it('refuses what JSON cannot carry instead of throwing', () => {
+ const s = makeModuleStorage('a');
+ /** @type {any} */
+ const cyclic = {};
+ cyclic.self = cyclic;
+ expect(s.set('c', cyclic)).toBe(false);
+ expect(s.set('f', () => 1)).toBe(false);
+ });
+});
+
+describe('the scene half', () => {
+ it('writeStored/readStored share the cache with a direct read', () => {
+ const full = sceneStorageKey('Towers', 'best');
+ writeStored(full, 12);
+ expect(readStored(full, 0)).toBe(12);
+ debugResetGameStorage();
+ expect(readStored(full, 0)).toBe(12);
+ });
+});