diff --git a/src/App.svelte b/src/App.svelte
index 6cd1bc9f3..0085c97a8 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -139,7 +139,7 @@ import { startMusicToolbox } from './lib/musicToolbox'
import { startSharedLibrary } from '$lib/sharedLibrary'
import { startSceneIdentity } from '$lib/sceneIdentity'
import GameChip from './components/hud/GameChip.svelte'
- import SimplifiedWaterNotice from './components/water/SimplifiedWaterNotice.svelte' // 36-fb-water F27
+ import AutoQualityNotice from './components/AutoQualityNotice.svelte' // 40 F12 (was SimplifiedWaterNotice)
import ShortcutSheet from './components/menu/ShortcutSheet.svelte' // 36 I3
import HudLayer from './components/hud/HudLayer.svelte'
import PickCycleHint from './components/menu/PickCycleHint.svelte' // 36 F22 / S6 / S12
@@ -452,7 +452,7 @@ import { startMusicToolbox } from './lib/musicToolbox'
editor's stand-in for a game's screens, which HudLayer no longer draws outside Play.
Editor-only (it hides itself in Play, in VR and in embed mode). -->
-
+
diff --git a/src/components/AutoQualityNotice.svelte b/src/components/AutoQualityNotice.svelte
new file mode 100644
index 000000000..2cfec0197
--- /dev/null
+++ b/src/components/AutoQualityNotice.svelte
@@ -0,0 +1,93 @@
+
diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte
index 9512d31e3..42a5f5ea2 100644
--- a/src/components/menu/Controls.svelte
+++ b/src/components/menu/Controls.svelte
@@ -25,7 +25,7 @@
import { sendPing } from '$lib/ping';
import { buildObjectMenuItems } from '$lib/objectMenu';
import * as THREE from 'three';
- import { onMount, setContext, tick, untrack } from 'svelte';
+ import { onMount, setContext, tick } from 'svelte';
import { createGesture } from '$lib/modalGrab';
import { writable, get } from 'svelte/store';
import { shareObject } from '$lib/objectPermissions';
@@ -599,30 +599,17 @@
const what = q.labels.join(', ').toLowerCase();
return q.pinned
? 'Held at reduced quality (' + what + '). Click to restore full quality.'
- : 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It comes back on its own when frames recover. Click to keep it this way.';
+ : q.held
+ ? 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It stays this way for this scene (it came back once and the frames dropped again). Click to keep it this way.'
+ : 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It comes back on its own when frames recover. Click to keep it this way.';
});
function qualityChipClick(node: HTMLElement) {
const click = () => (get(qualityState).pinned ? releaseQuality() : pinQuality());
node.addEventListener('click', click);
return { destroy() { node.removeEventListener('click', click); } };
}
- // …and once per session, a toast when it FIRST acts, because the chip lives in the object
- // list's footer and that window can be closed
- let qualityToasted = false;
- $effect(() => {
- const q = $qualityState;
- if (q.level > 0 && !qualityToasted) {
- qualityToasted = true;
- untrack(() =>
- showQualityToast('Quality reduced — this scene is heavy for this device (' + q.labels.join(', ').toLowerCase() + '). It comes back on its own.', [
- // 33 integrate: not "Restore …" — on a phone this toast now shows on every load, beside the
- // session's own "Restore" (the L1 Restore path), and a tap on the wrong one undid the phone fix
- { label: 'Use full quality', action: () => releaseQuality() },
- { label: 'Keep it', action: () => pinQuality() }
- ])
- );
- }
- });
+ // 40 F12: the once-per-session "Lowered … to keep it smooth · Keep full quality" toast is
+ // AutoQualityNotice (App root) — it used to live here and was gone whenever this window was closed.
// bottom status line: totals across the whole tree (N objects · M hidden)
let objectCount = $state(0);
diff --git a/src/components/water/SimplifiedWaterNotice.svelte b/src/components/water/SimplifiedWaterNotice.svelte
deleted file mode 100644
index 2dd593c2f..000000000
--- a/src/components/water/SimplifiedWaterNotice.svelte
+++ /dev/null
@@ -1,102 +0,0 @@
-
-
-{#if shown}
-
-
- Simplified water for this device —
-
- shows refraction.
-
-
-
-{/if}
-
-
diff --git a/src/lib/qualityGovernor.js b/src/lib/qualityGovernor.js
index ca30fed81..30e27c246 100644
--- a/src/lib/qualityGovernor.js
+++ b/src/lib/qualityGovernor.js
@@ -59,7 +59,8 @@ export const ingestDrawGap = writable(0);
/**
* For the chip and the suite.
- * @type {import('svelte/store').Writable<{level: number, max: number, pinned: boolean, labels: string[], reason: string, at: number, snoozedUntil: number}>}
+ * `held` (40 F12): recovery is held at this level because coming back from it flapped.
+ * @type {import('svelte/store').Writable<{level: number, max: number, pinned: boolean, labels: string[], reason: string, at: number, snoozedUntil: number, held: boolean}>}
*/
export const qualityState = writable({
level: 0,
@@ -68,7 +69,8 @@ export const qualityState = writable({
labels: /** @type {string[]} */ ([]),
reason: '',
at: 0,
- snoozedUntil: 0
+ snoozedUntil: 0,
+ held: false
});
/** The opt-out. LOCAL preference, default ON. */
@@ -132,7 +134,8 @@ function publish(level, reason) {
labels: stepLabelsAt(level),
reason,
at,
- snoozedUntil
+ snoozedUntil,
+ held: level > 0 && governor.flapLock() >= level
});
}
@@ -187,6 +190,9 @@ export function noteFrameForQuality(ms) {
if (wasLoading) {
wasLoading = false;
governor.settleFor(t);
+ // 40 F12: a new scene is new evidence — the last scene's flap lock does not carry over
+ governor.clearFlapLock();
+ if (get(qualityState).held) qualityState.update((s) => ({ ...s, held: false }));
}
governor.noteFrame(ms, t);
if (phone) notePhoneFrame(ms);
@@ -254,10 +260,25 @@ export function pinQuality() {
export function releaseQuality() {
pinned = false;
snoozedUntil = Date.now() + RELEASE_SNOOZE_MS;
+ governor.clearFlapLock();
governor.setLevel(0, now());
publish(0, 'released');
}
+/** 40 F12 — "Keep full quality" (the auto-quality notice): full quality now and NO automatic
+ * step for the rest of this page session. Not saved — Settings' auto switch stays on, so the
+ * next visit is protected again; turning that switch off and on ends the hold too. */
+export function keepFullQuality() {
+ releaseQuality();
+ snoozedUntil = Infinity;
+ publish(0, 'kept full quality');
+}
+
+/** 40 F12: the level recovery is held at because it flapped there (-1 = none). For the suite. */
+export function qualityFlapLock() {
+ return governor.flapLock();
+}
+
/** @param {boolean} on */
export function setAutoQuality(on) {
autoQuality.set(!!on);
@@ -273,6 +294,9 @@ autoQuality.subscribe((on) => {
return;
}
safeStorage.setItem('autoQuality', on ? 'true' : 'false');
+ // 40 F12: either way the switch is a fresh start — no flap lock, no "keep full quality" hold
+ governor.clearFlapLock();
+ snoozedUntil = 0;
if (!on) {
pinned = false;
governor.setLevel(0, now());
@@ -328,9 +352,9 @@ export const governorForTest = {
longTask(t) {
governor.noteLongTask(t);
},
- /** @param {number} level */
- setLevel(level) {
- governor.setLevel(level, now());
+ /** @param {number} level @param {number} [t] (40 F12: a synthetic clock, so real frames stay inert) */
+ setLevel(level, t) {
+ governor.setLevel(level, t ?? now());
publish(governor.level(), 'test');
},
reset() {
@@ -338,12 +362,16 @@ export const governorForTest = {
snoozedUntil = 0;
drawGapEngaged = false;
lastDecideAt = 0;
+ governor.clearFlapLock();
governor.setLevel(0, -1e9);
governor.forget();
ingestDrawGap.set(0);
publish(0, 'reset');
},
- level: () => governor.level()
+ level: () => governor.level(),
+ /** 40 F12: the flap lock, and the pre-F12 rule (clear it after every decision) */
+ flapLock: () => governor.flapLock(),
+ clearFlapLock: () => governor.clearFlapLock()
};
registerFrameObserver(noteFrameForQuality);
diff --git a/src/lib/qualityGovernorCore.js b/src/lib/qualityGovernorCore.js
index 890ce29b6..880909b4f 100644
--- a/src/lib/qualityGovernorCore.js
+++ b/src/lib/qualityGovernorCore.js
@@ -246,6 +246,17 @@ function percentile(sorted, q) {
* each transition itself a visible hitch). A step back up shortly after a walk down
* doubles the recovery hold for next time, capped; a step up long after the last walk down
* is just a heavier scene, and resets it.
+ *
+ * 40 F12 — THE FLAP LOCK. Doubling the hold only slowed the flap down: on a phone, Aquarium's
+ * water switched its refraction off and on by itself every 10-80 s (level 6 "post off" drops the
+ * screen-space water pass, so its frames are cheap and recover; level 5 brings the pass back and
+ * overloads 3 s later). The frames of a level say nothing about the cost of the level above it,
+ * so a recovery that had to be undone is not retried: stepping back UP to the level just walked
+ * down FROM (within `flapWindowMs`) makes that level the lowest recovery may reach until
+ * `clearFlapLock` — a new scene, "Keep full quality", the auto switch. Every automatic toggle
+ * (water, fluid, HDRI tier, shadows, resolution, AO, post, particles) derives from the level, so
+ * every one gets the same rule: at most one retry per scene. A scene that becomes LIGHT still
+ * walks back (that is new evidence, not the same frames again).
* @param {{timing?: Partial}} [opts]
*/
export function createGovernor(opts = {}) {
@@ -257,6 +268,9 @@ export function createGovernor(opts = {}) {
let level = 0;
let changedAt = -Infinity;
let lastDownAt = -Infinity;
+ /** 40 F12: the level the last walk down started from, and the flap lock (-1 = none) */
+ let lastDownFrom = -1;
+ let flapLock = -1;
let recoverHoldMs = timing.recoverWindowMs;
let settleUntil = -Infinity;
/** 31-perf P3: a live XR session's thresholds (xrThresholds), null = the profile's;
@@ -287,14 +301,17 @@ export function createGovernor(opts = {}) {
}
function change(/** @type {number} */ to, /** @type {number} */ now) {
- if (to < level) lastDownAt = now;
- else if (to > level)
+ if (to < level) {
+ lastDownAt = now;
+ lastDownFrom = level;
+ } else if (to > level) {
// a step up soon after a walk down is a FLAP: make the next recovery wait longer.
// A step up long after one is simply a scene that got heavier: forgive the history
- recoverHoldMs =
- now - lastDownAt < timing.flapWindowMs
- ? Math.min(timing.maxRecoverHoldMs, recoverHoldMs * 2)
- : timing.recoverWindowMs;
+ const flap = now - lastDownAt < timing.flapWindowMs;
+ recoverHoldMs = flap ? Math.min(timing.maxRecoverHoldMs, recoverHoldMs * 2) : timing.recoverWindowMs;
+ // 40 F12: straight back to the level the recovery left — lock it (see the header)
+ if (flap && to === lastDownFrom) flapLock = Math.max(flapLock, to);
+ }
level = to;
changedAt = now;
settleUntil = now + timing.settleMs;
@@ -359,6 +376,8 @@ export function createGovernor(opts = {}) {
change(level - 1, now);
return { level, moved: 'down', reason: 'scene is light', p95 };
}
+ // 40 F12: a recovery that already had to be undone is not retried
+ if (level <= flapLock) return { level, moved: null, reason: 'held (flapped)', p95 };
const calm = p95Over(now, recoverHoldMs);
if (calm != null && calm < t.underMs && tasks === 0) {
change(level - 1, now);
@@ -373,6 +392,13 @@ export function createGovernor(opts = {}) {
if (next !== level) change(next, now);
return level;
},
+ /** 40 F12: the lowest level recovery may reach because it flapped there (-1 = none) */
+ flapLock: () => flapLock,
+ /** 40 F12: a new scene / "Keep full quality" / the auto switch — the old flap is no evidence */
+ clearFlapLock() {
+ flapLock = -1;
+ lastDownFrom = -1;
+ },
/** 31-perf P3: judge against these (an XR session's rate) until cleared with null.
* @param {{overMs: number, underMs: number, budgetMs?: number} | null} t */
setThresholds(t) {
diff --git a/src/lib/qualityNoticeCore.js b/src/lib/qualityNoticeCore.js
new file mode 100644
index 000000000..c97967c37
--- /dev/null
+++ b/src/lib/qualityNoticeCore.js
@@ -0,0 +1,78 @@
+// 40 F12 — WHAT THE AUTO-QUALITY NOTICE SAYS. PURE and import-free (vitest drives it).
+//
+// Every automatic quality toggle — the governor's ladder (shadows, resolution, AO, the scene
+// look, particles) and what derives from it (the water losing its refraction, a fluid tank
+// dropping to drops) — follows ONE rule: when the device lowers it on its own, the person is
+// told once ("Lowered water quality to keep it smooth · Keep full quality"). One toast per
+// drop names every toggle that drop newly lowered; a toggle already announced this session is
+// not announced again (the chip in the object list still shows the current state).
+//
+// A snapshot is the state the notice watches:
+// {dprScale, shadowsOff, aoOff, postOff, particlesCapped, water, fluid}
+// where `water` / `fluid` are simplifiedNotice's flags (set only when QUALITY simplified them —
+// never for a headset or the user's own "Low" water pick).
+
+/** The toggles, in the order a toast names them (the most visible first). */
+export const QUALITY_TOGGLES = [
+ { key: 'water', name: 'water quality' },
+ { key: 'fluid', name: 'fluid quality' },
+ { key: 'shadows', name: 'shadows' },
+ { key: 'resolution', name: 'resolution' },
+ { key: 'ao', name: 'ambient occlusion' },
+ { key: 'post', name: 'the scene look' },
+ { key: 'particles', name: 'particles' }
+];
+
+/** @typedef {{dprScale?: number, shadowsOff?: boolean, aoOff?: boolean, postOff?: boolean, particlesCapped?: boolean, water?: boolean, fluid?: boolean}} QualitySnapshot */
+
+/** Is this toggle lowered in `s`? @param {string} key @param {QualitySnapshot | null | undefined} s */
+export function isLowered(key, s) {
+ if (!s) return false;
+ switch (key) {
+ case 'water':
+ return !!s.water;
+ case 'fluid':
+ return !!s.fluid;
+ case 'shadows':
+ return !!s.shadowsOff;
+ case 'resolution':
+ return Number(s.dprScale ?? 1) < 1;
+ case 'ao':
+ return !!s.aoOff;
+ case 'post':
+ return !!s.postOff;
+ case 'particles':
+ return !!s.particlesCapped;
+ }
+ return false;
+}
+
+/**
+ * The toggles lowered in `next` that were not in `prev` (a further resolution step counts only
+ * the first time resolution drops below full). @param {QualitySnapshot | null | undefined} prev
+ * @param {QualitySnapshot} next @returns {string[]}
+ */
+export function newlyLowered(prev, next) {
+ return QUALITY_TOGGLES.filter((t) => isLowered(t.key, next) && !isLowered(t.key, prev)).map((t) => t.key);
+}
+
+/**
+ * What to announce: `keys` minus everything announced before, in QUALITY_TOGGLES order.
+ * @param {string[]} keys @param {Iterable} announced @returns {string[]}
+ */
+export function toAnnounce(keys, announced) {
+ const seen = new Set(announced);
+ const want = new Set(keys);
+ return QUALITY_TOGGLES.filter((t) => want.has(t.key) && !seen.has(t.key)).map((t) => t.key);
+}
+
+/** "Lowered water quality and shadows to keep it smooth" @param {string[]} keys */
+export function noticeText(keys) {
+ const names = QUALITY_TOGGLES.filter((t) => keys.includes(t.key)).map((t) => t.name);
+ if (!names.length) return '';
+ const list = names.length === 1 ? names[0] : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1];
+ return 'Lowered ' + list + ' to keep it smooth';
+}
+
+/** The action's label (one place, so the suite and the docs agree). */
+export const KEEP_FULL_LABEL = 'Keep full quality';
diff --git a/src/lib/water/simplifiedNotice.js b/src/lib/water/simplifiedNotice.js
index 7eae6cd7f..1bcee8f4f 100644
--- a/src/lib/water/simplifiedNotice.js
+++ b/src/lib/water/simplifiedNotice.js
@@ -1,8 +1,8 @@
// 36-fb-water F27: "the quality level simplified the water / fluid on this device" — a LEAF
// (svelte/store only). waterRuntime sets `water` while a visible volume draws the Quest tier
// BECAUSE OF QUALITY (not a headset, not the user's own "Low" pick); fluidRuntime sets `fluid`
-// while a tank draws the points tier for the same reason. SimplifiedWaterNotice shows ONE small
-// dismissible line about it, once per session, never in Play and never in a headset.
+// while a tank draws the points tier for the same reason. AutoQualityNotice (40 F12) names it
+// as "water quality" / "fluid quality", once per session, never in Play and never in a headset.
import { writable, get } from 'svelte/store';
/** @type {import('svelte/store').Writable<{water: boolean, fluid: boolean}>} */
diff --git a/src/lib/water/waterRuntime.js b/src/lib/water/waterRuntime.js
index cde6586eb..d3fb70ce0 100644
--- a/src/lib/water/waterRuntime.js
+++ b/src/lib/water/waterRuntime.js
@@ -1434,7 +1434,7 @@ export function tickWater(_delta) {
return;
}
// 36-fb-water F27: the QUALITY LEVEL (not a headset, not the user's own Low) took the
- // refraction away from water on screen — SimplifiedWaterNotice says so, once a session
+ // refraction away from water on screen — AutoQualityNotice says so, once a session (40 F12)
noteSimplified(
'water',
tier === 'quest' && qualityPref === 'auto' && !rendererRef?.xr?.isPresenting && [...entries.values()].some((e) => shown(e.object))
diff --git a/tests/e2e/auto-quality-notice.test.cjs b/tests/e2e/auto-quality-notice.test.cjs
new file mode 100644
index 000000000..10e3335c0
--- /dev/null
+++ b/tests/e2e/auto-quality-notice.test.cjs
@@ -0,0 +1,248 @@
+// 40 F12 — "water refractions switch off/on by themselves on the phone" (the user's 1.31 review,
+// refractions-off.jpg / refractions--on.jpg). Through the REAL wiring (qualityGovernor + the
+// water runtime + AutoQualityNotice) on an emulated phone (390x844, touch) and a desktop
+// (1440x900):
+// 1. the phone's lighter start is announced once ("Lowered shadows, resolution and ambient
+// occlusion to keep it smooth");
+// 2. Aquarium: a drop to "post off" turns the refraction off and says so ONCE ("Lowered water
+// quality … to keep it smooth · Keep full quality"); the one retry comes back, overloads, and
+// the level is then HELD — no more switching for the rest of a simulated 10 minutes;
+// the counterfactual (the lock cleared after every decision = the 1.31 rule) switches > 8x;
+// a scene load clears the lock;
+// 3. a drop in Play is not announced until you leave Play; "Keep full quality" (a TAP) gives
+// full quality and the device stops lowering it; once per session;
+// 4. desktop: the same rule for shadows.
+// Frames are SYNTHETIC on a clock 1e9 ms ahead (governorForTest), so the page's own frames —
+// on a real timeline far behind it — can neither step nor recover the level mid-test.
+// OUT= writes the evidence screenshots (phone + desktop, dark + light).
+const h = require('./helpers.cjs');
+const fs = require('fs');
+const path = require('path');
+
+const OUT = process.env.OUT || '';
+const SC = [process.env.FBW_SCENES, path.resolve(__dirname, '../../../scenes-lane-39-int/examples'), path.resolve(__dirname, '../../../scenes/examples')]
+ .filter(Boolean)
+ .find((d) => fs.existsSync(path.join(/** @type {string} */ (d), 'aquarium', 'scene.tpscene')));
+const PHONE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 15; PKH110) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36' };
+const DESKTOP = { viewport: { width: 1440, height: 900 } };
+
+/** @param {any} page @param {string} slug */
+async function load(page, slug) {
+ const bytes = Array.from(fs.readFileSync(path.join(/** @type {string} */ (SC), slug, 'scene.tpscene')));
+ await page.evaluate(async (arr) => {
+ const s = /** @type {any} */ (window).__stores;
+ const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer);
+ await s.sessions.applySession(payload, { backup: false });
+ }, bytes);
+}
+
+/** install the synthetic driver and freeze the governor on the synthetic clock @param {any} page @param {number} level */
+async function freeze(page, level) {
+ await page.evaluate((lv) => {
+ const w = /** @type {any} */ (window);
+ w.__f12 = { t: 1e9 };
+ w.__stores.qualityGovernor.governorForTest.setLevel(lv, w.__f12.t);
+ w.__f12.t += 1000;
+ /**
+ * frames at `slow` ms on every third frame while the level is below `cheapFrom` (the
+ * screen-space water pass), on time otherwise; a decision every 250 ms like the wiring.
+ * @param {number} dur @param {number} cheapFrom @param {{slow?: number, noLock?: boolean}} [o]
+ */
+ w.__f12drive = (dur, cheapFrom, o = {}) => {
+ const Q = w.__stores.qualityGovernor;
+ const g = Q.governorForTest;
+ const slow = o.slow ?? 33.3;
+ /** @type {number[]} */ const moves = [];
+ const end = w.__f12.t + dur;
+ let next = w.__f12.t + 250;
+ let i = 0;
+ while (w.__f12.t < end) {
+ const ms = g.level() >= cheapFrom ? 16.7 : i % 3 === 0 ? slow : 16.7;
+ w.__f12.t += ms;
+ i++;
+ g.frame(ms, w.__f12.t);
+ if (w.__f12.t >= next) {
+ next = w.__f12.t + 250;
+ const d = Q.decideNow(w.__f12.t);
+ if (o.noLock) g.clearFlapLock();
+ if (d.moved) moves.push(d.level);
+ }
+ }
+ return { moves, level: g.level(), lock: g.flapLock() };
+ };
+ }, level);
+}
+
+/** @param {any} page */
+const state = (page) =>
+ page.evaluate(() => {
+ const S = /** @type {any} */ (window).__stores;
+ let q;
+ S.qualityGovernor.qualityState.subscribe((/** @type {any} */ v) => (q = v))();
+ return {
+ level: q?.level,
+ held: q?.held,
+ reason: q?.reason,
+ lock: S.qualityGovernor.qualityFlapLock(),
+ water: S.waterRuntime.waterDebug?.()?.tier ?? null,
+ toasts: [...document.querySelectorAll('.tp-toast-text')].map((e) => e.textContent?.trim() ?? '')
+ };
+ });
+
+/** the water tier a level gives on auto (waterRuntime.resolveTier) */
+const tierOf = (/** @type {number} */ lv) => (lv >= 6 ? 'quest' : lv >= 3 ? 'medium' : 'high');
+/** refraction switches along a list of levels @param {number[]} levels @param {number} start */
+function switches(levels, start) {
+ let n = 0;
+ let off = tierOf(start) === 'quest';
+ for (const lv of levels) {
+ const now = tierOf(lv) === 'quest';
+ if (now !== off) n++;
+ off = now;
+ }
+ return n;
+}
+
+/** @param {any} page @param {string} name */
+async function shots(page, name) {
+ if (!OUT) return;
+ for (const t of ['dark', 'light']) {
+ await page.evaluate((th) => /** @type {any} */ (window).__stores.themes.theme.set(th), t);
+ await page.waitForTimeout(400);
+ await page.screenshot({ path: path.join(OUT, `${name}-${t}.png`) });
+ }
+ await page.evaluate(() => /** @type {any} */ (window).__stores.themes.theme.set('dark'));
+}
+
+/** h.eventually, returning the state it accepted (null on timeout)
+ * @param {() => Promise} fn @param {(v: any) => boolean} pred @param {string} label @param {number} [timeout] */
+async function until(fn, pred, label, timeout = 10000) {
+ const t0 = Date.now();
+ let last;
+ while (Date.now() - t0 < timeout) {
+ last = await fn();
+ if (pred(last)) {
+ h.check(true, label);
+ return last;
+ }
+ await new Promise((r) => setTimeout(r, 400));
+ }
+ console.log(' last: ' + JSON.stringify(last));
+ h.check(false, label);
+ return null;
+}
+
+/** @param {any} page @param {RegExp} re */
+const toastLike = (page, re) => page.evaluate((src) => [...document.querySelectorAll('.tp-toast-text')].some((e) => new RegExp(src).test(e.textContent ?? '')), re.source);
+
+h.run(async () => {
+ if (OUT) fs.mkdirSync(OUT, { recursive: true });
+ h.check(!!SC, `an Aquarium scene to load (${SC})`);
+ const browser = await h.launch({ args: h.GPU_ARGS });
+
+ // ── 1. the phone's lighter start, announced once ─────────────────────────────────────────────
+ const A = await h.setupPage(browser, 'f12-phone', { context: PHONE });
+ h.check(await A.page.evaluate(() => /** @type {any} */ (window).__stores.qualityGovernor.phoneQuality()), 'the app judges this device a phone');
+ await until(() => state(A.page), (s) => s.toasts.some((t) => /^Lowered shadows, resolution and ambient occlusion to keep it smooth$/.test(t)), '1.1 the lighter start is announced: "Lowered shadows, resolution and ambient occlusion to keep it smooth"', 8000);
+ h.check(await A.page.evaluate(() => [...document.querySelectorAll('.tp-toast-action')].some((b) => b.textContent?.trim() === 'Keep full quality')), '1.2 …with a "Keep full quality" action');
+ h.check((await A.page.$('#simplified-water-notice')) === null, '1.3 the old "Simplified water" strip is gone (one notice for every toggle)');
+ await freeze(A.page, 4);
+ await A.page.evaluate(() => document.querySelectorAll('.tp-toast-x').forEach((b) => /** @type {HTMLElement} */ (b).click()));
+
+ // ── 2. Aquarium: off, one retry, then held ───────────────────────────────────────────────────
+ await load(A.page, 'aquarium');
+ await A.page.waitForTimeout(2500);
+ await A.page.evaluate(() => {
+ const w = /** @type {any} */ (window);
+ w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t);
+ w.__f12.t += 1000;
+ });
+ await h.eventually(() => state(A.page), (s) => s.water === 'medium', '2.0 (premise) level 5: Aquarium refracts on the half-res screen-space tier', 8000);
+ if (OUT) await A.page.screenshot({ path: path.join(OUT, '20-after-phone-aquarium-L5-refraction-on.png') });
+
+ const drop = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(3000, 6));
+ h.check(drop.level === 6 && drop.moves.join() === '6', `2.1 the screen-space pass overloads: 5 -> 6 (${drop.moves.join(',')})`);
+ const off1 = await until(() => state(A.page), (s) => s.water === 'quest' && s.toasts.some((t) => /^Lowered water quality/.test(t)), '2.2 refraction OFF, and the toast says so', 8000);
+ const waterToast = off1?.toasts.find((/** @type {string} */ t) => /^Lowered water quality/.test(t));
+ h.check(waterToast === 'Lowered water quality and the scene look to keep it smooth', `2.3 the words ("${waterToast}")`);
+ await shots(A.page, '21-after-phone-water-lowered-toast');
+
+ const retry = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(10000, 6));
+ h.check(retry.moves.join() === '5', `2.4 cheap frames at level 6: ONE retry back to 5 (${retry.moves.join(',')})`);
+ await h.eventually(() => state(A.page), (s) => s.water === 'medium', '2.5 the retry brings the refraction back', 8000);
+ const again = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(4000, 6));
+ h.check(again.moves.join() === '6' && again.lock === 6, `2.6 the retry overloads again: 5 -> 6 and the level is LOCKED (lock ${again.lock})`);
+ const held = await until(() => state(A.page), (s) => s.water === 'quest' && s.held === true, '2.7 refraction off, qualityState.held', 8000);
+ h.check(!!held && held.toasts.filter((/** @type {string} */ t) => /^Lowered water quality/.test(t)).length <= 1, '2.8 no second water toast (once per session)');
+ const ten = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(600000, 6));
+ h.check(ten.moves.length === 0 && ten.level === 6, `2.9 ten more minutes of steady frames: no switch at all (moves ${ten.moves.join(',') || 'none'})`);
+ const all = [...drop.moves, ...retry.moves, ...again.moves, ...ten.moves];
+ h.check(switches(all, 5) === 3, `2.10 refraction switched ${switches(all, 5)} times in ~10.3 min: off, the one retry, off (was every 10-80 s)`);
+ if (OUT) await A.page.screenshot({ path: path.join(OUT, '22-after-phone-aquarium-held-refraction-off.png') });
+
+ // counterfactual: the 1.31 rule (no lock) on the same page and the same frames
+ await A.page.evaluate(() => {
+ const w = /** @type {any} */ (window);
+ w.__stores.qualityGovernor.governorForTest.clearFlapLock();
+ w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t);
+ w.__f12.t += 1000;
+ });
+ const old = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(600000, 6, { noLock: true }));
+ h.check(switches(old.moves, 5) > 8, `2.11 COUNTERFACTUAL — without the lock the refraction switches ${switches(old.moves, 5)} times in 10 min`);
+
+ // a new scene clears the lock
+ await A.page.evaluate(() => {
+ const w = /** @type {any} */ (window);
+ w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t);
+ w.__f12.t += 1000;
+ });
+ await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(20000, 6));
+ h.check((await state(A.page)).lock === 6, '2.12 (premise) locked again');
+ await load(A.page, 'aquarium');
+ await h.eventually(() => state(A.page), (s) => s.lock === -1 && s.held === false, '2.12 a scene load clears the lock (new scene, new evidence)', 15000);
+ await A.ctx.close();
+
+ // ── 3. Play defers the notice; "Keep full quality" (a tap) ───────────────────────────────────
+ const B = await h.setupPage(browser, 'f12-phone-keep', { context: PHONE });
+ await freeze(B.page, 4);
+ await load(B.page, 'aquarium');
+ await B.page.waitForTimeout(2500);
+ await B.page.evaluate(() => {
+ const w = /** @type {any} */ (window);
+ w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t);
+ w.__f12.t += 1000;
+ w.__stores.isLocked.set(true);
+ });
+ await B.page.evaluate(() => /** @type {any} */ (window).__f12drive(3000, 6));
+ await B.page.waitForTimeout(1500);
+ h.check(!(await toastLike(B.page, /^Lowered water quality/)), '3.1 a drop while playing is not announced in Play');
+ await B.page.evaluate(() => /** @type {any} */ (window).__stores.isLocked.set(null));
+ await h.eventually(() => toastLike(B.page, /^Lowered water quality/), (v) => v, '3.2 …it is, once you are back in the editor', 8000);
+ if (OUT) await B.page.screenshot({ path: path.join(OUT, '23-after-phone-toast-before-tap.png') });
+ const keep = B.page.locator('.tp-toast', { hasText: 'Lowered water quality' }).locator('.tp-toast-action', { hasText: 'Keep full quality' });
+ await keep.tap();
+ const kept = await until(() => state(B.page), (s) => s.level === 0 && s.water === 'high', '3.3 tapping "Keep full quality": full quality, the water refracts at full resolution', 8000);
+ h.check(kept?.reason === 'kept full quality', `3.4 …recorded as kept (${kept?.reason})`);
+ const after = await B.page.evaluate(() => /** @type {any} */ (window).__f12drive(60000, 99));
+ h.check(after.moves.length === 0 && after.level === 0, `3.5 a minute of missed frames afterwards: it stays at full quality (moves ${after.moves.join(',') || 'none'})`);
+ if (OUT) await B.page.screenshot({ path: path.join(OUT, '24-after-phone-kept-full-quality.png') });
+ h.check((await h.pageErrors(B)).length === 0, '3.6 no page errors');
+ await B.ctx.close();
+
+ // ── 4. desktop: the same rule for every toggle (shadows) ─────────────────────────────────────
+ const D = await h.setupPage(browser, 'f12-desktop', { context: DESKTOP });
+ h.check(!(await D.page.evaluate(() => /** @type {any} */ (window).__stores.qualityGovernor.phoneQuality())), '4.0 (premise) a desktop');
+ await freeze(D.page, 0);
+ await load(D.page, 'aquarium');
+ await D.page.waitForTimeout(2500);
+ await D.page.evaluate(() => {
+ // a heavy scene for the desktop budget (the governor only acts on one)
+ /** @type {any} */ (window).__stores.sceneBudget.qualityBaseline.set({ objects: 0, triangles: 0, calls: 5000 });
+ });
+ const d1 = await D.page.evaluate(() => /** @type {any} */ (window).__f12drive(2500, 99, { slow: 60 }));
+ h.check(d1.moves.join() === '1', `4.1 missed frames on a heavy scene: shadows off (${d1.moves.join(',')})`);
+ await h.eventually(() => toastLike(D.page, /^Lowered shadows to keep it smooth$/), (v) => v, '4.2 "Lowered shadows to keep it smooth" · Keep full quality', 8000);
+ await shots(D.page, '25-after-desktop-shadows-lowered-toast');
+ h.check((await h.pageErrors(D)).length === 0, '4.3 no page errors');
+ await h.finish(browser);
+});
diff --git a/tests/e2e/fb-water-phone.test.cjs b/tests/e2e/fb-water-phone.test.cjs
index 1df269c6f..4963d371f 100644
--- a/tests/e2e/fb-water-phone.test.cjs
+++ b/tests/e2e/fb-water-phone.test.cjs
@@ -4,8 +4,8 @@
// left the Quest tier) — 36-int-124's regression probe as a check;
// 2. every quality level (pinned through the game-quality seam): a screenshot of both scenes
// and a perf row (calls, triangles, p50/p95 at CPU x4) -> PHONE_OUT/perf-phone.md;
-// 3. the one-time "Simplified water" notice: shown when quality takes refraction away, dismiss
-// works, once per session, never in Play.
+// 3. a game's Quality preset that simplifies the water is not announced (40 F12: the automatic
+// drops are AutoQualityNotice's, suite auto-quality-notice).
const h = require('./helpers.cjs');
const fs = require('fs');
const path = require('path');
@@ -95,42 +95,19 @@ h.run(async () => {
);
console.log(rows.join('\n'));
- // ── 3. the one-time notice — on a FRESH page: the level sweep above already used this
- // session's one notice (correctly), and the component remembers that for the page's life
- h.check((await A.page.evaluate(() => sessionStorage.getItem('water:simplifiedNoticeSeen'))) === '1', 'the sweep\'s first simplified level used the session\'s notice');
+ // ── 3. 40 F12: the old "Simplified water" strip became AutoQualityNotice (one toast per
+ // automatic drop, suite auto-quality-notice). What stays here: a game's own Quality preset is
+ // an explicit choice, so pinning a level that simplifies the water is NOT announced
await A.ctx.close();
- const B = await h.setupPage(browser, 'phone-notice', { context: PHONE });
- const A2 = B;
+ const A2 = await h.setupPage(browser, 'phone-notice', { context: PHONE });
await load(A2.page, 'aquarium');
await A2.page.waitForTimeout(3000);
- // in Play first: never shown there
- await A2.page.evaluate(() => {
- window.__stores.isLocked.set(true);
- window.__stores.qualityGovernor.applyGameQuality(6, 'test');
- });
- await A2.page.waitForTimeout(1500);
- h.check((await A2.page.$('#simplified-water-notice')) === null, 'no notice while playing');
- await A2.page.evaluate(() => window.__stores.isLocked.set(null));
- const note = await A2.page.waitForSelector('#simplified-water-notice', { timeout: 5000 }).catch(() => null);
- h.check(!!note, 'leaving Play, the notice says the water was simplified');
- const text = note ? await note.textContent() : '';
- h.check(/Simplified water for this device/.test(text) && /Water quality/.test(text), `...and where to give refraction back ("${text?.trim()}")`);
- if (OUT && note)
- for (const t of ['dark', 'light']) {
- await A2.page.evaluate((th) => window.__stores.themes.theme.set(th), t);
- await A2.page.waitForTimeout(300);
- await A2.page.screenshot({ path: path.join(OUT, `72-phone-simplified-notice-${t}.png`) });
- }
- await A2.page.evaluate(() => window.__stores.themes.theme.set('dark'));
- await A2.page.click('#simplified-water-notice .sw-x').catch(() => {});
- await A2.page.waitForTimeout(400);
- h.check((await A2.page.$('#simplified-water-notice')) === null, 'dismiss hides it');
- // once per session: down to full and back to simplified -> no second notice
- await A2.page.evaluate(() => window.__stores.qualityGovernor.applyGameQuality(0, 'test'));
- await A2.page.waitForTimeout(1000);
await A2.page.evaluate(() => window.__stores.qualityGovernor.applyGameQuality(6, 'test'));
+ await h.eventually(() => state(A2.page), (s) => s.water === 'quest', 'a pinned level 6: the water is simplified', 8000);
await A2.page.waitForTimeout(1500);
- h.check((await A2.page.$('#simplified-water-notice')) === null, 'once per session: it does not come back');
+ h.check((await A2.page.$('#simplified-water-notice')) === null, 'the old strip is gone');
+ h.check(!(await A2.page.evaluate(() => [...document.querySelectorAll('.tp-toast-text')].some((e) => /^Lowered water quality/.test(e.textContent ?? '')))), 'a game preset is not announced as an automatic drop');
+ if (OUT) await A2.page.screenshot({ path: path.join(OUT, '72-phone-game-preset-no-notice.png') });
await A2.page.evaluate(() => window.__stores.qualityGovernor.applyGameQuality(null));
h.check((await h.pageErrors(A2)).length === 0, 'no page errors');
await h.finish(browser);
diff --git a/tests/unit/qualityFlapLock.test.js b/tests/unit/qualityFlapLock.test.js
new file mode 100644
index 000000000..f4e96af01
--- /dev/null
+++ b/tests/unit/qualityFlapLock.test.js
@@ -0,0 +1,135 @@
+// 40 F12 — the user's phone: Aquarium's water switched its refraction OFF and ON by itself.
+// Level 6 ("post off") drops the water's screen-space pass, so its frames are cheap and the
+// governor recovered to level 5 after the hold — where the pass comes back and overloads 3 s
+// later. The old flap rule only doubled the hold (10 / 20 / 40 / 80 s), so the switch kept
+// flipping for as long as the scene was open. The flap LOCK: once a recovery had to be undone,
+// it is not retried (until a new scene, "Keep full quality", or the auto switch).
+import { describe, it, expect } from 'vitest';
+import { createGovernor, phoneThresholds, overridesAt } from '../../src/lib/qualityGovernorCore.js';
+
+const ctx = { profile: /** @type {'desktop'} */ ('desktop'), heavy: true, calls: 60 };
+
+/**
+ * A 60 Hz phone whose frame cost depends on the level: at `cheapFrom` and above the frames
+ * are on time, below it every third frame is missed (the screen-space water pass). Decides
+ * every 250 ms like the wiring and records every level change.
+ * @param {ReturnType} g @param {number} from @param {number} dur @param {number} cheapFrom
+ */
+function phone(g, from, dur, cheapFrom) {
+ /** @type {{t: number, level: number}[]} */
+ const moves = [];
+ let t = from;
+ let i = 0;
+ let next = from + 250;
+ while (t < from + dur) {
+ const ms = g.level() >= cheapFrom ? 16.7 : i % 3 === 0 ? 33.3 : 16.7;
+ t += ms;
+ i++;
+ g.noteFrame(ms, t);
+ if (t >= next) {
+ next = t + 250;
+ const d = g.decide(t, ctx);
+ if (d.moved) moves.push({ t: Math.round(t), level: d.level });
+ }
+ }
+ return { t, moves };
+}
+
+/** the water tier the level gives (waterRuntime.resolveTier's auto rule) @param {number} level */
+const waterTier = (level) => {
+ const o = overridesAt(level);
+ return o.postOff ? 'quest' : o.aoOff || o.dprScale < 0.8 ? 'medium' : 'high';
+};
+
+/** how many times the water's refraction switched (quest <-> screen-space) @param {{level: number}[]} moves @param {number} start */
+function refractionFlips(moves, start) {
+ let flips = 0;
+ let was = waterTier(start) === 'quest';
+ for (const m of moves) {
+ const now = waterTier(m.level) === 'quest';
+ if (now !== was) flips++;
+ was = now;
+ }
+ return flips;
+}
+
+describe('flap lock (F12)', () => {
+ it('the phone premise: level 5 refracts, level 6 does not', () => {
+ expect(waterTier(5)).toBe('medium');
+ expect(waterTier(6)).toBe('quest');
+ });
+
+ it('COUNTERFACTUAL: without the lock the refraction keeps switching for the whole session', () => {
+ const g = createGovernor();
+ g.setThresholds(phoneThresholds(60));
+ g.setLevel(5, 0);
+ // the lock disabled: clear it after every decision, which is the pre-F12 rule
+ const realDecide = g.decide;
+ g.decide = (t, c) => {
+ const d = realDecide(t, c);
+ g.clearFlapLock();
+ return d;
+ };
+ const { moves } = phone(g, 1000, 600000, 6);
+ expect(refractionFlips(moves, 5)).toBeGreaterThan(8);
+ });
+
+ it('with the lock: off, one retry, off for good — at most 3 switches in 10 minutes', () => {
+ const g = createGovernor();
+ g.setThresholds(phoneThresholds(60));
+ g.setLevel(5, 0);
+ const { moves } = phone(g, 1000, 600000, 6);
+ expect(refractionFlips(moves, 5)).toBeLessThanOrEqual(3);
+ expect(g.level()).toBe(6);
+ expect(g.flapLock()).toBe(6);
+ // and the last move happened within the first minute: nothing after that
+ expect(moves[moves.length - 1].t).toBeLessThan(61000);
+ });
+
+ it('a step up LONG after a walk down is a heavier scene, not a flap — no lock', () => {
+ const g = createGovernor();
+ g.setThresholds(phoneThresholds(60));
+ g.setLevel(6, 0);
+ let t = phone(g, 1000, 15000, 0).t; // cheap everywhere: recovers 6 -> 5
+ expect(g.level()).toBe(5);
+ // a long calm, then the scene gets heavy
+ const calm = phone(g, t, 60000, 0);
+ t = calm.t;
+ const heavyCtx = { ...ctx };
+ for (let i = 0; i < 400; i++) {
+ t += 33.3;
+ g.noteFrame(33.3, t);
+ }
+ const lv = g.level();
+ const d = g.decide(t, heavyCtx);
+ expect(d.moved).toBe('up');
+ expect(g.level()).toBe(lv + 1);
+ expect(g.flapLock()).toBe(-1);
+ });
+
+ it('a scene that becomes LIGHT still walks back through the lock (new evidence)', () => {
+ const g = createGovernor();
+ g.setThresholds(phoneThresholds(60));
+ g.setLevel(5, 0);
+ const { t } = phone(g, 1000, 120000, 6);
+ expect(g.flapLock()).toBe(6);
+ let tt = t;
+ for (let i = 0; i < 700; i++) {
+ tt += 16.7;
+ g.noteFrame(16.7, tt);
+ }
+ expect(g.decide(tt, { ...ctx, heavy: false })).toMatchObject({ moved: 'down', reason: 'scene is light' });
+ });
+
+ it('clearFlapLock (a new scene) gives recovery back', () => {
+ const g = createGovernor();
+ g.setThresholds(phoneThresholds(60));
+ g.setLevel(5, 0);
+ const { t } = phone(g, 1000, 120000, 6);
+ g.clearFlapLock();
+ expect(g.flapLock()).toBe(-1);
+ // cheap at every level now (a lighter scene): recovery walks down again
+ const after = phone(g, t, 200000, 0);
+ expect(after.moves.some((m) => m.level < 6)).toBe(true);
+ });
+});
diff --git a/tests/unit/qualityGovernor.test.js b/tests/unit/qualityGovernor.test.js
index f89a14ed1..2a1eac8ed 100644
--- a/tests/unit/qualityGovernor.test.js
+++ b/tests/unit/qualityGovernor.test.js
@@ -146,7 +146,12 @@ describe('createGovernor', () => {
expect(g.recoverHoldMs()).toBe(20000);
t = run(g, t, 10500, 16.7);
expect(g.decide(t, { heavy: true }).moved).toBe(null); // 10s is no longer enough
- t = run(g, t, 10000, 16.7);
+ // 40 F12: ...and that flap LOCKED level 1 — good frames never walk below it again
+ t = run(g, t, 30000, 16.7);
+ expect(g.decide(t, { heavy: true })).toMatchObject({ moved: null, reason: 'held (flapped)' });
+ expect(g.flapLock()).toBe(1);
+ // a new scene clears the lock; the doubled hold is already served, so it walks down
+ g.clearFlapLock();
expect(g.decide(t, { heavy: true }).moved).toBe('down');
// much later, a heavier scene: not a flap
t = run(g, t + 60000, 2100, 50);
diff --git a/tests/unit/qualityNotice.test.js b/tests/unit/qualityNotice.test.js
new file mode 100644
index 000000000..19a39c92a
--- /dev/null
+++ b/tests/unit/qualityNotice.test.js
@@ -0,0 +1,45 @@
+// 40 F12 — what the auto-quality notice says: one toast per drop, naming every toggle that drop
+// newly lowered, each toggle announced once per session ("Lowered water quality to keep it
+// smooth · Keep full quality").
+import { describe, it, expect } from 'vitest';
+import { newlyLowered, toAnnounce, noticeText, isLowered, QUALITY_TOGGLES, KEEP_FULL_LABEL } from '../../src/lib/qualityNoticeCore.js';
+import { overridesAt } from '../../src/lib/qualityGovernorCore.js';
+
+/** @param {number} level @param {Record} [extra] */
+const snap = (level, extra = {}) => ({ ...overridesAt(level), water: false, fluid: false, ...extra });
+
+describe('quality notice (F12)', () => {
+ it('the spec sentence, verbatim', () => {
+ expect(noticeText(['water'])).toBe('Lowered water quality to keep it smooth');
+ expect(KEEP_FULL_LABEL).toBe('Keep full quality');
+ });
+
+ it('lists several in toggle order with "and"', () => {
+ expect(noticeText(['ao', 'shadows', 'resolution'])).toBe('Lowered shadows, resolution and ambient occlusion to keep it smooth');
+ expect(noticeText(['post', 'water'])).toBe('Lowered water quality and the scene look to keep it smooth');
+ expect(noticeText([])).toBe('');
+ });
+
+ it('the phone start (level 0 -> 4) lowers shadows, resolution and AO', () => {
+ expect(newlyLowered(snap(0), snap(4))).toEqual(['shadows', 'resolution', 'ao']);
+ });
+
+ it('level 5 -> 6 with the water simplified names the water first', () => {
+ expect(newlyLowered(snap(5), snap(6, { water: true }))).toEqual(['water', 'post']);
+ });
+
+ it('a further resolution step is not a new drop; a recovery is not a drop', () => {
+ expect(newlyLowered(snap(2), snap(3))).toEqual([]); // 85 % -> 72 %
+ expect(newlyLowered(snap(6, { water: true }), snap(5))).toEqual([]);
+ });
+
+ it('once per session: an announced toggle is never announced again', () => {
+ expect(toAnnounce(['water', 'post'], ['post'])).toEqual(['water']);
+ expect(toAnnounce(['water'], ['water'])).toEqual([]);
+ });
+
+ it('every toggle in the list has a lowered rule', () => {
+ for (const t of QUALITY_TOGGLES) expect(isLowered(t.key, snap(9, { water: true, fluid: true }))).toBe(true);
+ for (const t of QUALITY_TOGGLES) expect(isLowered(t.key, snap(0))).toBe(false);
+ });
+});