8.4 KiB
DESIGN §31 — 0.2.6-dev
Part of DESIGN.md. Development after the 0.2.5 release.
31. Host bandwidth, layers 0 and 1
Unreleased. 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.
31.1 Layer 0 — small fixes
- No BGM while the music is silent. Muted, or the BGM volume at 0,
AudioManager.playBgmonly remembers the wanted track (a different one still playing stops);setVolumesfetches 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 measured solo sessions (the server counters: 173 KB of frames → 60 KB on the wire; replaying 216 captured frames, 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.pywrites 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 lossy at quality 90: 25.6 MB → 4.2 MB on the CN client's pages (lossless would be 17.4 MB). Tutorial pages could go lossy only after a side-by-side comparison; the maintainer looked at 2× crops of four pages on 2026-10-12 and chose quality 90 (GUIDE_WEBP_MODE;extract.py --webprewrites existing copies). Normative lines: design/client.md §13, ASSETS.md Post-processing. - Traffic counters.
GET /healthz?traffic=1addstraffic(HTTP body bytes and requests by category — assets, audio, code, data, other — and 304s; WebSocket wire bytes after compression and payload bytes before it) andassetHashes(the index state). The 问题反馈 diagnostics gain atraffic:line: the page's Resource TimingtransferSizeby the same categories (shared/assetPaths.js trafficCategory), other sites apart. Nothing is stored or uploaded. The maintainer's decision of 2026-10-12: only a loopback or private-network client gets the counters (server/http/routes.js, the address fromnet.js clientAddress, so TRUST_PROXY applies as for the WebSocket limits); any other client — also a visitor that a tunnel or proxy on this machine forwards — gets the plain report. - 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).
31.2 Layer 1 — content-hashed URLs, long caching, brotli
- Content-hash index (
server/http/assetIndex.js): 1 s after the start, SHA-256 (16 hex) of every file underpublic/assets/andpublic/fonts/(dot names skipped, linked folders followed once), computed off the main thread, cached in.cache/asset-hashes.jsonby 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.pngisWAH-Y.pngon this machine).SP_ASSET_INDEX=0turns it off. - Served manifests (
server/http/assetViews.js):/data/assets.jsonand/data/local-assets.jsongo out with every art URL carrying its file's hash as the first segment (/assets/~<hash>/…,/fonts/~<hash>/…; audio stays/assets/~<hash>/audio/….mp3). A Spine model — a{ skel, atlas, textures }object of data/assets.json, aspine/…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-cachewith 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
~hashroutes:static.jsstrips the segment for/assets/,/fonts/and/media/; a hash that names the file's current bytes (its own or its model's) answerspublic, max-age=31536000, immutable, any other the current file withno-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.jsturns/assets/~<hash>/audio/x.mp3into/media/~<hash>/x;server/http/media.jsresolves it like/media/x. The token death sound derived next tocharDiedropscharDie'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 namebrat least as high as gzip; gzip for the rest (*alone never picks brotli [ASSUMED]);-br/-gzETag suffixes. Measured against gzip-6 on this repository:.skel−27 %,data/*.json−26 %, vendor −14 %,public/jsandserver/sim−5 %,.atlas−7 %.
31.3 Measured sample session
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.
31.4 Open items and tests
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.
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.