From 49a374888a9c3a1adfd0c68cd1605198eeea345b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:38:22 +0300 Subject: [PATCH 001/104] [feat] 60 Q2 fast movement channel: unordered, unreliable second channel for pose streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fastChannel.js (leaf): a negotiated {ordered:false, maxRetransmits:0} data channel on each PeerJS connection's own RTCPeerConnection (id 7, no extra signaling), opened only when the other side advertises `fc: 1` in the modules handshake + a `fastchan` hello down the same conn - every pose message (camera, vrhands, move) carries `ps`, one monotonic counter per sender; receivers drop a pose older than the newest applied for that (sender, key) — on both channels and inside batch envelopes, so a late streamed move never overwrites the committed one - trailing keyframe: a stream's last pose is re-sent reliably (same ps) once the key goes quiet for 250 ms, to the peers that got it unreliably - streams marked fast: avatar camera (not when its extras change), VR hands (not on a rep switch), gizmo drag, physics bodies, VR held objects (not the release), play-mode grabs, possess rides; everything else stays on the reliable conn - graceful fallback: older peers / closed channel / >12 KB messages go reliable; kill switch safeStorage fastChannel=off; `fastchan` on the cloudHooks ALWAYS_ALLOWED floor - debug hook __stores.fastChannel (links, stale filter, keyframes, pose tap for the loss e2e) - unit: tests/unit/fastChannel.test.js (18) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 11 +- src/lib/cloudHooks.js | 3 + src/lib/fastChannel.js | 284 +++++++++++++++++++++++++++++++++ src/lib/peerHandler.svelte.js | 128 ++++++++++++++- src/lib/physics.js | 18 ++- src/lib/playInteract.js | 18 ++- src/lib/possess.js | 18 ++- src/lib/vr/grip.js | 18 ++- tests/unit/fastChannel.test.js | 231 +++++++++++++++++++++++++++ 9 files changed, 690 insertions(+), 39 deletions(-) create mode 100644 src/lib/fastChannel.js create mode 100644 tests/unit/fastChannel.test.js diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 9065cea28..c3526ac37 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -339,13 +339,15 @@ // kept rendering finger spheres). shouldSendHands forces it on a rep flip. const lens = [left?.joints?.length ?? 0, right?.joints?.length ?? 0]; if (!shouldSendHands({ moved, hasJoints, prevLens: lastSentJointLens, lens })) return; + // 60 Q2: a hands<->controllers switch is state (reliable); a pose of the same rep is stream + const sameRep = !!lastSentJointLens && lastSentJointLens[0] === lens[0] && lastSentJointLens[1] === lens[1]; lastSentJointLens = lens; lastHandsSendAt = now; for (let i = 0; i < 2; i++) { const pose = poses[i]; if (pose) lastHandPositions[i].fromArray(pose.pos); } - $peers.send({ type: 'vrhands', peerId: $peers.peer.id, left, right, active: true }); + $peers.send({ type: 'vrhands', peerId: $peers.peer.id, left, right, active: true }, { fast: sameRep }); } useTask((delta) => { @@ -414,7 +416,9 @@ nowMs - lastCameraSendAt >= camGapMs) { lastCameraSendAt = nowMs; camContentEuler.setFromQuaternion(camContentQuat); - $peers.send({ type: 'camera', peerId: $peers.peer.id, position: camContentPos.toArray(), rotation: [camContentEuler.x, camContentEuler.y, camContentEuler.z], ...camExtras }); + // 60 Q2: a pose of the stream rides the fast channel; a change of the extras (seat, + // feet, knocked…) is state and goes reliable, or a lost datagram could strand it + $peers.send({ type: 'camera', peerId: $peers.peer.id, position: camContentPos.toArray(), rotation: [camContentEuler.x, camContentEuler.y, camContentEuler.z], ...camExtras }, { fast: camExtrasKey === lastCameraExtras }); lastCameraExtras = camExtrasKey; lastCameraPosition.copy(camContentPos); lastCameraQuaternion.copy(camContentQuat); @@ -1732,13 +1736,14 @@ dropToSurface($TControls.object, $objectsGroup); } $TControls.visible = true; + // 60 Q2: the gizmo drag is a stream (fast); its keyframe lands the last pose $peers.send({ type: 'move', uuid: $TControls.object.uuid, pos: $TControls.object.position.toArray(), rot: $TControls.object.rotation.toArray(), scale: $TControls.object.scale.toArray() - }); + }, { fast: true }); } } diff --git a/src/lib/cloudHooks.js b/src/lib/cloudHooks.js index 5b09cd835..fa3cb43b8 100644 --- a/src/lib/cloudHooks.js +++ b/src/lib/cloudHooks.js @@ -31,6 +31,9 @@ const ALWAYS_ALLOWED = new Set([ 'getnodedefs', 'getjoints', 'getchat', // 37 R15: the chat-history REQUEST (its reply stays gateable, the getnodes rule) + // 60 Q2: "I opened the fast movement channel on this connection" — transport, no content. A + // gated hello would leave a viewer's avatar on the slow channel while the host thinks it is fast + 'fastchan', // A1/A2: WHERE A PEER IS STANDING. On the floor because it is the ROOM GATE'S OWN // EVIDENCE - a plugin that gated `atscene` would silently switch off scene adoption // AND every room gate built on it (`canApplyByRoom` reads no evidence, so it allows diff --git a/src/lib/fastChannel.js b/src/lib/fastChannel.js new file mode 100644 index 000000000..d20fb8f86 --- /dev/null +++ b/src/lib/fastChannel.js @@ -0,0 +1,284 @@ +// 60 Q2 — THE FAST MOVEMENT CHANNEL. +// +// Every message used to ride ONE PeerJS DataConnection per peer, which is ordered and reliable. +// That is what an edit needs and the opposite of what a pose stream needs: on a lossy link one +// lost packet holds back EVERYTHING behind it until SCTP retransmits it (head-of-line blocking), +// so an avatar that is sent 20 times a second freezes for a retransmit timeout and then jumps +// through a burst of poses that were all stale by the time they arrived. +// +// So each pair of peers opens a SECOND channel on the RTCPeerConnection they already share, +// `{ordered: false, maxRetransmits: 0}`: a lost pose is simply gone, and the next one (50 ms +// later) replaces it. Only latest-wins pose messages go there — avatars (`camera`), VR hands +// (`vrhands`) and the STREAMED `move`s of a drag, a held object or a physics body. Everything +// else — every edit, every committed move, every request — stays on the reliable channel. +// +// FOUR RULES: +// * A NEGOTIATED channel (`negotiated: true, id: FAST_CHANNEL_ID`) on the PeerJS connection's +// own RTCPeerConnection: no extra signaling, no second ICE run, no renegotiation (an SCTP +// association that exists takes more streams without one). Both sides must create it, so it +// is created only after the OTHER side said it can (`fc: 1` in the `modules` handshake, then +// a `fastchan` hello down the same conn so both ends pick the same RTCPeerConnection). +// * GRACEFUL FALLBACK: a peer that never said `fc: 1` (an older build), a channel that is not +// open yet, a message too big for one unreliable datagram — all go down the reliable conn +// exactly as before. The wire to an older peer is byte-identical except one additive field. +// * SEQUENCE NUMBERS: every pose message carries `ps`, one monotonic counter per sender. A +// receiver keeps the newest `ps` per (sender, key) and drops anything older — so an +// unordered datagram never moves an avatar backwards, and a late streamed `move` never +// overwrites the committed (reliable, newer) one that ended the drag. +// * A TRAILING KEYFRAME: the LAST pose of a stream may be the one that is lost, and nothing +// would ever correct it (a camera that stopped moving stops sending). So when a key goes +// quiet after a fast send, its last payload is re-sent once on the reliable channel with +// the SAME `ps` — dropped as a duplicate where the datagram arrived, applied where it didn't. +// +// Kill switch: `safeStorage.fastChannel = 'off'` (the e2e's "before" and "older build"). +import { safeStorage } from './safeStorage'; + +/** the SCTP stream id both ends give the negotiated channel (PeerJS's own channel is 0 or 1) */ +export const FAST_CHANNEL_ID = 7; +export const FAST_CHANNEL_LABEL = 'tp-fast'; +/** above this a message goes reliable: one unreliable message should fit a few datagrams, and a + * lost fragment loses the whole message */ +export const FAST_MAX_BYTES = 12000; +/** how long a key must be quiet before its trailing keyframe goes out */ +export const KEYFRAME_MS = 250; + +/** The pose types and how each is keyed: a camera/hands stream per sender, a move per object. */ +export const POSE_TYPES = new Set(['camera', 'vrhands', 'move']); + +/** @param {any} payload @returns {string | null} */ +export function poseKey(payload) { + const type = payload?.type; + if (type === 'camera' || type === 'vrhands') return type; + if (type === 'move' && typeof payload.uuid === 'string') return 'move:' + payload.uuid; + return null; +} + +/** Is the channel allowed on this device? @returns {boolean} */ +export function fastChannelEnabled() { + try { + return safeStorage.getItem('fastChannel') !== 'off'; + } catch { + return true; + } +} + +// Seeded from the clock (x100) so a RELOADED sender (same peer id, fresh counter) still starts +// above everything its previous page sent — up to 100k pose messages a second before it could +// outrun the clock. Receivers also forget a peer on disconnect. +let seq = Date.now() * 100; +/** @returns {number} */ +export function nextPoseSeq() { + return ++seq; +} + +/** + * Give a pose payload its `ps` (once — a keyframe re-send keeps the original). Non-pose + * payloads come back untouched. Returns the SAME object, stamped. + * @param {any} payload @returns {any} + */ +export function stampPose(payload) { + if ( + payload && + typeof payload === 'object' && + POSE_TYPES.has(payload.type) && + !Number.isFinite(payload.ps) + ) + payload.ps = nextPoseSeq(); + return payload; +} + +/** @type {((peerId: string, payload: any, via: 'fast' | 'reliable') => void) | null} */ +let poseTap = null; +/** + * A MEASUREMENT seam (the loss e2e): `fn` sees every pose that passed the stale filter, i.e. + * every pose the app applies, with the channel it came on. Null (the default) costs one check. + * @param {((peerId: string, payload: any, via: 'fast' | 'reliable') => void) | null} fn + */ +export function setPoseTap(fn) { + poseTap = typeof fn === 'function' ? fn : null; +} +/** @param {string} peerId @param {any} payload @param {'fast' | 'reliable'} via */ +export function tapPose(peerId, payload, via) { + if (poseTap && poseKey(payload) !== null) poseTap(peerId, payload, via); +} + +/** + * The receive-side stale filter. `accept` answers false for a pose older than (or equal to) + * the newest already applied from that sender for that key. A payload with no `ps` (an older + * build) is always accepted — and records nothing. + */ +export function createStaleFilter() { + /** `${peer}|${key}` -> newest ps @type {Map} */ + const newest = new Map(); + const stats = { accepted: 0, stale: 0 }; + return { + stats, + /** @param {string} peerId @param {any} payload @returns {boolean} */ + accept(peerId, payload) { + const ps = payload?.ps; + if (!Number.isFinite(ps)) return true; + const key = poseKey(payload); + if (!key) return true; + const id = peerId + '|' + key; + const last = newest.get(id); + if (last !== undefined && ps <= last) { + stats.stale++; + return false; + } + newest.set(id, ps); + stats.accepted++; + return true; + }, + /** @param {string} peerId */ + dropPeer(peerId) { + const prefix = peerId + '|'; + for (const id of [...newest.keys()]) if (id.startsWith(prefix)) newest.delete(id); + }, + clear() { + newest.clear(); + }, + size: () => newest.size + }; +} + +/** + * The trailing keyframes. `noteFast` (re)arms a key's timer with its newest payload and the + * peers that got it unreliably; `noteReliable` disarms it (a reliable send of the same key + * supersedes it). When a timer fires, `resend(payload, peerIds)` sends it on the reliable path. + * @param {(payload: any, peerIds: string[]) => void} resend + * @param {{delay?: number, setTimer?: (fn: () => void, ms: number) => any, clearTimer?: (t: any) => void}} [opts] + */ +export function createKeyframer(resend, opts = {}) { + const delay = opts.delay ?? KEYFRAME_MS; + const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms)); + const clearTimer = opts.clearTimer ?? ((t) => clearTimeout(t)); + /** key -> {timer, payload, peers} @type {Map}>} */ + const pending = new Map(); + const stats = { armed: 0, sent: 0 }; + return { + stats, + /** @param {string} key @param {any} payload @param {Iterable} peerIds */ + noteFast(key, payload, peerIds) { + const held = pending.get(key); + if (held) clearTimer(held.timer); + const peers = new Set(peerIds); + const entry = { payload, peers, timer: null }; + entry.timer = setTimer(() => { + if (pending.get(key) !== entry) return; + pending.delete(key); + stats.sent++; + resend(entry.payload, [...entry.peers]); + }, delay); + pending.set(key, entry); + stats.armed++; + }, + /** @param {string} key */ + noteReliable(key) { + const held = pending.get(key); + if (!held) return; + clearTimer(held.timer); + pending.delete(key); + }, + /** a peer left: it needs no keyframes @param {string} peerId */ + dropPeer(peerId) { + for (const entry of pending.values()) entry.peers.delete(peerId); + }, + clear() { + for (const entry of pending.values()) clearTimer(entry.timer); + pending.clear(); + }, + size: () => pending.size + }; +} + +/** + * The channels themselves, one per peer. `open(peerId, conn, onMessage)` creates the negotiated + * channel on `conn.peerConnection` (idempotent per RTCPeerConnection; a newer conn replaces an + * older one's channel). `trySend` answers false whenever the reliable path must carry it. + */ +export function createFastLinks() { + /** peerId -> {dc, pc} @type {Map} */ + const links = new Map(); + const stats = { opened: 0, sent: 0, received: 0, tooBig: 0, sendErrors: 0, parseErrors: 0 }; + /** @param {string} peerId */ + function close(peerId) { + const link = links.get(peerId); + if (!link) return; + links.delete(peerId); + try { + link.dc.close(); + } catch {} + } + return { + stats, + /** + * @param {string} peerId @param {any} conn a PeerJS DataConnection + * @param {(data: any, via: 'fast') => void} onMessage @returns {boolean} true when a channel exists now + */ + open(peerId, conn, onMessage) { + const pc = conn?.peerConnection; + if (!pc || typeof pc.createDataChannel !== 'function') return false; + const held = links.get(peerId); + if (held && held.pc === pc && held.dc.readyState !== 'closed') return true; + if (held) close(peerId); + let dc; + try { + dc = pc.createDataChannel(FAST_CHANNEL_LABEL, { + ordered: false, + maxRetransmits: 0, + negotiated: true, + id: FAST_CHANNEL_ID + }); + } catch { + return false; + } + const link = { dc, pc }; + links.set(peerId, link); + dc.onopen = () => { + stats.opened++; + }; + dc.onclose = () => { + if (links.get(peerId) === link) links.delete(peerId); + }; + dc.onmessage = (/** @type {MessageEvent} */ e) => { + if (typeof e.data !== 'string') return; + let data; + try { + data = JSON.parse(e.data); + } catch { + stats.parseErrors++; + return; + } + stats.received++; + onMessage(data, 'fast'); + }; + return true; + }, + /** @param {string} peerId @returns {boolean} */ + isOpen(peerId) { + return links.get(peerId)?.dc.readyState === 'open'; + }, + /** @param {string} peerId @param {string} text an already-serialized payload @returns {boolean} */ + trySend(peerId, text) { + const link = links.get(peerId); + if (!link || link.dc.readyState !== 'open') return false; + if (text.length > FAST_MAX_BYTES) { + stats.tooBig++; + return false; + } + try { + link.dc.send(text); + stats.sent++; + return true; + } catch { + stats.sendErrors++; + return false; + } + }, + close, + closeAll() { + for (const peerId of [...links.keys()]) close(peerId); + }, + peers: () => [...links.keys()] + }; +} diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 4e194783f..ba0cbd62f 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -30,6 +30,9 @@ import { canApply, getAuthProvider, dispatchCloudMessage, rolesInfo } from '$lib // dispatcher can reject a malformed message before any applier sees it. import { validateWireMessage } from '$lib/wireValidate'; import { holdForWireBatch } from '$lib/wireBatch'; +// 60 Q2: the fast movement channel (an unordered, unreliable second channel for pose streams) +import { createFastLinks, createStaleFilter, createKeyframer, stampPose, poseKey, fastChannelEnabled, setPoseTap, tapPose } from '$lib/fastChannel'; +import { registerDebugHook } from '$lib/debugHooks'; import { routePerfLive, perfLivePeerGone } from '$lib/perf/liveWire.js'; // 34 PF: the live perf stream (an import-free leaf) import { noteWireError } from '$lib/wireErrors'; import { noteWire } from '$lib/sceneBudget'; @@ -295,6 +298,20 @@ export class PeerConnection { this.dialCloud = new Map(); /** 25-F: peers a refusal dial is out to — their `peer-unavailable` is not news @type {Set} */ this.refusalDials = new Set(); + // 60 Q2: the fast movement channel (fastChannel.js). `fastLinks` = one unreliable channel per + // peer that advertised `fc: 1`; `staleFilter` drops a pose older than the newest applied from + // its sender; `keyframer` re-sends a stream's last pose reliably once the stream goes quiet. + this.fastLinks = createFastLinks(); + this.staleFilter = createStaleFilter(); + this.keyframer = createKeyframer((payload, peerIds) => this.sendReliableTo(payload, peerIds)); + registerDebugHook('fastChannel', { + links: this.fastLinks, + stale: this.staleFilter, + keyframes: this.keyframer, + enabled: fastChannelEnabled, + isOpen: (/** @type {string} */ peerId) => this.fastLinks.isOpen(peerId), + setPoseTap + }); // CN-3: an invite link can pin the signaling world (#A1B2C~srv=…). Parse it // HERE, before resolvePeerOptions runs — the peer.on('open') hash flow below @@ -834,6 +851,7 @@ export class PeerConnection { noteWireError(conn.peer, 'invalid:' + item.type); continue; } + if (!this.staleFilter.accept(conn.peer, item)) continue; // 60 Q2, as for a bare message try { dispatch(item); } catch (error) { @@ -1284,8 +1302,14 @@ export class PeerConnection { // 37 R1: `wb` is ADDITIVE — an older peer omits it and keeps getting bare messages if (data.wb === 1) this.wireBatchPeers.add(conn.peer); else this.wireBatchPeers.delete(conn.peer); + // 60 Q2: `fc` is ADDITIVE too — they can take a fast channel, so open ours on THIS + // conn and say so down it (`fastchan`), so both ends pick the same RTCPeerConnection + if (data.fc === 1 && this.openFastChannel(conn)) conn.send({ type: 'fastchan', v: 1 }); checkModuleVersions(data.versions); checkPeerAppVersion(data.appVersion); + } else if(data.type == 'fastchan') { + // 60 Q2: the other end opened its fast channel on this conn's RTCPeerConnection + this.openFastChannel(conn); } else if(data.type == 'getmodulestate') { sendModuleStates(data.sender); sendCameraPreviewState(); // 16-P5: ride the same late-joiner request @@ -1375,7 +1399,9 @@ export class PeerConnection { } }; - conn.on('data', (data) => { + // 60 Q2: ONE receive path for both channels — the reliable conn and the fast channel + // opened on its RTCPeerConnection (`openFastChannel` reads it off the conn) + const receive = (/** @type {any} */ data, /** @type {'fast' | 'reliable'} */ via = 'reliable') => { // 27-A (audit H1): SHAPE FIRST, before any gate reads `data.type`. A null, a // string or a number used to fall through the whole chain to // `data.startsWith(...)` and throw out of the handler, where peerjs swallowed @@ -1396,13 +1422,20 @@ export class PeerConnection { noteWireError(conn.peer, 'invalid:' + data.type); return; } + // 60 Q2: a pose older than the newest one applied from this sender is stale — an + // unordered datagram overtaken by the next one, or a streamed move that lost the race + // to the committed (reliable, newer) move that ended the drag + if (!this.staleFilter.accept(conn.peer, data)) return; + tapPose(conn.peer, data, via); try { dispatch(data); } catch (error) { // One bad message must not take this connection's handler down with it. noteWireError(conn.peer, data.type, error); } - }); + }; + conn.__tpReceive = receive; + conn.on('data', (/** @type {any} */ data) => receive(data)); } } @@ -1525,7 +1558,7 @@ export class PeerConnection { conn.send({type: 'userdata', userdata: users}) // V3: app version rides the modules handshake — old peers ignore the extras, // old senders omit them (checkPeerAppVersion is silent on absence) - conn.send({type: 'modules', versions: moduleVersions(), appVersion: APP_VERSION, sha: COMMIT_SHA, wb: 1}) + conn.send({type: 'modules', versions: moduleVersions(), appVersion: APP_VERSION, sha: COMMIT_SHA, wb: 1, ...(fastChannelEnabled() ? { fc: 1 } : {})}) // 37 R15: the chat so far. Chat is MESH-WIDE (not a room's content), so this goes out // whatever the connect decision / privacy hold below decides about the scene. conn.send({type: 'getchat', sender: this.peer.id}) @@ -1861,6 +1894,7 @@ export class PeerConnection { try { conn.close(); } catch (e) { /* already dead */ } } this.openedPeers.delete(peerId); + this.dropFastPeer(peerId); // 60 Q2 handleDisconnected(peerId); clearPeerPreview(peerId); // 16-P5 perfLivePeerGone(peerId); // 34 PF @@ -1892,6 +1926,10 @@ export class PeerConnection { this.reconnecting.clear(); // pending retry timers see a cleared map and no-op this.gracefulLeft.clear(); this.wantsStateFrom.clear(); + // 60 Q2: every fast channel, sequence and owed keyframe goes with the session + this.fastLinks.closeAll(); + this.staleFilter.clear(); + this.keyframer.clear(); for (const peerId of Object.keys(this.connections)) { const conn = this.connections[peerId]; delete this.connections[peerId]; @@ -1927,8 +1965,15 @@ export class PeerConnection { // Broadcast to every OPEN outgoing connection. Guards conn.open (peerjs // silently drops pre-open sends) and isolates each send so one dead/half-open // conn can't throw mid-loop and starve the rest of the mesh (172). - /** @param {any} payload */ - broadcast(payload) { + /** + * @param {any} payload + * @param {{fast?: boolean}} [opts] 60 Q2: `fast` = a latest-wins pose of a STREAM (a drag, a held + * object, a physics body, the avatar) that may ride the unreliable fast channel to peers that + * have one. Only pose types honour it (`poseKey`); everything else is reliable whatever it says. + */ + broadcast(payload, opts) { + // 60 Q2: every pose carries its sequence number — batched, fast or reliable alike + stampPose(payload); // 37 R1: inside a multi-object edit, room content is HELD and leaves as one envelope // when the edit closes (wireBatch.js); anything else flushes the held edit first if (holdForWireBatch(payload, ROOM_SCOPED.has(payload?.type), (out) => this.broadcast(out))) return; @@ -1978,6 +2023,13 @@ export class PeerConnection { // would ask round 35's question, which is why it is passed unconditionally. const host = gated ? get(sessionHost) : null; const secret = roomScoped && amPrivate(); + // 60 Q2: which peers took it unreliably (they are owed the trailing keyframe) + const key = poseKey(payload); + const fast = !!opts?.fast && key !== null && fastChannelEnabled(); + /** @type {string | null} */ + let text = null; + /** @type {string[]} */ + const fastPeers = []; Object.keys(this.connections).forEach(peerId => { const conn = this.connections[peerId]; if (!conn || !conn.open) return; @@ -1985,6 +2037,19 @@ export class PeerConnection { if (gated && elsewhereThan(where, mine, peerId, host)) return; if (roomScoped && gateHolds(peerId)) return; try { + if (fast && this.fastLinks.isOpen(peerId)) { + if (text === null) { + try { + text = JSON.stringify(payload); + } catch { + text = ''; + } + } + if (text && this.fastLinks.trySend(peerId, text)) { + fastPeers.push(peerId); + return; + } + } // 37 R1: a peer that never said `wb: 1` gets the envelope's items one by one if (payload?.type === 'batch' && !this.wireBatchPeers.has(peerId)) for (const item of payload.items) conn.send(item); @@ -1993,6 +2058,53 @@ export class PeerConnection { log('error', 'net', 'send failed', { peer: peerId, error: String(err) }); } }); + if (key !== null) { + if (fastPeers.length) this.keyframer.noteFast(key, payload, fastPeers); + else this.keyframer.noteReliable(key); + } + } + + /** + * 60 Q2: the trailing keyframe — a stream's LAST pose, re-sent on the reliable conn to the peers + * that got it unreliably, with its original `ps` (a duplicate where the datagram arrived). + * Re-checks the gates `broadcast` applies: the peer may have walked into another scene since. + * @param {any} payload @param {string[]} peerIds + */ + sendReliableTo(payload, peerIds) { + const mine = myScene()?.scene ?? ''; + const where = get(peerScenes); + const host = get(sessionHost); + for (const peerId of peerIds) { + const conn = this.connections[peerId]; + if (!conn || !conn.open) continue; + if (STREAM_TYPES.has(payload?.type) || ROOM_SCOPED.has(payload?.type)) { + if (elsewhereThan(where, mine, peerId, host)) continue; + } + if (ROOM_SCOPED.has(payload?.type) && (amPrivate() || gateHolds(peerId))) continue; + try { + conn.send(payload); + } catch (err) { + log('error', 'net', 'send failed', { peer: peerId, error: String(err) }); + } + } + } + + /** + * 60 Q2: open the fast channel to `conn.peer` on `conn`'s RTCPeerConnection (idempotent). + * Answers true when it created or already had one there; false when this device has the + * channel switched off or the conn exposes no RTCPeerConnection (the reliable path carries on). + * @param {any} conn @returns {boolean} + */ + openFastChannel(conn) { + if (!fastChannelEnabled() || !conn?.peer || typeof conn.__tpReceive !== 'function') return false; + return this.fastLinks.open(conn.peer, conn, conn.__tpReceive); + } + + /** 60 Q2: forget a peer's fast channel, its sequence numbers and its owed keyframes @param {string} peerId */ + dropFastPeer(peerId) { + this.fastLinks.close(peerId); + this.staleFilter.dropPeer(peerId); + this.keyframer.dropPeer(peerId); } /** @param {string} message @param {string} [type] */ @@ -2009,14 +2121,14 @@ export class PeerConnection { } } - /** @param {any} data */ - send(data) { + /** @param {any} data @param {{fast?: boolean}} [opts] 60 Q2: see `broadcast` */ + send(data, opts) { // viewer perms: a viewer's object CREATIONS never leave this machine — mark // them local-only + warn, and skip the broadcast (peers drop them anyway). if (gateCreationBroadcast(data)) return; if(data.type == 'create') this.sendMessage('created a ' + data.command.split(' ')[1], 'info'); - this.broadcast(data); + this.broadcast(data, opts); } } diff --git a/src/lib/physics.js b/src/lib/physics.js index b03b944bf..4ee66d466 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -1935,13 +1935,17 @@ function stepInner(now) { entry.lastSent ??= { pos: new THREE.Vector3(), quat: new THREE.Quaternion() }; entry.lastSent.pos.copy(object.position); entry.lastSent.quat.copy(object.quaternion); - peer.send({ - type: 'move', - uuid: object.uuid, - pos: object.position.toArray(), - rot: [object.rotation.x, object.rotation.y, object.rotation.z], - scale: object.scale.toArray() - }); + // 60 Q2: a body's pose stream rides the fast channel + peer.send( + { + type: 'move', + uuid: object.uuid, + pos: object.position.toArray(), + rot: [object.rotation.x, object.rotation.y, object.rotation.z], + scale: object.scale.toArray() + }, + { fast: true } + ); } } }); diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js index 4cf3925de..ba465f057 100644 --- a/src/lib/playInteract.js +++ b/src/lib/playInteract.js @@ -610,14 +610,18 @@ export function tickPlayInteract(delta, camera) { // deviation detector skips and the write-back excludes, so there is no // fight; when we are not, these moves refresh the EXTERNAL hold, which is // pre-existing behaviour simply fed at a usable rate + // 60 Q2: a held object's stream rides the fast channel if (peer) - peer.send({ - type: 'move', - uuid: grab.object.uuid, - pos: grab.object.position.toArray(), - rot: [grab.object.rotation.x, grab.object.rotation.y, grab.object.rotation.z], - scale: grab.object.scale.toArray() - }); + peer.send( + { + type: 'move', + uuid: grab.object.uuid, + pos: grab.object.position.toArray(), + rot: [grab.object.rotation.x, grab.object.rotation.y, grab.object.rotation.z], + scale: grab.object.scale.toArray() + }, + { fast: true } + ); } pokeScene(); return; diff --git a/src/lib/possess.js b/src/lib/possess.js index f74ca92e5..2cdbb0ad0 100644 --- a/src/lib/possess.js +++ b/src/lib/possess.js @@ -246,14 +246,18 @@ function tick(now) { state.lastSent = now; /** @type {any} */ const peer = get(peers); + // 60 Q2: the ride's pose stream rides the fast channel if (peer) - peer.send({ - type: 'move', - uuid: object.uuid, - pos: object.position.toArray(), - rot: [object.rotation.x, object.rotation.y, object.rotation.z], - scale: object.scale.toArray() - }); + peer.send( + { + type: 'move', + uuid: object.uuid, + pos: object.position.toArray(), + rot: [object.rotation.x, object.rotation.y, object.rotation.z], + scale: object.scale.toArray() + }, + { fast: true } + ); } } diff --git a/src/lib/vr/grip.js b/src/lib/vr/grip.js index a9b7fc95a..064b6311f 100644 --- a/src/lib/vr/grip.js +++ b/src/lib/vr/grip.js @@ -116,14 +116,18 @@ export function broadcastMove(object, force = false) { lastMoveSent = now; /** @type {any} */ const peer = get(peers); + // 60 Q2: the held stream is fast; a FORCED send (the release) is the pose that must land if (peer) - peer.send({ - type: 'move', - uuid: object.uuid, - pos: object.position.toArray(), - rot: object.rotation.toArray(), - scale: object.scale.toArray() - }); + peer.send( + { + type: 'move', + uuid: object.uuid, + pos: object.position.toArray(), + rot: object.rotation.toArray(), + scale: object.scale.toArray() + }, + { fast: !force } + ); } /** @param {any} object @param {any} before null = a PLAYER's grab (Interact): moved and diff --git a/tests/unit/fastChannel.test.js b/tests/unit/fastChannel.test.js new file mode 100644 index 000000000..1a9445fab --- /dev/null +++ b/tests/unit/fastChannel.test.js @@ -0,0 +1,231 @@ +// @ts-nocheck — fake RTCPeerConnection / timer fixtures; the module itself is typed +import { describe, it, expect } from 'vitest'; +import { + poseKey, + stampPose, + nextPoseSeq, + createStaleFilter, + createKeyframer, + createFastLinks, + FAST_CHANNEL_ID, + FAST_MAX_BYTES +} from '../../src/lib/fastChannel.js'; + +// 60 Q2: the fast movement channel's pure parts — keys, sequence numbers, the stale filter, +// the trailing keyframe and the channel bookkeeping (with a fake RTCPeerConnection). + +describe('poseKey', () => { + it('keys camera/hands per sender stream and moves per object', () => { + expect(poseKey({ type: 'camera', peerId: 'a' })).toBe('camera'); + expect(poseKey({ type: 'vrhands' })).toBe('vrhands'); + expect(poseKey({ type: 'move', uuid: 'u1' })).toBe('move:u1'); + }); + it('answers null for everything that is not a latest-wins pose', () => { + expect(poseKey({ type: 'object' })).toBeNull(); + expect(poseKey({ type: 'move' })).toBeNull(); + expect(poseKey(null)).toBeNull(); + }); +}); + +describe('stampPose', () => { + it('stamps poses with a monotonic ps, once', () => { + const a = stampPose({ type: 'move', uuid: 'x' }); + const b = stampPose({ type: 'camera' }); + expect(b.ps).toBeGreaterThan(a.ps); + const keep = a.ps; + stampPose(a); // a keyframe re-send keeps its original number + expect(a.ps).toBe(keep); + }); + it('leaves non-pose payloads untouched', () => { + const edit = { type: 'object', uuid: 'x' }; + stampPose(edit); + expect('ps' in edit).toBe(false); + }); + it('starts above the clock x100 so a reloaded sender outruns its previous page', () => { + expect(nextPoseSeq()).toBeGreaterThan((Date.now() - 60_000) * 100); + }); +}); + +describe('createStaleFilter', () => { + it('drops a pose older than (or equal to) the newest applied for that sender + key', () => { + const f = createStaleFilter(); + expect(f.accept('A', { type: 'camera', ps: 10 })).toBe(true); + expect(f.accept('A', { type: 'camera', ps: 9 })).toBe(false); // overtaken datagram + expect(f.accept('A', { type: 'camera', ps: 10 })).toBe(false); // keyframe duplicate + expect(f.accept('A', { type: 'camera', ps: 11 })).toBe(true); + expect(f.stats).toEqual({ accepted: 2, stale: 2 }); + }); + it('keeps keys apart: per object, per sender, per type', () => { + const f = createStaleFilter(); + expect(f.accept('A', { type: 'move', uuid: 'u1', ps: 50 })).toBe(true); + expect(f.accept('A', { type: 'move', uuid: 'u2', ps: 5 })).toBe(true); + expect(f.accept('B', { type: 'move', uuid: 'u1', ps: 5 })).toBe(true); + expect(f.accept('A', { type: 'camera', ps: 5 })).toBe(true); + }); + it('a late streamed move loses to the committed move that ended the drag', () => { + const f = createStaleFilter(); + const streamed = { type: 'move', uuid: 'u', ps: 100 }; + const committed = { type: 'move', uuid: 'u', ps: 101 }; + expect(f.accept('A', committed)).toBe(true); + expect(f.accept('A', streamed)).toBe(false); + }); + it('always accepts an unstamped message (an older build) and records nothing', () => { + const f = createStaleFilter(); + expect(f.accept('A', { type: 'move', uuid: 'u' })).toBe(true); + expect(f.accept('A', { type: 'move', uuid: 'u' })).toBe(true); + expect(f.size()).toBe(0); + expect(f.accept('A', { type: 'object', ps: 1 })).toBe(true); // not a pose + }); + it('forgets a peer on disconnect (a fresh page may restart lower)', () => { + const f = createStaleFilter(); + f.accept('A', { type: 'camera', ps: 100 }); + f.accept('B', { type: 'camera', ps: 100 }); + f.dropPeer('A'); + expect(f.accept('A', { type: 'camera', ps: 1 })).toBe(true); + expect(f.accept('B', { type: 'camera', ps: 1 })).toBe(false); + }); +}); + +/** a manual clock for the keyframer */ +function fakeTimers() { + let now = 0; + const timers = new Map(); + let id = 0; + return { + setTimer: (fn, ms) => (timers.set(++id, { fn, at: now + ms }), id), + clearTimer: (t) => timers.delete(t), + advance(ms) { + now += ms; + for (const [t, v] of [...timers]) if (v.at <= now) (timers.delete(t), v.fn()); + }, + pending: () => timers.size + }; +} + +describe('createKeyframer', () => { + it('re-sends the LAST fast pose once the key goes quiet, to the peers that got it fast', () => { + const t = fakeTimers(); + const sent = []; + const k = createKeyframer((p, peers) => sent.push([p.ps, peers]), { delay: 250, ...t }); + k.noteFast('camera', { ps: 1 }, ['B']); + t.advance(100); + k.noteFast('camera', { ps: 2 }, ['B', 'C']); + t.advance(200); + expect(sent).toEqual([]); // still streaming + t.advance(60); + expect(sent).toEqual([[2, ['B', 'C']]]); + t.advance(1000); + expect(sent.length).toBe(1); // once + }); + it('a reliable send of the same key supersedes the keyframe', () => { + const t = fakeTimers(); + const sent = []; + const k = createKeyframer((p) => sent.push(p), { delay: 250, ...t }); + k.noteFast('move:u', { ps: 1 }, ['B']); + k.noteReliable('move:u'); + t.advance(1000); + expect(sent).toEqual([]); + expect(t.pending()).toBe(0); + }); + it('keys are independent; a departed peer is owed nothing; clear disarms all', () => { + const t = fakeTimers(); + const sent = []; + const k = createKeyframer((p, peers) => sent.push([p.k, peers]), { delay: 10, ...t }); + k.noteFast('move:a', { k: 'a' }, ['B', 'C']); + k.noteFast('move:b', { k: 'b' }, ['B']); + k.dropPeer('C'); + t.advance(20); + expect(sent).toEqual([ + ['a', ['B']], + ['b', ['B']] + ]); + k.noteFast('move:c', { k: 'c' }, ['B']); + k.clear(); + t.advance(20); + expect(sent.length).toBe(2); + }); +}); + +/** a fake RTCPeerConnection whose channels open on demand */ +function fakePc() { + const made = []; + return { + made, + createDataChannel(label, init) { + const dc = { + label, + init, + readyState: 'connecting', + sent: [], + send(t) { + this.sent.push(t); + }, + close() { + this.readyState = 'closed'; + this.onclose?.(); + } + }; + made.push(dc); + return dc; + } + }; +} + +describe('createFastLinks', () => { + it('creates ONE negotiated unordered, no-retransmit channel per RTCPeerConnection', () => { + const links = createFastLinks(); + const pc = fakePc(); + const conn = { peerConnection: pc }; + expect(links.open('B', conn, () => {})).toBe(true); + expect(links.open('B', conn, () => {})).toBe(true); // idempotent + expect(pc.made.length).toBe(1); + expect(pc.made[0].init).toEqual({ + ordered: false, + maxRetransmits: 0, + negotiated: true, + id: FAST_CHANNEL_ID + }); + }); + it('falls back (false) until the channel is open, and for an oversized message', () => { + const links = createFastLinks(); + const pc = fakePc(); + links.open('B', { peerConnection: pc }, () => {}); + expect(links.trySend('B', '{}')).toBe(false); // connecting + pc.made[0].readyState = 'open'; + pc.made[0].onopen?.(); + expect(links.trySend('B', '{"type":"camera"}')).toBe(true); + expect(links.trySend('B', 'x'.repeat(FAST_MAX_BYTES + 1))).toBe(false); + expect(links.trySend('Z', '{}')).toBe(false); // no channel to that peer + expect(links.stats.tooBig).toBe(1); + }); + it('a newer connection replaces the old channel; a conn without an RTCPeerConnection is refused', () => { + const links = createFastLinks(); + const pc1 = fakePc(); + const pc2 = fakePc(); + links.open('B', { peerConnection: pc1 }, () => {}); + links.open('B', { peerConnection: pc2 }, () => {}); + expect(pc1.made[0].readyState).toBe('closed'); + expect(links.peers()).toEqual(['B']); + expect(links.open('C', {}, () => {})).toBe(false); + }); + it('parses JSON text into the receive path and ignores junk', () => { + const links = createFastLinks(); + const pc = fakePc(); + const got = []; + links.open('B', { peerConnection: pc }, (d) => got.push(d)); + pc.made[0].onmessage({ data: '{"type":"move","uuid":"u","ps":3}' }); + pc.made[0].onmessage({ data: 'not json' }); + pc.made[0].onmessage({ data: new ArrayBuffer(2) }); + expect(got).toEqual([{ type: 'move', uuid: 'u', ps: 3 }]); + expect(links.stats.parseErrors).toBe(1); + }); + it('close / closeAll drop the links', () => { + const links = createFastLinks(); + links.open('B', { peerConnection: fakePc() }, () => {}); + links.open('C', { peerConnection: fakePc() }, () => {}); + links.close('B'); + expect(links.peers()).toEqual(['C']); + links.closeAll(); + expect(links.peers()).toEqual([]); + }); +}); From ebd6948789572738ce519002af8ab6100cfd4712 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:40:25 +0300 Subject: [PATCH 002/104] [feat] share into the app + open files from the OS (60 Q4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest: share_target (POST multipart `files`: images, glb/gltf, tpscene), file_handlers (.tpscene, .glb, .gltf → /), launch_handler focus-existing - static/sw.js: receives the share POST (a static site has no server), parks the files in the `tp-share-inbox` cache and 303s to /?shared=; everything else is still a no-cache passthrough - src/lib/launchInbox.js: reads the inbox ONCE (cache deleted, ?shared= stripped) and takes launchQueue file launches; routes .tpscene → open (Sidebar Load path), models → import into the current scene (companions ride along), lone images → Explorer Library + "Show in Library"; waits for the scene group AND the restore-session answer first - autosave: restoreSettled() — resolves once the boot's restore question is answered (no snapshot / non-empty / auto-restored / Restore finished / Dismiss) - tests/unit/launchInbox.test.js (14): manifest keys, the real sw.js in a fake worker scope (park + 303, text-only, replace, passthrough), the worker → app round trip, classifier Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 3 + src/lib/autosave.js | 40 +++++- src/lib/launchInbox.js | 229 +++++++++++++++++++++++++++++++ static/manifest.webmanifest | 27 ++++ static/sw.js | 55 +++++++- tests/unit/launchInbox.test.js | 243 +++++++++++++++++++++++++++++++++ 6 files changed, 591 insertions(+), 6 deletions(-) create mode 100644 src/lib/launchInbox.js create mode 100644 tests/unit/launchInbox.test.js diff --git a/src/App.svelte b/src/App.svelte index db386f8ce..86d275788 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -295,6 +295,9 @@ import { startMusicToolbox } from './lib/musicToolbox' startShortcuts() startSnapping() startAutosave() + // 60 Q4: files shared into the app (share_target → sw.js inbox) or opened from the OS + // (file_handlers → launchQueue); they wait for the restore question (launchInbox.js) + if (!exportMode) import('$lib/launchInbox').then((m) => m.startLaunchInbox()).catch(() => {}) startSceneAssets() startCodeWorkspace() // 36-code: scriptEditorOpen -> a workspace tab, file follow startNetworkQuality() diff --git a/src/lib/autosave.js b/src/lib/autosave.js index b3ee84927..5254d301d 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -522,15 +522,32 @@ export function annotationsRestore(annotations) { if (annotations?.length && annotationsRestorer) annotationsRestorer(annotations); } +/** + * 60 Q4: "has this boot's restore question been ANSWERED?" — the launch inbox (a file shared + * into the app or opened from the OS) waits on it, because a model imported into the empty + * boot scene is wiped by a Restore pressed a second later, and a .tpscene opened over the + * prompt overwrites the very autosave the prompt offers. Resolves once: no snapshot, a scene + * already non-empty, an auto-restore finished, or the offer answered (Restore → after its + * load; Dismiss → at once). A restore's Cancel that re-offers does not re-arm it. + */ +let settleRestore = () => {}; +const restoreDecided = new Promise((resolve) => (settleRestore = () => resolve(undefined))); +/** the running applyRestore of an answered offer (restoreSnapshot), for restoreDecided + * @type {Promise | null} */ +let restoreJob = null; +export function restoreSettled() { + return restoreDecided; +} + async function checkRestore() { try { const snapshot = await idbGet('latest'); - if (!snapshot || (!snapshot.scene && !snapshot.nodes?.length)) return; + if (!snapshot || (!snapshot.scene && !snapshot.nodes?.length)) return settleRestore(); // wait for the scene group to exist, then only offer when it's still empty const unsubscribe = objectsGroup.subscribe((group) => { if (!group) return; setTimeout(() => unsubscribe(), 0); - if (group.children.length !== 0) return; + if (group.children.length !== 0) return settleRestore(); let armed = false; try { armed = typeof localStorage !== 'undefined' && !!safeStorage.getItem('restoreArmed'); @@ -545,11 +562,23 @@ async function checkRestore() { // 27-D: `risky` means the previous restore of this snapshot never reached a clean // flow tick. Auto-restoring it again is how one bad scene becomes a boot loop the // user cannot escape, so it always goes to the PROMPT, which says why. - if (get(autoRestoreEnabled) && !armed) autoRestore(offer); - else restoreAvailable.set(offer); + if (get(autoRestoreEnabled) && !armed) autoRestore(offer).finally(settleRestore); + else { + restoreAvailable.set(offer); + // answered = the offer cleared; a Restore clears it BEFORE its load starts, so + // the microtask lets restoreSnapshot publish the job first + const off = restoreAvailable.subscribe((value) => { + if (value) return; + queueMicrotask(() => { + off(); + Promise.resolve(restoreJob).finally(settleRestore); + }); + }); + } }); } catch (error) { log('warn', 'autosave', 'restore check failed', String(error)); + settleRestore(); } } @@ -788,7 +817,8 @@ export async function restoreSnapshot() { const offer = get(restoreAvailable); if (!offer) return; restoreAvailable.set(null); - const ok = await applyRestore(offer.snapshot, offer); + restoreJob = applyRestore(offer.snapshot, offer); + const ok = await restoreJob; // null: cancelled (it said so itself and re-offered) or superseded by another load if (ok === null) return; showToast( diff --git a/src/lib/launchInbox.js b/src/lib/launchInbox.js new file mode 100644 index 000000000..7080c99fe --- /dev/null +++ b/src/lib/launchInbox.js @@ -0,0 +1,229 @@ +// launchInbox.js — 60 Q4: files that arrive FROM THE OS rather than through a picker. +// +// Two doors, one router: +// · "Share → theprototype" (Android share sheet, desktop Chrome share) — the manifest's +// `share_target` POSTs the files to /share-target, which only the service worker can +// receive on a static site (static/sw.js): it parks them in the `tp-share-inbox` cache +// and redirects to /?shared=. `readShareInbox` collects them ONCE and deletes the +// cache. Chromium ignores `launch_handler` for share launches, so this is always a +// fresh navigation and the worker always sees the POST. +// · Double-click a .tpscene / .glb / .gltf on the desktop (installed PWA, `file_handlers`) +// — the files arrive on `window.launchQueue`. `launch_handler: focus-existing` sends a +// second double-click to the window already open, whose consumer runs again. +// +// What each file does (`classifyLaunchFiles`, PURE): a .tpscene OPENS (the Sidebar Load +// path — the scene replaces the world, unsaved, proposed to peers when connected; one at a +// time), models are IMPORTED into the current scene (the import dialog's path, so an .obj's +// .mtl and textures ride along), and an image on its own goes into the Explorer LIBRARY with +// a "Show in Library" action — a picture has no single right place in a 3D scene, and the +// library is where every texture/HUD/shader picker already reads from. +// +// Nothing runs until the boot is ready to take it: the scene group exists AND the +// restore-session question is answered (`restoreSettled`), because a model imported into +// the empty boot scene is wiped by a Restore pressed a second later, and a scene opened over +// the prompt overwrites the very autosave it offers. +import { objectsGroup } from '../stores/sceneStore'; +import { explorerClose, showInfoToast, showToast } from '../stores/appStore'; + +// kept in step with static/sw.js +export const SHARE_INBOX = 'tp-share-inbox'; +export const SHARE_PARAM = 'shared'; + +const SCENE_EXT = /\.tpscene$/i; +const MODEL_EXT = /\.(glb|gltf|obj|stl|fbx)$/i; +const COMPANION_EXT = /\.(mtl|png|jpe?g|webp|bmp|gif)$/i; +const IMAGE_EXT = /\.(png|jpe?g|webp|bmp|gif|heic|heif|avif)$/i; + +/** + * Sort a launch's files into what each will do. PURE. Images travel as a model's + * companions when a model came with them (an .obj and its textures), otherwise each one + * is a library image. + * @template {{name: string, type?: string}} F + * @param {F[]} files + * @returns {{scenes: F[], models: F[], companions: F[], images: F[], skipped: F[]}} + */ +export function classifyLaunchFiles(files) { + /** @type {{scenes: F[], models: F[], companions: F[], images: F[], skipped: F[]}} */ + const out = { scenes: [], models: [], companions: [], images: [], skipped: [] }; + const list = [...(files ?? [])].filter(Boolean); + const hasModel = list.some((f) => MODEL_EXT.test(f.name ?? '')); + for (const f of list) { + const name = f.name ?? ''; + if (SCENE_EXT.test(name)) out.scenes.push(f); + else if (MODEL_EXT.test(name)) out.models.push(f); + else if (hasModel && COMPANION_EXT.test(name)) out.companions.push(f); + else if (IMAGE_EXT.test(name) || String(f.type ?? '').startsWith('image/')) out.images.push(f); + else out.skipped.push(f); + } + return out; +} + +/** the boot is ready for a launch: the scene exists and the restore question is answered */ +async function bootReady() { + await new Promise((resolve) => { + const off = objectsGroup.subscribe((group) => { + if (!group) return; + queueMicrotask(() => off()); + resolve(undefined); + }); + }); + const { restoreSettled } = await import('./autosave'); + await restoreSettled(); +} + +/** + * Route a launch's files. Serialised, so two launches in quick succession (two + * double-clicks) land in order instead of interleaving their imports. + * @param {File[]} files @param {'share' | 'open'} source + * @returns {Promise<{scene: string | null, models: number, images: number, skipped: string[]}>} + */ +export function receiveLaunchFiles(files, source) { + const run = queue.then(() => route(files, source)); + queue = run.catch(() => {}); + return run; +} +/** @type {Promise} */ +let queue = Promise.resolve(); + +/** @param {File[]} files @param {'share' | 'open'} source */ +async function route(files, source) { + const result = { + scene: /** @type {string | null} */ (null), + models: 0, + images: 0, + skipped: /** @type {string[]} */ ([]) + }; + const c = classifyLaunchFiles(files); + result.skipped = c.skipped.map((f) => f.name); + if (!c.scenes.length && !c.models.length && !c.images.length) { + if (files.length) + showToast( + 'Cannot open ' + + result.skipped.join(', ') + + ' — theprototype takes .tpscene, .glb, .gltf and images' + ); + lastLaunch.value = { source, ...result }; + return result; + } + await bootReady(); + const fh = await import('./fileHandler.svelte'); + // the scene first: models shared WITH it land in it, not in the world it replaces + if (c.scenes.length) { + const scene = c.scenes[0]; + await fh.load(scene); + result.scene = scene.name; + if (c.scenes.length > 1) + showToast( + `Opened ${scene.name} — one scene at a time, so the other ${c.scenes.length - 1} stayed closed` + ); + } + if (c.models.length) { + const r = fh.importModelFiles([...c.models, ...c.companions]); + result.models = r.models; + } + if (c.images.length) { + const { importFiles, revealItem } = await import('./explorer'); + const created = await importFiles(c.images, null, { duplicates: 'reuse' }); + result.images = created.length; + if (created.length) { + const first = created[0]; + const label = + created.length === 1 + ? `${first.name} is in your Library` + : `${created.length} images are in your Library`; + showInfoToast( + `launch-images-${Date.now()}`, + `${label} — drag one onto an object to texture it.`, + [ + { + label: 'Show in Library', + action: () => { + explorerClose.set(false); + revealItem(first.id); + } + } + ] + ); + } + } + if (c.skipped.length) + showToast('Skipped ' + result.skipped.join(', ') + ' (not a scene, model or image)'); + lastLaunch.value = { source, ...result }; + return result; +} + +/** the last routed launch — read by the e2e debug hook */ +export const lastLaunch = { value: /** @type {any} */ (null) }; + +/** + * Collect what the service worker parked for a share, ONCE: every file in the inbox, then + * the cache is deleted and `?shared=` leaves the address bar (a reload must not re-import). + * @param {{caches?: CacheStorage, location?: Location, history?: History}} [env] injectable for tests + * @returns {Promise} + */ +export async function readShareInbox(env = globalThis) { + const loc = env.location; + if (!loc || !new URLSearchParams(loc.search).has(SHARE_PARAM)) return []; + try { + const url = new URL(loc.href); + url.searchParams.delete(SHARE_PARAM); + env.history?.replaceState(env.history.state, '', url.pathname + url.search + url.hash); + } catch { + /* the address bar keeps the param — the inbox is gone either way */ + } + const store = env.caches; + if (!store) return []; + /** @type {File[]} */ + const files = []; + try { + const inbox = await store.open(SHARE_INBOX); + const keys = [...(await inbox.keys())]; + const nth = (/** @type {Request} */ r) => Number(r.url.split('/').pop()); + keys.sort((a, b) => nth(a) - nth(b)); + for (const key of keys) { + const res = await inbox.match(key); + if (!res) continue; + const blob = await res.blob(); + let name = 'shared'; + try { + name = decodeURIComponent(res.headers.get('X-File-Name') || name); + } catch { + /* keep the fallback */ + } + files.push( + new File([blob], name, { type: blob.type || res.headers.get('Content-Type') || '' }) + ); + } + } catch { + /* an unreadable inbox is an empty one */ + } + await store.delete(SHARE_INBOX).catch(() => false); + return files; +} + +let started = false; +/** App boot: collect a share, and take file launches for the life of the page. */ +export function startLaunchInbox() { + if (started || typeof window === 'undefined') return; + started = true; + void readShareInbox().then((files) => { + if (files.length) void receiveLaunchFiles(files, 'share'); + }); + /** @type {any} */ + const lq = /** @type {any} */ (window).launchQueue; + if (lq && typeof lq.setConsumer === 'function') { + lq.setConsumer(async (/** @type {any} */ params) => { + const handles = Array.isArray(params?.files) ? params.files : []; + if (!handles.length) return; + const files = []; + for (const h of handles) { + try { + if (h && typeof h.getFile === 'function') files.push(await h.getFile()); + } catch { + /* a handle we may not read is skipped */ + } + } + if (files.length) void receiveLaunchFiles(files, 'open'); + }); + } +} diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest index d47f21fe2..087b0ed90 100644 --- a/static/manifest.webmanifest +++ b/static/manifest.webmanifest @@ -9,6 +9,33 @@ "background_color": "#111827", "theme_color": "#111827", "categories": ["productivity", "graphics", "developer"], + "share_target": { + "action": "/share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": ["image/*", "model/gltf-binary", "model/gltf+json", "application/x-tpscene", ".glb", ".gltf", ".tpscene"] + } + ] + } + }, + "file_handlers": [ + { + "action": "/", + "accept": { + "application/x-tpscene": [".tpscene"], + "model/gltf-binary": [".glb"], + "model/gltf+json": [".gltf"] + } + } + ], + "launch_handler": { "client_mode": ["focus-existing", "auto"] }, "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, diff --git a/static/sw.js b/static/sw.js index d1d669d25..bdb9059ac 100644 --- a/static/sw.js +++ b/static/sw.js @@ -10,9 +10,62 @@ // If precaching is ever added, wire skipWaiting()/clients.claim() to the // version poll first, or the update toast will be lying. +// +// 60 Q4 — the ONE exception, and it is not the app shell: the manifest's share_target +// POSTs "Share → theprototype" here (multipart, field `files`). A static site has no server +// to receive it, so the worker does: each shared file is parked in the `tp-share-inbox` +// cache under /share-inbox/ (name in X-File-Name), and the POST is answered with a 303 +// to /?shared=. The app (src/lib/launchInbox.js) reads the inbox ONCE and deletes +// the whole cache, so nothing outlives the hand-over. Text/url shares carry no file and +// just open the app. Kept in step with launchInbox.js: SHARE_INBOX, SHARE_PATH, SHARE_PARAM. + +const SHARE_INBOX = 'tp-share-inbox'; +const SHARE_PATH = '/share-target'; +const SHARE_PARAM = 'shared'; +const SHARE_MAX_FILES = 20; + +/** @param {Request} request */ +async function receiveShare(request) { + let count = 0; + try { + const form = await request.formData(); + const files = form + .getAll('files') + .filter((f) => typeof f === 'object' && f && 'arrayBuffer' in f) + .slice(0, SHARE_MAX_FILES); + if (files.length) { + // a previous share nobody collected is not mixed into this one + await caches.delete(SHARE_INBOX); + const inbox = await caches.open(SHARE_INBOX); + for (const file of files) { + await inbox.put( + new Request(new URL(`/share-inbox/${count++}`, self.location.origin).href), + new Response(file, { + headers: { + 'Content-Type': file.type || 'application/octet-stream', + 'X-File-Name': encodeURIComponent(file.name || `shared-${count}`) + } + }) + ); + } + } + } catch { + count = 0; + } + return Response.redirect( + new URL(count ? `/?${SHARE_PARAM}=${count}` : '/', self.location.origin).href, + 303 + ); +} + self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method === 'POST' && new URL(request.url).pathname === SHARE_PATH) { + event.respondWith(receiveShare(request)); + return; + } // straight to the network — no caches.match, no caches.put - event.respondWith(fetch(event.request)); + event.respondWith(fetch(request)); }); diff --git a/tests/unit/launchInbox.test.js b/tests/unit/launchInbox.test.js new file mode 100644 index 000000000..73cf989fc --- /dev/null +++ b/tests/unit/launchInbox.test.js @@ -0,0 +1,243 @@ +// 60 Q4 — share target / file handlers: the manifest keys, the service worker's share +// inbox (static/sw.js run in a fake worker scope), the app's inbox reader and the router's +// classification. The browser halves (an installed PWA receiving a share, launchQueue) are +// the e2e suite `launch-inbox` and the owner rows. +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { + SHARE_INBOX, + SHARE_PARAM, + classifyLaunchFiles, + readShareInbox +} from '../../src/lib/launchInbox.js'; + +const manifest = JSON.parse( + readFileSync(new URL('../../static/manifest.webmanifest', import.meta.url), 'utf8') +); +const swSource = readFileSync(new URL('../../static/sw.js', import.meta.url), 'utf8'); + +/** a CacheStorage good enough for both halves */ +function fakeCaches() { + /** @type {Map>} */ + const stores = new Map(); + const open = async (/** @type {string} */ name) => { + if (!stores.has(name)) stores.set(name, new Map()); + const m = /** @type {Map} */ (stores.get(name)); + return { + put: async (/** @type {Request} */ req, /** @type {Response} */ res) => + void m.set(new URL(req.url, 'https://app.test').pathname, res), + keys: async () => [...m.keys()].map((p) => new Request('https://app.test' + p)), + match: async (/** @type {Request} */ req) => m.get(new URL(req.url).pathname)?.clone() + }; + }; + return { stores, open, delete: async (/** @type {string} */ name) => stores.delete(name) }; +} + +/** run static/sw.js in a fake ServiceWorkerGlobalScope and hand back its fetch listener + * @param {any} caches */ +function loadWorker(caches) { + /** @type {Record} */ + const listeners = {}; + const self = { + location: new URL('https://app.test/sw.js'), + addEventListener: (/** @type {string} */ t, /** @type {Function} */ fn) => (listeners[t] = fn), + skipWaiting() {}, + clients: { claim() {} } + }; + let fetched = 0; + const fetchStub = async () => (fetched++, new Response('net')); + new Function('self', 'caches', 'fetch', swSource)(self, caches, fetchStub); + return { fetch: listeners.fetch, fetched: () => fetched }; +} + +/** @param {any} listener @param {Request} request */ +async function dispatch(listener, request) { + /** @type {Promise | null} */ + let answer = null; + listener({ + request, + respondWith: (/** @type {Promise} */ p) => (answer = Promise.resolve(p)) + }); + return /** @type {any} */ (answer); +} + +describe('manifest (60 Q4)', () => { + it('share_target POSTs multipart files to the path the worker takes', () => { + const st = manifest.share_target; + expect(st.action).toBe('/share-target'); + expect(st.method).toBe('POST'); + expect(st.enctype).toBe('multipart/form-data'); + expect(swSource).toContain(`const SHARE_PATH = '${st.action}'`); + const files = st.params.files; + expect(files).toHaveLength(1); + expect(files[0].name).toBe('files'); + expect(swSource).toMatch(/form\s*\.getAll\('files'\)/); + for (const a of [ + 'image/*', + '.glb', + '.gltf', + '.tpscene', + 'model/gltf-binary', + 'model/gltf+json' + ]) + expect(files[0].accept).toContain(a); + }); + it('file_handlers open .tpscene, .glb and .gltf in the app', () => { + const exts = manifest.file_handlers.flatMap((/** @type {any} */ h) => + Object.values(h.accept).flat() + ); + expect(exts.sort()).toEqual(['.glb', '.gltf', '.tpscene']); + for (const h of manifest.file_handlers) expect(h.action.startsWith('/')).toBe(true); + }); + it('launch_handler focuses the window already open', () => { + expect(manifest.launch_handler.client_mode[0]).toBe('focus-existing'); + }); + it('the worker and the app agree on the inbox name and the param', () => { + expect(swSource).toContain(`const SHARE_INBOX = '${SHARE_INBOX}'`); + expect(swSource).toContain(`const SHARE_PARAM = '${SHARE_PARAM}'`); + }); +}); + +describe('sw.js share inbox', () => { + const share = (/** @type {FormData} */ form) => + new Request('https://app.test/share-target', { method: 'POST', body: form }); + + it('parks every shared file and redirects to /?shared=', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + const form = new FormData(); + form.append('title', 'look'); + form.append( + 'files', + new File([new Uint8Array([1, 2, 3])], 'chair model.glb', { type: 'model/gltf-binary' }) + ); + form.append('files', new File(['png'], 'ref.png', { type: 'image/png' })); + const res = await dispatch(sw.fetch, share(form)); + expect(res.status).toBe(303); + expect( + new URL(/** @type {string} */ (res.headers.get('Location'))).pathname + + new URL(/** @type {string} */ (res.headers.get('Location'))).search + ).toBe('/?shared=2'); + expect(sw.fetched()).toBe(0); + const inbox = /** @type {Map} */ (caches.stores.get(SHARE_INBOX)); + expect([...inbox.keys()]).toEqual(['/share-inbox/0', '/share-inbox/1']); + const first = /** @type {Response} */ (inbox.get('/share-inbox/0')); + expect(decodeURIComponent(/** @type {string} */ (first.headers.get('X-File-Name')))).toBe( + 'chair model.glb' + ); + expect(new Uint8Array(await first.clone().arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); + }); + + it('a text-only share opens the app and parks nothing', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + const form = new FormData(); + form.append('url', 'https://example.com'); + const res = await dispatch(sw.fetch, share(form)); + expect(res.status).toBe(303); + expect(new URL(/** @type {string} */ (res.headers.get('Location'))).search).toBe(''); + expect(caches.stores.has(SHARE_INBOX)).toBe(false); + }); + + it('a new share replaces an uncollected one', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + const a = new FormData(); + a.append('files', new File(['a'], 'a.png')); + a.append('files', new File(['b'], 'b.png')); + await dispatch(sw.fetch, share(a)); + const b = new FormData(); + b.append('files', new File(['c'], 'c.glb')); + await dispatch(sw.fetch, share(b)); + expect([ + .../** @type {Map} */ (caches.stores.get(SHARE_INBOX)).keys() + ]).toEqual(['/share-inbox/0']); + }); + + it('everything else still goes straight to the network (no shell cache)', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + await dispatch(sw.fetch, new Request('https://app.test/')); + await dispatch(sw.fetch, new Request('https://app.test/other', { method: 'POST', body: 'x' })); + expect(sw.fetched()).toBe(2); + expect(caches.stores.size).toBe(0); + }); + + it('the round trip: what the worker parks, the app reads back once', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + const form = new FormData(); + form.append('files', new File(['scene-bytes'], 'Yard.tpscene')); + form.append('files', new File(['img'], 'photo 1.jpg', { type: 'image/jpeg' })); + const res = await dispatch(sw.fetch, share(form)); + const loc = new URL(/** @type {string} */ (res.headers.get('Location'))); + /** @type {string[]} */ + const replaced = []; + const env = { + caches: /** @type {any} */ (caches), + location: /** @type {any} */ ({ href: loc.href + '#peer', search: loc.search }), + history: /** @type {any} */ ({ + state: null, + replaceState: ( + /** @type {any} */ _s, + /** @type {string} */ _t, + /** @type {string} */ url + ) => replaced.push(url) + }) + }; + const files = await readShareInbox(env); + expect(files.map((f) => f.name)).toEqual(['Yard.tpscene', 'photo 1.jpg']); + expect(await files[0].text()).toBe('scene-bytes'); + expect(files[1].type).toBe('image/jpeg'); + expect(replaced).toEqual(['/#peer']); + expect(caches.stores.has(SHARE_INBOX)).toBe(false); + // a second read (a reload with the param still there) finds nothing + expect(await readShareInbox(env)).toEqual([]); + }); + + it('no ?shared= param = the inbox is not touched', async () => { + const caches = fakeCaches(); + await ( + await caches.open(SHARE_INBOX) + ).put(new Request('https://app.test/share-inbox/0'), new Response('x')); + const files = await readShareInbox({ + caches: /** @type {any} */ (caches), + location: /** @type {any} */ ({ href: 'https://app.test/', search: '' }) + }); + expect(files).toEqual([]); + expect(caches.stores.has(SHARE_INBOX)).toBe(true); + }); +}); + +describe('classifyLaunchFiles', () => { + const f = (/** @type {string} */ name, type = '') => ({ name, type }); + const names = (/** @type {any[]} */ xs) => xs.map((x) => x.name); + + it('scenes open, models import, a lone image goes to the library', () => { + const c = classifyLaunchFiles([ + f('Yard.tpscene'), + f('chair.GLB'), + f('tree.gltf'), + f('notes.txt') + ]); + expect(names(c.scenes)).toEqual(['Yard.tpscene']); + expect(names(c.models)).toEqual(['chair.GLB', 'tree.gltf']); + expect(names(c.skipped)).toEqual(['notes.txt']); + const img = classifyLaunchFiles([f('photo.jpg'), f('IMG_0001', 'image/heic')]); + expect(names(img.images)).toEqual(['photo.jpg', 'IMG_0001']); + expect(img.companions).toEqual([]); + }); + it('images WITH a model are its companions (an .obj and its textures)', () => { + const c = classifyLaunchFiles([f('house.obj'), f('house.mtl'), f('bricks.png')]); + expect(names(c.models)).toEqual(['house.obj']); + expect(names(c.companions)).toEqual(['house.mtl', 'bricks.png']); + expect(c.images).toEqual([]); + }); + it('a lone .mtl is not an image and is skipped', () => { + expect(names(classifyLaunchFiles([f('house.mtl')]).skipped)).toEqual(['house.mtl']); + }); + it('nothing in, nothing out', () => { + const c = classifyLaunchFiles([]); + expect(c.scenes.length + c.models.length + c.images.length + c.skipped.length).toBe(0); + }); +}); From 974e8be0411a37b2319e3d9dc3c367b26dc16236 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:48:01 +0300 Subject: [PATCH 003/104] [feat] 60 Q6 local asset cache: OPFS, keyed by content hash, LRU under a cap - assetCacheCore.js (zero-import leaf): url keys (+ a version tag for build-bundled files), derived keys (source hash + kind + settings, field order irrelevant), the index shape and its normalizer, usage (a shared blob counted once), the LRU eviction plan, orphans - assetCache.js: one blob per SHA-256 in OPFS (tp-asset-cache/blobs) + index.json; the same model on CacheStorage where OPFS cannot write; a plain fetch where neither exists. Reconciles orphan blobs / missing blobs on open; index flushed on a 400 ms debounce and on pagehide - kinds wired: pack files (packCache.js is now a url-keyed view over the cache, same API; 39 P5's CacheStorage copies are migrated once and that cache deleted), bundled skies (hdriRuntime, keyed by the build version), CC0 downloads (cc0Fetch.fetchBytes), converted textures (the import optimizer's output keyed by source hash + options + version), pack anim refs - navigator.storage.persist() asked once, from a toast with the reason, the first time something is cached (assetCache:persistAsked); never on boot, never again - never the app shell: only content urls reach it - unit: tests/unit/assetCacheCore.test.js (13); place-drag-cache reads the store through packCache.cachedPackBuffer instead of CacheStorage Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/animatedImports.js | 10 +- src/lib/assetCache.js | 454 ++++++++++++++++++++++++++++ src/lib/assetCacheCore.js | 190 ++++++++++++ src/lib/cc0/cc0Fetch.js | 21 +- src/lib/debugHooks.js | 2 + src/lib/hdri/hdriRuntime.js | 14 +- src/lib/importOptimize.js | 77 +++-- src/lib/packCache.js | 195 +++++------- tests/e2e/place-drag-cache.test.cjs | 3 +- tests/unit/assetCacheCore.test.js | 182 +++++++++++ 10 files changed, 995 insertions(+), 153 deletions(-) create mode 100644 src/lib/assetCache.js create mode 100644 src/lib/assetCacheCore.js create mode 100644 tests/unit/assetCacheCore.test.js diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js index d3cb6e020..87e77a6b6 100644 --- a/src/lib/animatedImports.js +++ b/src/lib/animatedImports.js @@ -481,10 +481,12 @@ async function fetchAnimRef(ref) { const url = packRefUrl(ref); let job = refFiles.get(url); if (!job) { - job = fetch(url).then((res) => { - if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url); - return res.arrayBuffer(); - }); + // 60 Q6: a pack file like any other — through the pack cache (the local asset cache) + job = import('./packCache').then((m) => + m.fetchPackBuffer(url).catch((/** @type {any} */ e) => { + throw new Error(String(e?.message ?? e) + ' ' + url); + }) + ); refFiles.set(url, job); job.catch(() => refFiles.delete(url)); } diff --git a/src/lib/assetCache.js b/src/lib/assetCache.js new file mode 100644 index 000000000..783c99266 --- /dev/null +++ b/src/lib/assetCache.js @@ -0,0 +1,454 @@ +import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; +import { + emptyIndex, + normalizeIndex, + usageOf, + planEviction, + orphanedBy, + urlKey, + CAP_CHOICES_MB, + DEFAULT_CAP_MB +} from './assetCacheCore.js'; + +// 60 Q6 — THE LOCAL ASSET CACHE: packs, skies, converted textures and downloaded imports, kept +// on this device in the Origin Private File System, keyed by CONTENT HASH (assetCacheCore.js +// has the model: keys -> hash, one blob per hash, LRU under a cap). +// +// WHY OPFS. 39 P5 kept pack files in CacheStorage, which can be listed and sized but stores a +// whole Response per url — the same bytes at two urls twice — and gives a page no way to read a +// file without materialising the Response. OPFS is a real per-origin filesystem: a blob per hash, +// one small index file, a byte count that is the file's own. Where OPFS cannot write (no +// `createWritable`: old Safari, a non-secure origin) the SAME model runs on CacheStorage, and +// where neither exists every call degrades to a plain network fetch, so no caller ever asks. +// +// WHAT IT NEVER HOLDS: the app shell. index.html, the JS/CSS chunks and the service worker stay +// under the no-cache SW rule — an update must always reach the next load. Only CONTENT reaches +// this module: pack files from the packs CDN, sky images, CC0 downloads, conversion outputs. +// +// PERSISTENCE: browsers may evict an origin's storage under pressure unless the page asked +// `navigator.storage.persist()`. It is asked ONCE, from a toast that says why, the first time +// something lands here (`assetCache:persistAsked`), never on boot and never again. +// +// A LEAF for the app (svelte/store + safeStorage + the pure core); the toast is a dynamic import. + +const DIR = 'tp-asset-cache'; +const CACHE_NAME = 'tp-asset-cache-v1'; +const CACHE_BASE = '/__tp-asset-cache/'; +const INDEX_FLUSH_MS = 400; +const CAP_KEY = 'assetCache:capMB'; +const ASKED_KEY = 'assetCache:persistAsked'; + +/** @typedef {{name: string, read(hash: string): Promise, write(hash: string, buf: ArrayBuffer): Promise, remove(hash: string): Promise, list(): Promise, readIndex(): Promise, writeIndex(text: string): Promise, wipe(): Promise}} Backend */ + +/** @returns {Promise} */ +async function opfsBackend() { + const storage = /** @type {any} */ (typeof navigator !== 'undefined' ? navigator.storage : null); + if (!storage || typeof storage.getDirectory !== 'function') return null; + const root = await storage.getDirectory(); + const dir = await root.getDirectoryHandle(DIR, { create: true }); + const blobs = await dir.getDirectoryHandle('blobs', { create: true }); + const probe = await dir.getFileHandle('.probe', { create: true }); + if (typeof probe.createWritable !== 'function') return null; + /** @param {any} handle @param {ArrayBuffer | string} data */ + const writeFile = async (handle, data) => { + const w = await handle.createWritable(); + await w.write(data); + await w.close(); + }; + return { + name: 'opfs', + async read(hash) { + try { + const fh = await blobs.getFileHandle(hash); + return await (await fh.getFile()).arrayBuffer(); + } catch { + return null; + } + }, + async write(hash, buf) { + await writeFile(await blobs.getFileHandle(hash, { create: true }), buf); + }, + async remove(hash) { + try { + await blobs.removeEntry(hash); + } catch {} + }, + async list() { + /** @type {string[]} */ + const out = []; + for await (const name of blobs.keys()) out.push(name); + return out; + }, + async readIndex() { + try { + const fh = await dir.getFileHandle('index.json'); + return JSON.parse(await (await fh.getFile()).text()); + } catch { + return null; + } + }, + async writeIndex(text) { + await writeFile(await dir.getFileHandle('index.json', { create: true }), text); + }, + async wipe() { + for await (const name of blobs.keys()) { + try { + await blobs.removeEntry(name); + } catch {} + } + } + }; +} + +/** the same model on CacheStorage (one Response per blob, one for the index) @returns {Promise} */ +async function cacheBackend() { + if (typeof caches === 'undefined') return null; + const cache = await caches.open(CACHE_NAME); + const blobUrl = (/** @type {string} */ hash) => CACHE_BASE + 'blob/' + hash; + return { + name: 'cachestorage', + async read(hash) { + const res = await cache.match(blobUrl(hash)); + return res ? res.arrayBuffer() : null; + }, + async write(hash, buf) { + await cache.put(blobUrl(hash), new Response(buf.slice(0))); + }, + async remove(hash) { + await cache.delete(blobUrl(hash)); + }, + async list() { + return (await cache.keys()) + .map((r) => new URL(r.url).pathname) + .filter((p) => p.startsWith(CACHE_BASE + 'blob/')) + .map((p) => p.slice((CACHE_BASE + 'blob/').length)); + }, + async readIndex() { + const res = await cache.match(CACHE_BASE + 'index.json'); + return res ? res.json() : null; + }, + async writeIndex(text) { + await cache.put( + CACHE_BASE + 'index.json', + new Response(text, { headers: { 'content-type': 'application/json' } }) + ); + }, + async wipe() { + for (const req of await cache.keys()) await cache.delete(req); + } + }; +} + +/** @type {Promise | null} */ +let backendJob = null; +/** @type {Backend | null} */ +let backend = null; +let index = emptyIndex(); +/** the index, republished on every change (Settings, Storage, packCache read it) */ +export const assetCacheIndex = writable(index); +/** which store is in use: 'opfs' | 'cachestorage' | 'none' | '' (not known yet) */ +export const assetCacheBackend = writable(''); + +/** @returns {number} */ +function readCapMB() { + const n = Number(safeStorage.getItem(CAP_KEY)); + return CAP_CHOICES_MB.includes(n) ? n : DEFAULT_CAP_MB; +} +/** the LRU cap in MB (Settings ▸ Storage); LOCAL, persisted */ +export const assetCacheCapMB = writable(readCapMB()); +let capRead = false; +assetCacheCapMB.subscribe((mb) => { + if (!capRead) return void (capRead = true); // the value we just read is not a choice + if (CAP_CHOICES_MB.includes(mb)) safeStorage.setItem(CAP_KEY, String(mb)); + if (backend) void enforceCap(); +}); + +const stats = { hits: 0, misses: 0, writes: 0, evicted: 0, fetches: 0 }; + +/** Open the store and read the index, once. Resolves whether a store is usable. @returns {Promise} */ +export function assetCacheReady() { + if (!backendJob) { + backendJob = (async () => { + /** @type {Backend | null} */ + let b = null; + try { + b = await opfsBackend(); + } catch { + b = null; + } + if (!b) { + try { + b = await cacheBackend(); + } catch { + b = null; + } + } + if (b) { + try { + index = normalizeIndex(await b.readIndex()); + } catch { + index = emptyIndex(); + } + // a tab that died between a blob write and its index flush leaves a blob no key + // names, and one that died mid-eviction a key whose blob is gone: reconcile both + try { + const held = new Set(await b.list()); + let dropped = false; + for (const [k, e] of Object.entries(index.entries)) { + if (!held.has(e.hash)) { + delete index.entries[k]; + dropped = true; + } + } + const named = new Set(Object.values(index.entries).map((e) => e.hash)); + for (const h of held) if (!named.has(h)) await b.remove(h); + if (dropped) scheduleFlush(); + } catch {} + } + backend = b; + assetCacheBackend.set(b ? b.name : 'none'); + publish(); + return b; + })(); + } + return backendJob.then((b) => !!b); +} + +function publish() { + assetCacheIndex.set(index); +} + +/** @type {ReturnType | null} */ +let flushTimer = null; +function scheduleFlush() { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + void flushIndex(); + }, INDEX_FLUSH_MS); +} +/** Write the index now (also on pagehide). */ +export async function flushIndex() { + if (!backend) return; + try { + await backend.writeIndex(JSON.stringify(index)); + } catch {} +} +if (typeof addEventListener === 'function') addEventListener('pagehide', () => void flushIndex()); + +/** SHA-256 hex of the bytes, or null where SubtleCrypto is missing @param {ArrayBuffer} buf */ +async function sha256(buf) { + try { + const digest = await crypto.subtle.digest('SHA-256', buf); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); + } catch { + return null; + } +} + +/** Is `key` held (by the index; the bytes may still turn out gone)? @param {string} key */ +export function hasCached(key) { + return !!index.entries[key]; +} + +/** The index row of `key`, or null @param {string} key */ +export function cachedEntry(key) { + return index.entries[key] ?? null; +} + +/** + * The cached bytes of `key`, or null. A hit stamps the key as just used (LRU). + * @param {string | null} key @returns {Promise} + */ +export async function readCached(key) { + if (!key || !(await assetCacheReady()) || !backend) return null; + const e = index.entries[key]; + if (!e) { + stats.misses++; + return null; + } + const buf = await backend.read(e.hash); + if (!buf) { + // the blob went (another tab cleared, the browser evicted): forget the row + delete index.entries[key]; + publish(); + scheduleFlush(); + stats.misses++; + return null; + } + e.lastUsed = Date.now(); + scheduleFlush(); + stats.hits++; + return buf; +} + +/** + * Keep `buffer` under `key`. Best effort: a full quota or a refused store leaves the app as it + * was (the bytes are already in memory). Resolves the content hash, or null when nothing was kept. + * @param {string | null} key @param {ArrayBuffer} buffer + * @param {{kind: string, type?: string, meta?: any}} info + * @returns {Promise} + */ +export async function writeCached(key, buffer, info) { + if (!key || !buffer || !(await assetCacheReady()) || !backend) return null; + const hash = await sha256(buffer); + if (!hash) return null; + const shared = Object.values(index.entries).some((e) => e.hash === hash); + try { + if (!shared) await backend.write(hash, buffer); + } catch { + return null; + } + const now = Date.now(); + index.entries[key] = { + hash, + bytes: buffer.byteLength, + kind: info.kind, + ...(info.type ? { type: info.type } : {}), + lastUsed: now, + at: now, + ...(info.meta !== undefined ? { meta: info.meta } : {}) + }; + stats.writes++; + await enforceCap([key]); + publish(); + scheduleFlush(); + maybeOfferPersist(); + return hash; +} + +/** Drop the least recently used keys until the cache fits its cap. @param {string[]} [protect] */ +async function enforceCap(protect = []) { + if (!backend) return; + const plan = planEviction(index, get(assetCacheCapMB) * 1024 * 1024, protect); + if (!plan.keys.length) return; + for (const k of plan.keys) delete index.entries[k]; + for (const h of plan.hashes) await backend.remove(h); + stats.evicted += plan.keys.length; + publish(); + scheduleFlush(); +} + +/** + * The bytes of a network file through the cache: the held copy when there is one (no network + * at all), else a fetch whose bytes are kept. Throws like fetch does on a failed request. + * @param {string} url @param {{kind: string, version?: string, init?: RequestInit}} opts + * @returns {Promise} + */ +export async function fetchCached(url, opts) { + const key = urlKey(url, opts.version); + const held = await readCached(key); + if (held) return held; + stats.fetches++; + const res = await fetch(url, opts.init); + if (!res.ok) throw Object.assign(new Error('HTTP ' + res.status), { status: res.status }); + const buffer = await res.arrayBuffer(); + void writeCached(key, buffer, { + kind: opts.kind, + type: res.headers.get('content-type') || undefined + }); + return buffer; +} + +/** + * Forget `keys`; blobs no other key names are deleted. Resolves the bytes freed. + * @param {string[]} keys @returns {Promise} + */ +export async function deleteCached(keys) { + if (!(await assetCacheReady()) || !backend) return 0; + const orphans = orphanedBy(index, keys); + let freed = 0; + for (const h of orphans) { + const e = Object.values(index.entries).find((x) => x.hash === h); + freed += e?.bytes ?? 0; + await backend.remove(h); + } + for (const k of keys) delete index.entries[k]; + publish(); + scheduleFlush(); + return freed; +} + +/** Settings ▸ Storage ▸ Clear: everything, every kind. Resolves the bytes freed. */ +export async function clearAssetCache() { + if (!(await assetCacheReady()) || !backend) return 0; + const freed = usageOf(index).bytes; + try { + await backend.wipe(); + } catch {} + index = emptyIndex(); + publish(); + await flushIndex(); + return freed; +} + +// ---- persistence: asked once, with the reason ------------------------------------------------ + +/** 'granted' | 'not-granted' | 'unsupported' | '' (not read yet) */ +export const persistState = writable(''); + +export async function refreshPersistState() { + const storage = /** @type {any} */ (typeof navigator !== 'undefined' ? navigator.storage : null); + if (!storage || typeof storage.persisted !== 'function') { + persistState.set('unsupported'); + return; + } + try { + persistState.set((await storage.persisted()) ? 'granted' : 'not-granted'); + } catch { + persistState.set('unsupported'); + } +} + +/** Was the question put already (on this device)? */ +export function persistAsked() { + return safeStorage.getItem(ASKED_KEY) === '1'; +} + +/** Ask the browser to keep this origin's storage. The ONE call site of `persist()`. */ +export async function requestPersist() { + safeStorage.setItem(ASKED_KEY, '1'); + const storage = /** @type {any} */ (typeof navigator !== 'undefined' ? navigator.storage : null); + if (!storage || typeof storage.persist !== 'function') { + persistState.set('unsupported'); + return false; + } + let ok = false; + try { + ok = await storage.persist(); + } catch {} + persistState.set(ok ? 'granted' : 'not-granted'); + return ok; +} + +let offered = false; +/** The first time something lands in the cache, say why we want to keep it — once, ever. */ +function maybeOfferPersist() { + if (offered || persistAsked()) return; + offered = true; + void (async () => { + await refreshPersistState(); + if (get(persistState) !== 'not-granted') return; // granted already, or no such API + safeStorage.setItem(ASKED_KEY, '1'); + const { showToastById } = await import('../stores/appStore.js'); + showToastById( + 'asset-cache-persist', + 'Downloaded packs and skies are kept on this device so scenes load faster and work offline. Keep them even when the browser is short of space?', + [ + { label: 'Keep them', action: () => void requestPersist() }, + { label: 'Not now', action: () => {} } + ] + ); + })(); +} + +/** for the suites (`__stores.assetCache`) */ +export function assetCacheStats() { + return { + ...stats, + backend: backend?.name ?? 'none', + ...usageOf(index), + capMB: get(assetCacheCapMB) + }; +} diff --git a/src/lib/assetCacheCore.js b/src/lib/assetCacheCore.js new file mode 100644 index 000000000..23a2ccc4b --- /dev/null +++ b/src/lib/assetCacheCore.js @@ -0,0 +1,190 @@ +// 60 Q6 — THE LOCAL ASSET CACHE, its pure half: keys, the index shape and the LRU plan. +// +// A ZERO-IMPORT LEAF (the wireBatch / alignCore shape), so every rule that decides WHAT is kept +// and WHAT goes is unit-testable with no OPFS and no browser. `assetCache.js` is the runtime +// that reads and writes bytes; this file only does arithmetic over the index. +// +// THE MODEL. Bytes are stored ONCE per content hash (a "blob"); the index maps a KEY to the hash +// it resolved to. Two keys may share a blob — the same file reached at two urls, or a converted +// texture identical to one already held — and a blob is freed only when no key refers to it. +// * url:[#v=] — a network file (pack files, skies, CC0 downloads). The +// hash is learned when the bytes arrive; the key is what a later load asks for. A version tag +// is part of the key where the url alone does not pin the bytes (a same-origin sky ships with +// the build, so it is keyed by the build too). +// * derived::: — the OUTPUT of a conversion (an optimized import's +// KTX2 textures), keyed by the INPUT's content hash and the exact settings. The same file +// imported again with the same settings never converts twice. +// +// LRU: every read stamps `lastUsed`; when the blobs' total passes the cap, keys go oldest first +// (never the protected ones — the key just written) until the blobs still referenced fit. + +/** @typedef {{hash: string, bytes: number, kind: string, type?: string, lastUsed: number, at: number, meta?: any}} CacheEntry */ +/** @typedef {{v: 1, entries: Record}} CacheIndex */ + +export const INDEX_VERSION = 1; +export const CACHE_KINDS = ['pack', 'sky', 'texture', 'import']; +/** the cap choices Settings offers, in MB (0 is not offered: Clear is the off switch) */ +export const CAP_CHOICES_MB = [256, 512, 1024, 2048]; +export const DEFAULT_CAP_MB = 1024; + +/** + * JSON with SORTED keys at every depth, so two equal settings objects give one key whatever + * order their fields were written in. + * @param {any} value @returns {string} + */ +export function stableStringify(value) { + if (value === undefined) return 'null'; + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return '[' + value.map((v) => stableStringify(v)).join(',') + ']'; + const keys = Object.keys(value) + .filter((k) => value[k] !== undefined) + .sort(); + return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}'; +} + +/** + * The key of a network file. Only absolute http(s) urls are cacheable — a blob:/data: url is + * already in memory, and a relative one must be resolved by the caller first. + * @param {string} url @param {string} [version] @returns {string | null} + */ +export function urlKey(url, version) { + if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return null; + const bare = url.replace(/#.*$/, ''); + return 'url:' + bare + (version ? '#v=' + version : ''); +} + +/** the url a `url:` key was made from (without its version tag), else null @param {string} key */ +export function urlOfKey(key) { + if (typeof key !== 'string' || !key.startsWith('url:')) return null; + return key.slice(4).replace(/#v=.*$/, ''); +} + +/** + * The key of a conversion's output. + * @param {string} kind @param {string} sourceHash @param {any} params @returns {string | null} + */ +export function derivedKey(kind, sourceHash, params) { + if (!kind || typeof sourceHash !== 'string' || !/^[0-9a-f]{16,}$/i.test(sourceHash)) return null; + return 'derived:' + kind + ':' + sourceHash.toLowerCase() + ':' + stableStringify(params ?? null); +} + +/** @returns {CacheIndex} */ +export function emptyIndex() { + return { v: INDEX_VERSION, entries: {} }; +} + +/** + * Bring a stored index back to shape: an unknown version or a malformed row is dropped (the + * cache is a cache — losing a row costs one download), never trusted. + * @param {any} raw @returns {CacheIndex} + */ +export function normalizeIndex(raw) { + const out = emptyIndex(); + if ( + !raw || + typeof raw !== 'object' || + raw.v !== INDEX_VERSION || + !raw.entries || + typeof raw.entries !== 'object' + ) + return out; + for (const [key, e] of Object.entries(raw.entries)) { + if (!e || typeof e !== 'object') continue; + if (typeof e.hash !== 'string' || !/^[0-9a-f]{16,}$/.test(e.hash)) continue; + if (!Number.isFinite(e.bytes) || e.bytes < 0) continue; + out.entries[key] = { + hash: e.hash, + bytes: e.bytes, + kind: CACHE_KINDS.includes(e.kind) ? e.kind : 'import', + ...(typeof e.type === 'string' ? { type: e.type } : {}), + lastUsed: Number.isFinite(e.lastUsed) ? e.lastUsed : 0, + at: Number.isFinite(e.at) ? e.at : 0, + ...(e.meta !== undefined ? { meta: e.meta } : {}) + }; + } + return out; +} + +/** + * The blobs the index refers to: hash -> {bytes, refs}. A blob's size is counted ONCE however + * many keys share it. + * @param {CacheIndex} index @returns {Map} + */ +export function blobsOf(index) { + /** @type {Map} */ + const blobs = new Map(); + for (const e of Object.values(index.entries)) { + const b = blobs.get(e.hash); + if (b) b.refs++; + else blobs.set(e.hash, { bytes: e.bytes, refs: 1 }); + } + return blobs; +} + +/** + * What the cache holds: total bytes (blobs counted once), keys, blobs, and bytes per kind (a + * shared blob counts toward the kind of the first key that names it, so the kinds sum to the + * total). + * @param {CacheIndex} index + */ +export function usageOf(index) { + /** @type {Record} */ + const byKind = Object.fromEntries(CACHE_KINDS.map((k) => [k, 0])); + const seen = new Set(); + let bytes = 0; + for (const e of Object.values(index.entries)) { + if (seen.has(e.hash)) continue; + seen.add(e.hash); + bytes += e.bytes; + byKind[e.kind] = (byKind[e.kind] ?? 0) + e.bytes; + } + return { bytes, keys: Object.keys(index.entries).length, blobs: seen.size, byKind }; +} + +/** + * THE LRU PLAN: which keys to drop so the referenced blobs fit `capBytes`, oldest `lastUsed` + * first (ties by key, so the plan is deterministic), never a `protect`ed key; and which blobs + * that frees (a blob goes only when its LAST key goes). Pure — the caller applies it. + * @param {CacheIndex} index @param {number} capBytes @param {Iterable} [protect] + * @returns {{keys: string[], hashes: string[], bytesAfter: number}} + */ +export function planEviction(index, capBytes, protect = []) { + const keep = new Set(protect); + const blobs = blobsOf(index); + let total = 0; + for (const b of blobs.values()) total += b.bytes; + /** @type {string[]} */ + const keys = []; + /** @type {string[]} */ + const hashes = []; + if (total <= capBytes) return { keys, hashes, bytesAfter: total }; + const order = Object.entries(index.entries) + .filter(([k]) => !keep.has(k)) + .sort((a, b) => a[1].lastUsed - b[1].lastUsed || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + for (const [key, e] of order) { + if (total <= capBytes) break; + keys.push(key); + const b = /** @type {{bytes: number, refs: number}} */ (blobs.get(e.hash)); + b.refs--; + if (b.refs === 0) { + hashes.push(e.hash); + total -= b.bytes; + } + } + return { keys, hashes, bytesAfter: total }; +} + +/** + * Which blobs no key refers to any more once `keys` are removed (the delete/clear-one path). + * @param {CacheIndex} index @param {Iterable} keys @returns {string[]} + */ +export function orphanedBy(index, keys) { + const gone = new Set(keys); + const still = new Set(); + const touched = new Set(); + for (const [k, e] of Object.entries(index.entries)) { + if (gone.has(k)) touched.add(e.hash); + else still.add(e.hash); + } + return [...touched].filter((h) => !still.has(h)); +} diff --git a/src/lib/cc0/cc0Fetch.js b/src/lib/cc0/cc0Fetch.js index 51cd8080c..702f351eb 100644 --- a/src/lib/cc0/cc0Fetch.js +++ b/src/lib/cc0/cc0Fetch.js @@ -5,11 +5,13 @@ // one IndexedDB store under `cc0:` with the time it arrived. A fresh entry answers with no // network at all; an expired one is refetched, and when that refetch FAILS (offline, the site // down, the relay down) the old answer is served with `stale: true`, so the browser keeps working -// on what it saw last and says so. Downloads are NOT cached here — they become Library items, -// which are content-hashed and deduped by the import entry already. +// on what it saw last and says so. Downloads become Library items (content-hashed and deduped by +// the import entry); 60 Q6 also keeps their BYTES in the local asset cache (`fetchBytes`). import { idbGet, idbPut } from '../idb'; import { safeStorage } from '../safeStorage'; import { PH_API, ACG_PAGE, DEFAULT_CC0_PROXY, classifyError } from './cc0Core.js'; +import { readCached, writeCached } from '../assetCache'; +import { urlKey } from '../assetCacheCore.js'; const HOUR = 3600_000; /** how long an answer is fresh, per endpoint family */ @@ -110,6 +112,21 @@ export async function acgResolve(file) { * @returns {Promise} */ export async function fetchBytes(url, onProgress, signal) { + // 60 Q6: a download is kept in the local asset cache (kind `import`), so importing the same + // file again — after deleting it from the library, or into another project — costs no network + const key = urlKey(url); + const held = await readCached(key); + if (held) { + onProgress?.(held.byteLength, held.byteLength); + return new Uint8Array(held); + } + const bytes = await downloadBytes(url, onProgress, signal); + void writeCached(key, /** @type {ArrayBuffer} */ (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)), { kind: 'import' }); + return bytes; +} + +/** @param {string} url @param {(done: number, total: number) => void} [onProgress] @param {AbortSignal} [signal] @returns {Promise} */ +async function downloadBytes(url, onProgress, signal) { let res; try { res = await fetch(url, { credentials: 'omit', signal }); diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 46aa9ba1b..0ccf384f9 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -43,6 +43,8 @@ export const DEBUG_HOOKS = [ ['placementPrefs', () => import('./placementPrefs.js')], ['placementDims', () => import('./placementDims.js')], ['packCache', () => import('./packCache.js')], + // 60 Q6: the local asset cache (OPFS) — index, stats, clear, persist + ['assetCache', () => import('./assetCache.js')], ['placeGhost', () => import('./placeGhost.js')], ['tourVR', () => import('./tours/vrPanel.js')], // 36-share (B13): the recorder + its stores diff --git a/src/lib/hdri/hdriRuntime.js b/src/lib/hdri/hdriRuntime.js index 846075bf2..9c9e497e2 100644 --- a/src/lib/hdri/hdriRuntime.js +++ b/src/lib/hdri/hdriRuntime.js @@ -17,6 +17,8 @@ import { hdriOf, resolveHdriTier, isHeadsetUA, TIER_MAX_WIDTH, downsampleRGBA, i import { bundledHdri } from './catalog.js'; import { skyEnv, envToneMapping, hdriStatus } from './skyEnv.js'; import { pageUrl } from '../export/exportBoot.js'; +import { fetchCached } from '../assetCache'; +import { APP_VERSION } from '../version'; import { qualityOverrides } from '../qualityGovernor'; import { isVRMode } from '../../stores/sceneStore'; import { hdriQuality } from './hdriPrefs.js'; @@ -72,9 +74,15 @@ async function fetchSource(src) { if (held) return held; const row = bundledHdri(src); if (row) { - const res = await fetch(pageUrl(row.file)); - if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + row.file); - const got = { buffer: await res.arrayBuffer(), name: row.file }; + // 60 Q6: through the local asset cache (kind `sky`). A bundled sky ships WITH the build, so + // its key carries the build's version: an updated file under the same name is fetched anew + let buffer; + try { + buffer = await fetchCached(pageUrl(row.file), { kind: 'sky', version: APP_VERSION }); + } catch (e) { + throw new Error(String(/** @type {any} */ (e)?.message ?? e) + ' for ' + row.file); + } + const got = { buffer, name: row.file }; remember(sources, src, got); return got; } diff --git a/src/lib/importOptimize.js b/src/lib/importOptimize.js index 3384b0b68..47cd46cf5 100644 --- a/src/lib/importOptimize.js +++ b/src/lib/importOptimize.js @@ -14,6 +14,10 @@ import { retainOriginal } from './decimate'; import { generatedGroup } from './lodGroupCore'; import { parseGltfSceneSafe } from './ktx2Twins'; import { optimizeGltf, OptimizeUnsupported } from './gltfOptimize.js'; +// 60 Q6: the converted output is kept in the local asset cache, keyed by the source hash + settings +import { readCached, writeCached, cachedEntry } from './assetCache'; +import { derivedKey } from './assetCacheCore.js'; +import { APP_VERSION } from './version'; import { importDecision, importOptions, @@ -157,34 +161,53 @@ export async function maybeOptimizeImport(parsed, source) { /** @type {any} */ let report = null; let optimizedBytes = null; + // 60 Q6: the same file under the same settings converts ONCE per build — the textures' + // resize + KTX2 encode is the slow part of an import, and its output is a pure function of both + const engineOptions = importOptions(settings); + const convertedKey = runEngine + ? derivedKey('texture', hash, { options: engineOptions, v: APP_VERSION }) + : null; + const converted = convertedKey ? await readCached(convertedKey) : null; if (runEngine) { const progressId = 'import-optimize-' + hash.slice(0, 12); - showInfoToast( - progressId, - 'Optimizing "' + source.label + '"… you can keep working while it runs.', - [], - undefined, - true - ); + if (!converted) + showInfoToast( + progressId, + 'Optimizing "' + source.label + '"… you can keep working while it runs.', + [], + undefined, + true + ); try { - const out = await optimizeGltf(bytes, importOptions(settings), { - onProgress: (p) => { - if (p.step === 'textures' && p.total) - showInfoToast( - progressId, - 'Optimizing "' + - source.label + - '": textures ' + - Math.min(p.total, (p.done ?? 0) + 1) + - ' / ' + - p.total + - '…', - [], - undefined, - true - ); - } - }); + const out = converted + ? { + bytes: new Uint8Array(converted), + report: cachedEntry(/** @type {string} */ (convertedKey))?.meta?.report ?? null + } + : await optimizeGltf(bytes, engineOptions, { + onProgress: (p) => { + if (p.step === 'textures' && p.total) + showInfoToast( + progressId, + 'Optimizing "' + + source.label + + '": textures ' + + Math.min(p.total, (p.done ?? 0) + 1) + + ' / ' + + p.total + + '…', + [], + undefined, + true + ); + } + }); + if (!converted && convertedKey) + void writeCached(convertedKey, out.bytes.slice().buffer, { + kind: 'texture', + type: 'model/gltf-binary', + meta: { report: out.report } + }); report = out.report; optimizedBytes = out.bytes; const gltfResult = await parseGltfSceneSafe( @@ -254,7 +277,9 @@ export async function maybeOptimizeImport(parsed, source) { optimizedFrom: original.hash, optimizeReport: summaryForItem, // 47 C2: the optimized copy names the same CC0 source as its original (Explorer Properties) - ...(/** @type {any} */ (original).source ? { source: /** @type {any} */ (original).source } : {}) + .../** @type {any} */ ((original).source + ? { source: /** @type {any} */ (original).source } + : {}) }); patchRecord(original.id, { optimizedHash: optimizedItem.hash }); } diff --git a/src/lib/packCache.js b/src/lib/packCache.js index 72f41132c..f2bebb62c 100644 --- a/src/lib/packCache.js +++ b/src/lib/packCache.js @@ -1,71 +1,77 @@ -import { writable, get } from 'svelte/store'; +import { derived, get } from 'svelte/store'; +import { + assetCacheIndex, + assetCacheReady, + readCached, + writeCached, + deleteCached, + fetchCached +} from './assetCache'; +import { urlKey, urlOfKey } from './assetCacheCore.js'; -// 39 P5 — PACK DOWNLOADS: the files of pack items this device has fetched, kept in ONE -// CacheStorage cache so the Explorer can say which items are downloaded (and how big they -// are), "Delete cache" can free one item's bytes while the item stays in its pack, and -// Storage can clear them all. +// 39 P5 — PACK DOWNLOADS: the files of pack items this device has fetched, so the Explorer can +// say which items are downloaded (and how big they are), "Delete cache" can free one item's bytes +// while the item stays in its pack, and Storage can clear them all. // -// Before this every pack file went through the browser's HTTP cache alone — invisible to -// the page, evictable at any time, and impossible to list or delete. CacheStorage is the -// one store a page can both enumerate and size. The HTTP cache still sits underneath (we -// cannot clear it), so "Delete cache" frees OUR copy: the next fetch goes to the network -// (or whatever the browser still holds) and is cached here again. +// 60 Q6: the bytes live in the LOCAL ASSET CACHE now (assetCache.js — OPFS, keyed by content +// hash, LRU under the Settings ▸ Storage cap) as kind `pack`. This module keeps its API — every +// caller (placement, scene pack refs, the Explorer badges, Storage) is unchanged — and is the +// url-keyed VIEW of that cache. The 39 P5 CacheStorage copies are moved over once +// (`migrateLegacy`) and that cache is deleted. // -// A LEAF: svelte/store only. LOCAL by construction — what a device has downloaded is a fact -// about this machine; nothing here travels, saves or undoes. Every call degrades to a plain -// network fetch where CacheStorage is missing (an exported game on file://, a non-secure -// origin, a private window that refuses it), so no caller ever has to ask. +// LOCAL by construction — what a device has downloaded is a fact about this machine; nothing +// here travels, saves or undoes. Every call degrades to a plain network fetch where no store +// works (an exported game on file://, a non-secure origin, a private window that refuses it). +/** 39 P5's CacheStorage name — only read once, to migrate what it holds */ export const PACK_CACHE_NAME = 'tp-pack-files-v1'; -/** the size header written beside each cached response (a Response's body cannot be sized - * without reading it, and the index must not read every blob on boot) */ -const BYTES_HEADER = 'x-tp-bytes'; -/** url -> cached bytes, for every file in the cache. The Explorer's badges and the Storage - * row read it; `loadPackCacheIndex` fills it once, every put/delete keeps it current. - * @type {import('svelte/store').Writable>} */ -export const packCacheIndex = writable(new Map()); -let indexLoaded = false; -/** @type {Promise | null} */ -let indexJob = null; +/** url -> cached bytes, for every pack file held. The Explorer's badges and the Storage row read + * it; it follows the asset cache's index. @type {import('svelte/store').Readable>} */ +export const packCacheIndex = derived(assetCacheIndex, (idx) => { + /** @type {Map} */ + const map = new Map(); + for (const [key, e] of Object.entries(idx.entries)) { + if (e.kind !== 'pack') continue; + const url = urlOfKey(key); + if (url) map.set(url, e.bytes); + } + return map; +}); /** Only real network files are cached — a blob:/data: url is already in memory. @param {string} url */ export function cacheableUrl(url) { return typeof url === 'string' && /^https?:\/\//.test(url); } -/** @returns {Promise} */ -async function openCache() { - try { - if (typeof caches === 'undefined') return null; - return await caches.open(PACK_CACHE_NAME); - } catch { - return null; - } +/** @type {Promise | null} */ +let migrateJob = null; +/** 60 Q6: move 39 P5's CacheStorage pack copies into the asset cache, then delete that cache. */ +function migrateLegacy() { + if (migrateJob) return migrateJob; + migrateJob = (async () => { + try { + if (typeof caches === 'undefined' || !(await caches.has(PACK_CACHE_NAME))) return; + const old = await caches.open(PACK_CACHE_NAME); + for (const req of await old.keys()) { + const res = await old.match(req); + if (!res) continue; + const buffer = await res.arrayBuffer(); + await writeCached(urlKey(req.url), buffer, { + kind: 'pack', + type: res.headers.get('content-type') || undefined + }); + } + await caches.delete(PACK_CACHE_NAME); + } catch {} + })(); + return migrateJob; } -/** Read the cache's keys (and sizes) into `packCacheIndex`, once. */ -export function loadPackCacheIndex() { - if (indexLoaded) return Promise.resolve(); - if (indexJob) return indexJob; - indexJob = (async () => { - const cache = await openCache(); - /** @type {Map} */ - const map = new Map(); - if (cache) { - try { - for (const req of await cache.keys()) { - const res = await cache.match(req); - const bytes = Number(res?.headers.get(BYTES_HEADER)) || 0; - map.set(req.url, bytes); - } - } catch {} - } - // a put that raced the scan is already in the store — keep it - packCacheIndex.update((live) => new Map([...map, ...live])); - indexLoaded = true; - })(); - return indexJob; +/** Open the cache and read its index (and migrate the old one), once. */ +export async function loadPackCacheIndex() { + await assetCacheReady(); + await migrateLegacy(); } /** @param {string} url @returns {boolean} */ @@ -86,95 +92,50 @@ export function cachedState(url, index = get(packCacheIndex)) { } /** - * The cached bytes of `url`, or null when it is not downloaded (or the cache is unusable). + * The cached bytes of `url`, or null when it is not downloaded (or no store works). * @param {string} url @returns {Promise} */ export async function cachedPackBuffer(url) { if (!cacheableUrl(url)) return null; - const cache = await openCache(); - if (!cache) return null; - try { - const res = await cache.match(url); - if (!res) { - // a stale index row (cleared in another tab) — forget it - if (get(packCacheIndex).has(url)) packCacheIndex.update((m) => (m.delete(url), new Map(m))); - return null; - } - return await res.arrayBuffer(); - } catch { - return null; - } + await loadPackCacheIndex(); + return readCached(urlKey(url)); } /** - * Keep `buffer` as the downloaded copy of `url`. Best effort: a full quota or a refused - * cache leaves the app exactly as it was (the bytes are already in memory). + * Keep `buffer` as the downloaded copy of `url`. Best effort. * @param {string} url @param {ArrayBuffer} buffer @param {string} [type] */ export async function putPackBuffer(url, buffer, type = 'model/gltf-binary') { if (!cacheableUrl(url) || !buffer) return false; - const cache = await openCache(); - if (!cache) return false; - try { - await cache.put( - url, - new Response(buffer.slice(0), { - headers: { 'content-type': type, [BYTES_HEADER]: String(buffer.byteLength), 'x-tp-cached-at': String(Date.now()) } - }) - ); - packCacheIndex.update((m) => new Map(m).set(url, buffer.byteLength)); - return true; - } catch { - return false; - } + return !!(await writeCached(urlKey(url), buffer, { kind: 'pack', type })); } /** - * The bytes of a pack file: the downloaded copy when there is one, else the network (and - * the result is kept). The one fetch every placement path shares. + * The bytes of a pack file: the downloaded copy when there is one, else the network (and the + * result is kept). The one fetch every placement path shares. * @param {string} url @param {RequestInit} [init] @returns {Promise} */ export async function fetchPackBuffer(url, init) { - const held = await cachedPackBuffer(url); - if (held) return held; - const res = await fetch(url, init); - if (!res.ok) throw Object.assign(new Error('HTTP ' + res.status), { status: res.status }); - const buffer = await res.arrayBuffer(); - void putPackBuffer(url, buffer, res.headers.get('content-type') || undefined); - return buffer; + if (!cacheableUrl(url)) { + const res = await fetch(url, init); + if (!res.ok) throw Object.assign(new Error('HTTP ' + res.status), { status: res.status }); + return res.arrayBuffer(); + } + await loadPackCacheIndex(); + return fetchCached(url, { kind: 'pack', init }); } /** - * Forget the downloaded copies of `urls`. Resolves the bytes freed (by the index's count). + * Forget the downloaded copies of `urls`. Resolves the bytes freed. * @param {string[]} urls @returns {Promise} */ export async function deletePackCache(urls) { - const cache = await openCache(); - let freed = 0; - const index = get(packCacheIndex); - for (const url of urls) { - const bytes = index.get(url) ?? 0; - let gone = false; - try { - gone = cache ? await cache.delete(url) : false; - } catch {} - if (gone || index.has(url)) freed += bytes; - } - packCacheIndex.update((m) => { - const next = new Map(m); - for (const url of urls) next.delete(url); - return next; - }); - return freed; + const keys = urls.map((u) => urlKey(u)).filter((k) => k !== null); + return deleteCached(/** @type {string[]} */ (keys)); } /** "Clear all pack downloads" (Storage). @returns {Promise} the bytes freed */ export async function clearPackCache() { await loadPackCacheIndex(); - const freed = cachedBytesOf([...get(packCacheIndex).keys()]); - try { - if (typeof caches !== 'undefined') await caches.delete(PACK_CACHE_NAME); - } catch {} - packCacheIndex.set(new Map()); - return freed; + return deletePackCache([...get(packCacheIndex).keys()]); } diff --git a/tests/e2e/place-drag-cache.test.cjs b/tests/e2e/place-drag-cache.test.cjs index 5198247c4..4cea135b9 100644 --- a/tests/e2e/place-drag-cache.test.cjs +++ b/tests/e2e/place-drag-cache.test.cjs @@ -59,7 +59,8 @@ const cacheFacts = (page) => s.packCache.packCacheIndex.subscribe((v) => (index = v))(); const urls = [...index.keys()]; const duck = urls.find((u) => /Duck\.glb$/.test(u)) ?? null; - const match = duck ? await (await caches.open(s.packCache.PACK_CACHE_NAME)).match(duck) : null; + // 60 Q6: the pack cache lives in the local asset cache (OPFS) now — ask it, not CacheStorage + const match = duck ? await s.packCache.cachedPackBuffer(duck) : null; return { urls, duck, bytes: duck ? index.get(duck) : 0, inCache: !!match, wanted: url }; }, DUCK_URL); diff --git a/tests/unit/assetCacheCore.test.js b/tests/unit/assetCacheCore.test.js new file mode 100644 index 000000000..41df5fda8 --- /dev/null +++ b/tests/unit/assetCacheCore.test.js @@ -0,0 +1,182 @@ +// @ts-nocheck — fixture indexes as plain literals; the module itself is typed +import { describe, it, expect } from 'vitest'; +import { + stableStringify, + urlKey, + urlOfKey, + derivedKey, + emptyIndex, + normalizeIndex, + blobsOf, + usageOf, + planEviction, + orphanedBy, + DEFAULT_CAP_MB, + CAP_CHOICES_MB +} from '../../src/lib/assetCacheCore.js'; + +// 60 Q6: the local asset cache's pure half — cache keys, the index, the LRU plan. + +const H = (c) => c.repeat(64); // a sha-256-shaped hex +const entry = (hash, bytes, lastUsed, kind = 'pack') => ({ hash, bytes, kind, lastUsed, at: 0 }); + +describe('cache keys', () => { + it('url keys: absolute http(s) only, fragment dropped, optional version tag', () => { + expect(urlKey('https://cdn.example/p/a.glb')).toBe('url:https://cdn.example/p/a.glb'); + expect(urlKey('https://cdn.example/p/a.glb#frag')).toBe('url:https://cdn.example/p/a.glb'); + expect(urlKey('https://x.test/hdri/sky.hdr', '1.41.0')).toBe( + 'url:https://x.test/hdri/sky.hdr#v=1.41.0' + ); + expect(urlKey('blob:https://x/1')).toBeNull(); + expect(urlKey('data:,x')).toBeNull(); + expect(urlKey('hdri/sky.hdr')).toBeNull(); + expect(urlKey(undefined)).toBeNull(); + }); + it('urlOfKey inverts a url key (without its version)', () => { + expect(urlOfKey(urlKey('https://a/b.glb'))).toBe('https://a/b.glb'); + expect(urlOfKey(urlKey('https://a/b.hdr', '9'))).toBe('https://a/b.hdr'); + expect(urlOfKey('derived:texture:abc:{}')).toBeNull(); + }); + it('derived keys: content hash + kind + settings, field order irrelevant', () => { + const a = derivedKey('texture', H('a'), { maxTextureSize: 2048, textures: 'ktx2+fallback' }); + const b = derivedKey('texture', H('A'), { textures: 'ktx2+fallback', maxTextureSize: 2048 }); + expect(a).toBe(b); + expect(a.startsWith('derived:texture:' + H('a') + ':')).toBe(true); + expect( + derivedKey('texture', H('a'), { maxTextureSize: 1024, textures: 'ktx2+fallback' }) + ).not.toBe(a); + expect( + derivedKey('texture', H('b'), { maxTextureSize: 2048, textures: 'ktx2+fallback' }) + ).not.toBe(a); + expect(derivedKey('texture', 'not-a-hash', {})).toBeNull(); + expect(derivedKey('', H('a'), {})).toBeNull(); + }); + it('stableStringify sorts keys at every depth and drops undefined fields', () => { + expect(stableStringify({ b: 1, a: { d: [2, { z: 1, y: 2 }], c: undefined } })).toBe( + '{"a":{"d":[2,{"y":2,"z":1}]},"b":1}' + ); + expect(stableStringify(null)).toBe('null'); + expect(stableStringify(undefined)).toBe('null'); + }); +}); + +describe('normalizeIndex', () => { + it('keeps good rows, drops malformed ones and unknown versions', () => { + const raw = { + v: 1, + entries: { + ok: { + hash: H('a'), + bytes: 10, + kind: 'sky', + lastUsed: 5, + at: 1, + type: 'image/vnd.radiance', + meta: { r: 1 } + }, + badHash: { hash: 'zz', bytes: 1, kind: 'pack' }, + badBytes: { hash: H('b'), bytes: -1, kind: 'pack' }, + oddKind: { hash: H('c'), bytes: 3, kind: 'weird' } + } + }; + const idx = normalizeIndex(raw); + expect(Object.keys(idx.entries).sort()).toEqual(['oddKind', 'ok']); + expect(idx.entries.ok).toEqual({ + hash: H('a'), + bytes: 10, + kind: 'sky', + type: 'image/vnd.radiance', + lastUsed: 5, + at: 1, + meta: { r: 1 } + }); + expect(idx.entries.oddKind.kind).toBe('import'); + expect(normalizeIndex({ v: 2, entries: raw.entries })).toEqual(emptyIndex()); + expect(normalizeIndex(null)).toEqual(emptyIndex()); + expect(normalizeIndex('junk')).toEqual(emptyIndex()); + }); +}); + +describe('usage', () => { + it('counts a blob shared by two keys ONCE', () => { + const idx = { + v: 1, + entries: { + k1: entry(H('a'), 100, 1), + k2: entry(H('a'), 100, 2, 'import'), + k3: entry(H('b'), 50, 3, 'sky') + } + }; + expect(blobsOf(idx).get(H('a'))).toEqual({ bytes: 100, refs: 2 }); + const u = usageOf(idx); + expect(u).toEqual({ + bytes: 150, + keys: 3, + blobs: 2, + byKind: { pack: 100, sky: 50, texture: 0, import: 0 } + }); + }); +}); + +describe('planEviction (LRU)', () => { + const idx = () => ({ + v: 1, + entries: { + old: entry(H('a'), 400, 10), + mid: entry(H('b'), 300, 20), + fresh: entry(H('c'), 300, 30) + } + }); + it('does nothing under the cap', () => { + expect(planEviction(idx(), 1000)).toEqual({ keys: [], hashes: [], bytesAfter: 1000 }); + }); + it('drops the least recently used first until it fits', () => { + expect(planEviction(idx(), 700)).toEqual({ keys: ['old'], hashes: [H('a')], bytesAfter: 600 }); + expect(planEviction(idx(), 350)).toEqual({ + keys: ['old', 'mid'], + hashes: [H('a'), H('b')], + bytesAfter: 300 + }); + }); + it('never drops a protected key (the one just written), even when it alone is over the cap', () => { + expect(planEviction(idx(), 100, ['fresh'])).toEqual({ + keys: ['old', 'mid'], + hashes: [H('a'), H('b')], + bytesAfter: 300 + }); + }); + it('a shared blob is freed only when its LAST key goes', () => { + const shared = { + v: 1, + entries: { a1: entry(H('a'), 500, 1), a2: entry(H('a'), 500, 50), b: entry(H('b'), 100, 10) } + }; + // a1 goes first but frees nothing (a2 still names the blob); b goes next and frees 100 + expect(planEviction(shared, 550)).toEqual({ + keys: ['a1', 'b'], + hashes: [H('b')], + bytesAfter: 500 + }); + }); + it('is deterministic on equal timestamps (key order)', () => { + const tie = { v: 1, entries: { z: entry(H('a'), 10, 1), y: entry(H('b'), 10, 1) } }; + expect(planEviction(tie, 10).keys).toEqual(['y']); + }); +}); + +describe('orphanedBy', () => { + it('names only the blobs no remaining key refers to', () => { + const idx = { + v: 1, + entries: { a1: entry(H('a'), 5, 1), a2: entry(H('a'), 5, 1), b: entry(H('b'), 5, 1) } + }; + expect(orphanedBy(idx, ['a1'])).toEqual([]); + expect(orphanedBy(idx, ['a1', 'a2', 'b']).sort()).toEqual([H('a'), H('b')]); + expect(orphanedBy(idx, ['missing'])).toEqual([]); + }); +}); + +describe('the cap choices', () => { + it('offer the default', () => { + expect(CAP_CHOICES_MB).toContain(DEFAULT_CAP_MB); + }); +}); From 7827405ffde7bfcc9d76f1466112e99aa51d080e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:48:01 +0300 Subject: [PATCH 004/104] =?UTF-8?q?[feat]=2060=20Q6=20Settings=20=E2=96=B8?= =?UTF-8?q?=20Storage=20+=20Storage=20breakdown=20rows;=20Q2/Q6=20e2e=20su?= =?UTF-8?q?ites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings ▸ Storage (StorageSettings.svelte, Workspace group): what the cache holds by kind, Clear, the size limit (256 MB / 512 MB / 1 GB / 2 GB, LRU), the persistence state with "Ask the browser", and the Storage breakdown button - Storage breakdown: a "Cached downloads" category (skies, converted textures, downloaded files), one row per kind, reclaimed through the cache's own delete - e2e: asset-cache-opfs (second load served from OPFS with no request, persist asked once, no shell in the index, Settings Clear, the 39 P5 migration), fast-channel-loss (p95 pose gap at 0/5/10 % loss before/after through a WebRTC loss shim), fast-channel-fallback Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/Settings.svelte | 7 + .../menu/settings/StorageSettings.svelte | 167 +++++++++++ src/lib/settings/categories.js | 1 + src/lib/storageUsage.js | 35 +++ tests/e2e/asset-cache-opfs.test.cjs | 201 +++++++++++++ tests/e2e/fast-channel-fallback.test.cjs | 81 ++++++ tests/e2e/fast-channel-loss.test.cjs | 275 ++++++++++++++++++ 7 files changed, 767 insertions(+) create mode 100644 src/components/menu/settings/StorageSettings.svelte create mode 100644 tests/e2e/asset-cache-opfs.test.cjs create mode 100644 tests/e2e/fast-channel-fallback.test.cjs create mode 100644 tests/e2e/fast-channel-loss.test.cjs diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 32df297f6..65d03db7e 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -48,6 +48,7 @@ import ShortcutsSettings from './settings/ShortcutsSettings.svelte'; import SceneSettings from './settings/SceneSettings.svelte'; // checkpoints, loading, water render inside import ExplorerSettings from './settings/ExplorerSettings.svelte'; + import StorageSettings from './settings/StorageSettings.svelte'; // 60 Q6: the local asset cache import NodeTypesSection from './NodeTypesSection.svelte'; // 36 B7 // 36-export: the export defaults (its own file; also the Publish / Export modal's Settings tab) import ExportSettingsSection from './ExportSettingsSection.svelte'; @@ -84,6 +85,8 @@ $: if ((settingsQuery || '').trim() || ($settingsOpen && $settingsSection === 'accessibility')) accessibilityExpanded = true; let sceneExpanded = false; let explorerExpanded = false; + let storageExpanded = false; // 60 Q6: Settings ▸ Storage (StorageSettings.svelte) + $: if ((settingsQuery || '').trim() || ($settingsOpen && $settingsSection === 'storage')) storageExpanded = true; let connectionExpanded = false; let aboutExpanded = false; let vrExpanded = false; // D7: edit-cap toasts deep-link here ('vr') @@ -562,6 +565,10 @@ {#snippet header()}Explorer{/snippet} + + {#snippet header()}Storage{/snippet} + + {#snippet header()}VR{/snippet} diff --git a/src/components/menu/settings/StorageSettings.svelte b/src/components/menu/settings/StorageSettings.svelte new file mode 100644 index 000000000..ebf30e7aa --- /dev/null +++ b/src/components/menu/settings/StorageSettings.svelte @@ -0,0 +1,167 @@ + + + + +
+
+ + {#snippet desc()} + + {#if $assetCacheBackend === 'none'} + This browser gives the page no storage to keep files in, so everything is downloaded + each time. + {:else} + {fmtBytes(usage.bytes)} in {usage.blobs} + {usage.blobs === 1 ? 'file' : 'files'}{parts ? ' — ' + parts : ''}. Packs, skies, + converted textures and downloads are kept so they load again without the network. The + app itself is never kept here, so updates always arrive. + {/if} + {#if cleared} {cleared}{/if} + + {/snippet} + + + + assetCacheCapMB.set(Number(v))} + /> + + + {#snippet desc()} + + {#if $persistState === 'granted'} + The browser keeps this storage even when it is short of space. + {:else if $persistState === 'unsupported'} + This browser decides by itself when to clear site storage. + {:else} + The browser may clear the cache when it is short of space. Asking lets it keep it; some + browsers decide by how often you visit. + {/if} + + {/snippet} + {#if $persistState === 'not-granted'} + + {/if} + + + + +
+
+ + diff --git a/src/lib/settings/categories.js b/src/lib/settings/categories.js index 30e4b0356..9b323cd7d 100644 --- a/src/lib/settings/categories.js +++ b/src/lib/settings/categories.js @@ -27,6 +27,7 @@ const META = { shortcuts: { group: 'general', order: 5, description: 'Click a shortcut’s keys, then press the new combination.' }, scene: { group: 'workspace', order: 6, description: 'How the 3D view draws, saves and behaves on this device.' }, explorer: { group: 'workspace', order: 7, description: 'Files, sharing and the recycle bin.' }, + storage: { group: 'workspace', order: 7.5, description: 'Downloads kept on this device for faster, offline loads: size, limit and Clear.' }, // 60 Q6 nodetypes: { group: 'workspace', order: 8, description: 'Hide the node types you never use from the palette, add menus and search on this device.' }, import: { group: 'workspace', order: 8.5, description: 'What happens to big models and textures as they come in.' }, export: { group: 'workspace', order: 9, description: 'What the next exported game starts with.' }, diff --git a/src/lib/storageUsage.js b/src/lib/storageUsage.js index f34eccd5b..161b764ba 100644 --- a/src/lib/storageUsage.js +++ b/src/lib/storageUsage.js @@ -63,6 +63,7 @@ import { clearSavedSession } from './autosave'; import { checkpoints, deleteCheckpoint, KEY as KEY_CHECKPOINT, INDEX_KEY as KEY_CHECKPOINT_INDEX } from './checkpoints'; import { fmtBytes } from './transferLedger'; import { loadPackCacheIndex, packCacheIndex, deletePackCache, clearPackCache } from './packCache'; +import { assetCacheIndex, deleteCached } from './assetCache'; // 60 Q6: the rest of the local asset cache import { clearMeasuredDims } from './placementDims'; import { PACKS_BASE, packs } from './packs'; import { listImageVersions, forgetImageVersions, isImageVersionKey } from './image/imageVersions'; // 40-image @@ -186,6 +187,12 @@ export const CATEGORIES = [ label: 'Pack downloads', note: 'Files of pack items this device downloaded. Clearing them keeps every item in its pack: it downloads again the next time it is placed or a scene needs it.' }, + { + // 60 Q6: the rest of the local asset cache (OPFS) — Settings ▸ Storage has its limit and Clear + key: 'assetcache', + label: 'Cached downloads', + note: 'Skies, converted textures and downloaded files kept on this device so they load again without the network. Clearing them only costs a download (or a conversion) the next time.' + }, { key: 'modules', label: 'Installed modules', @@ -736,6 +743,31 @@ export async function scanStorage() { }); } catch {} + // ---- 60 Q6: the asset cache's other kinds, one row per kind --------------------- + try { + const labels = /** @type {Record} */ ({ sky: 'Skies', texture: 'Converted textures', import: 'Downloaded files' }); + /** @type {Map, bytes: number}>} */ + const byKind = new Map(); + for (const [k, e] of Object.entries(get(assetCacheIndex).entries)) { + if (e.kind === 'pack') continue; // listed per pack above + const row = byKind.get(e.kind) ?? { keys: [], hashes: new Set(), bytes: 0 }; + row.keys.push(k); + if (!row.hashes.has(e.hash)) (row.hashes.add(e.hash), (row.bytes += e.bytes)); + byKind.set(e.kind, row); + } + for (const [kind, row] of byKind) + push({ + id: 'assetcache:' + kind, + category: 'assetcache', + label: labels[kind] ?? kind, + sub: row.hashes.size + (row.hashes.size === 1 ? ' file' : ' files'), + bytes: row.bytes, + removable: true, + kind: 'assetcache', + ref: row.keys + }); + } catch {} + /** @type {StorageCategory[]} */ const categories = CATEGORIES.map((c) => { const rows = (buckets.get(c.key) ?? []).sort((a, b) => b.bytes - a.bytes || a.label.localeCompare(b.label)); @@ -808,6 +840,9 @@ export async function reclaimRow(row) { case 'packcache': // 39 P5: the item stays in its pack; only this device's copy goes return await deletePackCache(/** @type {string[]} */ (row.ref ?? [])); + case 'assetcache': + // 60 Q6: only this device's copies go; the next use downloads (or converts) again + return await deleteCached(/** @type {string[]} */ (row.ref ?? [])); case 'packdims': await clearMeasuredDims(); return row.bytes; diff --git a/tests/e2e/asset-cache-opfs.test.cjs b/tests/e2e/asset-cache-opfs.test.cjs new file mode 100644 index 000000000..3c67c4dfc --- /dev/null +++ b/tests/e2e/asset-cache-opfs.test.cjs @@ -0,0 +1,201 @@ +// 60 Q6 — THE LOCAL ASSET CACHE (OPFS): +// §1 a pack file and a sky are downloaded ONCE: after a reload the second load reads them from +// OPFS and the network sees no request for either (counted on the wire, not believed) +// §2 navigator.storage.persist() is asked exactly once, from the toast that says why — not on +// boot, not again after a reload +// §3 the app shell is never kept: only content keys are in the index +// §4 Settings ▸ Storage shows what it holds and Clear empties it (index and OPFS files) +// §5 39 P5's CacheStorage pack copies are moved over and that cache deleted +// +// APP_URL=https://localhost:5452/ node tests/e2e/asset-cache-opfs.test.cjs +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const STATIC = path.join(__dirname, '../../static/library'); +const DUCK = + 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@format-1/default/Duck/glTF-Binary/Duck.glb'; +const DUCK_BYTES = fs.statSync(path.join(STATIC, 'default/Duck/glTF-Binary/Duck.glb')).size; +const SKY = 'hdri/meadow_2_1k.hdr'; +const LEGACY = + 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@format-1/default/legacy-probe.glb'; + +/** counts persist() calls across reloads (sessionStorage survives a reload in one tab) and + * makes persisted() answer false, so the app has a reason to ask */ +function persistSpy() { + const storage = navigator.storage; + storage.persist = async () => { + sessionStorage.setItem( + 'e2e:persist', + String(Number(sessionStorage.getItem('e2e:persist') || 0) + 1) + ); + return true; + }; + storage.persisted = async () => Number(sessionStorage.getItem('e2e:persist') || 0) > 0; +} + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { init: persistSpy }); + const hits = { duck: 0, sky: 0 }; + A.page.on('request', (req) => { + const u = req.url(); + if (/Duck\.glb$/.test(u)) hits.duck++; + if (u.endsWith('/' + SKY)) hits.sky++; + }); + await A.ctx.route(/cdn\.jsdelivr\.net\/gh\/theprototype-app\/packs@[^/]+\/.*\.glb$/, (route) => + route.fulfill({ + contentType: 'model/gltf-binary', + body: fs.readFileSync(path.join(STATIC, 'default/Duck/glTF-Binary/Duck.glb')) + }) + ); + + const facts = () => + A.page.evaluate(async () => { + const s = window.__stores; + await s.assetCache.assetCacheReady(); + let idx; + s.assetCache.assetCacheIndex.subscribe((v) => (idx = v))(); + return { + stats: s.assetCache.assetCacheStats(), + keys: Object.keys(idx.entries), + persist: Number(sessionStorage.getItem('e2e:persist') || 0) + }; + }); + + // ---- §5 first: a 39 P5 CacheStorage copy is waiting from an older build ---------------------- + await A.page.evaluate(async (url) => { + const c = await caches.open('tp-pack-files-v1'); + await c.put( + url, + new Response(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), { + headers: { 'content-type': 'model/gltf-binary' } + }) + ); + }, LEGACY); + + let f = await facts(); + h.check(f.stats.backend === 'opfs', 'the cache runs on OPFS (' + f.stats.backend + ')'); + h.check(f.persist === 0, '§2 premise: persist() was not asked on boot'); + + // ---- §1 first load: downloads ----------------------------------------------------------------- + const first = await A.page.evaluate( + async ({ duck, sky }) => { + const s = window.__stores; + const a = await s.packCache.fetchPackBuffer(duck); + const b = await s.assetCache.fetchCached(new URL(sky, document.baseURI).href, { + kind: 'sky', + version: 'e2e' + }); + return { duck: a.byteLength, sky: b.byteLength }; + }, + { duck: DUCK, sky: SKY } + ); + h.check( + first.duck === DUCK_BYTES && hits.duck === 1, + `first load fetched the pack file once (${first.duck} bytes, ${hits.duck} request)` + ); + h.check( + first.sky > 100000 && hits.sky === 1, + `first load fetched the sky once (${first.sky} bytes, ${hits.sky} request)` + ); + await h.eventually(facts, (x) => x.keys.length >= 2, 'both are in the cache index'); + + // §5: the legacy copy moved over and the old cache is gone + await A.page.evaluate(() => window.__stores.packCache.loadPackCacheIndex()); + const legacy = await A.page.evaluate( + async (url) => ({ + old: await caches.has('tp-pack-files-v1'), + held: !!(await window.__stores.packCache.cachedPackBuffer(url)) + }), + LEGACY + ); + h.check( + !legacy.old && legacy.held, + '§5 the 39 P5 CacheStorage copy moved into the asset cache and that cache is deleted' + ); + + // ---- §2 the question, once ---------------------------------------------------------------------- + const toast = A.page.locator('.tp-toast', { hasText: 'kept on this device' }).first(); + await toast.waitFor({ timeout: 10000 }); + h.check(true, '§2 the toast says why it wants to keep the cache'); + await toast.getByRole('button', { name: 'Keep them' }).click(); + await h.eventually( + facts, + (x) => x.persist === 1, + '§2 "Keep them" asked navigator.storage.persist() once' + ); + + // ---- reload: the second load is served from OPFS --------------------------------------------- + await A.page.waitForTimeout(800); // the index flush (400 ms debounce) + await h.freshReload(A); + await h.answerRestore(A).catch(() => {}); + const second = await A.page.evaluate( + async ({ duck, sky }) => { + const s = window.__stores; + const a = await s.packCache.fetchPackBuffer(duck); + const b = await s.assetCache.fetchCached(new URL(sky, document.baseURI).href, { + kind: 'sky', + version: 'e2e' + }); + return { duck: a.byteLength, sky: b.byteLength, stats: s.assetCache.assetCacheStats() }; + }, + { duck: DUCK, sky: SKY } + ); + h.check( + second.duck === DUCK_BYTES && second.sky === first.sky, + '§1 the second load has the same bytes' + ); + h.check( + hits.duck === 1 && hits.sky === 1, + `§1 the second load made NO request: pack ${hits.duck}, sky ${hits.sky} (total since boot)` + ); + h.check( + second.stats.hits >= 2 && second.stats.fetches === 0, + `§1 both came from the cache (hits ${second.stats.hits}, fetches ${second.stats.fetches})` + ); + await A.page.waitForTimeout(1500); + f = await facts(); + h.check( + f.persist === 1, + '§2 persist() still asked exactly once after the reload (' + f.persist + ')' + ); + h.check( + (await A.page.locator('.tp-toast', { hasText: 'kept on this device' }).count()) === 0, + '§2 and the toast does not come back' + ); + + // ---- §3 content only ---------------------------------------------------------------------------- + const shell = f.keys.filter((k) => /\.(js|css|html)(#|$)|\/_app\/|sw\.js|version\.json/.test(k)); + h.check( + shell.length === 0, + '§3 nothing of the app shell is in the cache (' + JSON.stringify(f.keys) + ')' + ); + + // ---- §4 Settings ▸ Storage ----------------------------------------------------------------------- + await A.page.evaluate(() => { + window.__stores.settingsSection.set('storage'); + window.__stores.settingsOpen.set(true); + }); + const usage = A.page.locator('#asset-cache-usage'); + await usage.waitFor({ timeout: 15000 }); + const text = await usage.innerText(); + h.check( + /packs/.test(text) && /skies/.test(text) && /\d/.test(text), + '§4 Settings ▸ Storage shows the use by kind: "' + text.slice(0, 90) + '…"' + ); + await A.page.locator('#asset-cache-clear').click(); + await h.eventually(facts, (x) => x.keys.length === 0, '§4 Clear empties the index'); + const files = await A.page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle('tp-asset-cache'); + const blobs = await dir.getDirectoryHandle('blobs'); + let n = 0; + for await (const _ of blobs.keys()) n++; + return n; + }); + h.check(files === 0, '§4 and the OPFS blob files (' + files + ' left)'); + h.check(/Freed/.test(await usage.innerText()), '§4 it says what it freed'); + + await h.finish(browser); +}); diff --git a/tests/e2e/fast-channel-fallback.test.cjs b/tests/e2e/fast-channel-fallback.test.cjs new file mode 100644 index 000000000..e1c49d726 --- /dev/null +++ b/tests/e2e/fast-channel-fallback.test.cjs @@ -0,0 +1,81 @@ +// 60 Q2 — GRACEFUL FALLBACK: a peer without the fast channel (an older build; here B with +// `fastChannel = off` from boot, so it advertises no `fc` and ignores the hello) gets every +// pose down the reliable conn, and nothing is opened on either side. Then the same pair the +// other way round: B's own stream reaches A. And the avatar really moves on the receiver. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B', { storage: { fastChannel: 'off' } }); + await h.connect(A, B); + await A.page.waitForTimeout(2000); + + const open = { + a: await A.page.evaluate((id) => window.__stores.fastChannel.isOpen(id), B.id), + b: await B.page.evaluate((id) => window.__stores.fastChannel.isOpen(id), A.id), + aPeers: await A.page.evaluate(() => window.__stores.fastChannel.links.peers()) + }; + h.check( + !open.a && !open.b && open.aPeers.length === 0, + 'no fast channel is opened with a peer that lacks it ' + JSON.stringify(open) + ); + + /** stream n camera poses from `from` with {fast:true}; `to` records how they arrived */ + async function stream(from, to, n) { + await to.page.evaluate((fid) => { + window.__poses = []; + window.__stores.fastChannel.setPoseTap((peer, d, via) => { + if (peer === fid && d.type === 'camera') window.__poses.push({ via, x: d.position[0] }); + }); + }, from.id); + await from.page.evaluate( + (n) => + new Promise((resolve) => { + let p; + window.__stores.peers.subscribe((x) => (p = x))(); + let i = 0; + const t = setInterval(() => { + p.send( + { + type: 'camera', + peerId: p.peer.id, + position: [2 + i * 0.01, 1.6, -1], + rotation: [0, 0.5, 0] + }, + { fast: true } + ); + if (++i >= n) (clearInterval(t), resolve(null)); + }, 50); + }), + n + ); + await to.page.waitForTimeout(1500); + return to.page.evaluate(() => window.__poses); + } + + const toB = await stream(A, B, 60); + h.check(toB.length === 60, `A -> B: every pose arrived on the fallback (${toB.length}/60)`); + h.check( + toB.every((p) => p.via === 'reliable'), + 'A -> B: all of them on the reliable conn' + ); + const avatar = await B.page.evaluate((aid) => { + let scene; + window.__stores.globalScene.subscribe((s) => (scene = s))(); + const m = scene?.getObjectByName(aid); + return m ? m.position.x : null; + }, A.id); + h.check( + avatar !== null && Math.abs(avatar - (2 + 59 * 0.01)) < 1e-6, + `B draws A's avatar at the last pose (${avatar})` + ); + + const toA = await stream(B, A, 40); + h.check( + toA.length === 40 && toA.every((p) => p.via === 'reliable'), + `B -> A: every pose arrived, reliable (${toA.length}/40)` + ); + + await h.finish(browser); +}); diff --git a/tests/e2e/fast-channel-loss.test.cjs b/tests/e2e/fast-channel-loss.test.cjs new file mode 100644 index 000000000..b560d0f35 --- /dev/null +++ b/tests/e2e/fast-channel-loss.test.cjs @@ -0,0 +1,275 @@ +// 60 Q2 — THE FAST MOVEMENT CHANNEL ON A LOSSY LINK, MEASURED. +// +// Two real peers. A streams its avatar pose at 20 Hz through the app's own send path +// (`peers.send({type:'camera'…}, {fast: true})`, what Scene.svelte does); B times every pose +// the app APPLIES (the `fastChannel.setPoseTap` seam sits after the stale filter, on both +// channels) and the gaps between them give the p95. Six phases on ONE connection: +// before (A's channel switched off = every pose on the reliable conn, the pre-Q2 wire) and +// after, each at 0 / 5 / 10 % loss. +// +// THE LOSS MODEL (a WebRTC loss shim — tc netem needs root and would hit every lane on this +// box). Installed on both pages before boot, it wraps every RTCDataChannel's message delivery: +// * every message gets a one-way delay of BASE_MS ± JITTER_MS; +// * on an UNRELIABLE channel (maxRetransmits set) a lost message is gone; +// * on a RELIABLE ordered channel a lost message is RETRANSMITTED RTX_MS later (doubling on a +// repeated loss, the SCTP back-off) and every later message waits behind it — head-of-line +// blocking, which is what SCTP's ordered delivery does with a gap. +// RTX_MS = 200 stands for SCTP's recovery on a 20 Hz stream: fast retransmit needs three later +// packets (~150 ms at 20 Hz) plus a round trip; the RTO path is slower still. A seeded PRNG per +// page, so a rerun draws the same losses. +// +// Also asserted: a reliable EDIT stream (numbered chat messages) arrives complete and in order +// in every phase; and the trailing keyframe lands A's final pose exactly after the stream stops. +// Evidence: EVIDENCE/fast-channel-loss.json + .md (table of p95 / p50 / max / received). +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const BASE_MS = 20; +const JITTER_MS = 5; +const RTX_MS = 200; +const HZ = 20; +const PHASE_MS = Number(process.env.FC_PHASE_MS || 20000); +const LOSSES = [0, 0.05, 0.1]; +const EVIDENCE = + process.env.EVIDENCE || path.join(process.env.HOME || '.', '.code/lanes-30/after-60/60-net'); + +/** runs in the page before any app code */ +function lossShim(cfg) { + window.__loss = { + p: 0, + base: cfg.base, + jitter: cfg.jitter, + rtx: cfg.rtx, + lost: { fast: 0, reliable: 0 }, + msgs: { fast: 0, reliable: 0 } + }; + let seed = cfg.seed >>> 0; + const rand = () => { + seed = (seed + 0x6d2b79f5) >>> 0; + let t = seed; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const state = new WeakMap(); + /** wrap one listener so delivery follows the model */ + function wrap(dc, fn) { + return function (event) { + const L = window.__loss; + const unreliable = dc.maxRetransmits !== null || dc.maxPacketLifeTime !== null; + const kind = unreliable ? 'fast' : 'reliable'; + L.msgs[kind]++; + let at = performance.now() + L.base + (rand() * 2 - 1) * L.jitter; + if (L.p > 0 && rand() < L.p) { + L.lost[kind]++; + if (unreliable) return; // gone + let rtx = L.rtx; + at += rtx; + while (rand() < L.p) { + rtx *= 2; + at += rtx; + } + } + let s = state.get(dc); + if (!s) state.set(dc, (s = { last: 0 })); + if (!unreliable) { + // ordered: nothing overtakes the message before it + at = Math.max(at, s.last); + s.last = at; + } + const self = this; + setTimeout(() => fn.call(self, event), Math.max(0, at - performance.now())); + }; + } + const proto = RTCDataChannel.prototype; + const desc = Object.getOwnPropertyDescriptor(proto, 'onmessage'); + Object.defineProperty(proto, 'onmessage', { + configurable: true, + get() { + return this.__onmsg ?? null; + }, + set(fn) { + this.__onmsg = fn; + desc.set.call(this, typeof fn === 'function' ? wrap(this, fn) : fn); + } + }); + const add = proto.addEventListener; + proto.addEventListener = function (type, fn, opts) { + if (type === 'message' && typeof fn === 'function') + return add.call(this, type, wrap(this, fn), opts); + return add.call(this, type, fn, opts); + }; +} + +/** @param {number[]} xs @param {number} q */ +const quantile = (xs, q) => { + if (!xs.length) return NaN; + const s = [...xs].sort((a, b) => a - b); + return s[Math.min(s.length - 1, Math.floor(q * (s.length - 1) + 0.5))]; +}; + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { + init: [lossShim, { base: BASE_MS, jitter: JITTER_MS, rtx: RTX_MS, seed: 6001 }] + }); + const B = await h.setupPage(browser, 'B', { + init: [lossShim, { base: BASE_MS, jitter: JITTER_MS, rtx: RTX_MS, seed: 6002 }] + }); + await h.connect(A, B); + + // the channel opened both ways (the modules `fc` + `fastchan` hello) + await h.eventually( + async () => ({ + a: await A.page.evaluate((id) => window.__stores.fastChannel.isOpen(id), B.id), + b: await B.page.evaluate((id) => window.__stores.fastChannel.isOpen(id), A.id) + }), + (r) => r.a && r.b, + 'the fast channel is open on both ends after the handshake' + ); + const hasPc = await A.page.evaluate((id) => { + let p; + window.__stores.peers.subscribe((x) => (p = x))(); + return !!p.connections[id]?.peerConnection; + }, B.id); + h.check(hasPc, 'the PeerJS connection exposes its RTCPeerConnection'); + + // B's tap: arrival times of A's applied camera poses, and the chat edits in arrival order + await B.page.evaluate((aid) => { + window.__poses = []; + window.__stores.fastChannel.setPoseTap((peer, d, via) => { + if (peer === aid && d.type === 'camera') + window.__poses.push({ t: performance.now(), via, x: d.position[0], ps: d.ps }); + }); + }, A.id); + + /** one phase: set loss on both, stream for PHASE_MS, collect B's gaps */ + async function phase(mode, loss) { + const fastOn = mode === 'after'; + for (const peer of [A, B]) await peer.page.evaluate((p) => (window.__loss.p = p), loss); + await A.page.evaluate( + (on) => + on ? localStorage.removeItem('fastChannel') : localStorage.setItem('fastChannel', 'off'), + fastOn + ); + await B.page.evaluate(() => (window.__poses = [])); + const tag = mode + '-' + Math.round(loss * 100); + const sent = await A.page.evaluate( + ({ ms, hz, tag }) => + new Promise((resolve) => { + let p; + window.__stores.peers.subscribe((x) => (p = x))(); + let n = 0; + let edits = 0; + const t0 = performance.now(); + const timer = setInterval(() => { + const x = n * 0.01; + // exactly the call Scene.svelte makes for a moving camera + p.send( + { type: 'camera', peerId: p.peer.id, position: [x, 1.6, 0], rotation: [0, 0, 0] }, + { fast: true } + ); + n++; + // a reliable EDIT every 10th tick (2 a second), numbered + if (n % 10 === 0) p.sendMessage('edit ' + tag + ' ' + edits++); + if (performance.now() - t0 >= ms) { + clearInterval(timer); + resolve({ n, edits, lastX: (n - 1) * 0.01 }); + } + }, 1000 / hz); + }), + { ms: PHASE_MS, hz: HZ, tag } + ); + // let retransmits drain and the trailing keyframe land + await B.page.waitForTimeout(1500); + const got = await B.page.evaluate((tag) => { + let msgs = []; + window.__stores.messages?.subscribe?.((m) => (msgs = m))(); + const edits = (msgs || []) + .map((m) => String(m?.message ?? '')) + .filter((m) => m.startsWith('edit ' + tag + ' ')) + .map((m) => Number(m.split(' ').pop())); + return { poses: window.__poses, edits, loss: window.__loss }; + }, tag); + const times = got.poses.map((p) => p.t); + const gaps = times.slice(1).map((t, i) => t - times[i]); + const viaFast = got.poses.filter((p) => p.via === 'fast').length; + const lastX = got.poses.length ? got.poses[got.poses.length - 1].x : NaN; + const row = { + mode, + loss, + sent: sent.n, + received: got.poses.length, + viaFast, + p50: Math.round(quantile(gaps, 0.5)), + p95: Math.round(quantile(gaps, 0.95)), + p99: Math.round(quantile(gaps, 0.99)), + max: Math.round(Math.max(...gaps)), + editsSent: sent.edits, + editsInOrder: got.edits.length === sent.edits && got.edits.every((v, i) => v === i), + finalPoseLanded: Math.abs(lastX - sent.lastX) < 1e-9 + }; + console.log(JSON.stringify(row)); + return row; + } + + const rows = []; + for (const loss of LOSSES) { + rows.push(await phase('before', loss)); + rows.push(await phase('after', loss)); + } + for (const peer of [A, B]) await peer.page.evaluate(() => (window.__loss.p = 0)); + + for (const r of rows) { + const name = `${r.mode} @ ${Math.round(r.loss * 100)} %`; + h.check(r.editsInOrder, `${name}: every reliable edit arrived, in order (${r.editsSent})`); + h.check(r.finalPoseLanded, `${name}: the final pose landed exactly (trailing keyframe)`); + if (r.mode === 'after') + h.check( + r.viaFast > r.received * 0.8, + `${name}: poses rode the fast channel (${r.viaFast}/${r.received})` + ); + else h.check(r.viaFast === 0, `${name}: the channel switched off sends every pose reliable`); + } + const at = (mode, loss) => rows.find((r) => r.mode === mode && r.loss === loss); + h.check( + at('after', 0.05).p95 < 150, + `after @ 5 %: p95 pose gap < 150 ms (release bar) — ${at('after', 0.05).p95} ms` + ); + h.check( + at('after', 0.05).p95 < at('before', 0.05).p95, + `5 %: fast p95 ${at('after', 0.05).p95} < reliable p95 ${at('before', 0.05).p95}` + ); + h.check( + at('after', 0.1).p95 < at('before', 0.1).p95, + `10 %: fast p95 ${at('after', 0.1).p95} < reliable p95 ${at('before', 0.1).p95}` + ); + + try { + fs.mkdirSync(EVIDENCE, { recursive: true }); + const meta = { + at: new Date().toISOString(), + model: { BASE_MS, JITTER_MS, RTX_MS, HZ, PHASE_MS }, + rows + }; + fs.writeFileSync(path.join(EVIDENCE, 'fast-channel-loss.json'), JSON.stringify(meta, null, 2)); + const md = [ + '# 60 Q2 — pose gap on a lossy link (fast-channel-loss e2e)', + '', + `Model: one-way ${BASE_MS} ± ${JITTER_MS} ms; reliable loss = retransmit after ${RTX_MS} ms (doubling) with head-of-line blocking; fast loss = dropped. ${HZ} Hz stream, ${PHASE_MS / 1000} s per phase, gaps between APPLIED poses at the receiver.`, + '', + '| channel | loss | sent | applied | p50 ms | p95 ms | p99 ms | max ms | edits in order | final pose |', + '|---|---|---|---|---|---|---|---|---|---|', + ...rows.map( + (r) => + `| ${r.mode === 'after' ? 'fast (after)' : 'reliable (before)'} | ${Math.round(r.loss * 100)} % | ${r.sent} | ${r.received} | ${r.p50} | ${r.p95} | ${r.p99} | ${r.max} | ${r.editsInOrder ? 'yes' : 'NO'} | ${r.finalPoseLanded ? 'yes' : 'NO'} |` + ) + ]; + fs.writeFileSync(path.join(EVIDENCE, 'fast-channel-loss.md'), md.join('\n') + '\n'); + } catch (e) { + console.log('evidence write failed: ' + e); + } + + await h.finish(browser); +}); From 7669ad73a5c660bb6e092728346b8e14dce0c148 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:52:03 +0300 Subject: [PATCH 005/104] =?UTF-8?q?[feat]=2060=20Q1:=20Play=20here=20?= =?UTF-8?q?=E2=80=94=20start=20Play=20from=20the=20editor=20camera,=20Stop?= =?UTF-8?q?=20brings=20the=20view=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The player starts where the editor camera is: X/Z under it, the feet on the first solid surface straight below (scene meshes on the shared BVH trees, then the physics ground plane; hidden objects, sensors, water and reference images do not count), facing the view's heading. Nothing below → the scene's spawn; no spawn either → the camera's eye. A session-only override that currentSpawn() returns first, so desktop Play, the VR walker, api.respawnPlayer and the game shell all honour it for that round; leaving Play clears it and restores the editor camera, its orbit target and the selection. Entry points: Ctrl+Shift+Enter, a row in Play's right-click / phone long-press menu, an optional toolbar roster button (Customize), phone More ▸ Tools & View, the viewport menu's Tools, and the VR radial Tools ▸ Play here / Back to edit (Interact from where you stand; Edit restores the exact reference space and world grab). Height follows the scene's locomotion: desktop Play is grounded by default (37 R24 pins the eye), and the VR walker stands on real surfaces only while a simulation runs. Tests: unit playHere (the spawn raycast through a real three scene), e2e play-here (desktop + phone N6 folded) and play-here-iwer (IWER, applied-offset spy). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/Controls.svelte | 20 ++- src/components/phone/PhoneShell.svelte | 4 + src/components/ui/Icon.svelte | 7 +- src/lib/debugHooks.js | 2 + src/lib/playHere.js | 189 ++++++++++++++++++++ src/lib/playHereCore.js | 68 +++++++ src/lib/playSpawn.js | 22 ++- src/lib/shortcuts.js | 13 ++ src/lib/ui/phoneBar.js | 2 +- src/lib/vr/frame.js | 3 +- src/lib/vr/locomotion.js | 4 +- src/lib/vr/modes.js | 83 ++++++++- src/lib/vrControls.js | 4 +- src/lib/vrRadialMenu.js | 25 ++- tests/e2e/play-here-iwer.test.cjs | 147 ++++++++++++++++ tests/e2e/play-here.test.cjs | 235 +++++++++++++++++++++++++ tests/unit/phoneBar.test.js | 2 +- tests/unit/playHere.test.js | 130 ++++++++++++++ 18 files changed, 949 insertions(+), 11 deletions(-) create mode 100644 src/lib/playHere.js create mode 100644 src/lib/playHereCore.js create mode 100644 tests/e2e/play-here-iwer.test.cjs create mode 100644 tests/e2e/play-here.test.cjs create mode 100644 tests/unit/playHere.test.js diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index b330b8cbf..ac75c7478 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -65,6 +65,8 @@ import { dockShownKeys, dockOccupants, bottomInset, FLOW_FAMILY, armDockMode, DOCK_TITLES } from '$lib/bottomDock'; import { togglePanel } from '$lib/panelToggles'; import { requestPlay, willEnterXR, willEnterAR, xrSessionFailed, playAsItems } from '$lib/playMode'; + // 60 Q1: Play here — a roster button (Customize), a row in Play's own menu, Ctrl+Shift+Enter + import { playHere, playHereItems, playingHere } from '$lib/playHere'; // 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'; @@ -1035,6 +1037,8 @@ // 41b H7: the "Play as" rows live in $lib/playMode (`playAsItems`) — the phone bar's Play // opens the same ones, so the two buttons write the same choice the same way ...playAsItems(), + // 60 Q1: Play here (the phone's Play menu carries the same row) + ...playHereItems(), // 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 @@ -1344,6 +1348,18 @@ tint: () => ICON_OFF, run: () => redo() }, + // 60 Q1: PLAY HERE — an OPTIONAL roster button (Customize toolbar / the "+" list place it): + // Play with the player where the editor camera is, and the view comes back on Stop. Not on + // the default bar, so a profile that never asks for it is byte-identical; the same act is + // Play's right-click row and Ctrl+Shift+Enter. + playhere: { + title: 'Play here — start from this view (Ctrl+Shift+Enter)', + label: 'Play here', + slot: 'play-here-button', + icon: 'map-pin', + tint: () => ($playingHere ? ICON_ON : ICON_OFF), + run: () => playHere() + }, multiselect: { get title() { return $multiSelectMode @@ -1366,7 +1382,9 @@ /** 42 I9: the bell and the peers trigger open their panels from the top-right corner, so they * may only live in the top-right group (moved, removed, re-added there — never elsewhere) */ const HOME_ONLY: Record = { notifications: 'top', peers: 'top' }; - const ROSTER_IDS = [...DEFAULT_ORDER, ...OPTIONAL_VIEWS, ...CORNER_LEFT, ...CORNER_RIGHT, ...TOP_DEFAULT, ...TOUCH_DEFAULT]; + /** 60 Q1: optional tool buttons (listed in Customize unchecked, like the optional views) */ + const OPTIONAL_TOOLS = ['playhere']; + const ROSTER_IDS = [...DEFAULT_ORDER, ...OPTIONAL_VIEWS, ...OPTIONAL_TOOLS, ...CORNER_LEFT, ...CORNER_RIGHT, ...TOP_DEFAULT, ...TOUCH_DEFAULT]; /** 42 I7: the name a MENU prints for a button (its short label, else its title) */ function labelOf(id: string) { return id === SPACER ? 'Play' : (BUTTONS[id]?.label ?? BUTTONS[id]?.title ?? id); diff --git a/src/components/phone/PhoneShell.svelte b/src/components/phone/PhoneShell.svelte index 8d945ce5f..5fc5bbc08 100644 --- a/src/components/phone/PhoneShell.svelte +++ b/src/components/phone/PhoneShell.svelte @@ -86,6 +86,7 @@ import { phoneBarTap, openPhoneTab, closePhoneTab, phoneDockShown, phoneTabsOpen } from '$lib/phoneDock'; import { toggleViewportFocus } from '$lib/focusMode'; import { requestPlay, willEnterXR, willEnterAR, playAsItems } from '$lib/playMode'; + import { playHere, playHereItems } from '$lib/playHere'; // 60 Q1 import { visibleDockKey, DOCK_TITLES, dockMinimized, dockHeight } from '$lib/bottomDock'; import { micActive, pttActive, toggleMic } from '$lib/voiceChat'; import { aiReady } from '$lib/ai/providers'; @@ -441,6 +442,7 @@ const at = list.indexOf('play'); return [ ...playAsItems(), + ...playHereItems(), // 60 Q1 ...($hudIsGame ? [ { section: 'Game' }, @@ -496,6 +498,8 @@ * @type {Record void, pressed?: () => boolean, disabled?: () => boolean, badge?: () => number, dot?: () => string}>} */ const ACTIONS = { play: { label: 'Play', icon: 'play', run: fromMore(() => requestPlay()) }, + // 60 Q1: Play from where the camera is (More ▸ Tools & View; placeable on the bar) + playhere: { label: 'Play here', short: 'Here', hint: 'Play here — start from this view', icon: 'map-pin', run: fromMore(() => playHere()) }, add: { label: 'Add', icon: 'plus', run: () => add() }, // 41b H1/H5: Objects, Configure Scene and Notes are dock tabs — the bar shows / minimizes them objects: { label: 'Objects', icon: 'list', run: fromMore(() => phoneBarTap('objects')), pressed: () => $visibleDockKey === 'objects' && !$dockMinimized }, diff --git a/src/components/ui/Icon.svelte b/src/components/ui/Icon.svelte index 9f039dc0b..adafb5fcf 100644 --- a/src/components/ui/Icon.svelte +++ b/src/components/ui/Icon.svelte @@ -270,7 +270,10 @@ X, Zap, ZoomIn, - ZoomOut + ZoomOut, + // 60 Q1 + Q5: Play here, reference image + MapPin, + ImageUp } from '@lucide/svelte'; import ToolIcon from './ToolIcon.svelte'; import { iconSize, ICON_STROKE } from '$lib/ui/icons.js'; @@ -393,6 +396,8 @@ 'lock-open': LockOpen, magnet: Magnet, map: MapIcon, + 'map-pin': MapPin, + 'image-up': ImageUp, 'maximize-2': Maximize2, 'minimize-2': Minimize2, menu: Menu, diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 46aa9ba1b..bd9e1094b 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -371,6 +371,8 @@ export const DEBUG_HOOKS = [ ['touchActions', () => import('./touchActions')], ['touchSpec', () => import('./touchSpec')], ['playMode', () => import('./playMode')], + // 60 Q1: Play here + ['playHere', () => import('./playHere')], // 36-export: the export builder, its stores, the export boot + runtime state ['exportBuilder', () => import('./export/exportBuilder.js')], ['exportStores', () => import('./export/exportStores.js')], diff --git a/src/lib/playHere.js b/src/lib/playHere.js new file mode 100644 index 000000000..f39e65eba --- /dev/null +++ b/src/lib/playHere.js @@ -0,0 +1,189 @@ +// 60 Q1: PLAY HERE — Play, with the player standing where the editor camera is looking from. +// Roblox Studio's "Play Here" / Godot's PlayAtCamPos: test the corner you are building without +// walking there from the spawn, and come back to exactly the view you left. +// +// The whole act is four things around the ordinary play press: +// 1. remember the editor camera (pose + orbit target) and the selection; +// 2. resolve a spawn from the camera (playSpawn.playHereSpawnFrom — pure, unit-tested): the +// feet on the first solid surface straight under the eye, facing the view's heading; nothing +// under it → the scene's spawn; no spawn either → the eye stays where the camera is; +// 3. hand that spawn to playSpawn as a session-only OVERRIDE — `currentSpawn()` is the one read +// every spawn path shares (PointerLockControls, the VR walker, api.respawnPlayer, the game +// shell), so a character controller, a game's restart and a fall out of the world all +// respawn HERE for this round, with no second code path; +// 4. `requestPlay()` — the same press as the FAB, so VR/AR vs desktop is still decided once. +// Leaving Play (any exit: Esc, the touch ✕, Back, a HUD quit) clears the override and puts the +// camera and the selection back. +// +// A near-LEAF on purpose (three, stores, playSpawn, playMode, bvhPicking, scenePhysics): it is +// imported from shortcuts.js, which sits in history.js's import subtree. The selection restore +// reaches objectActions by DYNAMIC import for that reason. +import * as THREE from 'three'; +import { get, writable } from 'svelte/store'; +import { editorCam, orbitControls, isLocked, isVRMode, objectsGroup, selectedObjects, selectedObject } from '../stores/sceneStore'; +import { requestPlay } from './playMode'; +import { desktopSpawn, setPlayHereSpawn, playHereSpawn, SPAWN_EYE } from './playSpawn'; +import { playHereSpawnFrom, headingOf, surfaceBelowIn } from './playHereCore'; +import { ensureBoundsTrees } from './bvhPicking'; +import { scenePhysicsGround } from './scenePhysics'; + +/** true from a Play here press until that Play ends (the toolbar cell's tint, the suites' view) */ +export const playingHere = writable(false); + +/** @type {{position: number[], quaternion: number[], target: number[] | null, selection: string[]} | null} */ +let saved = null; +/** the last resolution, for the suites and the debug hook */ +/** @type {{position: number[], yaw: number, source: string} | null} */ +let lastSpawn = null; + +/** + * The y of the first SOLID surface straight under `eye` — the scene's meshes (on the shared pick's + * BVH bounds trees) and the physics ground plane when it is on. Hidden objects, sensors, water and + * reference images do not count (playHereCore.notGround). null = nothing below. + * @param {number[]} eye + * @returns {number | null} + */ +export function surfaceBelow(eye) { + /** @type {any} */ + const group = get(objectsGroup); + if (group?.children?.length) ensureBoundsTrees(group); + return surfaceBelowIn(group, eye, get(scenePhysicsGround)); +} + +/** the editor camera as a Play here view: eye + heading @returns {{eye: number[], yaw: number} | null} */ +export function editorView() { + /** @type {any} */ + const cam = get(editorCam); + if (!cam?.isObject3D) return null; + cam.updateMatrixWorld(true); + const eye = cam.getWorldPosition(new THREE.Vector3()); + const q = cam.getWorldQuaternion(new THREE.Quaternion()); + return { eye: eye.toArray(), yaw: headingOf(q) }; +} + +/** resolve (without playing) where Play here would put the player — the suites' and the button's view */ +export function resolvePlayHere() { + const view = editorView(); + if (!view) return null; + return playHereSpawnFrom(view, surfaceBelow, desktopSpawn(), SPAWN_EYE); +} + +function snapshotEditor() { + /** @type {any} */ + const cam = get(editorCam); + /** @type {any} */ + const controls = get(orbitControls); + const set = get(selectedObjects); + /** @type {any} */ + const primary = get(selectedObject); + return { + position: cam.position.toArray(), + quaternion: cam.quaternion.toArray(), + target: controls?.target ? controls.target.toArray() : null, + selection: set.length ? set.map((/** @type {any} */ o) => o?.uuid ?? o).filter(Boolean) : primary?.uuid ? [primary.uuid] : [] + }; +} + +/** + * THE PRESS. Synchronous all the way to `requestPlay` (an XR session is only granted inside the + * gesture that asked for it). @returns {boolean} whether Play was asked for + */ +export function playHere() { + if (get(isLocked) === true || get(isVRMode)) return false; + /** @type {any} */ + const cam = get(editorCam); + if (!cam?.isObject3D) return false; + const spawn = resolvePlayHere(); + if (!spawn) return false; + saved = snapshotEditor(); + lastSpawn = spawn; + setPlayHereSpawn(spawn); + playingHere.set(true); + requestPlay(); + // a press that could not start Play (already in a transient exit, a refused session) must not + // leave the override standing for the NEXT ordinary Play: the queued replay sets isLocked + // within a macrotask, the XR path within its watchdog — both clear through the exit below + return true; +} + +/** put the editor camera and the selection back the way Play here found them */ +function restoreEditor() { + const was = saved; + saved = null; + if (!was) return; + /** @type {any} */ + const cam = get(editorCam); + if (cam?.isObject3D) { + cam.position.fromArray(was.position); + cam.quaternion.fromArray(was.quaternion); + cam.updateMatrixWorld(true); + } + /** @type {any} */ + const controls = get(orbitControls); + if (controls?.target && was.target) { + controls.target.fromArray(was.target); + // damping would otherwise ease the view from wherever it drifted + controls.update?.(); + if (cam?.isObject3D) { + cam.position.fromArray(was.position); + cam.quaternion.fromArray(was.quaternion); + } + } + if (was.selection.length) { + void import('./objectActions') + .then((m) => { + /** @type {any} */ + const group = get(objectsGroup); + const live = was.selection.filter((uuid) => !!group?.getObjectByProperty?.('uuid', uuid)); + const now = get(selectedObjects).map((/** @type {any} */ o) => o?.uuid ?? o); + if (!live.length || (live.length === now.length && live.every((uuid) => now.includes(uuid)))) return; + m.applySelectionSet(live); + }) + .catch(() => { + /* a selection that cannot come back is not worth an error */ + }); + } +} + +/** the end of a Play here round: whatever ended it */ +export function endPlayHere() { + if (!get(playingHere) && !playHereSpawn()) return; + setPlayHereSpawn(null); + playingHere.set(false); + restoreEditor(); +} + +/** the row Play's own menus carry (the desktop FAB's right-click, the phone bar's long press) */ +export function playHereItems() { + return [ + { section: 'Start' }, + { + label: 'Play here', + tooltip: 'Start Play where the camera is, facing the view — leaving Play brings this view back (Ctrl+Shift+Enter)', + action: () => void playHere() + } + ]; +} + +/** test/debug view */ +export function playHereDebug() { + return { playing: get(playingHere), override: playHereSpawn(), last: lastSpawn, saved }; +} + +// The exit, on the state and not on any one button (the playMode W4 rule): `isLocked` leaving +// `true` after a Play here press ends the round. A press that never reached Play (refused XR, no +// lock) is cleared when the watchdog / transient settles back to the editor. +if (typeof window !== 'undefined') { + let wasPlaying = get(isLocked) === true; + isLocked.subscribe((v) => { + const playing = v === true; + if (wasPlaying && !playing) endPlayHere(); + wasPlaying = playing; + }); + // a Play here that turned into an XR session ends with the session (isVRMode back to false) + let wasVR = get(isVRMode); + isVRMode.subscribe((v) => { + if (wasVR && !v && get(isLocked) !== true) endPlayHere(); + wasVR = !!v; + }); +} diff --git a/src/lib/playHereCore.js b/src/lib/playHereCore.js new file mode 100644 index 000000000..8739ae542 --- /dev/null +++ b/src/lib/playHereCore.js @@ -0,0 +1,68 @@ +// 60 Q1: THE PLAY HERE RULE — three only, no stores, so the unit layer runs it (playHere.js is the +// store-reading half; playSpawn re-exports the two pure functions). +import * as THREE from 'three'; + +/** + * Where Play here puts the player: the feet on the first solid surface straight under the eye, + * facing the view's heading; nothing under it → the scene's own spawn (with its own facing); no + * spawn either → the eye stays exactly where the camera is (feet = eye − eyeHeight), which is where + * a flying player would start anyway. + * @param {{eye: number[], yaw: number}} view the editor camera: eye + heading (rotation.y, 0 faces −Z) + * @param {(eye: number[]) => number | null} surfaceBelow y of the first solid surface under `eye`, or null + * @param {{position: number[], yaw: number} | null} fallback the scene's spawn + * @param {number} eyeHeight how far above the feet the eye stands (SPAWN_EYE on a screen, the head height in VR) + * @returns {{position: [number, number, number], yaw: number, source: 'surface' | 'spawn' | 'camera'}} + */ +export function playHereSpawnFrom(view, surfaceBelow, fallback, eyeHeight) { + const [x, y, z] = view.eye; + const yaw = Number.isFinite(view.yaw) ? view.yaw : 0; + const floor = surfaceBelow(view.eye); + if (floor != null && Number.isFinite(floor) && floor <= y) return { position: [x, floor, z], yaw, source: 'surface' }; + if (fallback) { + const [fx, fy, fz] = fallback.position; + return { position: [fx, fy, fz], yaw: Number.isFinite(fallback.yaw) ? fallback.yaw : 0, source: 'spawn' }; + } + return { position: [x, y - eyeHeight, z], yaw, source: 'camera' }; +} + +/** the heading of an orientation — rotation.y in YXZ order, which stays right when the view looks + * straight down (a forward vector has no horizontal part there) @param {any} quaternion */ +export function headingOf(quaternion) { + return new THREE.Euler().setFromQuaternion(quaternion, 'YXZ').y; +} + +/** something a player cannot stand on: hidden (itself or an ancestor), a sensor, water, a reference image + * @param {any} object @param {any} [root] where the walk stops */ +export function notGround(object, root = null) { + for (let node = object; node && node !== root; node = node.parent) { + if (node.visible === false) return true; + const data = node.userData ?? {}; + if (data.water || data.physics?.sensor || data.underlay) return true; + } + return false; +} + +/** + * The y of the first SOLID surface straight under `eye`: the meshes under `root` and the physics + * ground plane when it is on and below the eye. null = nothing below. + * @param {any} root the scene objects group (or null) + * @param {number[]} eye + * @param {{enabled?: boolean, height?: number} | null} [ground] + * @returns {number | null} + */ +export function surfaceBelowIn(root, eye, ground = null) { + let best = /** @type {number | null} */ (null); + if (root?.children?.length) { + root.updateMatrixWorld(true); + const ray = new THREE.Raycaster(new THREE.Vector3(eye[0], eye[1], eye[2]), new THREE.Vector3(0, -1, 0)); + for (const hit of ray.intersectObjects(root.children, true)) { + const object = /** @type {any} */ (hit.object); + if (!object?.isMesh || notGround(object, root)) continue; + best = hit.point.y; + break; + } + } + const h = Number(ground?.height); + if (ground?.enabled && Number.isFinite(h) && h <= eye[1] && (best == null || h > best)) best = h; + return best; +} diff --git a/src/lib/playSpawn.js b/src/lib/playSpawn.js index f62636359..40a8f2f16 100644 --- a/src/lib/playSpawn.js +++ b/src/lib/playSpawn.js @@ -11,13 +11,33 @@ import { get } from 'svelte/store'; import { playerCam } from '../stores/sceneStore'; import { resolvePlaySettings } from './playSettings'; import { globalScene } from '../stores/sceneStore'; +// 60 Q1: the Play here rule (pure, three only) lives in its own leaf so the unit layer can run it +export { playHereSpawnFrom, headingOf } from './playHereCore'; /** the play camera's eye above the feet: the walker's 1.7 m person */ export const SPAWN_EYE = 1.7; +// 60 Q1: PLAY HERE — a session-only spawn that beats every authored one while it is set. It is +// written by playHere (desktop/phone) and the VR radial's Play here, and cleared when that Play +// ends, so `currentSpawn()` stays the ONE read every spawn path shares (PointerLockControls, the +// VR spawnPlayer, a module's api.respawnPlayer, the game shell's restart). LOCAL: it never +// replicates, saves or undoes — "where I pressed Play here" is a fact about this peer. +/** @type {{position: [number, number, number], yaw: number} | null} */ +let playHereOverride = null; + +/** @param {{position: [number, number, number], yaw: number} | null} spawn */ +export function setPlayHereSpawn(spawn) { + playHereOverride = spawn ? { position: [spawn.position[0], spawn.position[1], spawn.position[2]], yaw: spawn.yaw } : null; +} + +/** the Play here spawn while one is in force (test/debug view too) */ +export function playHereSpawn() { + return playHereOverride; +} + /** the spawn in force right now, or null @returns {{position: [number, number, number], yaw: number} | null} */ export function currentSpawn() { - return resolvePlaySettings(get(globalScene)).spawn; + return playHereOverride ?? resolvePlaySettings(get(globalScene)).spawn; } /** the spawn a DESKTOP view honours — the one in force unless it is VR-only (30b core-games) diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index b399cf7d6..d4df4e1d2 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -47,6 +47,7 @@ import { toggleSidebar } from './sidebars'; // 44 M7 (a leaf: app stores + safeS // shortcuts sits inside history's import family and a cycle there TDZ-crashes the // SSR prerender. import { requestPlay } from './playMode'; +import { playHere } from './playHere'; // 60 Q1 import { selectedObject, selectedObjects } from '../stores/sceneStore'; import { safeStorage } from './safeStorage'; // 53 A3: does a key press belong to the 3D view (a zero-import leaf) @@ -695,6 +696,18 @@ export const shortcuts = [ label: 'Play / Enter VR·AR (right-click the play button for modes)', action: () => requestPlay() }, + { + // 60 Q1: PLAY HERE — the play key with Shift: the player starts where the editor camera + // is (feet on the surface under it, facing the view), and leaving Play brings the view + // and the selection back. playHere is a near-leaf (playSpawn/playMode/bvhPicking), so + // the static edge is cycle-safe from here. + id: 'scene.playHere', + keys: 'Ctrl+Shift+Enter', + group: 'Scene', + scope: 'global', + label: 'Play here — start from the editor camera', + action: () => playHere() + }, { // 19-B P4: the element-snap master switch. Deliberately NOT the grid switch // — the grid keeps `snapEnabled` (VR's applySnapMode owns that one). diff --git a/src/lib/ui/phoneBar.js b/src/lib/ui/phoneBar.js index 43ba1a7a1..b5b3e262d 100644 --- a/src/lib/ui/phoneBar.js +++ b/src/lib/ui/phoneBar.js @@ -26,7 +26,7 @@ export const WINDOW_ITEMS = ['play', 'add', 'more', 'objects', 'stats', 'chat', export const EDIT_ITEMS = ['inspect', 'move', 'rotate', 'scale', 'undo', 'redo', 'multiselect', 'interact']; /** More ▸ Tools & View (46 B1: + the scene budget — the toolbar chip's phone form; a folded bar has no * room for it by default, so it waits here and can be dragged onto the bar like any item) */ -export const VIEW_ITEMS = ['tools', 'focus', 'budget']; +export const VIEW_ITEMS = ['tools', 'focus', 'budget', 'playhere']; // 60 Q1: + Play here /** every key the bar may hold */ export const BAR_CATALOG = [...WINDOW_ITEMS, ...EDIT_ITEMS, ...VIEW_ITEMS]; diff --git a/src/lib/vr/frame.js b/src/lib/vr/frame.js index 599b9fa3d..206789af8 100644 --- a/src/lib/vr/frame.js +++ b/src/lib/vr/frame.js @@ -75,7 +75,7 @@ import { vrJumpHeight, updateHeightLift } from './locomotion.js'; -import { modeHand, toggleVRMode, payPendingSpawn, updateModeLabel } from './modes.js'; +import { modeHand, toggleVRMode, payPendingSpawn, payPendingPlayHere, updateModeLabel } from './modes.js'; import { vrHovered, vrPanelCursor, @@ -216,6 +216,7 @@ export function updateVRControls() { return; } payPendingSpawn(); // 30b P4 + payPendingPlayHere(); // 60 Q1 updateHeightLift(); // 36: Settings ▸ VR ▸ Body (seated / height), applied once per change // open menu/panel are modal for the sticks: sector nav / scrolling own them; // a RIGHT-hand grab owns the right stick too (reel/scale beats teleport, 100); diff --git a/src/lib/vr/locomotion.js b/src/lib/vr/locomotion.js index a56f7d6df..1fd4f9c35 100644 --- a/src/lib/vr/locomotion.js +++ b/src/lib/vr/locomotion.js @@ -15,6 +15,8 @@ import { moduleWorldChildren } from '../moduleWorld'; import { teleportVerdictPure, shrinkBox, BOUNDS_INSET, PROBE_HEIGHT } from '../teleportRules'; import { walkable as dungeonWalkable } from '../dungeonPlay'; import { resolvePlaySettings, playPublishers } from '../playSettings'; +// 60 Q1: the spawn read every spawn path shares (a Play here override first, else the scene's) +import { currentSpawn } from '../playSpawn'; import { locomotionPolicy, vrSpawnOffsets, yawForward } from '../locomotionPolicy'; import { resolveWalk, charControl } from '../charController'; import { kitPlayRules } from '../kit/runtime.js'; @@ -790,7 +792,7 @@ export function vrJumpHeight() { * no move. @returns {boolean} whether the player was moved */ export function spawnPlayer() { - const spawn = resolvePlaySettings(get(globalScene)).spawn; + const spawn = currentSpawn(); if (!spawn || !renderer?.xr?.getSession?.()) return false; const viewer = viewerNow(); if (!viewer) return false; diff --git a/src/lib/vr/modes.js b/src/lib/vr/modes.js index 77ce9b95a..d2652b9b9 100644 --- a/src/lib/vr/modes.js +++ b/src/lib/vr/modes.js @@ -2,7 +2,7 @@ // 34 R4 (A5): one concern of src/lib/vrControls.js, which re-exports the public names unchanged. import * as THREE from 'three'; import { get } from 'svelte/store'; -import { globalScene, editorMode } from '../../stores/sceneStore'; +import { globalScene, editorMode, worldRig } from '../../stores/sceneStore'; import { resolvePlaySettings, playPublishers } from '../playSettings'; import { hudDocs, isGameHud } from '../hudDocs'; import { setEditorMode, toggleEditorMode } from '../objectActions'; @@ -14,6 +14,9 @@ import { hapticPulse } from './haptics.js'; import { controllerIndexFor } from './input.js'; import { noteXRBaseSpace, viewerNow, spawnPlayer } from './locomotion.js'; import { handOf, bindingOf, bindingLabel, controlName } from './bindings.js'; +// 60 Q1: Play here in a headset — the same spawn rule and override the desktop press uses +import { currentSpawn, playHereSpawnFrom, headingOf, setPlayHereSpawn } from '../playSpawn'; +import { surfaceBelow, playingHere } from '../playHere'; // ---- 30b P4: ENTER INTERACT, SWITCH IN VR, SPAWN ------------------------------------------ // Contract C1: pressing Play in VR on a GAME enters Interact; the left Y button toggles @@ -84,6 +87,81 @@ export function payPendingSpawn() { spawnPlayer(); } +// ---- 60 Q1: PLAY HERE IN A HEADSET -------------------------------------------------------------- +// The radial's Tools ▸ Play here: Interact (the headset's Play) starting where you stand, not on the +// game's spawn — the feet on the surface under your head, facing where you look — and the next +// switch back to Edit puts you (and the world grab you had) back exactly where you were. A viewer +// pose exists only inside the session's frame callback, so the press is PAID on the next frame +// (frame.js, beside payPendingSpawn) — the recenter rule. +let playHerePending = false; +/** @type {{space: any, rig: {p: number[], q: number[], s: number[]} | null} | null} */ +let playHereSaved = null; + +/** the radial's press @returns {boolean} whether a Play here was queued */ +export function requestVRPlayHere() { + if (!renderer?.xr?.getSession?.() || get(editorMode) === 'interact') return false; + playHerePending = true; + return true; +} + +/** a Play here owed since the press, paid on the first frame with a viewer pose */ +export function payPendingPlayHere() { + if (!playHerePending) return; + const viewer = viewerNow(); + if (!viewer) return; + playHerePending = false; + if (get(editorMode) === 'interact') return; + /** @type {any} */ + const rig = get(worldRig); + playHereSaved = { + space: renderer.xr.getReferenceSpace(), + rig: rig ? { p: rig.position.toArray(), q: rig.quaternion.toArray(), s: rig.scale.toArray() } : null + }; + // where the head is in the CONTENT frame: Interact resets the world grab to 1:1, so the + // content point under you is where you will stand + const head = new THREE.Vector3(viewer.head.x, viewer.head.y, viewer.head.z); + let yaw = viewer.yaw; + if (rig) { + rig.updateMatrixWorld(true); + rig.worldToLocal(head); + yaw -= headingOf(rig.quaternion); + } + resetWorldRig(); + setPlayHereSpawn(playHereSpawnFrom({ eye: head.toArray(), yaw }, surfaceBelow, currentSpawn(), viewer.headHeight)); + playingHere.set(true); + // → the editorMode subscription → enterInteractVR → spawnPlayer, which reads the override + setEditorMode('interact'); +} + +/** back to Edit (or the session ended): the override goes, and the view you left comes back */ +function endVRPlayHere(/** @type {boolean} */ restore) { + playHerePending = false; + const was = playHereSaved; + playHereSaved = null; + if (!was) return; + setPlayHereSpawn(null); + playingHere.set(false); + if (!restore) return; + try { + if (was.space) renderer.xr.setReferenceSpace(was.space); + } catch { + /* a space from an ended session — nothing to go back to */ + } + /** @type {any} */ + const rig = get(worldRig); + if (rig && was.rig) { + rig.position.fromArray(was.rig.p); + rig.quaternion.fromArray(was.rig.q); + rig.scale.fromArray(was.rig.s); + rig.updateMatrixWorld(true); + } +} + +/** test/debug view */ +export function vrPlayHereDebug() { + return { pending: playHerePending, saved: !!playHereSaved }; +} + /** @type {any} */ let modeLabel = null; /** @type {any} */ let modeLabelCanvas = null; let modeLabelText = ''; @@ -165,7 +243,8 @@ let lastEditorMode = get(editorMode); editorMode.subscribe((mode) => { if (mode === lastEditorMode) return; lastEditorMode = mode; - if (!renderer?.xr?.getSession?.()) return; + if (!renderer?.xr?.getSession?.()) return endVRPlayHere(false); if (mode === 'interact') enterInteractVR(); + else endVRPlayHere(true); // 60 Q1 updateModeLabel(); }); diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 58120f8ef..03becf97d 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -224,5 +224,7 @@ export { modeHand, onVRSessionStart, toggleVRMode, - vrModeLabelDebug + vrModeLabelDebug, + requestVRPlayHere, + vrPlayHereDebug } from './vr/modes.js'; diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js index 95972ebaa..9e86e0de1 100644 --- a/src/lib/vrRadialMenu.js +++ b/src/lib/vrRadialMenu.js @@ -6,8 +6,10 @@ import { vrWireframeSelection, vrEditMenuOpen, vrSnapMenuOpen, - vrToolMode + vrToolMode, + editorMode } from '../stores/sceneStore'; +import { playingHere } from './playHere'; // 60 Q1 (a near-leaf: playSpawn / playMode / stores) import { environment, setEnvironment, ENVIRONMENT_PRESETS } from './environment'; import { simulating, remoteSimulating, toggleSimulation } from './physics'; import { setMicMode, vrMicMode } from './voiceChat'; @@ -307,6 +309,27 @@ function registerBuiltins() { action: () => toggleSimulation() }); + // 60 Q1: PLAY HERE — Interact (the headset's Play) from where you stand, facing where you look; + // the same entry (or the mode button) brings you back to Edit where you were. The press is paid + // on the next XR frame (vr/modes.js), so the dynamic import's microtask costs nothing. + registerVRMenuEntry({ + id: 'playhere', + group: 'tools', + label: () => (get(playingHere) && get(editorMode) === 'interact' ? 'Back to edit' : 'Play here'), + icon: () => (get(playingHere) && get(editorMode) === 'interact' ? 'square' : 'play'), + order: 6, + closes: true, + active: () => get(playingHere), + visible: () => get(editorMode) !== 'interact' || get(playingHere), + action: () => { + if (get(playingHere) && get(editorMode) === 'interact') { + void import('./objectActions').then((m) => m.setEditorMode('edit')); + return; + } + void import('./vr/modes.js').then((m) => m.requestVRPlayHere()); + } + }); + // Add ▸ — the desktop Add menu's names; ids resolve in executeVRMenuAction's switch, which spawns // the primitive 2 m ahead of the camera (spawnPrimitive) [ diff --git a/tests/e2e/play-here-iwer.test.cjs b/tests/e2e/play-here-iwer.test.cjs new file mode 100644 index 000000000..de9c4a9e2 --- /dev/null +++ b/tests/e2e/play-here-iwer.test.cjs @@ -0,0 +1,147 @@ +// 60 Q1: PLAY HERE IN A HEADSET (IWER — the app's own WebXR path end to end). The radial's +// Tools ▸ Play here switches to Interact (the headset's Play) with the FEET on the surface under the +// head, facing where the head looks — not on the scene's spawn — and Tools ▸ Back to edit (or the mode +// button) puts the viewer, and any world grab they had, back exactly where they were. +// +// IWER's offset reference spaces never reach the XR camera it reports (vr-comfort-iwer's note), so +// the rows read WHAT THE APP APPLIED: every XRRigidTransform the rig moves by is recorded — the +// horizontal move, the lift and the turn of a spawn — and the restore is read as the reference +// space OBJECT itself (Back to edit hands back the very space Edit had). +const h = require('./helpers.cjs'); +const iwer = require('./iwer.cjs'); + +const near = (a, b, eps = 0.06) => Math.abs(a - b) < eps; +const mode = (page) => page.evaluate(() => { let v; window.__stores.editorMode.subscribe((x) => (v = x))(); return v; }); +const SPY = () => { + if (window.__rigSpy) return; + const Orig = window.XRRigidTransform; + window.__rigSpy = []; + window.XRRigidTransform = class extends Orig { + constructor(p, o) { + super(p, o); + window.__rigSpy.push({ p: p ? [p.x || 0, p.y || 0, p.z || 0] : [0, 0, 0], yaw: o ? 2 * Math.atan2(o.y || 0, o.w ?? 1) : 0 }); + } + }; +}; +/** the offsets applied since the last call: {move (m, horizontal), lift (m), turns: [yaw…]} */ +const applied = (page) => + page.evaluate(() => { + const list = window.__rigSpy.splice(0); + let x = 0, y = 0, z = 0; + const turns = []; + for (const r of list) { + if (Math.abs(r.yaw) > 1e-4) turns.push(+r.yaw.toFixed(3)); + else { + x += r.p[0]; + y += r.p[1]; + z += r.p[2]; + } + } + return { move: +Math.hypot(x, z).toFixed(3), lift: +(-y).toFixed(3), turns, n: list.length }; + }); +/** remember the live reference space as `name`, or ask whether it is still that one */ +const space = (page, name, op) => + page.evaluate(({ name, op }) => { + let r; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + const now = r.xr.getReferenceSpace(); + if (op === 'keep') return !!(window['__space_' + name] = now); + return now === window['__space_' + name]; + }, { name, op }); +const rigPose = (page) => + page.evaluate(() => { let r; window.__stores.worldRig.subscribe((x) => (r = x))(); return { p: r.position.toArray(), s: r.scale.x }; }); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { init: iwer.initScript() }); + const P = A.page; + h.check(await iwer.installed(P), 'premise: IWER owns navigator.xr'); + + // a box at (8, _, −6) whose top is 1 m up (below the 1.6 m head) + await P.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await P.waitForTimeout(500); + const top = await P.evaluate(() => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const b = g.children.at(-1); + b.scale.set(4, 1, 4); + b.position.set(8, 0.5, -6); + b.updateMatrixWorld(true); + return new s.THREE.Box3().setFromObject(b).max.y; + }); + // a plain scene: the session starts in Edit; the spawn (which makes it a game) comes after + await iwer.enter(P); + await iwer.frames(P, 4); + h.check((await mode(P)) === 'edit', 'premise: the headset is in Edit'); + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { spawn: { position: [-10, 0, 10], yaw: 0 } } })); + await P.evaluate(SPY); + + // ---- 1. stand over the box looking half-left, Tools ▸ Play here --------------------------------- + await iwer.pose(P, 'head', [8, 1.6, -6], { yaw: 0.5 }); + await iwer.frames(P, 4); + await space(P, 'edit', 'keep'); + await applied(P); + const entry = await P.evaluate(() => window.__stores.vrRadialMenu.ringEntries('tools').map((e) => e.id)); + h.check(entry.includes('playhere'), `the radial's Tools ring offers Play here (${JSON.stringify(entry)})`); + await P.evaluate(() => window.__stores.vrControls.executeVRMenuAction('playhere')); + await iwer.frames(P, 8); + h.check((await mode(P)) === 'interact', 'Play here switches to Interact'); + const dbg = await P.evaluate(() => window.__stores.playHere.playHereDebug()); + h.check( + dbg.playing === true && dbg.override && near(dbg.override.position[0], 8) && near(dbg.override.position[2], -6) && near(dbg.override.position[1], top) && near(dbg.override.yaw, 0.5, 0.02), + `the round's spawn: under the head, on the box top, facing the head (${JSON.stringify(dbg.override)})` + ); + let a = await applied(P); + h.check(a.move < 0.05, `...so the player is NOT moved across the world to the spawn (moved ${a.move} m)`); + h.check(near(a.lift, top, 0.05), `...the feet are lifted onto the box top (lift ${a.lift} m vs ${top.toFixed(2)})`); + h.check(a.turns.every((t) => Math.abs(t) < 0.02), `...and the view keeps its facing (turns ${JSON.stringify(a.turns)})`); + h.check(!(await space(P, 'edit', 'same')), 'Interact plays in a moved reference space'); + + // ---- 2. Back to edit: the very reference space Edit had -------------------------------------- + h.check(!!(await P.evaluate(() => window.__stores.vrRadialMenu.ringEntries('tools').find((e) => e.id === 'playhere'))), 'the entry stays while the round runs'); + await P.evaluate(() => window.__stores.vrControls.executeVRMenuAction('playhere')); + await iwer.frames(P, 6); + h.check((await mode(P)) === 'edit', 'Back to edit: Edit'); + h.check(await space(P, 'edit', 'same'), '...in the reference space Edit had (the view is where it was)'); + const after = await P.evaluate(() => window.__stores.playHere.playHereDebug()); + h.check(after.override === null && after.playing === false, 'the override is gone'); + + // ---- 3. a world grab (miniature) is put back too; Interact played it at 1:1 ------------------- + await P.evaluate(() => { + let r; + window.__stores.worldRig.subscribe((x) => (r = x))(); + r.scale.setScalar(0.5); + r.position.set(4, 0, -3); + r.updateMatrixWorld(true); + }); + await iwer.frames(P, 3); + await applied(P); + await P.evaluate(() => window.__stores.vrControls.executeVRMenuAction('playhere')); + await iwer.frames(P, 8); + let rig = await rigPose(P); + h.check((await mode(P)) === 'interact' && rig.s === 1 && rig.p.every((x) => x === 0), `Play here plays at 1:1 (${JSON.stringify(rig)})`); + // the content point under the head: (8 − 4) / 0.5 = 8, (−6 + 3) / 0.5 = −6 → over the box again + const o3 = (await P.evaluate(() => window.__stores.playHere.playHereDebug())).override; + h.check(o3 && near(o3.position[0], 8, 0.1) && near(o3.position[2], -6, 0.1) && near(o3.position[1], top), `...from the content point under the head (${JSON.stringify(o3)})`); + // the mode button (left Y) is the other way back + await iwer.tap(P, 'left', 5); + await iwer.frames(P, 6); + rig = await rigPose(P); + h.check((await mode(P)) === 'edit', 'left Y: back to Edit'); + h.check(rig.s === 0.5 && near(rig.p[0], 4) && near(rig.p[2], -3), `...and the world grab is back (${JSON.stringify(rig)})`); + h.check(await space(P, 'edit', 'same'), '...in the reference space Edit had'); + + // ---- 4. an ordinary Interact afterwards uses the scene's spawn (the override did not leak) ----- + await applied(P); + await iwer.tap(P, 'left', 5); + await iwer.frames(P, 8); + h.check((await mode(P)) === 'interact', 'left Y: an ordinary Interact'); + a = await applied(P); + const toSpawn = Math.hypot(-10 - 8, 10 + 6); + h.check(near(a.move, toSpawn, 0.15), `...moves the player to the scene's spawn (${a.move} m vs ${toSpawn.toFixed(2)})`); + h.check(a.turns.some((t) => Math.abs(Math.abs(t) - 0.5) < 0.03), `...and turns them to its facing (turns ${JSON.stringify(a.turns)})`); + + await iwer.exit(P); + await h.finish(browser); +}); diff --git a/tests/e2e/play-here.test.cjs b/tests/e2e/play-here.test.cjs new file mode 100644 index 000000000..bccffe173 --- /dev/null +++ b/tests/e2e/play-here.test.cjs @@ -0,0 +1,235 @@ +// 60 Q1: PLAY HERE (desktop + phone). The player starts where the editor camera is — the feet on the +// surface straight under it, facing the view's heading — and leaving Play puts the editor view and the +// selection back. Nothing under the camera → the scene's spawn; no spawn either → the camera's own eye. +// The override is a ONE-ROUND thing: the next ordinary Play lands on the scene's spawn again. +// HEIGHT: desktop Play is GROUNDED by default (37 R24 — PointerLockControls pins the eye at the +// scene's eye height), so the surface height counts where the scene lets the player fly (or a walk +// controller owns Y); a grounded scene keeps X/Z + facing and its own eye height. Both are covered. +// Entry points covered: Ctrl+Shift+Enter, the Play button's right-click row, the optional toolbar +// button, and on a phone the Play long-press row + More ▸ Play here. +// IWER (the headset) lives in play-here-iwer.test.cjs. +const h = require('./helpers.cjs'); + +const store = (name) => `(() => { let v; window.__stores.${name}.subscribe((x) => (v = x))(); return v; })()`; +const near = (a, b, eps = 0.06) => Math.abs(a - b) < eps; +const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a)); +const SPAWN = { position: [3, 0, -4], yaw: Math.PI / 2 }; +/** the toolbar with the optional Play here button placed at the end of the bar */ +const BAR = JSON.stringify({ + order: ['move', 'rotate', 'scale', 'pivot', 'mode', 'objects', 'flow', 'explorer', 'animation', 'playhere'], + hidden: [], + spacerIndex: 5, + seen: ['move', 'rotate', 'scale', 'pivot', 'mode', 'objects', 'flow', 'explorer', 'animation', 'playhere', 'ai', 'add', 'chat', 'mic', 'notes', 'notifications', 'peers', 'undo', 'redo', 'multiselect'] +}); + +/** the play camera's world eye + heading, the editor camera's pose + orbit target */ +const cams = (page) => + page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; }; + const pc = get(s.playerCam); + const ec = get(s.editorCam); + const oc = get(s.orbitControls); + const f = new THREE.Vector3(0, 0, -1).applyQuaternion(pc.getWorldQuaternion(new THREE.Quaternion())); + return { + player: pc.getWorldPosition(new THREE.Vector3()).toArray(), + playerYaw: Math.atan2(-f.x, -f.z), + editor: ec.position.toArray(), + editorQ: ec.quaternion.toArray(), + target: oc?.target?.toArray() ?? null, + locked: get(s.isLocked) + }; + }); + +/** park the editor camera at `pos` looking at `target` (the OrbitControls own the view, so through them) */ +const aim = (page, pos, target) => + page.evaluate( + ({ pos, target }) => { + const s = window.__stores; + const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; }; + const cam = get(s.editorCam); + const oc = get(s.orbitControls); + oc.target.set(...target); + cam.position.set(...pos); + cam.lookAt(...target); + oc.update(); + cam.updateMatrixWorld(true); + }, + { pos, target } + ); + +const waitLocked = async (page, want) => { + for (let i = 0; i < 40; i++) { + if ((await page.evaluate(store('isLocked'))) === want) return true; + await page.waitForTimeout(100); + } + return false; +}; + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1440, height: 900 } }, storage: { controlsLayout: BAR } }); + const P = A.page; + + // ---- the world: a wide box far from the origin (top at y = 5), the scene's spawn elsewhere ---- + await P.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await P.waitForTimeout(500); + const box = await P.evaluate(() => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const b = g.children.at(-1); + b.scale.set(10, 5, 10); + b.position.set(40, 0, -35); + b.updateMatrixWorld(true); + const bb = new s.THREE.Box3().setFromObject(b); + b.position.y -= bb.min.y; // sit it on the ground: its top is then its height + b.updateMatrixWorld(true); + const top = new s.THREE.Box3().setFromObject(b).max.y; + s.objectActions.applySelectionSet([b.uuid]); + return { uuid: b.uuid, top }; + }); + h.check(box.top > 4, `premise: a box with its top at ${box.top.toFixed(2)} m, far from the origin`); + // flying allowed: the spawn's height is honoured (the grounded case is row 1b) + await P.evaluate((spawn) => window.__stores.scenePhysics.setScenePhysics({ play: { spawn, locomotion: { fly: true } } }), SPAWN); + + // ---- 1. Ctrl+Shift+Enter from a far corner: on the box roof, facing the view ------------------ + const EYE_AT = [40, 14, -32]; + await aim(P, EYE_AT, [30, 0, -42]); + const before = await cams(P); + const resolved = await P.evaluate(() => window.__stores.playHere.resolvePlayHere()); + h.check(resolved?.source === 'surface' && near(resolved.position[1], box.top), `the resolver lands on the roof (${JSON.stringify(resolved)})`); + await P.mouse.move(700, 450); + await P.keyboard.press('Control+Shift+Enter'); + h.check(await waitLocked(P, true), 'Ctrl+Shift+Enter enters Play'); + await P.waitForTimeout(400); + let c = await cams(P); + h.check(near(c.player[0], 40) && near(c.player[2], -32), `the player stands under the camera, not on the spawn (${c.player.map((v) => v.toFixed(2))})`); + h.check(near(c.player[1], box.top + 1.7), `the eye is 1.7 m above the roof (${c.player[1].toFixed(2)} vs ${(box.top + 1.7).toFixed(2)})`); + h.check(near(wrap(c.playerYaw - Math.PI / 4), 0, 0.03), `facing the view's heading (${c.playerYaw.toFixed(3)} vs ${(Math.PI / 4).toFixed(3)})`); + h.check((await P.evaluate(store('playHere.playingHere'))) === true, 'playingHere is on while the round runs'); + + // Stop: the view and the selection come back + h.check(await h.leavePlay(A), 'Escape leaves Play'); + await P.waitForTimeout(300); + c = await cams(P); + h.check(c.editor.every((v, i) => near(v, before.editor[i], 0.01)), `the editor camera is back where it was (${c.editor.map((v) => v.toFixed(2))})`); + h.check(c.target && c.target.every((v, i) => near(v, before.target[i], 0.01)), `...looking at the same orbit target (${JSON.stringify(c.target)})`); + h.check(c.editorQ.every((v, i) => near(v, before.editorQ[i], 0.01)), '...with the same orientation'); + const sel = await P.evaluate(store('selectedObjects')); + h.check(sel.map((o) => o?.uuid ?? o).includes(box.uuid), `the selection is back (${JSON.stringify(sel.map((o) => o?.uuid ?? o))})`); + const dbg = await P.evaluate(() => window.__stores.playHere.playHereDebug()); + h.check(dbg.override === null && dbg.playing === false, `the override is gone with the round (${JSON.stringify({ o: dbg.override, p: dbg.playing })})`); + + // ---- 1b. a GROUNDED scene (the default): X/Z and facing from the camera, the scene's eye height -- + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: { fly: false } } })); + await P.keyboard.press('Control+Shift+Enter'); + h.check(await waitLocked(P, true), 'grounded: Play here'); + await P.waitForTimeout(400); + c = await cams(P); + h.check(near(c.player[0], 40) && near(c.player[2], -32) && near(c.player[1], 1.7), `grounded: under the camera at the scene's eye height (${c.player.map((v) => v.toFixed(2))})`); + h.check(near(wrap(c.playerYaw - Math.PI / 4), 0, 0.03), `grounded: facing the view (${c.playerYaw.toFixed(3)})`); + await h.leavePlay(A); + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: { fly: true } } })); + + // ---- 2. the next ORDINARY Play lands on the scene's spawn (the override does not leak) -------- + await P.keyboard.press('Control+Enter'); + h.check(await waitLocked(P, true), 'Ctrl+Enter: an ordinary Play'); + await P.waitForTimeout(400); + c = await cams(P); + h.check(near(c.player[0], 3) && near(c.player[2], -4) && near(c.player[1], 1.7), `an ordinary Play spawns on the scene's spawn (${c.player.map((v) => v.toFixed(2))})`); + await h.leavePlay(A); + + // ---- 3. the Play button's right-click row; nothing under the camera → the scene's spawn --------- + await aim(P, [0, -6, 20], [0, -6, 0]); // under the ground plane: nothing below + await P.click('#play-button', { button: 'right' }); + await P.waitForTimeout(300); + const rows = await P.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim())); + h.check(rows.includes('Play here'), `Play's own menu carries "Play here" (${JSON.stringify(rows)})`); + await P.getByRole('menuitem', { name: 'Play here' }).click(); + h.check(await waitLocked(P, true), 'the menu row enters Play'); + await P.waitForTimeout(400); + c = await cams(P); + const last = (await P.evaluate(() => window.__stores.playHere.playHereDebug())).last; + h.check(last?.source === 'spawn', `nothing under the camera: the scene's spawn (${JSON.stringify(last)})`); + h.check(near(c.player[0], 3) && near(c.player[2], -4), `...the player is on it (${c.player.map((v) => v.toFixed(2))})`); + h.check(near(wrap(c.playerYaw - SPAWN.yaw), 0, 0.03), `...facing the spawn's own yaw (${c.playerYaw.toFixed(3)})`); + await h.leavePlay(A); + + // ---- 4. the toolbar button; no ground, no spawn → the camera's own eye ------------------------ + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ ground: { enabled: false }, play: { spawn: null } })); + await aim(P, [70, 9, 70], [60, 9, 60]); + h.check(await P.locator('#play-here-button').isVisible(), 'the optional toolbar button is on the bar when placed'); + await P.click('#play-here-button'); + h.check(await waitLocked(P, true), 'the toolbar button enters Play'); + await P.waitForTimeout(400); + c = await cams(P); + h.check(near(c.player[0], 70) && near(c.player[1], 9) && near(c.player[2], 70), `no surface, no spawn: the eye stays at the camera (${c.player.map((v) => v.toFixed(2))})`); + h.check(near(wrap(c.playerYaw - Math.PI / 4), 0, 0.03), `...facing the view (${c.playerYaw.toFixed(3)})`); + await h.leavePlay(A); + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ ground: { enabled: true } })); + await A.page.context().close(); + + // ---- 5. a phone (N6 folded): Play's long-press row and More ▸ Play here ------------------------ + const FOLDED = { width: 390, height: 896 }; + const B = await h.setupPage(browser, 'phone', { + context: { viewport: FOLDED, hasTouch: true, isMobile: true, deviceScaleFactor: 2.9 }, + storage: { toursSeen: '{"editor-touch":true,"editor":true}' } + }); + const Q = B.page; + await Q.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await Q.waitForTimeout(500); + await Q.evaluate(() => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const b = g.children.at(-1); + b.scale.set(6, 2, 6); + b.position.set(-20, 1, 15); + b.updateMatrixWorld(true); + }); + const top = await Q.evaluate(() => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + return new s.THREE.Box3().setFromObject(g.children.at(-1)).max.y; + }); + await aim(Q, [-20, 10, 15], [-20, 0, 5]); + const phoneBefore = await cams(Q); + // long press on Play = its contextmenu + await Q.locator('#ps-bar [data-bar-item="play"]').dispatchEvent('contextmenu', { clientX: 195, clientY: 860 }); + await Q.waitForTimeout(300); + const phoneRows = await Q.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim())); + h.check(phoneRows.includes('Play here'), `the phone Play menu carries "Play here" (${JSON.stringify(phoneRows)})`); + await Q.getByRole('menuitem', { name: 'Play here' }).click(); + h.check(await waitLocked(Q, true), 'phone: the row enters Play'); + await Q.waitForTimeout(400); + c = await cams(Q); + // a phone scene is grounded by default: X/Z from the camera, the eye at the scene's height + h.check(near(c.player[0], -20) && near(c.player[2], 15) && near(c.player[1], 1.7), `phone: under the camera, grounded (${c.player.map((v) => v.toFixed(2))}, box top ${top.toFixed(2)})`); + h.check(near(wrap(c.playerYaw), 0, 0.03), `phone: facing the view (${c.playerYaw.toFixed(3)})`); + // the touch ✕ is the phone's way out + const exitBtn = Q.locator('#touch-play-exit, [data-touch-exit], button[aria-label="Exit play"]').first(); + if (await exitBtn.isVisible().catch(() => false)) await exitBtn.click(); + else await Q.evaluate(() => window.__stores.playMode.exitPlay()); + h.check(await waitLocked(Q, null), 'phone: Play ends'); + await Q.waitForTimeout(300); + c = await cams(Q); + h.check(c.editor.every((v, i) => near(v, phoneBefore.editor[i], 0.01)), `phone: the editor view is back (${c.editor.map((v) => v.toFixed(2))})`); + + // More ▸ Tools & View ▸ Play here + await Q.locator('#ps-bar [data-bar-item="more"]').click(); + await Q.waitForTimeout(400); + const tile = Q.locator('#ps-tile-playhere'); + h.check(await tile.isVisible().catch(() => false), 'phone: More lists Play here'); + await tile.click(); + h.check(await waitLocked(Q, true), 'phone: the More tile enters Play'); + await Q.waitForTimeout(300); + c = await cams(Q); + h.check(near(c.player[0], -20) && near(c.player[2], 15), `phone (More): under the camera again (${c.player.map((v) => v.toFixed(2))})`); + await Q.evaluate(() => window.__stores.playMode.exitPlay()); + await waitLocked(Q, null); + + await h.finish(browser); +}); diff --git a/tests/unit/phoneBar.test.js b/tests/unit/phoneBar.test.js index cf14a21b6..4f1a7050f 100644 --- a/tests/unit/phoneBar.test.js +++ b/tests/unit/phoneBar.test.js @@ -28,7 +28,7 @@ describe('phoneBar', () => { it('the catalog: More ▸ Windows / Edit / Tools & View, every one placeable', () => { expect(WINDOW_ITEMS.slice(0, 5)).toEqual(['play', 'add', 'more', 'objects', 'stats']); expect(EDIT_ITEMS).toEqual(['inspect', 'move', 'rotate', 'scale', 'undo', 'redo', 'multiselect', 'interact']); - expect(VIEW_ITEMS).toEqual(['tools', 'focus', 'budget']); + expect(VIEW_ITEMS).toEqual(['tools', 'focus', 'budget', 'playhere']); // 60 Q1: + Play here expect(new Set(BAR_CATALOG).size).toBe(BAR_CATALOG.length); }); diff --git a/tests/unit/playHere.test.js b/tests/unit/playHere.test.js new file mode 100644 index 000000000..f15cead1b --- /dev/null +++ b/tests/unit/playHere.test.js @@ -0,0 +1,130 @@ +// @ts-nocheck — plain fixture scenes +// 60 Q1: Play here — where the player lands. The ray straight down from the editor camera through +// a real three scene (boxes, a hidden box, a sensor, water, a reference image, the physics ground +// plane), the heading of the view, and the fallbacks: the scene's spawn, then the camera's own eye. +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { playHereSpawnFrom, headingOf, surfaceBelowIn, notGround } from '../../src/lib/playHereCore.js'; + +const EYE = 1.7; +const GROUND_ON = { enabled: true, height: 0 }; +const GROUND_OFF = { enabled: false, height: 0 }; + +/** a box whose top face sits at `top`, centred on (x, z), `size` wide */ +function box(x, z, top, size = 2, userData = {}) { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(size, 1, size), new THREE.MeshBasicMaterial()); + mesh.position.set(x, top - 0.5, z); + Object.assign(mesh.userData, userData); + return mesh; +} +function scene(...children) { + const group = new THREE.Group(); + group.name = 'sceneObjects'; + for (const c of children) group.add(c); + return group; +} +const below = (root, ground) => (eye) => surfaceBelowIn(root, eye, ground); + +describe('surfaceBelowIn (the spawn raycast)', () => { + it('lands on the first surface straight under the eye — the roof over the floor', () => { + const root = scene(box(0, 0, 1), box(0, 0, 4, 1)); + expect(surfaceBelowIn(root, [0, 10, 0], GROUND_ON)).toBeCloseTo(4); + // beside the tall box, the low one + expect(surfaceBelowIn(root, [0.8, 10, 0.8], GROUND_ON)).toBeCloseTo(1); + }); + it('falls through to the physics ground plane, and not when it is off', () => { + const root = scene(box(0, 0, 1)); + expect(surfaceBelowIn(root, [20, 10, 20], GROUND_ON)).toBe(0); + expect(surfaceBelowIn(root, [20, 10, 20], GROUND_OFF)).toBeNull(); + expect(surfaceBelowIn(null, [0, 3, 0], { enabled: true, height: 2 })).toBe(2); + }); + it('a ground plane ABOVE the eye is not under it', () => { + expect(surfaceBelowIn(scene(), [0, 1, 0], { enabled: true, height: 2 })).toBeNull(); + }); + it('a surface above the eye is not under it (inside a building you stand on its floor)', () => { + const root = scene(box(0, 0, 0.5, 6), box(0, 0, 5, 6)); + expect(surfaceBelowIn(root, [0, 3, 0], GROUND_ON)).toBeCloseTo(0.5); + }); + it('skips what a player cannot stand on: hidden, sensors, water, reference images', () => { + const hidden = box(0, 0, 3); + hidden.visible = false; + const hiddenParent = new THREE.Group(); + hiddenParent.visible = false; + hiddenParent.add(box(0, 0, 3.5)); + const root = scene( + box(0, 0, 1), + hidden, + hiddenParent, + box(0, 0, 4, 2, { physics: { sensor: true } }), + box(0, 0, 5, 2, { water: { kind: 'surface' } }), + box(0, 0, 6, 2, { underlay: true }) + ); + expect(surfaceBelowIn(root, [0, 10, 0], GROUND_ON)).toBeCloseTo(1); + expect(notGround(hiddenParent.children[0], root)).toBe(true); + expect(notGround(root.children[0], root)).toBe(false); + }); + it('only meshes count (a line or points under the camera is not a floor)', () => { + const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(-1, 3, 0), new THREE.Vector3(1, 3, 0)])); + const root = scene(line); + expect(surfaceBelowIn(root, [0, 10, 0], GROUND_ON)).toBe(0); + }); + it('reads world positions through parents (a group moved up carries its floor)', () => { + const parent = new THREE.Group(); + parent.position.set(0, 3, 0); + parent.add(box(0, 0, 1)); + expect(surfaceBelowIn(scene(parent), [0, 10, 0], GROUND_ON)).toBeCloseTo(4); + }); +}); + +describe('playHereSpawnFrom', () => { + const spawn = { position: [5, 0, -5], yaw: 1.2 }; + it('stands the feet on the surface under the eye, facing the view', () => { + const root = scene(box(30, -40, 2)); + const s = playHereSpawnFrom({ eye: [30, 12, -40], yaw: 0.5 }, below(root, GROUND_ON), spawn, EYE); + expect(s.source).toBe('surface'); + expect(s.position[0]).toBe(30); + expect(s.position[1]).toBeCloseTo(2); + expect(s.position[2]).toBe(-40); + expect(s.yaw).toBe(0.5); + }); + it('nothing under the camera → the scene spawn, with its own facing', () => { + const s = playHereSpawnFrom({ eye: [30, 12, -40], yaw: 0.5 }, below(scene(), GROUND_OFF), spawn, EYE); + expect(s).toEqual({ position: [5, 0, -5], yaw: 1.2, source: 'spawn' }); + }); + it('below the ground plane → the scene spawn too', () => { + const s = playHereSpawnFrom({ eye: [0, -3, 0], yaw: 0 }, below(scene(), GROUND_ON), spawn, EYE); + expect(s.source).toBe('spawn'); + }); + it('no surface and no spawn → the eye stays where the camera is', () => { + const s = playHereSpawnFrom({ eye: [1, 4, 2], yaw: -0.3 }, below(scene(), GROUND_OFF), null, EYE); + expect(s.source).toBe('camera'); + expect(s.position[1] + EYE).toBeCloseTo(4); + expect(s.yaw).toBe(-0.3); + }); + it('a VR head height is the eye height there', () => { + const s = playHereSpawnFrom({ eye: [0, 5, 0], yaw: 0 }, () => null, null, 1.4); + expect(s.position[1]).toBeCloseTo(3.6); + }); + it('a non-finite heading faces −Z rather than poisoning the camera', () => { + const s = playHereSpawnFrom({ eye: [0, 5, 0], yaw: NaN }, () => 0, null, EYE); + expect(s.yaw).toBe(0); + }); +}); + +describe('headingOf', () => { + it('is rotation.y for a level view', () => { + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0.8, 0, 'YXZ')); + expect(headingOf(q)).toBeCloseTo(0.8); + }); + it('keeps the heading of an editor camera tilted down (the usual three-quarter view)', () => { + const cam = new THREE.PerspectiveCamera(); + cam.position.set(-10, 10, 10); + cam.lookAt(0, 0, 0); + // looking toward +X/−Z from (-10, _, 10): the heading is −45° + expect(headingOf(cam.quaternion)).toBeCloseTo(-Math.PI / 4); + }); + it('stays defined looking straight down', () => { + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0.6, 0, 'YXZ')); + expect(headingOf(q)).toBeCloseTo(0.6, 5); + }); +}); From 7585d8369589becdfc63520b851d584e0b8f6728 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:52:08 +0300 Subject: [PATCH 006/104] =?UTF-8?q?[wip]=2060=20Q5:=20reference=20image=20?= =?UTF-8?q?underlays=20=E2=80=94=20store/wire,=20layer,=20window,=20drop?= =?UTF-8?q?=20menu=20(untested)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit underlays.js (LOCAL per scene in IndexedDB; Share with session = underlay / underlaydelete / getunderlays, owner-only rows, ROOM_SCOPED, request on the floor, validated), UnderlayLayer (under the world rig, outside sceneObjects, on the editor helper layer, hidden in Play/Interact, no-op raycast), UnderlayWindow (opacity, size, height, ground / facing the view, share, remove) + the image-drop 'Use as reference' menu, viewport Tools rows. Viewport/room thumbnails now render without the helper layer. NOT yet run: svelte-check ratchet after the type fix, the underlay e2e suite. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 11 + src/components/Scene.svelte | 5 + src/components/UnderlayLayer.svelte | 101 +++++++ src/components/menu/UnderlayWindow.svelte | 211 ++++++++++++++ src/components/menu/ViewportMenu.svelte | 17 ++ src/lib/cloudHooks.js | 4 +- src/lib/cloudPlugin.js | 6 + src/lib/peerHandler.svelte.js | 12 + src/lib/peerScenes.js | 3 + src/lib/sessions.js | 7 + src/lib/underlayPose.js | 45 +++ src/lib/underlays.js | 318 ++++++++++++++++++++++ src/lib/wireValidate.js | 6 + 13 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 src/components/UnderlayLayer.svelte create mode 100644 src/components/menu/UnderlayWindow.svelte create mode 100644 src/lib/underlayPose.js create mode 100644 src/lib/underlays.js diff --git a/src/App.svelte b/src/App.svelte index db386f8ce..2190c072c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -67,6 +67,8 @@ import SculptToolbar from './components/menu/SculptToolbar.svelte' import SplineToolbar from './components/menu/SplineToolbar.svelte' import MeasureToolbar from './components/menu/MeasureToolbar.svelte' // 47 M1 + import UnderlayWindow from './components/menu/UnderlayWindow.svelte' // 60 Q5 + import { underlayDropMenu } from '$lib/underlays' import ModuleToolboxLayer from './components/ui/ModuleToolboxLayer.svelte' // 48 D1: the node graph debugger's panel (loaded the first time it opens) import { debugOpen } from '$lib/flowDebug.js' @@ -388,6 +390,13 @@ import { startMusicToolbox } from './lib/musicToolbox' // an unsupported file — importFile pairs them up. const companionExt = /\.(mtl|png|jpe?g|webp|bmp|gif)$/i const companions = files.filter((file) => companionExt.test(file.name)) + // 60 Q5: a drop of nothing but images is not a model's companions — they used to be + // dropped silently. Ask what they are for: a reference image to build over, or the Explorer. + const imageExt = /\.(png|jpe?g|webp|bmp|gif)$/i + if (files.every((file) => imageExt.test(file.name))) { + underlayDropMenu.set({ x: event.clientX, y: event.clientY, files }) + return + } files.forEach((file) => { const name = file.name.toLowerCase() if (modelExt.test(name)) importFile(file, file.name.replace(modelExt, ''), undefined, undefined, companions) @@ -451,6 +460,8 @@ import { startMusicToolbox } from './lib/musicToolbox' + + diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 9065cea28..b507495ab 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -105,6 +105,7 @@ // 36 A2: the headset browser's own "Enter VR" offer — decided by us, not threlte's default import { xrOfferMode } from '$lib/xrOffer'; import MeasureOverlay from './MeasureOverlay.svelte'; + import UnderlayLayer from './UnderlayLayer.svelte'; // 60 Q5 import AnnotationPins from './AnnotationPins.svelte'; import PingMarkers from './PingMarkers.svelte'; import PingHighlights from './PingHighlights.svelte'; @@ -1811,6 +1812,10 @@ + + + + + {/if} + {#if $drawerSlot && $showRoomsButton && !squeezedView} @@ -864,6 +877,21 @@ background: color-mix(in srgb, var(--ink-bad) 16%, transparent); cursor: pointer; } + .cx-setting-badge { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border: 0; + border-radius: var(--radius-pill); + font-size: var(--fs-badge); + font-weight: 600; + white-space: nowrap; + color: var(--ink-warn); + background: color-mix(in srgb, var(--ink-warn) 16%, transparent); + cursor: pointer; + } .cx-refused[data-result='full'] { color: var(--ink-warn); background: color-mix(in srgb, var(--ink-warn) 16%, transparent); diff --git a/src/components/menu/NotificationCenter.svelte b/src/components/menu/NotificationCenter.svelte index 18f3de554..1c0656e57 100644 --- a/src/components/menu/NotificationCenter.svelte +++ b/src/components/menu/NotificationCenter.svelte @@ -7,7 +7,7 @@ // missed (or dismissed while a modal was open) is still recoverable. Placed in the // top-right chrome next to the peers/profile cluster (Users.svelte), mirroring the // peers popover pattern (absolute dropdown, click-catcher backdrop). - import { notifications, notificationsUnread, notificationCenterOpen } from '../../stores/appStore.js'; + import { notifications, notificationsUnread, notificationCenterOpen, notificationActions } from '../../stores/appStore.js'; function toggle() { const willOpen = !$notificationCenterOpen; @@ -18,6 +18,16 @@ function clearAll() { notifications.set([]); notificationsUnread.set(0); + notificationActions.set({}); + } + + /** F-5: the newest entry of a group that still offers buttons @param {any[]} items */ + function liveActionsOf(items) { + for (let i = items.length - 1; i >= 0; i--) { + const acts = $notificationActions[items[i].id]; + if (acts?.length) return acts; + } + return null; } // NOTES-38 #13 (user): a message that repeats ("Cannot reach the peer server. Retrying…" @@ -95,6 +105,13 @@ {g.text} {ago(g.latest.ts)} + {#if liveActionsOf(g.items)} + + {#each liveActionsOf(g.items) ?? [] as a (a.label)} + + {/each} + + {/if} {#if g.items.length > 1 && open[g.text]}
    {#each [...g.items].reverse() as n (n.id)} @@ -129,6 +146,24 @@ .notif-pop :global(.wc-body) { max-height: 60vh; } + .notif-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; + } + .notif-act { + font-size: var(--fs-badge); + padding: 2px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--accent); + cursor: pointer; + } + .notif-act:hover { + background: var(--surface-hover); + } .notif-list { margin: 0; padding: 4px 0; diff --git a/src/lib/admitNotice.js b/src/lib/admitNotice.js new file mode 100644 index 000000000..45024e075 --- /dev/null +++ b/src/lib/admitNotice.js @@ -0,0 +1,25 @@ +// F-5 (roadmap 55 "Fixes for 54", test flight T14): an automatic admission is never silent. +// The cloud rooms plugin admits guests to a listed room with "Allow guests to join +// automatically" on, and the host saw nothing at all — no card, no toast, no history. Core +// now tells the host about EVERY automatic admission; the auth provider may say why and +// offer to switch the setting off (authProvider.admitNotice). Pure: no stores, no peers. + +/** @param {string} peerId */ +export function shortPeer(peerId) { + return String(peerId ?? '').slice(0, 6).toUpperCase(); +} + +/** + * @param {string} peerId + * @param {any} provided what `authProvider.admitNotice(peerId, cloudMeta)` returned (or threw → null) + * @returns {{ text: string, turnOff: (() => void) | null, turnOffLabel: string }} + */ +export function composeAdmitNotice(peerId, provided) { + const p = provided && typeof provided === 'object' ? provided : null; + const text = + String(p?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 200) || + shortPeer(peerId) + ' joined automatically — your session settings let them in without approval.'; + const turnOff = typeof p?.turnOff === 'function' ? p.turnOff : null; + const turnOffLabel = String(p?.turnOffLabel ?? '').trim().slice(0, 24) || 'Turn off'; + return { text, turnOff, turnOffLabel }; +} diff --git a/src/lib/cloudHooks.js b/src/lib/cloudHooks.js index ba14693fd..365fb14f7 100644 --- a/src/lib/cloudHooks.js +++ b/src/lib/cloudHooks.js @@ -126,6 +126,10 @@ export function hasCapabilityProvider() { * present it replaces `authorize`: `cloudMeta` is the joiner's dial data * (cloudApi.connectToPeer's 2nd arg, null when absent); 'deny' refuses with no * approval card; an object is the normal card, carrying `label`. + * admitNotice(peerId, cloudMeta): {text?, turnOff?, turnOffLabel?} | null — F-5 (fix-live, + * optional). Core tells the HOST about EVERY automatic admission (toast + notification + * history, with Remove); the provider says WHY in `text` and may offer `turnOff()` (e.g. + * switch "Allow guests to join automatically" off). Null/absent = the generic notice. * @type {any} */ let authProvider = null; @@ -413,3 +417,25 @@ export const sceneHeart = writable(null); export function setSceneHeart(info) { sceneHeart.set(info && typeof info === 'object' && typeof info.toggle === 'function' ? info : null); } + +/** + * F-5 (fix-live): A STANDING SETTING THAT IS NEVER INVISIBLE — a small badge on the Connect + * pill, e.g. "Public · guests join" while a listed room auto-admits guests. The plugin sets it + * (cloudApi.setConnectBadge) whenever the setting changes; null hides it. Shape: + * { text: string (≤ 32 chars), title?: string (tooltip, ≤ 200), onClick?: () => void } + * @type {import('svelte/store').Writable<{text: string, title: string, onClick: (() => void) | null} | null>} */ +export const connectBadge = writable(null); + +/** @param {any} badge */ +export function setConnectBadge(badge) { + const text = badge && typeof badge === 'object' ? String(badge.text ?? '').trim().slice(0, 32) : ''; + connectBadge.set( + text + ? { + text, + title: String(badge.title ?? '').slice(0, 200), + onClick: typeof badge.onClick === 'function' ? badge.onClick : null + } + : null + ); +} diff --git a/src/lib/cloudPlugin.js b/src/lib/cloudPlugin.js index e995e99d3..0795bbaa6 100644 --- a/src/lib/cloudPlugin.js +++ b/src/lib/cloudPlugin.js @@ -30,7 +30,7 @@ import { safeStorage } from './safeStorage'; // (svelte/store + safeStorage), so a static edge from here closes nothing. import { aiProviders, aiActiveProvider, aiEnabled, addAiProvider, updateAiProvider, removeAiProvider, setAiActiveProvider, setAiEnabled } from './ai/providers'; import { meshProviders, meshActiveProvider, meshGenEnabled, addMeshProvider, updateMeshProvider, removeMeshProvider, setMeshActiveProvider, setMeshGenEnabled } from './ai/meshProviders'; -import { meshJobStatus, onExportBuilt, onTemplateOpen, setSceneHeart, problemReporter, setWhatsNewCloud } from './cloudHooks'; +import { meshJobStatus, onExportBuilt, onTemplateOpen, setSceneHeart, problemReporter, setWhatsNewCloud, setConnectBadge } from './cloudHooks'; // 36-community (C4): a leaf (svelte/store only) — safe as a static edge import { gameIdentity, ensureGameId, forkGameId } from './gameIdentity.js'; import { exportMode } from './export/exportBoot.js'; @@ -539,6 +539,9 @@ export function makeCloudApi() { /** C6: the heart for the scene on screen (play-link start card + pause menu): * `{count, liked, toggle() → Promise<{liked, count} | null>}`, or null to remove it. */ setSceneHeart: (/** @type {any} */ info) => setSceneHeart(info), + /** F-5 (fix-live): a small badge on the Connect pill for a standing setting the host + * should never forget ({text, title?, onClick?}; null hides). Probe with typeof. */ + setConnectBadge: (/** @type {any} */ badge) => setConnectBadge(badge), // --- CL-5 continuity (roadmap 37 / R13, 37-continuity): ADDITIVE, typeof-probed, no bump --- /** A counter that moves on EVERY scene change — objects, graphs, animation, looks, sky, physics, diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 789e4a071..a9ccf6a07 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -35,6 +35,7 @@ import { createFastLinks, createStaleFilter, createKeyframer, stampPose, poseKey import { registerDebugHook } from '$lib/debugHooks'; import { routePerfLive, perfLivePeerGone } from '$lib/perf/liveWire.js'; // 34 PF: the live perf stream (an import-free leaf) import { noteWireError } from '$lib/wireErrors'; +import { composeAdmitNotice, shortPeer } from '$lib/admitNotice'; import { ICE_DROP_GRACE_MS, UNAVAILABLE_STRIKES, quietPeerError, unavailablePeerId } from '$lib/peerDrop'; import { noteWire } from '$lib/sceneBudget'; import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler'; @@ -116,7 +117,7 @@ import { applyObjectFile } from '$lib/animatedImports'; import { applyRemoteBehavior } from '$lib/packBehavior'; import { sendArchBehaviorStates } from '$lib/arch/archParts.js'; import { lockedObjects, selectedObject, peerHands, objectsGroup, pokeScene } from '../stores/sceneStore'; -import { addMessage, peers, userdata, pendingApprovals, waitingForApproval, showToast, pushNotification, chatHistory, mergeChatHistory } from '../stores/appStore'; +import { addMessage, peers, userdata, pendingApprovals, waitingForApproval, showToast, showLastingToast, pushNotification, chatHistory, mergeChatHistory } from '../stores/appStore'; // 41 G21: the signaling link's ups and downs are a STATE on the Connect pill's dot, never toasts import { GIVE_UP_ATTEMPTS } from '$lib/connectionStatus'; import { get } from 'svelte/store'; @@ -292,6 +293,9 @@ export class PeerConnection { /** F-3: peerId -> "not on the signaling server" answers while we redial them * @type {Map} */ this.unavailableStrikes = new Map(); + /** F-5: peers the host removed from an automatic admission — their re-dials are + * refused for the rest of this tab's session, guests setting or not @type {Set} */ + this.removedPeers = new Set(); /** @type {Set} peers we mean to exchange FULL STATE with — the * host we're joining (requestConnect) or a joiner we approved. Mesh-fill * dials (the `hosts` flow) don't request state, and an adopted inbound @@ -729,6 +733,12 @@ export class PeerConnection { // 'admit' | 'deny' | {label} (knock). 'deny' refuses with no card — told to a // joiner that can hear it. Without `decide`, `authorize` as before. const cloudMeta = conn?.metadata?.cloud ?? null; + // F-5: someone the host removed stays out (the setting would just let them back) + if (this.removedPeers.has(conn.peer)) { + if (conn?.metadata?.jr) this.sendJoinResult(conn.peer, 'denied'); + conn.close(); + return; + } const verdict = auth && typeof auth.decide === 'function' ? auth.decide(conn.peer, cloudMeta) : undefined; if (verdict === 'deny') { if (conn?.metadata?.jr) this.sendJoinResult(conn.peer, 'denied'); @@ -759,6 +769,7 @@ export class PeerConnection { } get(peers).send({ type: 'userdata', userdata: get(userdata) }); this.approveDialBack(conn.peer); + this.announceAutoAdmit(conn.peer, auth, cloudMeta); } } catch (e) { console.error('cloud auth provider threw:', e); @@ -1938,6 +1949,48 @@ export class PeerConnection { }, delay); } + // F-5: an AUTOMATIC admission is never silent. A toast AND a notification-history entry + // that keeps its buttons: Turn off (when the provider offers it — e.g. "Allow guests to + // join automatically") and Remove. The provider says why; without one, a generic line. + /** @param {string} peerId @param {any} auth @param {any} cloudMeta */ + announceAutoAdmit(peerId, auth, cloudMeta) { + let provided = null; + try { + provided = typeof auth?.admitNotice === 'function' ? auth.admitNotice(peerId, cloudMeta) : null; + } catch (e) { + console.error('cloud auth provider admitNotice threw:', e); + } + const notice = composeAdmitNotice(peerId, provided); + /** @type {{label: string, action: () => void}[]} */ + const actions = []; + if (notice.turnOff) { + const turnOff = notice.turnOff; + actions.push({ label: notice.turnOffLabel, action: () => { try { turnOff(); } catch (e) { console.error('turnOff threw:', e); } } }); + } + actions.push({ label: 'Remove', action: () => this.removeAdmittedPeer(peerId) }); + log('info', 'net', 'peer admitted automatically', { peer: peerId }); + showLastingToast(notice.text, actions); + } + + // F-5: Remove from the auto-admit notice. Local to this host: drop them from the roster, + // close our link, and refuse their re-dials for the rest of the session. (The mesh-wide + // kick with a block list is roadmap 55 A9; this is the host's own door.) + /** @param {string} peerId */ + removeAdmittedPeer(peerId) { + this.removedPeers.add(peerId); + this.gracefulLeft.add(peerId); // their close is expected — no redial + const roster = /** @type {any[]} */ (get(userdata)).filter((/** @type {any} */ e) => e[0] !== peerId); + userdata.set(roster); + if (this.openedPeers.has(peerId)) this.finalizeDisconnect(peerId, true); + else { + const conn = this.connections[peerId]; + delete this.connections[peerId]; + try { conn?.close(); } catch (e) { /* never opened */ } + } + this.broadcast({ type: 'userdata', userdata: get(userdata) }); + showToast(shortPeer(peerId) + ' was removed. They cannot rejoin while this tab stays open.'); + } + // F-3: the signaling server answered "no such peer" to a redial. The dial it named // stays un-open (peerjs does not close it), so clear it — the next check redials at // once instead of waiting out the dial grace — and after a few answers in a row stop diff --git a/src/stores/appStore.js b/src/stores/appStore.js index 7d589411c..18578e1df 100644 --- a/src/stores/appStore.js +++ b/src/stores/appStore.js @@ -762,6 +762,36 @@ export function pushNotification(text, kind = 'info') { const entry = { id: `n${Date.now()}_${++_notifId}`, text, ts: Date.now(), kind }; notifications.update((list) => [...list, entry].slice(-50)); notificationsUnread.update((c) => c + 1); + return entry.id; +} + +/** F-5 (fix-live): the buttons a notification-history entry KEEPS until one is pressed, + * keyed by entry id. Runtime only — a function cannot be persisted, so after a reload the + * entry is plain text again (the decision it offered belonged to that session anyway). + * @type {import('svelte/store').Writable void}[]>>} */ +export const notificationActions = writable({}); + +/** + * F-5: a decision toast whose buttons ALSO live on its notification-history entry, so a + * notice missed while the toast was up can still be acted on from the bell. Pressing a + * button in either place answers both (the history entry loses its buttons). + * @param {string} message @param {{label: string, action: () => void}[]} actions + * @returns {string} the history entry id + */ +export function showLastingToast(message, actions) { + /** @type {string} */ + let id = ''; + const answer = () => notificationActions.update((m) => { + if (!(id in m)) return m; + const next = { ...m }; + delete next[id]; + return next; + }); + const wrapped = actions.map((a) => ({ ...a, action: () => { answer(); a.action(); } })); + toastStore.update((toast) => [...toast, { text: message, actions: wrapped }]); + id = pushNotification(message, 'action') || ''; + if (id) notificationActions.update((m) => ({ ...m, [id]: wrapped })); + return id; } /** diff --git a/tests/unit/admitNotice.test.js b/tests/unit/admitNotice.test.js new file mode 100644 index 000000000..47d82cde3 --- /dev/null +++ b/tests/unit/admitNotice.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { get } from 'svelte/store'; +import { composeAdmitNotice, shortPeer } from '../../src/lib/admitNotice.js'; +import { connectBadge, setConnectBadge } from '../../src/lib/cloudHooks.js'; + +// F-5: an automatic admission is never silent, and a standing "guests join" setting is visible. +describe('F-5 auto-admit notice', () => { + it('uses the provider text and its Turn off', () => { + let off = 0; + const n = composeAdmitNotice('e6f54', { + text: 'E6F54 joined as a guest (view-only) because Allow guests to join automatically is on.', + turnOff: () => off++ + }); + expect(n.text).toMatch(/joined as a guest/); + expect(n.turnOffLabel).toBe('Turn off'); + n.turnOff?.(); + expect(off).toBe(1); + }); + it('falls back to a generic line with no Turn off (no provider, a throwing one, junk)', () => { + for (const provided of [null, undefined, 'x', 42, { text: ' ' }, { turnOff: 'nope' }]) { + const n = composeAdmitNotice('a1b2c', provided); + expect(n.text).toBe('A1B2C joined automatically — your session settings let them in without approval.'); + expect(n.turnOff).toBeNull(); + } + }); + it('caps what the provider can put on screen', () => { + const n = composeAdmitNotice('a1b2c', { text: 'x'.repeat(500), turnOffLabel: 'y'.repeat(80), turnOff() {} }); + expect(n.text.length).toBe(200); + expect(n.turnOffLabel.length).toBe(24); + }); + it('shortPeer is the six-char upper-case id the other join toasts use', () => { + expect(shortPeer('e6f54')).toBe('E6F54'); + expect(shortPeer(/** @type {any} */ (undefined))).toBe(''); + }); +}); + +describe('F-5 Connect pill badge seam', () => { + it('normalises, caps and clears', () => { + setConnectBadge({ text: ' Public · guests join ', title: 'Anyone can join', onClick: () => {} }); + const b = get(connectBadge); + expect(b?.text).toBe('Public · guests join'); + expect(b?.title).toBe('Anyone can join'); + expect(typeof b?.onClick).toBe('function'); + setConnectBadge({ text: 'z'.repeat(99) }); + expect(get(connectBadge)?.text.length).toBe(32); + expect(get(connectBadge)?.onClick).toBeNull(); + for (const v of [null, {}, { text: '' }, 'Public']) { + setConnectBadge(v); + expect(get(connectBadge)).toBeNull(); + } + }); +}); From 5b222f939bc9c0045d5d929ba2dbdd929edd39bc Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 11:03:02 +0300 Subject: [PATCH 017/104] =?UTF-8?q?[feat]=20pipeline-1=20P1:=20desk=20batt?= =?UTF-8?q?ery=20=E2=80=94=20end-of-half=20retry,=20per-check=20fingerprin?= =?UTF-8?q?ts,=20release=20baselines,=20flake=20log,=20coverage=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/ci/battery-desk.cjs runs tests/e2e/battery.json the way an integrator does (one half per e2e slot, the self-hosted / production-host / modules-flight cases from data), reruns every red once at the end of its half, fingerprints each failed check, saves a per-tag baseline and diffs a union run against it (pre-existing / flaky / NEW). battery.json gains modules, desk and exclude; the 1.43 battery's extra suites join as optional on runners. tests/unit/batteryDesk.test.js: every e2e file is listed or excluded. Co-Authored-By: Claude Opus 5.5 (1M context) --- RELEASING.md | 11 + scripts/ci/battery-desk.cjs | 426 +++++++++++++++++++++++++++++++++ scripts/ci/battery.cjs | 14 +- tests/e2e/battery.json | 80 ++++++- tests/unit/batteryDesk.test.js | 77 ++++++ 5 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 scripts/ci/battery-desk.cjs create mode 100644 tests/unit/batteryDesk.test.js diff --git a/RELEASING.md b/RELEASING.md index 768e5289c..ba5c034af 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -85,6 +85,17 @@ released version instead of drifting (it carried a stale 1.8.0 before 1.11.0). ``` (plus every changed module file; the same three lines with `scenes`/`format-2` and `packs`/`format-1`). A modules merge alone no longer reaches production's gallery. +- **Record the release's known reds** (the desk battery's baseline, pipeline-1): right after the tag, run the + battery once ON THE TAG — two halves, each on a pc slot where the e2e-slot setup has them — and save it: + ```sh + node scripts/ci/battery-desk.cjs run --half A --root --out --name blA # and --half B + node scripts/ci/battery-desk.cjs baseline save vX.Y.Z + ``` + The next integrator runs the same halves on its union and calls `battery-desk.cjs diff --baseline vX.Y.Z `: + a red whose every twice-red check is red on the tag too is PRE-EXISTING (no A/B run), a red that went green on + the end-of-half retry is FLAKY (logged; `battery-desk.cjs flakes` prints the rates), and only NEW reds need a look. + A new `tests/e2e/*.test.cjs` goes into `tests/e2e/battery.json` (suites, or `exclude` with a reason) — + `tests/unit/batteryDesk.test.js` fails on a file in neither. - The peers warn (never block) on version mismatches, and `.tpscene`/`.tpmodule` files confirm before loading a NEWER format int — older files always load silently. Bump `SESSION_FORMAT`/`MODULE_FORMAT` only when the shape actually diff --git a/scripts/ci/battery-desk.cjs b/scripts/ci/battery-desk.cjs new file mode 100644 index 000000000..90bf0bda3 --- /dev/null +++ b/scripts/ci/battery-desk.cjs @@ -0,0 +1,426 @@ +#!/usr/bin/env node +// THE INTEGRATORS' BATTERY ON A DESK, AGAINST A BASELINE OF THE LAST RELEASE. +// +// battery.cjs runs tests/e2e/battery.json in shards on GitHub runners. This is the same list run the +// way an integrator runs it before a release: one half at a time on an e2e slot, against a dev server +// the slot started, with the suites a public runner skips (the self-hosted signaling box, the +// production hostname mapped here, the modules repo's flights). What it adds over a plain loop: +// +// - EVERY RED RUNS ONCE MORE, AT THE END OF THE HALF, not straight after. A red that came from the +// moment (a sibling's build filling the box, a cold vite pre-bundle) meets a different moment the +// second time. Green then = flaky: logged with its checks, never handed to the integrator. +// - EVERY FAILED CHECK GETS A FINGERPRINT (suite + the FAIL line with numbers, JSON and paths taken +// out), so "the same red as on the release" is a set lookup, not an A/B run. +// - A BASELINE per release tag: the battery once on the tag right after the release (`baseline`). +// `diff` sorts a run's reds into pre-existing (every check red in both runs is red on the tag too), +// flaky (no check red twice) and NEW — the only ones that need the integrator. +// - ONE SUMMARY LINE at the end (stdout, and /.done), so a lane that started this in the +// background is woken by the exit, not by polling. +// +// node scripts/ci/battery-desk.cjs run --half A [--of 2] [--out dir] [--name u1A] [--root tree] [--no-retry] +// node scripts/ci/battery-desk.cjs run --only a,b,MOD:untangle ... +// node scripts/ci/battery-desk.cjs baseline save ... # record a tag's runs +// node scripts/ci/battery-desk.cjs diff [--baseline tag|file] ... # exit 1 when anything is NEW +// node scripts/ci/battery-desk.cjs flakes [--days 30] # flake rate per suite +// node scripts/ci/battery-desk.cjs coverage # every e2e file listed or excluded +// +// State (baselines, the outcome log) lives in $BATTERY_STATE, default ~/.local/state/theprototype/battery. +// The environment a suite reads (APP_URL, MODULES_REPO, the *_TPSCENE paths, PEER_CONFIG_SELF) is the +// caller's: source the integrator's env file first, exactly as for the old battery.sh. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const { load, assign, counts } = require('./battery.cjs'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const STATE = process.env.BATTERY_STATE || path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'theprototype', 'battery'); + +/** + * @typedef {import('./battery.cjs').Battery & { modules?: Record, desk?: { 'prod-host'?: string[], 'peer-self'?: string[], gpu?: string[] }, + * exclude?: Record }} DeskBattery + * @typedef {{ rc: number|null, secs: number, pass: number, fail: number, last: string, fps: string[] }} Attempt + * @typedef {{ name: string, status: 'pass'|'flaky'|'red'|'gone', first: Attempt, retry?: Attempt, fps: string[] }} DeskResult + * @typedef {{ name: string, root: string, commit: string, slot: string, started: string, secs: number, results: DeskResult[] }} DeskRun + * @typedef {{ tag: string, commit: string, made: string, runs: string[], suites: Record }} Baseline + */ + +// ---------------------------------------------------------------- fingerprints + +/** + * One failed check as a stable key: the line a suite printed for it, without what changes from run to + * run — numbers (ms, px, counts, line:col), JSON details, absolute paths, colour codes. "dock resize + * widens (320 → 440)" and "(318 → 437)" are the same check failing; "the Cut button could not be + * clicked" and "the Cut button is disabled" are not. + * @param {string} line + */ +function normalise(line) { + return line + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/^\s*(✗|FAIL|not ok)\s*:?\s*/, '') + .replace(/\{.*\}/g, '{…}') + .replace(/(?:\/[\w.@+-]+)+\/([\w.@+-]+)/g, '$1') + .replace(/0x[0-9a-f]+/gi, '#') + .replace(/\d+(?:\.\d+)?/g, '#') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 200); +} + +/** + * The fingerprints of one suite's log: one per FAIL line, the SCRIPT FAILED message, a timeout, and — + * when a red log names none of those (a crash before the first check) — its last line. + * @param {string} suite @param {string} log @param {number|null} rc + * @returns {string[]} + */ +function fingerprints(suite, log, rc) { + if (rc === 0) return []; + const out = new Set(); + for (const l of log.split('\n')) { + if (/^\s*(?:✗|FAIL\b|not ok\b)/.test(l)) out.add(`${suite} :: ${normalise(l)}`); + else if (/^\s*SCRIPT FAILED/.test(l)) out.add(`${suite} :: ${normalise(l)}`); + } + if (/\[battery\] TIMEOUT/.test(log)) out.add(`${suite} :: TIMEOUT`); + if (!out.size) out.add(`${suite} :: rc=${rc} ${normalise((log.split('\n').filter((l) => l.trim()).pop() || '').slice(0, 200))}`); + return [...out].sort(); +} + +// ---------------------------------------------------------------- the run + +/** the desk set: every listed suite (skip is about public runners) and the modules flights @param {DeskBattery} b */ +function deskEntries(b) { + return /** @type {[string, number][]} */ ([...Object.entries(b.suites), ...Object.entries(b.modules || {}).map(([m, s]) => [`MOD:${m}`, s])]); +} + +/** @param {DeskBattery} b @param {string} half @param {number} of */ +function half(b, half, of) { + const i = half.toUpperCase().charCodeAt(0) - 65; + const shards = assign({ ...b, skip: {}, suites: Object.fromEntries(deskEntries(b)) }, of); + if (!(i >= 0 && i < of)) throw new Error(`--half ${half}: there are ${of} halves (A..${String.fromCharCode(64 + of)})`); + return shards[i].suites; +} + +/** @param {string} url @returns {Promise} */ +function reachable(url) { + const u = new URL(url); + return new Promise((resolve) => { + const req = require('https').get({ host: u.hostname, port: u.port, path: u.pathname, rejectUnauthorized: false, timeout: 300000 }, (res) => { + res.resume(); + res.on('end', () => resolve((res.statusCode || 0) < 500)); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(false)); + }); +} + +/** + * One suite, the way battery.sh ran it: node in the tree, the caller's environment, the per-suite + * cases the list names (desk.prod-host: APP_URL on the production hostname, which /etc/hosts maps + * here; desk.peer-self: the self-hosted signaling box; MOD:x: the modules repo's runner, GPU ones + * with E2E_GPU=1). + * @param {DeskBattery} b @param {string} name @param {string} root @param {string} logFile + * @returns {Promise} + */ +function attempt(b, name, root, logFile) { + const env = { ...process.env }; + let cmd = ['node', path.join(root, 'tests', 'e2e', `${name}.test.cjs`)]; + let cwd = root; + let limit = 1500; + if (name.startsWith('MOD:')) { + const m = name.slice(4); + cwd = process.env.MOD_REPO || process.env.MODULES_REPO || ''; + cmd = ['node', 'tests/run.cjs', m]; + if (b.desk?.gpu?.includes(name)) env.E2E_GPU = '1'; + else limit = 1200; + } else if (b.desk?.['prod-host']?.includes(name)) env.APP_URL = (env.APP_URL || '').replace('localhost', 'theprototype.app'); + else if (b.desk?.['peer-self']?.includes(name)) env.PEER_CONFIG = env.PEER_CONFIG_SELF || ''; + const fd = fs.openSync(logFile, 'w'); + const t0 = Date.now(); + return new Promise((resolve) => { + const child = spawn(cmd[0], cmd.slice(1), { cwd: cwd || root, env, stdio: ['ignore', fd, fd], detached: true }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { + process.kill(-(child.pid || 0), 'SIGKILL'); + } catch {} + }, limit * 1000); + const done = (/** @type {number|null} */ code) => { + clearTimeout(timer); + fs.closeSync(fd); + if (timedOut) fs.appendFileSync(logFile, `\n[battery] TIMEOUT after ${limit}s\n`); + const text = fs.readFileSync(logFile, 'utf8'); + const rc = timedOut ? null : code; + resolve({ rc, secs: Math.round((Date.now() - t0) / 1000), ...counts(text), fps: fingerprints(name, text, rc) }); + }; + child.on('exit', done); + child.on('error', () => done(127)); + }); +} + +/** battery.sh's summary line, so the integrators' greps and `battery.cjs refresh --summary` still read it @param {string} name @param {Attempt} a */ +function line(name, a) { + return `${name} rc=${a.rc} pass~${a.pass} fail~${a.fail} ${a.secs}s :: ${a.last.slice(0, 140)}`; +} + +/** @param {string} root */ +function commitOf(root) { + try { + return require('child_process').execFileSync('git', ['-C', root, 'describe', '--tags', '--always', '--dirty'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return '?'; + } +} + +/** @param {object} row */ +function logOutcome(row) { + try { + fs.mkdirSync(STATE, { recursive: true }); + fs.appendFileSync(path.join(STATE, 'outcomes.jsonl'), JSON.stringify(row) + '\n'); + } catch {} +} + +/** @param {string[]} argv @param {string} flag */ +function opt(argv, flag) { + const i = argv.indexOf(flag); + return i >= 0 ? argv[i + 1] : undefined; +} + +/** @param {string[]} argv */ +async function run(argv) { + const b = /** @type {DeskBattery} */ (load(opt(argv, '--battery'))); + const root = path.resolve(opt(argv, '--root') || ROOT); + const of = Number(opt(argv, '--of') || 2); + const h = opt(argv, '--half'); + const only = opt(argv, '--only'); + const list = opt(argv, '--list'); + const names = only ? only.split(',') : list ? fs.readFileSync(list, 'utf8').split(/\s+/).filter(Boolean) : h ? half(b, h, of) : deskEntries(b).map(([n]) => n); + const name = opt(argv, '--name') || (h ? `half${h.toUpperCase()}` : 'run'); + const out = path.resolve(opt(argv, '--out') || process.env.BATTERY_OUT || path.join(root, 'test-results', 'battery')); + const retry = !argv.includes('--no-retry'); + const url = process.env.APP_URL || ''; + fs.mkdirSync(path.join(out, 'logs'), { recursive: true }); + const sumFile = path.join(out, `${name}.txt`); + const slot = process.env.E2E_SLOT_PC ? `pc:${process.env.E2E_SLOT_PC}` : process.env.E2E_SLOT_REMOTE ? 'remote' : process.env.E2E_SLOT ? 'local' : 'none'; + const commit = commitOf(root); + const started = new Date().toISOString(); + const t0 = Date.now(); + fs.writeFileSync(sumFile, `snapshot: ${commit} root=${root} url=${url} slot=${slot} suites=${names.length}\n`); + const say = (/** @type {string} */ s) => { + console.log(s); + fs.appendFileSync(sumFile, s + '\n'); + }; + /** @type {Map} */ + const results = new Map(); + // the dev server's first SSR + client graph is the slow load; one warm-up so the first suite does not pay it + if (url) await reachable(url); + for (const n of names) { + if (!n.startsWith('MOD:') && !fs.existsSync(path.join(root, 'tests', 'e2e', `${n}.test.cjs`))) { + const a = { rc: null, secs: 0, pass: 0, fail: 0, last: 'no such suite in this tree', fps: [] }; + results.set(n, { name: n, status: 'gone', first: a, fps: [] }); + say(`${n} rc=gone :: no such suite in this tree`); + continue; + } + // a dead dev server turns every later suite red for the wrong reason: wait for it, say so + if (url && !n.startsWith('MOD:') && !(await reachable(url))) say(`[battery] dev server unreachable before ${n}`); + const a = await attempt(b, n, root, path.join(out, 'logs', `${n.replace(':', '-')}.log`)); + results.set(n, { name: n, status: a.rc === 0 ? 'pass' : 'red', first: a, fps: a.fps }); + say(line(n, a)); + for (const f of a.fps) fs.appendFileSync(sumFile, ` ${f.split(' :: ')[1]}\n`); + } + const reds = [...results.values()].filter((r) => r.status === 'red'); + if (retry && reds.length) { + say(`[battery] retry: ${reds.length} red suite(s) once more, at the end of the half`); + for (const r of reds) { + const a = await attempt(b, r.name, root, path.join(out, 'logs', `${r.name.replace(':', '-')}.retry.log`)); + r.retry = a; + if (a.rc === 0) { + r.status = 'flaky'; + r.fps = []; + } else { + // a check red in BOTH runs is the real one; checks red once are the moment's + const both = r.first.fps.filter((f) => a.fps.includes(f)); + r.fps = both.length ? both : a.fps; + } + say(`retry ${line(r.name, a)}${a.rc === 0 ? ' -> FLAKY' : ''}`); + } + } + for (const r of results.values()) { + logOutcome({ t: started, commit, slot, suite: r.name, status: r.status, first: r.first.fps, retry: r.retry?.fps }); + } + /** @type {DeskRun} */ + const rec = { name, root, commit, slot, started, secs: Math.round((Date.now() - t0) / 1000), results: [...results.values()] }; + fs.writeFileSync(path.join(out, `${name}.json`), JSON.stringify(rec, null, 1)); + const all = [...results.values()]; + const n = (/** @type {string} */ s) => all.filter((r) => r.status === s).length; + const redNames = all.filter((r) => r.status === 'red').map((r) => r.name); + const summary = `BATTERY-DONE ${name} @${commit} ${slot}: ${all.length} suites, ${n('pass')} green, ${n('flaky')} flaky, ${redNames.length} red${redNames.length ? ` (${redNames.join(' ')})` : ''}, ${n('gone')} gone, ${Math.round(rec.secs / 60)} min -> ${path.join(out, name + '.json')}`; + say(summary); + fs.writeFileSync(path.join(out, `${name}.done`), summary + '\n'); + process.exit(redNames.length ? 1 : 0); +} + +// ---------------------------------------------------------------- baselines and the diff + +/** @param {string[]} dirs @returns {DeskRun[]} */ +function readRuns(dirs) { + /** @type {DeskRun[]} */ + const runs = []; + for (const d of dirs) { + const st = fs.statSync(d); + const files = st.isDirectory() ? fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => path.join(d, f)) : [d]; + for (const f of files) { + const j = JSON.parse(fs.readFileSync(f, 'utf8')); + if (Array.isArray(j.results) && j.started) runs.push(j); + } + } + return runs; +} + +/** @param {string} tag @param {string[]} dirs */ +function baselineSave(tag, dirs) { + const runs = readRuns(dirs); + if (!runs.length) throw new Error(`no desk runs (.json) in ${dirs.join(' ')}`); + /** @type {Baseline} */ + const bl = { tag, commit: runs[0].commit, made: new Date().toISOString(), runs: runs.map((r) => `${r.name} ${r.slot} ${r.started}`), suites: {} }; + for (const r of runs) { + for (const s of r.results) { + // a baseline remembers EVERY check it saw red, on either attempt: a flaky check on the tag is + // a known one on the union too + const fps = [...new Set([...s.first.fps, ...(s.retry?.fps || [])])].sort(); + bl.suites[s.name] = { status: s.status, fps }; + } + } + const dir = path.join(STATE, 'baselines'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${tag}.json`); + fs.writeFileSync(file, JSON.stringify(bl, null, 1)); + const by = (/** @type {string} */ s) => Object.values(bl.suites).filter((v) => v.status === s).length; + console.log(`baseline ${tag} @${bl.commit}: ${Object.keys(bl.suites).length} suites (${by('pass')} green, ${by('flaky')} flaky, ${by('red')} red, ${by('gone')} gone) -> ${file}`); +} + +/** the newest saved baseline, by file time, when the caller names none */ +function latestBaseline() { + const dir = path.join(STATE, 'baselines'); + const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.json')) : []; + files.sort((x, y) => fs.statSync(path.join(dir, y)).mtimeMs - fs.statSync(path.join(dir, x)).mtimeMs); + return files[0] ? path.join(dir, files[0]) : ''; +} + +/** + * @param {Baseline} bl @param {DeskRun[]} runs + * @returns {{ green: string[], flaky: string[], known: string[], fresh: { name: string, fps: string[], why: string }[], gone: string[] }} + */ +function classify(bl, runs) { + /** @type {ReturnType} */ + const out = { green: [], flaky: [], known: [], fresh: [], gone: [] }; + for (const r of runs) { + for (const s of r.results) { + if (s.status === 'pass') out.green.push(s.name); + else if (s.status === 'flaky') out.flaky.push(s.name); + else if (s.status === 'gone') out.gone.push(s.name); + else { + const base = bl.suites[s.name]; + const unknown = s.fps.filter((f) => !base?.fps.includes(f)); + if (!unknown.length) out.known.push(s.name); + else out.fresh.push({ name: s.name, fps: unknown, why: !base ? 'not in the baseline' : base.status === 'pass' ? 'green on the baseline' : 'a check the baseline did not fail' }); + } + } + } + return out; +} + +/** @param {string[]} argv */ +function diff(argv) { + const named = opt(argv, '--baseline'); + const file = !named ? latestBaseline() : named.endsWith('.json') ? named : path.join(STATE, 'baselines', `${named}.json`); + if (!file || !fs.existsSync(file)) throw new Error(`no baseline ${named || '(none saved)'} — run the battery on the release tag and \`baseline save\` it`); + const dirs = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--baseline'); + /** @type {Baseline} */ + const bl = JSON.parse(fs.readFileSync(file, 'utf8')); + const c = classify(bl, readRuns(dirs)); + console.log(`vs baseline ${bl.tag} @${bl.commit}: ${c.green.length} green, ${c.flaky.length} flaky (green on the retry), ${c.known.length} pre-existing, ${c.fresh.length} NEW, ${c.gone.length} gone`); + if (c.flaky.length) console.log(` flaky: ${c.flaky.join(' ')}`); + if (c.known.length) console.log(` pre-existing (same checks red on ${bl.tag}; no A/B needed): ${c.known.join(' ')}`); + for (const f of c.fresh) { + console.log(` NEW ${f.name} — ${f.why}`); + for (const fp of f.fps) console.log(` ${fp.split(' :: ')[1]}`); + } + process.exit(c.fresh.length ? 1 : 0); +} + +/** @param {string[]} argv */ +function flakes(argv) { + const days = Number(opt(argv, '--days') || 30); + const since = Date.now() - days * 86400e3; + const file = path.join(STATE, 'outcomes.jsonl'); + /** @type {Record} */ + const per = {}; + const rows = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : []; + for (const l of rows) { + const r = JSON.parse(l); + if (Date.parse(r.t) < since || r.status === 'gone') continue; + const p = (per[r.suite] ||= { runs: 0, flaky: 0, red: 0 }); + p.runs++; + if (r.status === 'flaky') p.flaky++; + if (r.status === 'red') p.red++; + } + const list = Object.entries(per).filter(([, p]) => p.flaky).sort((x, y) => y[1].flaky / y[1].runs - x[1].flaky / x[1].runs || (x[0] < y[0] ? -1 : 1)); + const runs = Object.values(per).reduce((t, p) => t + p.runs, 0); + const flaky = Object.values(per).reduce((t, p) => t + p.flaky, 0); + console.log(`last ${days} days: ${runs} suite runs, ${flaky} flaky (${runs ? ((100 * flaky) / runs).toFixed(1) : 0} %), ${list.length} suites ever flaky`); + for (const [n, p] of list) console.log(` ${((100 * p.flaky) / p.runs).toFixed(0).padStart(3)} % ${n} (${p.flaky} of ${p.runs} runs flaky, ${p.red} red twice)`); +} + +// ---------------------------------------------------------------- coverage + +/** + * Every tests/e2e/*.test.cjs is in the battery or excluded with a reason, and nothing listed is + * missing. A suite nobody listed is a suite no release ever runs — the reason this check exists. + * @param {DeskBattery} b @param {string[]} files suite names on disk + */ +function coverage(b, files) { + const excluded = new Set(Object.values(b.exclude || {}).flat()); + const listed = new Set([...Object.keys(b.suites), ...excluded]); + return { + unlisted: files.filter((f) => !listed.has(f)), + missing: [...listed].filter((n) => !files.includes(n)), + both: Object.keys(b.suites).filter((n) => excluded.has(n)) + }; +} + +function e2eFiles(root = ROOT) { + return fs + .readdirSync(path.join(root, 'tests', 'e2e')) + .filter((f) => f.endsWith('.test.cjs')) + .map((f) => f.slice(0, -'.test.cjs'.length)) + .sort(); +} + +if (require.main === module) { + const [cmd, ...argv] = process.argv.slice(2); + try { + if (cmd === 'run') run(argv); + else if (cmd === 'baseline' && argv[0] === 'save' && argv[1]) baselineSave(argv[1], argv.slice(2)); + else if (cmd === 'diff') diff(argv); + else if (cmd === 'flakes') flakes(argv); + else if (cmd === 'coverage') { + const c = coverage(/** @type {DeskBattery} */ (load()), e2eFiles()); + for (const n of c.unlisted) console.log(`unlisted: ${n} — add it to tests/e2e/battery.json suites, or to exclude with a reason`); + // listed-but-absent is information, not a failure: a feat branch carries the list of a + // release whose suites have not merged into it yet, and both runners report them as gone + for (const n of c.missing) console.log(`(listed, no file here: ${n})`); + for (const n of c.both) console.log(`both: ${n} is a suite AND excluded`); + process.exit(c.unlisted.length + c.both.length ? 1 : 0); + } else { + console.error('usage: battery-desk.cjs run --half A|B [--of N] [--only a,b] [--list f] [--out d] [--name n] [--root tree] [--no-retry]\n' + ' | baseline save ... | diff [--baseline tag|file] ... | flakes [--days N] | coverage'); + process.exit(2); + } + } catch (e) { + console.error(`battery-desk: ${/** @type {Error} */ (e).message}`); + process.exit(2); + } +} + +module.exports = { normalise, fingerprints, classify, coverage, e2eFiles, deskEntries, half }; diff --git a/scripts/ci/battery.cjs b/scripts/ci/battery.cjs index f994cf223..4e5c7dd8c 100755 --- a/scripts/ci/battery.cjs +++ b/scripts/ci/battery.cjs @@ -27,7 +27,7 @@ const BATTERY = path.join(ROOT, 'tests', 'e2e', 'battery.json'); /** * @typedef {{ comment?: string, refs: Record, shards: {target_minutes: number, max: number, min: number}, * pc?: {games: string[], levels: string[]}, skip: Record, optional: Record, setup: Record, - * suites: Record }} Battery + * suites: Record, modules?: Record, desk?: Record, exclude?: Record }} Battery * @typedef {{ name: string, status: 'pass'|'flaky'|'fail'|'timeout'|'gone', required: boolean, rc: number|null, secs: number, * retrySecs?: number, pass: number, fail: number, last: string, shard: number }} SuiteResult */ @@ -300,6 +300,10 @@ function write(b) { `\t"setup": ${obj(b.setup, '\t')}`, `\t"suites": ${obj(b.suites, '\t')}` ]; + // the desk battery's keys (battery-desk.cjs): written only when present, so a file without them stays byte-identical + if (b.modules) body.push(`\t"modules": ${obj(b.modules, '\t')}`); + if (b.desk) body.push(`\t"desk": ${obj(b.desk, '\t')}`); + if (b.exclude) body.push(`\t"exclude": ${obj(Object.fromEntries(Object.entries(b.exclude).map(([k, v]) => [k, [...v].sort()])), '\t')}`); fs.writeFileSync(BATTERY, `{\n${body.join(',\n')}\n}\n`); } @@ -309,8 +313,14 @@ function refresh(argv) { const lists = opts(argv, '--list'); if (lists.length) { // the integrator's battery lists ARE the battery: names not in them leave, new ones arrive at 120 s - const names = new Set(lists.flatMap((f) => fs.readFileSync(f, 'utf8').split(/\s+/).filter((n) => n && !n.startsWith('MOD:')))); + const all = lists.flatMap((f) => fs.readFileSync(f, 'utf8').split(/\s+/).filter(Boolean)); + const names = new Set(all.filter((n) => !n.startsWith('MOD:'))); b.suites = Object.fromEntries([...names].map((n) => [n, b.suites[n] ?? 120])); + // the modules repo's flights (MOD:x) are the desk battery's, never a runner's + const mods = all.filter((n) => n.startsWith('MOD:')).map((n) => n.slice(4)); + if (mods.length) b.modules = Object.fromEntries(mods.map((m) => [m, b.modules?.[m] ?? 600])); + // a suite that joined the battery is no longer excluded + if (b.exclude) for (const k of Object.keys(b.exclude)) b.exclude[k] = b.exclude[k].filter((/** @type {string} */ n) => !names.has(n)); } /** @type {Record} */ const desk = {}; diff --git a/tests/e2e/battery.json b/tests/e2e/battery.json index 5d5d97255..b3eed92e3 100644 --- a/tests/e2e/battery.json +++ b/tests/e2e/battery.json @@ -1,5 +1,5 @@ { - "comment": "deps-auto: the e2e battery deps.yml runs in shards on GitHub-hosted runners (scripts/ci/battery.cjs). suites = name -> seconds on a runner (2x the desk time until a runner has measured it); skip = cannot run on a public runner (the reason); optional = runs and reports, does not gate (the reason); setup = extra install a suite needs. Refresh after an integrator battery: node scripts/ci/battery.cjs refresh --list --list --summary ... (RELEASING.md, Dependency updates). pc = the games (fps) and levels (shots) scripts/ci/pc-battery.sh measures on a desktop GPU.", + "comment": "deps-auto: the e2e battery deps.yml runs in shards on GitHub-hosted runners (scripts/ci/battery.cjs). suites = name -> seconds on a runner (2x the desk time until a runner has measured it); skip = cannot run on a public runner (the reason); optional = runs and reports, does not gate (the reason); setup = extra install a suite needs. Refresh after an integrator battery: node scripts/ci/battery.cjs refresh --list --list --summary ... (RELEASING.md, Dependency updates). pc = the games (fps) and levels (shots) scripts/ci/pc-battery.sh measures on a desktop GPU. Desk battery (scripts/ci/battery-desk.cjs): modules = the modules repo flights (MOD:x) with desk seconds; desk = per-suite cases (gpu: E2E_GPU=1, peer-self: PEER_CONFIG=$PEER_CONFIG_SELF, prod-host: APP_URL on the production hostname); exclude = reason -> e2e files that are deliberately NOT in the battery (tests/unit/batteryCoverage.test.js fails on a file in neither).", "refs": { "modules": {"repo":"theprototype-app/modules","ref":"80fca17f7cd963cf698d6b5f29beaff14f28f9e1"}, "packs": {"repo":"theprototype-app/packs","ref":"360f31686652f0f9c7e240ff09785c6b6c60d678"}, @@ -17,37 +17,66 @@ }, "optional": { "animation-sync": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "ar-table": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "behaviours": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "char-controller": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "checkpoint-clean": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "code-popout": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "collider-live": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "context-menu-redesign": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "edit-move-games": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "explorer-files": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "fb-water": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "flow-code": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "flow-code-text": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "flow-physics-actions": "flaky on every app/driver pair (deps-1 ab3, 2026-10-23)", "flow-physics-nodes": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "game-loop-v4": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "game-sky-run": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "helpers-in-play": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "hud-editor": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "hud-persist": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "hud-screen-model": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "kit-instancing": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "lock-search": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "mesh-toolbox-redesign": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "model-preview": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "net-handshake": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "net-reconnect": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "net-stress": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "panels": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "phone-sheets": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "physics-discoverability": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "physics-kinematic": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "play-exits": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "play-reentry": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "pointer-capture-play": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "post-play-mode": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "scene-budget": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "scene-post": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "scene-post-effects": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "scene-versions": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "script-node-v2": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "search-menus": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "select-41": "G4 flick timing flake (deps-1 ab3: 12/12 green on --local, red under load)", + "session-scenes": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "sessions": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "settings-layout": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "shader-sync": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "shared-text": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "shared-text-doc": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", "sound-node": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "themes": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "timeline-export": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", "touch-actions": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", - "water-migration": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)" + "touch-play": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "union-50": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "vr-locomotion-flags": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "vr-mode-spawn": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "vr-passthrough": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "vr-walk-interact": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green", + "water-migration": "red on the unchanged base too (deps-1 A/B on 1f3b9dd5, 2026-10-23)", + "workspace-restore": "joined from the 1.43 desk battery (pipeline-1): not yet measured on a GitHub runner, so it reports and does not gate until a deps run has seen it green" }, "setup": { "agent-bridge": "agent" @@ -65,6 +94,7 @@ "animation-sync": 120, "animation-window": 28, "aquarium-reef": 48, + "ar-table": 120, "arch-primitives": 98, "auto-quality-notice": 60, "autosave-object-flows": 28, @@ -83,7 +113,9 @@ "char-controller": 116, "character-studio": 86, "chat-v2": 70, + "checkpoint-clean": 132, "code-node": 64, + "code-popout": 120, "code-sidebars": 118, "collider-custom": 30, "collider-decompose": 244, @@ -136,6 +168,7 @@ "explorer-delete-confirm": 216, "explorer-drag-fixes": 274, "explorer-drop": 168, + "explorer-files": 120, "explorer-header-panels": 52, "explorer-keyboard": 34, "explorer-multiselect": 366, @@ -152,6 +185,8 @@ "file-preview": 640, "files-format-row": 276, "floating-window-focus": 106, + "flow-code": 120, + "flow-code-text": 360, "flow-debugger": 74, "flow-dock-toggle": 32, "flow-impact-static": 30, @@ -197,12 +232,16 @@ "gltf-export-selection": 24, "hdri": 54, "heic-import": 284, + "helpers-in-play": 48, "hotkeys-layout": 26, "hud-core": 72, + "hud-editor": 46, "hud-editor-wysiwyg": 60, "hud-kinds": 28, + "hud-persist": 120, "hud-picker": 34, "hud-play-keyboard": 50, + "hud-screen-model": 70, "hud-sync": 286, "image-editor": 140, "import-button": 52, @@ -262,8 +301,10 @@ "modules-phone-jobs": 38, "move-wire-shape": 150, "multiselect-v2": 706, + "net-handshake": 120, "net-mesh": 30, "net-peer-id": 120, + "net-reconnect": 494, "net-stress": 120, "network-disconnect": 158, "node-breadcrumb": 76, @@ -310,12 +351,16 @@ "place-drag-cache": 44, "place-drag-extras": 22, "placeholders": 98, + "play-exits": 42, "play-interact": 188, "play-menu-mode": 74, "play-mode-menu": 54, + "play-reentry": 120, "player-access": 124, + "pointer-capture-play": 120, "pool-toys-waterline": 32, "post-backends": 22, + "post-play-mode": 120, "prefabs": 286, "profiler-sidebars": 148, "profiler-themes": 46, @@ -343,12 +388,16 @@ "scene-post-effects": 120, "scene-post-ui": 156, "scene-switch-keep": 626, + "scene-versions": 120, "scene-versions-in-file": 32, + "script-node-v2": 120, "script-nodes": 448, "scrub-undo": 116, "search-menus": 120, "select-41": 114, "select-inside-36": 38, + "session-scenes": 560, + "sessions": 120, "settings-autorestore-colors": 150, "settings-inventory": 32, "settings-inventory-b": 36, @@ -368,6 +417,8 @@ "shader-post-domain": 98, "shader-sync": 120, "shader-window": 126, + "shared-text": 120, + "shared-text-doc": 372, "sheet-handoff": 214, "shortcut-rebind": 146, "shortcut-warnings": 44, @@ -424,6 +475,7 @@ "toolbar-topright-43": 214, "toolbox-window": 78, "touch-actions": 154, + "touch-play": 194, "touch-tools": 332, "tours": 424, "tpscene": 98, @@ -436,6 +488,7 @@ "union-47": 46, "union-48": 120, "union-49": 22, + "union-50": 432, "union-53": 74, "unsaved-toast-stable": 214, "user-modules": 184, @@ -450,14 +503,18 @@ "vr-inset-confirm": 18, "vr-iwer": 22, "vr-locomotion": 32, + "vr-locomotion-flags": 30, + "vr-mode-spawn": 120, "vr-panel-sharpness": 12, "vr-panels-v3": 28, + "vr-passthrough": 120, "vr-radial-menu": 32, "vr-settings-36": 154, "vr-settings-immediacy": 14, "vr-settings-panel": 18, "vr-snapturn-mirror": 28, "vr-teleport": 18, + "vr-walk-interact": 120, "vr-world": 36, "water": 68, "water-migration": 72, @@ -478,6 +535,25 @@ "window-tabs-move": 30, "wire-hardening": 22, "workspace-layouts": 214, + "workspace-restore": 52, "z-scale-layering": 104 + }, + "modules": { + "dungeon-realms": 458, + "football": 200, + "fx-car-blocks": 60, + "untangle": 146 + }, + "desk": { + "gpu": ["MOD:fx-car-blocks","MOD:untangle"], + "peer-self": ["arch-primitives","game-race"], + "prod-host": ["error-reports"] + }, + "exclude": { + "a VR (IWER) editing suite not in the 1.43 integrator battery: triage owed (test-health-1)": ["music-vr-patch","novr-inset","play-here-iwer","vr-ai-panel","vr-ai-voice","vr-bounded-teleport","vr-box-select","vr-chat","vr-collider-edit","vr-controller-handedness","vr-create-face","vr-create-face-facing","vr-edit-caps","vr-edit-menu","vr-editmenu-reposition","vr-editmenu-tabs","vr-editmode-persist","vr-face-correctness","vr-face-edit","vr-face-live-adjust","vr-face-manip","vr-face-move-fix","vr-face-polygon","vr-face-weld","vr-grips-by-mode","vr-helper-eyes","vr-hz-pinch","vr-keyboard","vr-keyboard-search","vr-laser-panels","vr-mesh-undo","vr-multi-prefab","vr-multiselect-ring","vr-nav-suppression","vr-objects-groups","vr-objects-panel","vr-palette","vr-panel-sharpness-all","vr-panels-on-top","vr-peer-approve","vr-peer-hands","vr-peer-hands-net","vr-ping-accuracy","vr-prefabs","vr-props-panel","vr-ptt","vr-radial-stick","vr-ray-hover","vr-reticle-v2","vr-sleeve","vr-snap-menu","vr-source-rebind","vr-stats","vr-stretch","vr-stretch-fix","vr-stretch-sliders","vr-sweep","vr-teleport-handedness","vr-teleport-toggle","vr-two-hand-grab","vr-ungroup","vr-vertex-edit","vr-vertex-handle-bounds","vr-vertex-hold","vr-vertex-polish","vr-vertex-ray","vr-vertex-trigger","vr-window-grab","vr-world-grab","vr-world-reel","vr-worldgrab-presence"], + "a docs / screenshot generator, not a gate": ["flow-node-docs","shader-node-docs","vr-eye-shots"], + "a frame-rate / timing measurement: run by hand on an --exclusive slot, never in a battery half": ["avatar-knocked-perf","avatars-budget","import-budget","mesh-budget","perf-detailed","perf-live","perf-moment","perf-stale-modules","perf-stutter","place-drag-perf"], + "not in the 1.43 integrator battery (listA5/listB5): kept for focused runs, triage owed (test-health-1)": ["add-menu","add-menu-cursor","advanced-mode","ai-assistant","ai-local-model","ai-presets","ai-reference","anim-pack-refs","animated-base","animation-autokey","animation-autokey-material","animation-bake","animation-look-channels","animation-loop-pause","animation-markers","animation-marquee","animation-node","animation-onion","animation-persist","animation-relative-motion","annotation-anchor","approval-timeout","asset-cache-opfs","audio-devices","audio-engine","audio-harness","audio-pack","audio-patch","audio-sdk","author-kit","avatar-fly","avatar-v2","button","camera-bookmarks","camera-clipping","camera-looks","camera-objects","camera-preview-control","checkpoints","clear-scene","code-workspace","collectibles-v2","collision-groups","colocation-anchor","colocation-ar-mode","colocation-calibrate","colocation-core","colocation-nudge","colocation-presence","connect-pin-slide-43","content-index-cache","contest-starters","controls-state","controls-transform-tint","convert-to-mesh","deleted-folders","deselect-unlock","desktop-face-gizmo","dial-metadata","drawing","dungeon","dungeon-play","duplicate-after-create","duplicate-parity","edit-overlay-gaps","editmesh-focus","editmesh-mode-memory","env-fog-sticks","environment-v2","explorer-columns","explorer-duplicate","explorer-duplicate-scene-folder","explorer-expandable","explorer-inline-input","explorer-menu-icons","explorer-mount-open-peers","explorer-mounts","explorer-mounts-edit","explorer-rename","explorer-tree-floor","faces-nested-toolbar","faces-toolbar","far-clipping","fast-channel-fallback","fast-channel-loss","fb-input","fbx-mtl-import","file-windows-esc","flow-adjustable-params","flow-audit","flow-background","flow-contact-filter","flow-curvy-edges","flow-customnode-io","flow-explorer-floating","flow-input-moddefs","flow-mouse-bindings","flow-new-nodes","flow-node-duplicate","flow-node-undocked","flow-nodes-extended","flow-object-embed","flow-palette","flow-palette-reflow","flow-particle","flow-properties","flow-spawner","flow-window-mobile","gallery-submit","game-editor-flow","game-effects","game-feel-nodes","game-haptics","game-loop","game-loop-v2","game-loop-v3","game-presence","geometry-lock","geometry-params","gizmo-orbit-leak","gizmo-recall","grab-key","graph-games-lint","graph-tidy","graph-tree","grid-snapping","hand-models","hud-actions","hud-content","hud-editor-tools","hud-inputs","hud-nodes","hud-visibility","import-duplicates","import-reduce-gate","kit-entities","launch-inbox","level-templates","level-templates-33","light-aim","light-catalog","llms-txt","load-primitives-first-36","load-supersede-36","material-sharing","material-types","mesh-adjust","mesh-advanced-ops","mesh-bevel","mesh-bridge-normals","mesh-cleanup","mesh-edge-bevel","mesh-edge-gizmo","mesh-edge-mode","mesh-edge-vertex-ops","mesh-edit-display","mesh-edit-materials","mesh-edit-popup","mesh-falloff-rotate-scale","mesh-falloff-sync","mesh-fixes-round2","mesh-fixes-round3","mesh-generation","mesh-gizmo-modes","mesh-grab-uv","mesh-inset-move","mesh-loop-cut","mesh-loop-hardening","mesh-loop-select","mesh-multishell-ops","mesh-ops","mesh-ops-p5b","mesh-pivot-gizmo","mesh-proportional","mesh-quad-select","mesh-selection-undo","mesh-session-undo","mesh-symmetrize","mesh-symmetry-live","mesh-uv-preserve","mesh-vertex-bevel","mesh-vertex-slide","model-preview-controls","module-lifecycle-repo","module-node-io","module-pointer-seam","module-sdk","module-sdk-world","module-seed-graph","module-storage","module-update-on-load","module-world-root","modules-discoverability","multi-select","multiselect-menu","music-clock","music-flow-nodes","music-prefabs","music-sampling","music-template","music-toolbox","net-backoff","net-locks","network-quality","node-editor-open-view","node-search","node-ux-library","note-replies","object-list-drag","object-list-raise","object-list-size","object-origin","object-properties","object-search","object-sync","objectlist-hidden-icon","objectlist-search","open-core-m1","origin-rows","outline-scene-swap","pack-behavior","pack-undo","packs-explorer","palette","palette-groups","particles","path-node","peer-ice-config","peer-variables","pick-tiny-objects","ping","ping-highlights","ping-v2","placeholders-peers","play-free-cursor","play-here","play-reach-jump","pong","possess","prefab-explorer","prefab-library","prefab-propagation","preview-animation","private-scene","problem-report","project-file","project-manifest","race-touch-vr","recording","resync-converge","roadmap-13-notifications-notes","roadmap-13-viewport-camera","save-as-formats","scene-adopt","scene-assets","scene-default-material","scene-duplicate","scene-folders","scene-identity","scene-isolation","scene-levels","scene-load","scene-music","scene-poke","scene-presence","scene-rename","scene-rooms","scene-rooms-matrix","scene-rooms-truth","scene-stress","scene-switch","scene-switch-clear","scene-switch-games","script-guard","sdk-game-seams","sdk-input","sdk-pointer-ray","select-through","selection-extras","session-clock","session-download-formats","session-scene-data","session-zip","sessions-open","sessions-open-peers","sessions-packs","shader-editor","shader-inspector","shader-module-flow","shader-persist","shader-scene-default","shadows","share-gate-defer","share-stash","shared-library","shared-library-copies","sidebar-toggle","small-fixes-37","snap-advanced","spatial-voice","spline-tool","stars-clap","start-view","start-view-hold","stroke-streaming","switcher-mux","template-modules","terrain","topo-channel","towers-levels","trackpad-nav","trackpad-pinch","ui-fixes-15b","underlay","uv-dense","uv-live-faces","uv-materials","uv-pen-pressure","uv-slots","uv-slots-persist","uv-smart-unwrap","uv-target","uv-texture-params","uv-texture-tools","uv-transform","uv-unwrap","uv-unwrap-module","value-graph","variadic-nodes","version","vertices-create-face","view-mode","viewport-selection","viewport-tools-menu","walker-sensors","watch-look","waypoint-viz","wheel-classify"], + "the 38 R1 behaviour lock: npm run e2e -- @lock (tests/e2e/lock.json), not the release battery": ["dock-tabs-coexist","flow-dock-mixed","flow-dock-tabs","flow-explorer-dock","inspector-multi-edit","node-drag-fields","units"] } } diff --git a/tests/unit/batteryDesk.test.js b/tests/unit/batteryDesk.test.js new file mode 100644 index 000000000..ba6dcaeab --- /dev/null +++ b/tests/unit/batteryDesk.test.js @@ -0,0 +1,77 @@ +// pipeline-1: the desk battery (scripts/ci/battery-desk.cjs) — check fingerprints, the baseline diff, and +// the guard that every tests/e2e file is in tests/e2e/battery.json or excluded with a reason. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { normalise, fingerprints, classify, coverage, e2eFiles, half } = require('../../scripts/ci/battery-desk.cjs'); +const battery = require('../../tests/e2e/battery.json'); + +/** @param {string} name @param {'pass'|'flaky'|'red'|'gone'} status @param {string[]} fps */ +const res = (name, status, fps = []) => ({ name, status, first: { rc: status === 'pass' ? 0 : 1, secs: 1, pass: 1, fail: fps.length, last: '', fps }, fps }); + +describe('battery-desk: a failed check is the same check on another run', () => { + it('drops numbers, JSON details, paths and colour codes', () => { + expect(normalise('FAIL dock resize widens (320 → 440)')).toBe(normalise('FAIL dock resize widens (318 → 437)')); + expect(normalise('FAIL an occupied edge takes a tab ({"flow":"right","listShown":false})')).toBe('an occupied edge takes a tab ({…})'); + expect(normalise(' at /home/x/run-50-int/tests/e2e/touch-actions.test.cjs:235:92')).toBe('at touch-actions.test.cjs:#:#'); + expect(normalise('\x1b[31mFAIL\x1b[0m the Cut button could not be clicked')).toBe('the Cut button could not be clicked'); + }); + it('keeps different checks apart', () => { + expect(normalise('FAIL the Cut button could not be clicked')).not.toBe(normalise('FAIL the Cut button is disabled')); + }); + it('fingerprints FAIL lines, SCRIPT FAILED and timeouts; a green log has none', () => { + const log = 'PASS a\nFAIL b took 1200 ms\nSCRIPT FAILED: locator.click: Timeout 30000ms exceeded.\n1 FAILURES\n'; + expect(fingerprints('s', log, 1)).toEqual(['s :: SCRIPT FAILED: locator.click: Timeout #ms exceeded.', 's :: b took # ms']); + expect(fingerprints('s', log, 0)).toEqual([]); + expect(fingerprints('s', '✓ a\n✗ b is red\n', 1)).toEqual(['s :: b is red']); + expect(fingerprints('s', 'PASS a\n[battery] TIMEOUT after 1500s\n', null)).toContain('s :: TIMEOUT'); + expect(fingerprints('s', 'booting\nError: listen EADDRINUSE :5480\n', 1)).toEqual(['s :: rc=1 Error: listen EADDRINUSE :#']); + }); +}); + +describe('battery-desk: a run against the release baseline', () => { + const baseline = { + tag: 'v1.0.0', + commit: 'abc', + made: '', + runs: [], + suites: { old: { status: 'red', fps: ['old :: x'] }, mixed: { status: 'red', fps: ['mixed :: x'] }, fine: { status: 'pass', fps: [] } } + }; + it('same checks red on the tag = pre-existing; a new check or a suite green on the tag = NEW', () => { + const run = { + name: 'u1A', + root: '', + commit: '', + slot: '', + started: '', + secs: 0, + results: [res('old', 'red', ['old :: x']), res('mixed', 'red', ['mixed :: x', 'mixed :: y']), res('fine', 'red', ['fine :: z']), res('fresh', 'red', ['fresh :: q']), res('wobbly', 'flaky'), res('ok', 'pass')] + }; + const c = classify(baseline, [run]); + expect(c.known).toEqual(['old']); + expect(c.flaky).toEqual(['wobbly']); + expect(c.green).toEqual(['ok']); + expect(c.fresh.map((/** @type {{name: string}} */ f) => f.name)).toEqual(['mixed', 'fine', 'fresh']); + expect(c.fresh[0].fps).toEqual(['mixed :: y']); + expect(c.fresh[1].why).toBe('green on the baseline'); + expect(c.fresh[2].why).toBe('not in the baseline'); + }); +}); + +describe('battery-desk: every e2e file is in the battery or excluded', () => { + it('tests/e2e/battery.json lists or excludes every *.test.cjs', () => { + const c = coverage(battery, e2eFiles()); + expect(c.unlisted, 'add these to tests/e2e/battery.json suites (a measured time) or to exclude with a reason').toEqual([]); + expect(c.both).toEqual([]); + }); + it('the guard notices a new file', () => { + expect(coverage({ ...battery, exclude: {} }, ['brand-new-suite']).unlisted).toEqual(['brand-new-suite']); + }); + it('the two halves split the desk set (suites + modules flights) with nothing lost', () => { + const a = half(battery, 'A', 2); + const b = half(battery, 'B', 2); + expect(a.length + b.length).toBe(Object.keys(battery.suites).length + Object.keys(battery.modules || {}).length); + expect(a.filter((/** @type {string} */ n) => b.includes(n))).toEqual([]); + }); +}); From 14a16ef5e415129586824fbb509d74c5244a0030 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 11:10:05 +0300 Subject: [PATCH 018/104] fix-live F-5: the setting badge also shows on the connected chip A connected pill collapses to its chip, which is exactly when a host forgets a remembered 'guests join' setting; the badge was gated off there. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/Connect.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index 4f5c52276..533f8b5be 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -659,7 +659,7 @@ > {/if} - {#if $connectBadge && !squeezedView && !compact} + {#if $connectBadge && !squeezedView}