0.2.6-dev: merge claude/bw-l01 — host bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket permessage-deflate, lossless WebP 玩法说明 pages, traffic counters, content-hashed asset URLs with immutable caching, brotli q5) on top of the 0.2.5 release; its note becomes DESIGN §31 in docs/history/0.2.6.md, CHANGELOG 0.2.6-dev, version 0.2.6-dev; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 18:28:50 +08:00
commit 3d5d6b380d
47 files changed
+2325 -136

No files matched your search

+78
View File
@@ -577,6 +577,63 @@ describe('AudioManager', () => {
globalThis.fetch = origFetch;
}
});
test('BGM is not downloaded while the music is muted or at volume 0; the wanted track starts once it is audible', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;
const urls = [];
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const tick = () => new Promise((r) => setTimeout(r, 10));
const lobby = manifest.audio.bgm.lobby.loop;
const prep = manifest.audio.bgm.prep.loop;
assert.notEqual(lobby, prep, 'premise: two different loops');
try {
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
a.setVolumes({ muted: true });
a.install();
a.playBgm('lobby');
fw.fire('pointerdown');
await tick();
assert.equal(a.unlocked, true);
assert.equal(a.bgmSilent, true);
assert.ok(!asked(urls, lobby) && !asked(urls, manifest.audio.bgm.lobby.intro), 'muted: nothing fetched at unlock');
a.playBgm('prep');
await tick();
assert.ok(!asked(urls, prep), 'muted: a phase change fetches nothing');
assert.equal(a.wantBgm, 'prep', 'the wanted track is remembered');
a.setVolumes({ muted: false });
await tick();
assert.ok(asked(urls, prep), 'unmuted: the track the screen wants now is fetched');
assert.ok(!asked(urls, lobby), 'never the track wanted before');
assert.equal(a.bgm?.loopUrl, prep);
// BGM volume 0 is silent too: another track stops and is not fetched; SFX keep playing
a.setVolumes({ bgm: 0 });
a.playBgm('lobby');
await tick();
assert.ok(!asked(urls, lobby), 'BGM volume 0: no download');
assert.equal(a.bgm, null, 'the other track stopped');
a.sfx('buy');
await tick();
assert.ok(asked(urls, manifest.audio.sfx.ui.buy), 'SFX are not gated by the BGM volume');
a.setVolumes({ bgm: 0.5 });
await tick();
assert.ok(asked(urls, lobby), 'audible again: fetched');
assert.equal(a.bgm?.loopUrl, lobby);
// the loop that already plays keeps playing through a silent phase change, and is not fetched again
a.setVolumes({ muted: true });
const n = urls.length;
const started = fw.made.started;
a.playBgm('lobby');
a.setVolumes({ muted: false });
await tick();
assert.equal(a.bgm?.loopUrl, lobby);
assert.equal(urls.length, n, 'no new request');
assert.equal(fw.made.started, started, 'no restart');
} finally {
globalThis.fetch = origFetch;
}
});
test('fetch failures are swallowed', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;
@@ -598,6 +655,27 @@ describe('AudioManager', () => {
console.warn = origWarn;
}
});
test('a served manifest\'s content-hashed audio URL is fetched as /media/~<hash>/…, falling back to the hashed .mp3 URL', async () => {
const H = '0123456789abcdef';
const raw = manifest.audio.bgm.prep.loop.replace(/^\/assets\//, `/assets/~${H}/`);
const served = { ...manifest, audio: { ...manifest.audio, bgm: { ...manifest.audio.bgm, prep: { loop: raw } } } };
const fw = fakeWindow();
const urls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (u) => { urls.push(u); return u.startsWith('/media/') ? { ok: false, status: 404 } : { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
try {
const a = new AudioManager({ win: fw.win, getManifest: () => served });
a.install();
fw.fire('pointerdown');
a.playBgm('prep');
await new Promise((r) => setTimeout(r, 20));
assert.deepEqual(urls, [raw.replace(/^\/assets\/(~[0-9a-f]{16})\/audio\/(.+)\.mp3$/, '/media/$1/$2'), raw]);
assert.match(urls[0], new RegExp(`^/media/~${H}/bgm/`));
assert.equal(a.bgm?.loopUrl, raw);
} finally {
globalThis.fetch = origFetch;
}
});
test('音频先走无扩展名的 /media/ 路由;只有它 404 才回退到带扩展名的原地址', async () => {
const raw = manifest.audio.bgm.prep.loop;
const media = mediaUrl(raw);
+13 -1
View File
@@ -11,7 +11,10 @@ test('buying Matterhorn sends two live layer cues through the real client; resyn
const srv = await startRealServer({ fast: { timerScale: 0.1, startRound: 2, kit: 0, shop: ['chess_char_1_02_a'] } });
const c = new Client(P, srv.base, 'bond-audio', { prefix: 'bond-audio' });
try {
await c.open(); await c.enter('叠层音效测试');
await c.open();
const mediaHits = [];
c.page.on('response', (r) => { const p = new URL(r.url()).pathname; if (p.startsWith('/media/')) mediaHits.push({ p, status: r.status(), cache: r.headers()['cache-control'] }); });
await c.enter('叠层音效测试');
await c.click('.mode-card', '独立模拟'); await c.click('.diff-card', '险境');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor(s => !!s.room, 'solo room');
@@ -41,6 +44,15 @@ test('buying Matterhorn sends two live layer cues through the real client; resyn
assert.deepEqual(result.frames.map(m => [m.bondId, m.add]).sort(), [['kjeragShip', 2], ['steadShip', 2]]);
assert.equal(result.cues.length, 2); assert.equal(result.playing, true); assert.equal(result.state, 'running');
assert.ok(result.cues.every(x => /act1autochess_b_ui_buffup/.test(x.url)));
// host bandwidth layer 1: the served manifest names the cue with its content hash and the client fetches it through
// the extension-less /media/~<hash>/… route, cached immutably (skipped with SP_ASSET_INDEX=0: plain URLs)
if (result.cues[0].url.includes('/~')) {
assert.ok(result.cues.every(x => /^\/assets\/~[0-9a-f]{16}\/audio\//.test(x.url)), JSON.stringify(result.cues));
const hit = mediaHits.find(h => /^\/media\/~[0-9a-f]{16}\/.*act1autochess_b_ui_buffup$/.test(h.p));
assert.ok(hit, mediaHits.map(h => h.p).join(' '));
assert.equal(hit.status, 200);
assert.equal(hit.cache, 'public, max-age=31536000, immutable');
}
const count = result.cues.length;
// g.watch on own prep asks the real server for state again, without a new gain.
await c.page.evaluate(() => globalThis.__SP__.net.request('g.watch', { fieldId: `n:${globalThis.__SP__.store.get().me.playerId}` }));
+13
View File
@@ -163,6 +163,19 @@ describe('emotes and 玩法说明 pages: local art first, then the mirror copy (
assert.ok(!src.includes('该页面暂时无法显示'), 'no blank notice left');
});
test('the guide preloads only the next page (host bandwidth layer 0): wraps around, skips a failed copy, never the previous page', async () => {
const { guidePreloadUrl } = await import('../../public/js/ui/guide.js');
const page = (k) => ({ urls: [`/assets/local/guide/${k}.webp`, `/assets/ui/guide/${k}.png`] });
const pages = ['autochess_home_1', 'autochess_home_2', 'autochess_home_3'].map(page);
assert.equal(guidePreloadUrl(pages, 0), '/assets/local/guide/autochess_home_2.webp');
assert.equal(guidePreloadUrl(pages, 2), '/assets/local/guide/autochess_home_1.webp', 'wraps to the first page');
assert.equal(guidePreloadUrl(pages, 1, new Set(['/assets/local/guide/autochess_home_3.webp'])), '/assets/ui/guide/autochess_home_3.png', 'the copy that has not failed');
assert.equal(guidePreloadUrl([page('autochess_home_1')], 0), null, 'a single page needs no preload');
assert.equal(guidePreloadUrl([], 0), null);
const src = readFileSync(path.join(ROOT, 'public/js/ui/guide.js'), 'utf8');
assert.deepEqual(src.match(/^\s+preload\(.*$/gm).map((l) => l.trim()), ['preload(guidePreloadUrl(pages, i, failed));'], 'one preload call in the viewer');
});
test('tools/assets/plan.mjs GUIDE_PAGES is the viewer\'s page list (GUIDE_CHAPTERS), in reading order', async () => {
const { GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
assert.deepEqual([...GUIDE_PAGES], GUIDE_CHAPTERS.flatMap((c) => c.pages.map(([key]) => key)));
+15 -2
View File
@@ -38,7 +38,7 @@ const CHROME_ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-use
*/
class Client {
constructor(puppeteer, base, label, { w = 1920, h = 1080 } = {}) {
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0 });
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0, hashed: { spine: 0, media: 0, other: 0 } });
}
async open(query = '') {
@@ -50,7 +50,14 @@ class Client {
page.on('console', (m) => { if (m.type() === 'error') this.problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => this.problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => this.problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
page.on('response', (r) => { if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`); });
page.on('response', (r) => {
if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`);
// the served manifest's content-hashed URLs (docs/ASSETS.md): Spine files, audio through /media/~<hash>/, the rest
const p = new URL(r.url()).pathname;
if (r.status() === 200 && /^\/(?:assets|media)\/~[0-9a-f]{16}\//.test(p)) {
this.hashed[/\.(?:skel|atlas)$|\/spine\//.test(p) ? 'spine' : p.startsWith('/media/') ? 'media' : 'other']++;
}
});
page.on('dialog', (d) => d.dismiss().catch(() => {}));
await page.goto(`${this.base}/${query}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
@@ -443,6 +450,8 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
const { startServer } = await import('../../server/index.js');
puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
// the content-hash index first, so the pages load the served manifests' /assets/~<hash>/… URLs like a player's
await srv.assetIndex?.whenReady();
base = `http://127.0.0.1:${srv.port}`;
mkdirSync(OUT, { recursive: true });
});
@@ -628,6 +637,10 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
assert.ok(did.inspected && did.bond, 'detail panel + bond popup');
assert.ok(did.emote, 'emote bubble reached the teammate');
assert.ok(did.enemies >= 0, 'enemy drawer opened');
if (srv.assetIndex) {
host.note(`content-hashed responses: ${JSON.stringify(host.hashed)}`);
assert.ok(host.hashed.spine > 0 && host.hashed.media > 0 && host.hashed.other > 0, `Spine, audio and art through hashed URLs: ${JSON.stringify(host.hashed)}`);
}
await assertClean(both);
} finally {
console.log([...host.log, ...guest.log].join('\n'));