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

19 KiB
Raw Blame History

DESIGN §31 — 0.2.6-dev

Part of 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.7 Official scene light gains (#549)

GitHub #549 (@xcdoge, confirmed by other players): the official battlefield scenes of 0.2.5 read much darker than the official client. 0.2.5 scaled the two light terms of sceneMaterial below the values the extractor stores — SCENE_LIGHT.direct 0.8 and .indirect 0.4, tuned by eye against one PRTS screenshot and marked [ASSUMED]. From 0.2.6 both are 1: the key light at the scene's light.intensity and the lightmap's baked linear light as decoded (scenes.py decode_rgbm). The maintainer compared the two on 2026-10-13 and chose the official values. PR #554 by @xcdoge also raised LIGHTING.emissive from 0.25 to 1; that part is not taken: it retunes the procedural metal board and its glass, which were matched to screenshots, and does not touch the scene meshes. ambient 0.2 (a mesh without a lightmap page) stays [ASSUMED]. The water (#549's second half, PR #555) is unchanged here.

Tests: test/render/board3d-scene.test.js; the 3D board browser suite with the extracted scenes.

31.8 Deploy animation replays (PR #416)

PR #416 by @Cromemadnd, written against 0.2.2 and ported onto the current files.

Seen. A prep piece never played its Spine Start clip or a landing sound outside a battle. A piece put on the board through the direction wheel played nothing: the state that shows the move arrives while the wheel's hold still pins the piece on its tile, so setPrep's branch for a piece entering the board was never reached. A purchase only flashed (fx.deploy), and the board rebuilt after every battle stood still.

Official / decision. The maintainer confirmed from official play on 2026-10-13 that at the start of every round the operators on the board replay their Start animation and landing sound one after another in deployment order. The PR also plays them when an operator is bought and when it enters the board or swaps tiles; its author says the official client does so. Those two triggers are [ASSUMED] on that account. The PR's late-model case (a deploy asked before the Spine model has loaded) was fixed in 0.2.3 (§28.22: the late model continues Start at the elapsed game time) and stays as it is.

Now.

  • UnitView.land() (render/units.js) plays the piece's Start clip again through onDeploy (its fade-in and the late-model continuation) and asks audio.deployCue for the landing sound: the piece's battle deploy sound (deploySfxUrl: its own ON_UNIT_BORN file at its bank gain, else b_char_set / b_char_tokenset at 0.5) through the SFX channel's volume and mute, in landing voices of their own (AudioManager._playLanding: LAND_VOICES 3 at once, a 160 ms cooldown per piece; the battle limiter is not used). A landing over the cap takes the oldest landing's voice, whose copy fades out over 30 ms, instead of being dropped: 192 of the 210 operators land with the same 1.41 s b_char_set and the round start lands one every 50 ms, so the battle rule (≤ 2 copies of a file, the rest dropped) left all but the first two landings of a chain silent (Grok's review of this port). With 3 voices every copy rings 150 ms before it is taken over — b_char_set's attack, its loudest part, lies in its first 100 ms, and b_char_tokenset has 95 % of its energy in 150 ms — while the summed level stays one copy above the battle's two. The field view asks for the two generic files when a match screen comes up (warmLandings; kept until the first gesture or until the SFX are audible), so a round start's first landings never wait for a download. One landing makes one sound: a piece whose Start still plays keeps it and sounds nothing more, whether the clip runs on its model, is owed to a model still loading, or was cued less than a clip length ago. That covers a purchase followed by a move, and the round start's replay reaching a piece just put down. Only an idle prep piece lands. A battle unit never does: the sim's deploy events keep the silent onDeploy, and audio.handleBattleEvents makes their sound (the PR's view-side sound would have sounded most battle deployments a second time). Nor does a piece that is attacking, dying, lifted by a drag or in another clip, or any piece while the tab is hidden (the clip and the suspended audio would play it stale on return).
  • Round start (ui/gameLogic roundStartReplay, screens/game.js, render/app.js replayDeploys). During 回合开始 the own board's pieces land one after another in deployment order, each turn chained 50 ms after the one before. The order is the battle's (deployOrder, as Battle.start): operators by column from the left, top to bottom within a column, then the summons the same way, in board coordinates. The PR used the m.private board list, which is reading order. The screen asks after its field-view wiring effect, so the view already holds the round's pieces, and it marks the round only once the view has taken the sequence. A view still loading or a board not built yet is asked again on the next render of 回合开始; the PR marked the round first, so a late view never replayed. A re-pushed state (a reconnect's resync) or a re-render of the same round plays nothing more, and nothing is replayed outside 回合开始. The timers end with prep mode (enterBattle, which a scouted board also uses), with a fresh prep scene and with destroy(); a turn whose piece is gone or rebuilt passes over it.
  • Spectators and watched boards: the replay plays on the viewer's own board only. A scouted or watched teammate's prep board, a spectator seat (no m.private), an eliminated player and the boss partner's half in a Final Assault prep get none.
  • Purchase and board triggers (render/app.js setPrep). An operator that has just arrived on the bench lands: a purchase, a reward or a grant, and a merge's elite on its bench slot or on the copy's tile. A summon that arrives with its owner's placement does not, nor does an item. A piece that a new state puts on a board tile it was not on lands when that state arrives, whether it came from the bench, from another tile or as the partner of a swap. The PR landed a piece at the wheel's confirm instead, restarting any Start that had run 0.3 s (a purchase followed by a move sounded twice); its round-start sequence forced every piece, one that had just landed included, and it landed every move on the bench and every retreat. Here turning in place, a retreat and bench moves do not land. That is narrower than the PR's wheel hook, which fired on every confirm, a turn in place included. The deploy flash (fx.deploy) is unchanged.

[ASSUMED] The purchase and board triggers (the PR author's account). The 50 ms gap (DEPLOY_REPLAY_MS, the PR's value; no source gives the official one). The 3 landing voices taken over oldest first (the official born bank allows any number of copies; three keep every landing's attack within a bounded level). Summon pieces taking part in the replay after the operators, as in the battle's deployment. The clip length a landing counts as playing when the model's own is unknown (START_CLIP_FALLBACK 1 s; the operators' Start clips run 0.77–1.2 s). A page reloaded during 回合开始 replays once in the new page.

Tests. test/render/deploy-replay.test.js (the clip, one sound, a second trigger, a late model, the refusals, a hidden tab; the app.js wiring), test/ui/deploy-replay.test.js (deployOrder, roundStartReplay, the screen wiring; deployCue on the real AudioManager, SfxLimiter and buffer cache with a fake Web Audio context: 8 b_char_set landings 50 ms apart start 8 times, at most 3 at once, the oldest taken over each time; a 36-piece board within the cap; the per-piece cooldown; a landing taken over while it downloads never starts; the warm-up; volume and mute), test/render/playtest6-promote.test.js (the elite's cue), and the browser check test/ui/deploy-replay.e2e.test.js (SP_E2E=1, mock harness: SETTLE → 回合开始 replays the board in deployment order about 50 ms apart with one sound each that really starts in the page's audio, at most 3 landing voices at once, once per round and again the next round; in the 休整期 a purchase lands, a move within its Start sounds nothing more, a later move lands, a turn in place does not). Golden: unchanged (client only).

31.9 Optional ammo count (PR #552)

PR #552 by @IceCodeNew.

Seen. While an ammo skill runs, the SP bar is the yellow magazine (one cell per round, 0.2.2) with no number. A magazine whose cells are under 2 px draws no cuts between them (AMMO_BAR.minCell), so a large one (240 rounds) cannot be counted. The PR draws remaining/capacity under the bar.

Decision. The official client shows no such number. The maintainer's decision of 2026-10-13: an optional setting, off by default, like 「官方伤害数字」 (§30.20).

Now. 设置 →「弹药数量」 (ammoCount) is sanitised with the other settings: a missing or non-boolean saved value is off. Its hint says the official interface has no such number. The field view takes the value at creation and live (setSettings). UnitView._updateAmmoCount draws left/magazine from the exact b.snap ammo counts (拉特兰's extra rounds and refills included), centred under the ammo bar in its yellow at 10–16 px by the zoom, and moves the rows under the bars (狼影 pips, the purse, the element gauge) down by its height. It hides with the magazine (the skill's end, an older snapshot without the row), in the prep view, after death and for enemies; while the option is off nothing is built. The simplified 2D board has no ammo bar (its SP bar reads the tuple's SP), so it shows no count. The diagnostics' settings line names ammo count when it is on. Display only: the sim, the protocol and the data are unchanged. The PR's last commit inlined the readout into _updateHud; here it is a method like the purse's (_updateCoins), behind the setting.

[ASSUMED] Nothing new: the font, size and place are the PR's.

Tests. test/render/hud-readouts.test.js (off: nothing drawn and the rows below unmoved; on: the exact count through spending, refills, capacity changes and dense magazines, hidden at the skill's end, in the prep view, after death and for enemies, the live toggle), test/ui/ammo-count.test.js (normalisation, the toggle and its hint in every language pack, the view's live setting, the diagnostics line), test/ui/gameLogic.test.js (sanitizeSettings). Golden: unchanged.