From 409fec90d5fc743fde579ea48fcb66b65fab5e31 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 12 Oct 2026 16:52:40 +0800 Subject: [PATCH] 0.2.6-dev: the browser suites load the served manifests' content-hashed URLs (e2e servers wait for the asset index) and check Spine, audio and art through them; the history note of host bandwidth layers 0 and 1 for the next version; golden: unchanged --- docs/history/0.2.5.md | 85 ++++++++++++++++++++++++++++++++++ test/e2e/client.mjs | 7 +++ test/render/browser.test.js | 35 ++++++++++++++ test/ui/bond-audio.e2e.test.js | 14 +++++- test/ui/real.e2e.test.js | 17 ++++++- 5 files changed, 155 insertions(+), 3 deletions(-) diff --git a/docs/history/0.2.5.md b/docs/history/0.2.5.md index 0a4a0da0..16ae7f78 100644 --- a/docs/history/0.2.5.md +++ b/docs/history/0.2.5.md @@ -464,3 +464,88 @@ continues the same layer gap (191 → 206). The human settle row first differs a 267 → 282; final bonds move 奥术 27 → 30, 拉特兰 17 → 23 and 精准 53 → 59. This scenario was not among the 45 that already differed from 0.2.4 (§30.9, §30.16), so the release-relative total is now 46. The other 286 digests match the preceding candidate. + +### 0.2.6-dev — host bandwidth, layers 0 and 1 (to be placed in the next version's file) + +The maintainer's decision of 2026-10-12 on the bandwidth study (its measurements: a new player's first match costs the +host about 52 MB, audio ≈ 40 % of it; returning players re-download after a re-extraction, a `setup --force`, a Docker +rebuild or a changed tunnel address): do layer 0 (small fixes) and layer 1 (content-hashed URLs, long caching, brotli) +first. Later layers (WebP / Opus variants, an asset-only service worker, a desktop client mode, importing an asset pack in +the browser) are agreed and not part of this note. Nothing here touches the battle sim, the protocol messages or the +committed data. + +Layer 0: +- **No BGM while the music is silent.** Muted, or the BGM volume at 0, `AudioManager.playBgm` only remembers the wanted + track (a different one still playing stops); `setVolumes` fetches and starts it once the music is audible again. SFX + and voices were already gated. Normative line: design/client.md Audio. +- **WebSocket permessage-deflate** (`server/http/websocket.js WS_DEFLATE`): frames from 1 KB, no server context + takeover, a 2 KB server window, zlib memLevel 7 (about 72 KB of zlib state per connection). [ASSUMED] the parameters + come from one measured solo session (216 server frames: 169 KB → 63 KB; windows above 2 KB gained nothing); co-op + messages are the same kinds. A client that offers a narrower server window than 2 KB, which ws would refuse with a 400, + connects uncompressed instead (`narrowDeflateOffer`). Normative line: design/network.md §8 Transport. +- **玩法说明 pages as WebP.** `tools/local-extract/extract.py` writes a WebP copy beside each of the 19 local pages and + the manifest lists it (`GUIDE_WEBP_MODE`); the guide preloads only the next page instead of both neighbours. The + copies are lossless (25.6 MB → 17.4 MB on the CN client's pages). Lossy quality 90 would make them 4.2 MB; per the + decision that tutorial pages may go lossy only after a side-by-side comparison, that switch (one constant, then + `extract.py --webp`) is left to the maintainer. Normative lines: design/client.md §13, ASSETS.md Post-processing. +- **Traffic counters.** `GET /healthz?traffic=1` adds `traffic` (HTTP body bytes and requests by category — assets, + audio, code, data, other — and 304s; WebSocket wire bytes after compression and payload bytes before it) and + `assetHashes` (the index state). The 问题反馈 diagnostics gain a `traffic:` line: the page's Resource Timing + `transferSize` by the same categories (`shared/assetPaths.js trafficCategory`), other sites apart. Nothing is stored + or uploaded. +- **DEPLOY.md**: a stable address keeps every player's cache, settings and reconnect token (a quick tunnel's new domain + is a new site to the browser); HTTP/2 through the proxy saves round trips, not bytes; browsers offer brotli on HTTPS + pages and localhost only; a Cloudflare cache rule for `/assets/`, `/fonts/`, `/media/` (with the risk note). + +Layer 1: +- **Content-hash index** (`server/http/assetIndex.js`): 1 s after the start, SHA-256 (16 hex) of every file under + `public/assets/` and `public/fonts/` (dot names skipped, linked folders followed once), computed off the main thread, + cached in `.cache/asset-hashes.json` by path + size + mtime (12,691 files / 724 MB here: 1.3 s the first time, 0.25 s + after). A file whose size or mtime differs from its record is never vouched for; serving it asks for a rescan (at most + one per 10 s). [ASSUMED] the 1 s delay and the 10 s rescan gap. A name that differs from a file's only in case finds + that file (the manifest's `/assets/module/wah-y.png` is `WAH-Y.png` on this machine). `SP_ASSET_INDEX=0` turns it off. +- **Served manifests** (`server/http/assetViews.js`): `/data/assets.json` and `/data/local-assets.json` go out with every + art URL carrying its file's hash as the first segment (`/assets/~/…`, `/fonts/~/…`; audio stays + `/assets/~/audio/….mp3`). A Spine model — a `{ skel, atlas, textures }` object of data/assets.json, a `spine/…` + group of data/local-assets.json — carries one hash for all its files, since pixi-spine finds the atlas and the pages + next to the skeleton. `no-cache` with the view's own ETag; rebuilt in memory when a manifest or the hashes change; the + committed files are never written. Until the index is ready, without it, or on a static host: the files as committed. +- **The `~hash` routes**: `static.js` strips the segment for `/assets/`, `/fonts/` and `/media/`; a hash that names the + file's current bytes (its own or its model's) answers `public, max-age=31536000, immutable`, any other the current + file with `no-cache` (an old page keeps working). Every indexed file's ETag is its content hash, on plain URLs too. + Path segments, never `?v=`: `validSpine`'s `/\.skel$/`, `SKILL_MODE_FILE`'s `.mp3`, pixi-spine's basename and folder + resolution all need the URL to end in the file name. +- **Media**: `public/js/media.js` turns `/assets/~/audio/x.mp3` into `/media/~/x`; `server/http/media.js` + resolves it like `/media/x`. The token death sound derived next to `charDie` drops `charDie`'s hash (`unhashedUrl`). +- **Brotli** (`server/http/files.js`): quality 5 on the fly with the same memory cache (one entry per file and + encoding), for clients that name `br` at least as high as gzip; gzip for the rest (`*` alone never picks brotli + [ASSUMED]); `-br` / `-gz` ETag suffixes. Measured against gzip-6 on this repository: `.skel` −27 %, `data/*.json` + −26 %, vendor −14 %, `public/js` and `server/sim` −5 %, `.atlas` −7 %. + +Measured sample session (headless Chrome, a fresh profile, `test/e2e/fastServer.mjs` solo match from round 3 to the +result after round 5, host bytes only, response headers included; the shop and the enemies vary between runs): + +| session | code | data | assets | audio | WebSocket (server → client) | host total | +|---|---:|---:|---:|---:|---:|---:| +| before, music on | 3.16 MB | 0.67 MB | 9.0–9.7 MB | 7.9 MB | 163–179 KB (no compression) | 20.8–21.4 MB | +| after, music on | 2.95 MB | 0.62 MB | 9.5 MB | 8.0 MB | 60 KB on the wire (173 KB payload) | 21.1 MB | +| before, muted | 3.16 MB | 0.67 MB | 9.6 MB | 7.3 MB (BGM) | 165 KB | 20.7 MB | +| after, muted | 2.95 MB | 0.62 MB | 9.5 MB | 0 | 57 KB on the wire | 13.1 MB | + +A first visit with music on saves little (brotli on code and data, the WebSocket); a muted player saves the BGM. The +caching gains are for returning players: within a day both versions answer from the browser cache, but after that the +old version revalidated every asset of the session (≈ 340 requests here) and re-downloaded everything whose mtime had +changed, while a hashed URL is never asked for again until its bytes change. + +Open: `/fonts/fonts.css` (linked by index.html) and the woff2 files it names keep plain URLs (content ETags only); the +optional versioned vendor URLs (layer 1.5) are not done; lossy tutorial pages wait for the comparison. + +Tests: `test/ui/audio.test.js` (BGM gating; hashed audio through `/media/~hash/`), `test/ws-compression.test.js`, +`test/local-extract.test.js` (guide WebP), `test/ui/localArt.test.js` (next-page preload), `test/traffic.test.js`, +`test/asset-paths.test.js`, `test/diag.test.js` (traffic line), `test/asset-index.test.js` (index, views, `~hash` +routes), `test/lobby.test.js` (brotli negotiation, streamed brotli, content ETags), `test/media-url.test.js`, +`test/content/tokens_summons.test.js`; the browser suites `test/render/browser.test.js` (Spine through hashed URLs, +`RENDER_E2E=1`), `test/ui/bond-audio.e2e.test.js` (`SP_E2E=1`, the cue through `/media/~hash/`) and +`test/ui/real.e2e.test.js` (`SP_REAL_E2E=1`: the co-op run loads Spine, audio and art through hashed URLs). The e2e +servers wait for the asset index before the first page (`test/e2e/client.mjs startRealServer`, the in-process ones in +their `before`), so the browser suites see the URLs a player sees. Golden: unchanged. diff --git a/test/e2e/client.mjs b/test/e2e/client.mjs index 74318fba..982fde27 100644 --- a/test/e2e/client.mjs +++ b/test/e2e/client.mjs @@ -109,6 +109,13 @@ export async function startRealServer(opts = {}) { if (Date.now() - t0 > 20000) { child.kill('SIGKILL'); throw new Error(`server did not start:\n${logs.join('\n')}`); } await sleep(150); } + // the content-hash index (server/http/assetIndex.js, built ~1 s after the start): wait for it, so a page loads the + // served manifest's /assets/~/… URLs like a player's would (none: SP_ASSET_INDEX=0); bounded, never fatal + for (const t1 = Date.now(); Date.now() - t1 < 20000;) { + const h = await fetch(`${base}/healthz?traffic=1`).then((r) => r.json(), () => null); + if (!h || !h.assetHashes || h.assetHashes.ready) break; + await sleep(150); + } return { base, port, logs, // `{ hard: true }`: SIGKILL at once — a crash: no room.closed 'shutdown' reaches the clients diff --git a/test/render/browser.test.js b/test/render/browser.test.js index a2433ca6..8664187c 100644 --- a/test/render/browser.test.js +++ b/test/render/browser.test.js @@ -23,6 +23,8 @@ describe('render engine in headless Chrome', { skip }, () => { const puppeteer = (await import('puppeteer-core')).default; const { startServer } = await import('../../server/index.js'); srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + // the content-hash index first: every page then loads the served manifest's /assets/~/… URLs (ASSETS.md) + await srv.assetIndex?.whenReady(); browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] }); mkdirSync(OUT, { recursive: true }); }); @@ -73,6 +75,39 @@ describe('render engine in headless Chrome', { skip }, () => { }); } + test('Spine models load through the served manifest\'s content-hashed URLs, cached immutably (host bandwidth layer 1)', async (t) => { + if (!srv.assetIndex) { t.skip('SP_ASSET_INDEX=0: no content hashes'); return; } + const page = await browser.newPage(); + const spine = []; + const problems = []; + page.on('response', (r) => { + const u = new URL(r.url()); + if (/\.(skel|atlas)$/.test(u.pathname) || (/\/spine\//.test(u.pathname) && /\.png$/.test(u.pathname))) spine.push({ path: u.pathname, status: r.status(), cache: r.headers()['cache-control'] }); + if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); + }); + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); + try { + await page.setViewport({ width: 1600, height: 900 }); + await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&t=18&panel=0`); + await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 }); + await wait(3500); + const st = await page.evaluate(() => window.__demo.stats()); + assert.ok(st.units > 0); + assert.equal(st.spine.failed, 0, 'no Spine load failures'); + assert.ok(spine.length >= 6, `Spine files requested: ${spine.length}`); + for (const s of spine) { + assert.match(s.path, /^\/assets\/~[0-9a-f]{16}\//, `${s.path}: a content-hashed URL`); + assert.equal(s.status, 200, s.path); + assert.equal(s.cache, 'public, max-age=31536000, immutable', `${s.path}: skeleton, atlas and pages share the model hash`); + } + // the atlas pixi-spine derived from each skeleton came under the skeleton's segment + for (const sk of spine.filter((s) => s.path.endsWith('.skel'))) { + assert.ok(spine.some((s) => s.path === sk.path.replace(/\.skel$/, '.atlas')), `${sk.path}: its atlas`); + } + assert.deepEqual(problems, []); + } finally { await page.close(); } + }); + test('Swire coins from a real snapshot remain separate from the elemental gauge', async () => { const { makeBattle } = await import('../helpers/battleHarness.js'); const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 10, col: 4, skillIndex: 1 }] }); diff --git a/test/ui/bond-audio.e2e.test.js b/test/ui/bond-audio.e2e.test.js index 997f88de..4ed425c2 100644 --- a/test/ui/bond-audio.e2e.test.js +++ b/test/ui/bond-audio.e2e.test.js @@ -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/~/… 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}` })); diff --git a/test/ui/real.e2e.test.js b/test/ui/real.e2e.test.js index 5d9d2155..09b3aef2 100644 --- a/test/ui/real.e2e.test.js +++ b/test/ui/real.e2e.test.js @@ -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/~/, 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/~/… 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'));