Files
ark-stronghold-protocol/docs/history/0.2.6.md
T
herryyun 7c25c89992
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
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

409 lines
37 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
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).