CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
409 lines
37 KiB
Markdown
409 lines
37 KiB
Markdown
# DESIGN §31 — 0.2.6
|
||
|
||
Part of [DESIGN.md](../DESIGN.md). This chapter records 0.2.6, released on 2026-10-13.
|
||
|
||
## 31. Host bandwidth (layers 0 and 1) and community follow-up
|
||
|
||
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 / SpineActor.update`, `render/units.js impostorBox / _impBox / _impBypass`,
|
||
`render/impostor.js`, `render/app.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.
|
||
A skeleton stays queued only while an actor uses it: when the last one is destroyed (a model swap, a battlefield torn
|
||
down) its pending clips and its scratch skeleton are dropped, so nothing holds a skeleton the Spine store let go or is
|
||
measured ahead of the next scene's models, and a later actor queues again what was never measured. (2) The box is the
|
||
idle clip, then each other clip in the order of the area it adds while the box stays within 5 × the idle clip's area
|
||
(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`: 355 of the roster's 681 deploy clips — the drop
|
||
from above, up to 1,700 units tall — and the looping or held clips of 13 skeletons with screen-wide effects, among
|
||
them 帕拉斯's skills and 诗怀雅's attack) is drawn directly while it plays; the unit keeps its slot (`_impBypass`). So is a
|
||
clip whose sampling threw (recorded as not measurable, never as drawing nothing — the scissor would cut it). The box
|
||
is computed once per unit: no bounds work per frame. The setup pose needs no room: a model frozen before its first
|
||
update — built while its unit is stunned and it has no stun clip (202 of the 210 operators have none), or dead without
|
||
a death or idle clip — showed its setup pose, which no clip shows (隐现's Back model stands 900 units right of its feet
|
||
in it); `SpineActor.update` now poses it once at its clip's first frame (again on the next frame if that update
|
||
threw). (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. Each redrawn slot is a batch flush of its own, and PIXI uploaded its one batch
|
||
vertex buffer again for every flush, which stalls Apple GPUs (Chrome, ANGLE Metal, Apple M4: the 120-unit crowd at
|
||
1920 × 1080 ran at 42–58 fps instead of 60, by the machine's load), so `render/app.js` turns PIXI's
|
||
`BatchRenderer.canUploadSameBuffer` off: a buffer per flush, PIXI's own setting on iOS. Switched back and forth within
|
||
one page it gave 60 fps against 42 (DPR 2) and 50 (DPR 1) with the shared buffer — turning it off only around the
|
||
atlas pass gave 55 — for ≈ 0.3 ms more render CPU per frame; under load it also steadied the 0.2.5 renderer (51 → 59
|
||
fps at DPR 2).
|
||
- **Cost**: slot sizes grow with the boxes. On the roster they add up to 1.26 × the old area (the median skeleton 1.13
|
||
×). The 120-unit stress crowd at 1600 × 900 (DPR 1) uses 5.5 M px of slots instead of 4.4 M (two of the three atlas
|
||
pages, no private render target). At DPR 2 a page holds a quarter of the CSS pixels: the crowd at 1280 × 720 keeps
|
||
98–108 of its units in the atlas instead of 118–120, at 1920 × 1080 59–61 instead of 58–59 (its private render targets
|
||
4.4 M px instead of 2.8 M); at 844 × 390, DPR 3, all 120 stay in two pages. 60 fps in every case, before and after
|
||
(runs on a quiet machine). A unit that finds no slot keeps a private render target, as before; the atlas keeps its ≤ 3
|
||
+ 2 pages. CPU: `tools/perfbench.mjs` (844 × 390, DPR 2, quality medium / low, where those battles draw through the
|
||
atlas; four battles at a 4 × CPU throttle, two interleaved runs before and after): the view's JS 4.69 → 5.04 ms per
|
||
frame, the impostor-redraw stage +21 % (+0.12 ms per frame), all busy JS +0.4 %, 60 fps in every run.
|
||
- [ASSUMED]: the growth limits (5 × / 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 (42 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: clip bounds, an attachment shown for 0.05
|
||
s, an alpha-0 slot, the budgeted queue, a skeleton leaving the queue with its last actor, a clip whose sampling
|
||
throws; a model frozen before its first update — stunned without a stun clip, dead without a death or idle clip —
|
||
posed once at its clip's first frame, again after an update that threw; `impostorBox` / `playedClips`; every atlas
|
||
slot's clip box and eraser; UnitView: drawn directly until measured, built while stunned, a skeleton without an Idle
|
||
clip, the over-clip bypass keeping its slot, an unmeasurable attack or idle clip drawn directly, 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; 隐现's Back model
|
||
built while stunned shows its Idle's first frame inside the box, its setup pose lies outside it; 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.
|
||
|
||
### 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.
|
||
|
||
### 31.10 魂灵之影 stays on the field's boards (#557, PR #558)
|
||
|
||
Issue #557; PR #558 by @IceCodeNew, adapted.
|
||
|
||
**Seen.** The boss / hidden simulation rect includes rows 0–1, the mapped hand and 临时整备区. `shadowTile` accepted any
|
||
standable tile of her range there, so a bottom-row 维什戴尔 summoned 魂灵之影 onto the bench through her deployment talent
|
||
or S3 when her nearer tiles were taken.
|
||
|
||
**Now.** A candidate must also be on a board of this field (`Battle.onFieldBoard`, the "deployable" of the other summons
|
||
and of the 突袭 landing): a teammate's half on a pair field counts; a boss field's hand / 临时整备区 rows and the empty half
|
||
of a solo boss field do not. With no free candidate the summon is skipped. Range, occupancy and the order (nearest to her,
|
||
then the lower row, then the left column) are unchanged. The PR cut the bench rows by number; that left a standable tile
|
||
of an empty half legal, so the port uses the field's boards instead (the Raid-only empty-half exception, §29.13, stays
|
||
Raid's).
|
||
|
||
Sources: [PRTS 维什戴尔](https://prts.wiki/w/维什戴尔), the talent's nearest / lower-row / left-column deployable-tile rule;
|
||
[issue #557](https://github.com/sganggs/Stronghold-Protocol/issues/557). No new [ASSUMED] rule.
|
||
|
||
**Tests.** `test/content/op_wisdel.test.js`: the PR's case (boss and hidden fields, both sides, a full range and one free
|
||
combat tile, through the deployment talent and an actual S3 activation), and a solo / pair case — her own free tiles
|
||
filled, the empty half takes no shadow and the teammate's half does. Normative: DESIGN §3
|
||
([geometry-time.md](../design/geometry-time.md)); API note: [SIM.md](../SIM.md). Golden: unchanged.
|
||
|
||
### 31.11 Device slabs on the official scenes (#569)
|
||
|
||
Issue #569 by @A1muE4aY.
|
||
|
||
**Seen.** With an official scene on the 3D board, 战场#03's two raised ranged tiles beside the blue gate were drawn flat,
|
||
while the operators placed on them stood at their raised height. The scene replaced the whole procedural board
|
||
(`scene.js setStage` skipped its board, glass, decal and pipe buckets), and the raised slabs of the stage devices —
|
||
射击台 (`platform`, 0.26), 特制水上平台 (`waterPlatform`, 0.2) and 封印的地面 (`sealedFloor`, 0.05, a hazard decal
|
||
top) — exist only in that board bucket (`layout.js slabDevice`). The scenes do not contain them: they are runtime
|
||
devices of the stage (`stageDevices`), and the scene geometry has no raised top on those tiles. Affected while a scene
|
||
is shown: 战场#03 (射击台), 战场#05(上半) (the 16 water platforms of the normal field, 32 on the boss field — the pool read
|
||
as empty water) and 战场#06(上半) (射击台 and 封印的地面).
|
||
|
||
**Now.** `buildBoard` repeats each device slab into `buckets.raised` (tops and sides; a sealed floor's hazard top into
|
||
`buckets.raisedDecal`), with the side faces reaching `RAISED_FOOT` (0.08) below the tile so no gap shows against the
|
||
scene's ground (about z 0). With an official scene `setStage` draws only those two buckets of the procedural board, in
|
||
the board's own materials; without one the board draws the slabs itself as before. The 'h' high ground is not repeated:
|
||
the scenes model it (act2 m01's plates and act2 m02's sandstone blocks, checked side by side); an earlier draft that
|
||
also drew our 'h' plates covered them. Units already stood at `tiles.js heightAt`, which includes the device heights.
|
||
|
||
**[ASSUMED]** The slabs keep the metal board's plate and side textures: no official prefab of these devices is extracted.
|
||
|
||
**Tests.** `test/render/board3d.test.js` (every stage and area: one plate or decal top and four sides per slab device,
|
||
every vertex on a slab tile at its top or its foot; 战场#03 and #05(上半) have them; act2 m01 / m02 repeat nothing),
|
||
`test/render/board3d-scene.test.js` (an official scene on 战场#03 keeps the slabs in the board material and nothing else of
|
||
the procedural board; dropping the scene restores the board; act2 m02 adds no slab mesh). Screenshots of the five
|
||
affected stages before / after / metal board were compared by hand. Golden: unchanged (client only).
|
||
|
||
### 31.12 Account binding before entry (fork integration, 2026-10-14)
|
||
|
||
Owner request: integrate the working account module at 博士代号, automatically use the bound role's nickname, retain
|
||
Skland QR for official/Bilibili channels, and keep later upstream merges small. The callsign label and nickname are
|
||
hidden until binding, then shown as plain text without an input; 绑定账号
|
||
replaces 开始 until a named role is obtained, and returns after unbinding or an empty role list. Boot and local-match
|
||
recovery recheck the binding before accepting a cached entry. The dialog, styles, entry policy and session resolver
|
||
live in the module's own directories, with small title/boot/recovery hooks. Game-entry requests use the
|
||
existing reconnect token and a reserved namespace; the independent diagnostic routes require an administrator key
|
||
and cannot read or mutate these rows. No new WebSocket messages or simulation rules. Multiple roles select the default, retain a
|
||
still-valid choice on refresh/reopen, and immediately update the callsign when switched. No manual callsign entry.
|
||
The official account-page icons are referenced remotely; authorization redirects remain provider-operated.
|
||
The nickname retains its person icon. Opening the scan tab automatically fetches a QR: replacement is every 10 s,
|
||
switching to 60 s from the first status 101 (waiting for phone confirmation), with manual refresh retained. Refresh
|
||
keeps the old QR visible until the new image is encoded, then replaces its source in place without a blank frame. Requests
|
||
are serial and check confirmation before replacement; switching tabs or hiding the page pauses timers, and closing
|
||
the dialog or binding stops them. The provider's 101 is exposed as `awaitingConfirmation` without changing `waiting`.
|
||
|
||
Persistent login uses a 30-day HS256 JWT in an HttpOnly, SameSite=Strict cookie (Secure over HTTPS), with signing
|
||
key and revocable login/role records in the same SQLite database. New tabs and restarted servers restore the
|
||
selected role without another provider login. The JWT contains no provider credentials. Account APIs derive
|
||
ownership on the server, reject cross-site requests and foreign role selection, and default-close diagnostic access.
|
||
The optional WebSocket account policy resolves hello and every room/game request to the owned role; a copied game
|
||
reconnect token alone cannot claim it. UID plus server region determines stable game identity, allowing duplicate
|
||
nicknames; optional `gameId` and name changes do not change that identity. Unbind clears credentials and revokes
|
||
old JWTs/open-socket authority; late provider replies cannot restore a cancelled binding. Static/media/pack paths
|
||
deny the configured database and its WAL/SHM files, including aliases and Windows casing. These changes follow the
|
||
owner's explicit persistence/access-control request; rooms/matches remain memory-only.
|
||
|
||
Reference: [skland.md](../skland.md). Tests cover token rejection/player isolation, nickname selection, authenticated
|
||
requests and the real title/modal in a browser with a stub account provider (QR, SMS and password/CAPTCHA continuation,
|
||
landscape-phone layout, hidden/plain-text callsign with person icon, empty roles, unbinding, fresh-tab JWT restoration
|
||
and cached-entry checks). Server tests cover forged JWT/claims, foreign roles, logout/replay, restart/expiry,
|
||
protected database files, cancelled provider replies and same-name/different-UID/region identities.
|
||
Deterministic timer tests cover automatic QR generation, exact 10/60 s boundaries, pause/resume, late responses,
|
||
manual refresh versus confirmation and retry after failure. Golden: unchanged
|
||
(no simulation changes).
|
||
|
||
### 31.13 Account-scoped cross-device recovery (fork integration, 2026-10-14)
|
||
|
||
Owner request: continue implementing recovery with the account across devices. The title now offers 恢复账号对局
|
||
from authenticated server state, replacing the visible local-token recovery shortcuts. A device with no local save
|
||
can sign in independently, select the same UID + provider region, inspect its retained seat and continue it. Login,
|
||
cookie restoration and lookup alone do not disconnect another device; Start or recovery confirmation explicitly
|
||
obtains the role's reconnect token. Only a successful welcome enters the UI, rotates the token and replaces the old
|
||
socket. Old-device reloads and duplicated tabs cannot silently take the role back; the connection banner can still
|
||
explicitly continue through checked account entry, and anonymous welcomes return to the title. The UI handles empty, loading,
|
||
stale/error and refresh states and warns about taking over a live connection.
|
||
|
||
The server checks both credentials and the selected role, never a supplied player/name/UID/region. Public account
|
||
ids encode 128 digest bits as base64url after a_ (24 characters): the previous full hexadecimal digest exceeded
|
||
player-id and normal-field limits, rejecting watch/kick/battle-result messages. No wire limit needs to change. Recovery selectors
|
||
belong to a particular room/match; stale or foreign selectors fail without minting a fresh game. Looking up a seat
|
||
does not extend its lifetime. The normal lobby cleanup runs before an expired stable role id is reused, preventing
|
||
an orphaned old match. The same Match/PlayerState is resumed, including funds, shop and lineup; no simulation rule,
|
||
protocol message or generated data changes. Solo 24-hour/co-op 10-minute disconnect windows and waiting-room grace
|
||
are unchanged. Matches remain memory-only: this is not save recovery after a server restart, nor synchronization of
|
||
local preferences/statistics. These are owner-requested account policies, not deviations from official gameplay.
|
||
|
||
Normative updates: design/network.md §8 and its recovery note, design/client.md required-account entry, META.md §1.3;
|
||
references: skland.md §5 and ARCHITECTURE.md / DEPLOY.md. Tests: test/skland/recovery.test.js exercises actual Match
|
||
objects through independent HTTP/WS clients; test/skland/recovery.browser.test.js runs two isolated browser contexts
|
||
with a stub provider, plus old-device and duplicated-tab checks and desktop/phone layout. Existing account, local
|
||
identity, i18n and docs checks are rerun. Golden moves: none (no match/sim rules changed).
|