166 lines
15 KiB
Markdown
166 lines
15 KiB
Markdown
# DESIGN §31 — 0.2.6-dev
|
||
|
||
Part of [DESIGN.md](../DESIGN.md). Development after the 0.2.5 release.
|
||
|
||
## 31. Host bandwidth (layers 0 and 1) and community follow-up
|
||
|
||
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.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 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.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 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 --webp` rewrites existing copies). 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. The maintainer's decision of 2026-10-12: only a loopback or private-network client gets the counters
|
||
(`server/http/routes.js`, the address from `net.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 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/~<hash>/…`, `/fonts/~<hash>/…`; audio stays
|
||
`/assets/~<hash>/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/~<hash>/audio/x.mp3` into `/media/~<hash>/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 %.
|
||
|
||
### 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.
|
||
|
||
### 31.5 Strategy skip eligibility
|
||
|
||
PR #546 by @Xinglan233. The strategy-selection page only enables skipping while another unpicked player remains. The published order
|
||
keeps finished players for display, so its total length cannot establish that a skip has a recipient. The client
|
||
counts order entries absent from `draft.picks`, alongside the existing turn and quota checks. The server's
|
||
`skipBand` already rejects the final player with `BAD_TARGET / nobody to pass to`; its rule remains unchanged.
|
||
Regression covers normal two-, three- and four-human browser flows, the final turn, and exhausted quota.
|
||
|
||
### 31.6 Impostor bounds and slot clearing (#542)
|
||
|
||
`render/spine.js clipBounds / stepClipBounds`, `render/units.js impostorBox / _impBox / _impBypass`, `render/impostor.js`.
|
||
|
||
- **Seen** (GitHub #542 by @ImPQlc; quality 高, usually from round 7 on): 野鬃's flag cut, offset and leaving stray pixels
|
||
while she idles; 浊心斯卡蒂's head cut in her S3 flight (Skill_3_Begin); 隐现's pistol muzzle cut, offset and leaving
|
||
stray pixels during her skill.
|
||
- **Cause** (reproduced in headless Chrome: the dev demo's stress crowd, 120 units at impostor interval 4, the three held
|
||
in their widest poses): a unit's impostor slot was sized once from the pose it showed when it first became an impostor,
|
||
plus a margin (`_impBox`: the pose's bounds + 30 % / 22 %). A later pose past that box was drawn past the slot's edge:
|
||
cut in the unit's own sprite and painted into whatever lay beside the slot on the atlas page. A page is cleared on its
|
||
first flush only and each eraser covers its own slot, so a spill into free space stayed, and a spill into another unit's
|
||
slot showed on that unit until it was redrawn in a frame without the spill. 浊心斯卡蒂's flight reaches y −704 (skeleton
|
||
units) against a box top near −516 (54–57 px past her slot in 5 of 5 runs); 野鬃's idle flag reaches x −434, 103 units
|
||
past a box sized from her setup pose (0–47 px past her slot over 5 runs: it depended on that first pose); after one clean
|
||
redraw of every impostor the atlas pages held 0–1,405 pixels outside every live slot. 隐现's own poses fit her box (the
|
||
silencer reaches x 197, her box 225): her muzzle sits at her slot's edge, where a neighbour's spill lands. Of the
|
||
roster's 681 skeletons, 598 play a clip that leaves a box sized that way from their idle's first frame. No clipping
|
||
attachment is involved (`spine.js _eyeMaskFallback`, #177, unchanged).
|
||
- **Now**: (1) every clip a unit can play (its manifest roles with every skill's set and the Run cycle, its `FORMS` clip
|
||
sets and change clips) is measured once per skeleton data on a scratch skeleton: 60 poses a second, at most 49 per clip;
|
||
a region by its attachment rectangle, a mesh by its vertices; slots drawn at alpha 0 skipped. It is queued when the model
|
||
loads and sampled within 2.5 ms per frame (`stepClipBounds`, called by `render/app.js`), ≈ 9 ms per skeleton on the
|
||
roster (53 ms at most), shared by every unit of that skeleton; a unit is drawn directly until its box is ready. (2) The
|
||
box is the setup pose ∪ the idle clip (never smaller than the setup pose), then each other clip in the order of the area
|
||
it adds while the box stays within 4 × that core (a looping or held clip) or 2 × (a one-off clip: the deploy clip, a
|
||
skill's, stun's or move's begin and end, a form's change), padded by 3 % + 8 units. A clip that does not fit (`over`: 319
|
||
of the roster's 681 deploy clips — the drop from above, up to 1,700 units tall — and the looping clips of 6 skeletons with
|
||
screen-wide effects) is drawn directly while it plays; the unit keeps its slot (`_impBypass`). The box is computed once
|
||
per unit: no bounds work per frame. (3) The atlas draws each slot's skeleton in a container masked by the slot rectangle,
|
||
which PIXI draws as a scissor rect (no stencil, no render-pass break): nothing paints outside a slot, and the slot's
|
||
eraser clears all that an earlier pose can have left in it.
|
||
- **Cost**: slot sizes grow with the boxes. The 120-unit stress crowd at 1600 × 900 (DPR 1) uses 8.7 M px of slots instead
|
||
of 4.4 M (2.7 instead of 1.2 atlas pages, still no private render target). On the roster the boxes add up to 1.86 × the
|
||
old area (the median skeleton 1.39 ×; without the setup-pose floor it would be 1.23 ×). The atlas keeps its ≤ 3 + 2 pages;
|
||
a unit that finds no slot keeps a private render target, as before. At DPR 2 a page holds a quarter of the CSS pixels:
|
||
the same crowd at 1280 × 720 keeps 39 of its 120 units in the atlas instead of 119 (81 private render targets; 55
|
||
instead of 60 fps in headless Chrome), at 1920 × 1080 39 instead of 58; at 844 × 390, DPR 3, all 120 stay in two pages.
|
||
CPU: one batch flush per redrawn slot.
|
||
`tools/perfbench.mjs` (844 × 390, DPR 2, quality medium / low, where those battles draw through the atlas; 12 runs of
|
||
6 s): total busy JS +0.8 %, the impostor-redraw stage +14 % (+0.1–0.25 ms per frame at a 4 × CPU throttle), 60 fps in
|
||
every run before and after.
|
||
- Open: the setup-pose floor doubles the cost. A model shows its setup pose only when it is frozen before its first update
|
||
(built while stunned without a stun clip, or dead without a death or idle clip). If such a model were posed at its
|
||
clip's first frame instead, the setup pose could be dropped from the box: the stress crowd's slots would add up to
|
||
1.16 × the old area (DPR 1), and at 1280 × 720, DPR 2, 116 of the 120 units would stay in the atlas (60 fps). Kept as
|
||
specified for #542; the maintainer's call.
|
||
- [ASSUMED]: the growth limits (4 × / 2 ×), the 3 % + 8 padding, the sampling rate and the 2.5 ms budget. A pose between two
|
||
samples can still pass the padded box for a frame (33 of the 681 skeletons by a denser 60 Hz check, all in long clips
|
||
sampled sparser than that); the scissor cuts it and leaves nothing behind.
|
||
- Tests: `test/render/impostor-bounds.test.js` (synthetic Spine 3.8 skeletons: setup and clip bounds, an attachment shown
|
||
for 0.05 s, an alpha-0 slot, the budgeted queue; `impostorBox` / `playedClips`; every atlas slot's clip box and eraser;
|
||
UnitView: drawn directly until measured, the over-clip bypass keeping its slot, the legacy fallback, a pen figure),
|
||
`test/render/impostor-bounds-models.test.js` (the three operators through the real runtime: every pose of every clip
|
||
they play, at 30 Hz, inside their box; the old box cut 野鬃 by 103 units and 浊心斯卡蒂 by 188; skips without the
|
||
assets), `test/render/impostor-bounds.browser.test.js` (`RENDER_E2E=1`: the held stress crowd — inside their slots, no
|
||
atlas pixel outside a live slot; the scissor on a bare page; 野鬃's deploy drawn directly), `test/render/boardart.test.js`
|
||
(the allocator's test doubles). Dev: `render-demo.html?scene=stress&ops=<chessId>,…`. Golden: unchanged.
|