Files
ark-stronghold-protocol/docs/history/0.2.6.md
T

166 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.