实现账号绑定、持久登录与跨设备对局恢复
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

This commit is contained in:
herryyun committed 2026-10-14 23:22:50 +08:00
1 parent 488db49f11
commit 7c25c89992
62 files changed
+6855 -1005

No files matched your search

+5 -1
View File
@@ -25,7 +25,9 @@ effects), [DATA.md](DATA.md) (generated data), [ASSETS.md](ASSETS.md) (art and a
- **The server** is one Node process: plain `node:http` plus `ws`, no framework (`server/index.js` wires
`server/http/`). It serves the static client, the generated data and the sim's source files, and runs every room and
match in memory — rounds, shop, economy, bots, validation. Nothing is written to disk.
match in memory — rounds, shop, economy, bots, validation. Optional account binding (`SP_SKLAND=1`) persists
credentials and JWT login records in SQLite ([skland.md](skland.md)); a selected role can resume its retained seat
across devices through authenticated account recovery. Match state remains in memory, not in SQLite.
- **The browser** loads native ES modules with no bundler and no build step; the libraries are vendored into
`public/vendor/` by `tools/vendor.mjs` on `npm install`.
- **The battle simulation** (`server/sim/`) is pure ESM without any Node API, served read-only at `/sim/`. Both sides
@@ -79,6 +81,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
| `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` |
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`, permessage-deflate), `static.js` (the mounts, the `~<hash>` asset routes), `media.js`, `files.js` (MIME, brotli / gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `traffic.js` (bytes sent by category and on the WebSocket, `/healthz?traffic=1`), `assetIndex.js` (the content hashes of `public/assets/` and `public/fonts/`, cached in `.cache/asset-hashes.json`), `assetViews.js` (the served `/data/assets.json` and `/data/local-assets.json`: every art URL with its hash segment; ASSETS.md), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
| `server/skland/` | opt-in provider binding and SQLite login records; `auth.js` verifies account ownership, `accountHttp.js` handles login/entry/recovery, `recovery.js` describes only the selected role's retained room or match |
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
| `server/data.js` | loads `data/*.json` once (frozen) |
| `server/packs.js` | the content packs (PACKS.md): finds the language packs of `public/i18n/` and the pack folders of `packs/`, validates them, answers `/packs/index.json` and which pack files may be served; re-reads the folders when they change |
@@ -102,6 +105,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|---|---|
| `public/index.html`, `public/js/main.js` | the page and its entry: boot, the router (title → lobby → room → game) |
| `public/js/net.js`, `public/js/store.js`, `public/js/data.js` | the socket client, the observable store, the data loader (`/data/*.json`, with the English overlay) |
| `public/js/skland/` | the account dialog, role selection, entry gate and server-backed cross-device recovery picker; no local match-token list is used for account recovery |
| `public/js/battle/` | `runner.js` (the local battle: loads `/sim/`, steps it, reports), `observe.js` (who may watch which field) |
| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `cultivation.js` (its 潜能 / 练度 controls), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` |
| `public/js/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node |
+53 -13
View File
@@ -13,7 +13,7 @@
| 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 790 MB。 |
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。唯一例外是打开可选的森空岛账号绑定(`SP_SKLAND=1`,第 3.2 节):它的凭据库需要持久化和备份。
## 1. Windows 小主机:一步步
@@ -180,7 +180,7 @@ cloudflared tunnel --url http://localhost:3000
2. 路由器「虚拟服务器 / 端口转发」:外部端口 3000(或任意端口)→ 内部 `主机IP:3000`,TCP。
3. 朋友访问 `http://<你的公网 IP>:外部端口`。
注意:游戏没有账号系统,知道地址的人都能进来。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),但仍建议不玩时关掉转发,或优先用 Tailscale。
本分支的标题入口要求绑定账号(第 3.2 节);启用模块后,服务器也会校验房间/对局操作对应的账号角色。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),建议不玩时关掉转发,或优先用 Tailscale。公网账号登录使用 HTTPS(2.4)。
### 2.4 反向代理与 HTTPS(有域名时)
@@ -231,8 +231,20 @@ https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://
## 3. Docker
### 3.1 一条命令(docker compose)
仓库根目录的 `docker-compose.yml` 已经把构建、端口、重启策略和数据卷写好,在项目根目录执行:
```bash
# A) 构建时下载素材(需要联网,约 550 MB)
docker compose up -d # 构建镜像并后台启动(首次会构建;HTTP 服务在 http://<主机>:3000)
docker compose logs -f # 跟随日志;docker compose ps 看状态
docker compose down # 停止并删除容器(数据卷 skland-data 保留)
```
不用 compose 时等价的原始命令(`FETCH_ASSETS=1` 在构建时下载素材,约 550 MB,需要联网;不下载则用占位图,或改用 B 挂载宿主机素材):
```bash
# A) 构建时下载素材
docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 .
docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
@@ -242,22 +254,50 @@ docker run -d --name stronghold -p 3000:3000 --restart unless-stopped \
-v "$PWD/public/assets:/app/public/assets:ro" stronghold-protocol
```
镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等)。健康检查:`GET /healthz`。
镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`,也不含 `docker-compose.yml`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等),另有第 3.2 节的森空岛两项。健康检查:`GET /healthz`。
docker compose 示例:
### 3.2 森空岛账号绑定与数据卷
可选的**账号绑定**(`server/skland/`,见 [skland.md](skland.md))把账号凭据、JWT 签名密钥和可撤销的登录记录存进同一个 SQLite 文件;这是服务器需要跨重启保留的账号数据(房间和对局仍然只在内存里,第 0 节)。JWT 以 HttpOnly Cookie 保存 30 天,刷新、重新开页面或重启服务不用重新绑定;解绑会使旧 JWT 失效。不设置 `SP_SKLAND` 时模块不启用、不建库,`/skland/*` 统一返回 JSON 404;本分支的标题入口仍要求绑定,因此开服需要启用它。需要含 `node:sqlite` 的 Node 22.5 或更新版本。
| 环境变量 | 默认 | 说明 |
|---|---|---|
| `SP_SKLAND` | 空(关闭) | 设为 `1` 时打开账号绑定模块 |
| `SP_SKLAND_DB` | `data/skland.db` | SQLite 凭据库的路径(相对路径相对项目根目录);父目录不存在时自己创建 |
| `SP_AUTH_SECRET` | 自动生成并存入数据库 | 可选的 JWT 签名密钥,至少 32 字节;不要每次启动随机改变,轮换会使现有登录失效 |
| `SP_PUBLIC_ORIGIN` | 按请求来源判定 | 可选的固定访问地址,例如 `https://game.example.com`,用于同源校验和 HTTPS Cookie |
| `SP_SKLAND_DIAGNOSTIC_KEY` | 空(关闭) | 仅管理员诊断接口使用;调用需 `X-SP-Diagnostic-Key`,不要填进前端或发给玩家 |
`docker-compose.yml` 里这两个变量都指向挂载的具名卷:
```yaml
services:
stronghold:
build:
context: .
args: { FETCH_ASSETS: "1" }
ports: ["3000:3000"]
restart: unless-stopped
environment:
SP_VERIFY: "off"
SP_SKLAND: "1"
SP_SKLAND_DB: /app/skland-data/skland.db
volumes:
- skland-data:/app/skland-data
```
- **为什么用卷**:容器以 `USER node` 运行,删除容器、`docker compose down` 或重建镜像都会丢掉镜像层里的文件;数据库放在具名卷 `skland-data` 上,重启、`docker compose down` 和 `docker compose up --build` 之后绑定关系都还在。`docker compose down -v`(或 `docker volume rm`)才会删除它。
- **为什么用具名卷而不是绑定挂载**:Windows / macOS / Linux 上行为一致,不需要先建宿主机目录、也不用管容器内 `node`(uid 1000)对宿主机目录的权限。要在宿主机上直接读这个文件时,把挂载换成 `- ./skland-data:/app/skland-data`(目录已在 `.gitignore` 里)。
- **关掉模块**:把 `SP_SKLAND` 和 `SP_SKLAND_DB` 两行删掉或注释掉即可(卷可以留着不用),服务器就不再保存任何账号凭据。手工 `docker run` 时对应地去掉 `-e SP_SKLAND=1 -e SP_SKLAND_DB=…` 两个参数。
- **改用别的路径**:`SP_SKLAND_DB` 换成卷里的其它位置(例如 `/app/skland-data/skland-2.db`)。内置静态服务会拒绝下载当前配置的库及 WAL/SHM 文件,但仍建议放在所有静态目录之外,避免其它文件服务器或代理规则泄露。
公网使用 HTTPS;代理要保留 Host 并传递 `X-Forwarded-Proto`(2.4),也可显式设置 `SP_PUBLIC_ORIGIN`。
HTTPS 下 Cookie 带 Secure;本地 `127.0.0.1` 开发使用 HTTP。游戏内用角色 UID + 区服标识账号,昵称可以重复。
登录记录不保存密码,JWT 也不携带鹰角/森空岛凭据。
**随账号跨设备恢复**:新设备登录同一账号并选择同一区服角色后,可在标题页点击「恢复账号对局」,无需复制
本机缓存。查询对局或自动恢复登录不会挤掉旧设备;点击「开始」或确认恢复后才接续原席位,旧设备断开且不会
自动抢回连接。恢复仍受原有断线期限限制(独立模拟 24 小时、同盟对局 10 分钟,等候房间另受 60 秒宽限期限制),
**不包含服务器重启后的对局存档**。只会查询当前角色,昵称相同或其他区服不会串号。
> **这个数据库是凭据,按密钥对待。** `skland.db` 里是明文保存的 `access_token`、长期 `cred` 和签名密钥 `cred_token`,拿到文件等于拿到对应账号的接口权限(skland.md §9):
> - **限制权限**:只让运行服务器的账号可读,例如 `chmod 700 skland-data && chmod 600 skland-data/skland.db*`(WAL 模式下还会有 `-wal` / `-shm` 两个附属文件,同样处理)。在宿主机上运行而非容器里时默认路径是 `data/skland.db`,权限同理。
> - **绝不通过 HTTP 暴露**:卷挂在 `/app/skland-data`,不在 `public/`、`data/`、`shared/` 任何静态挂载之下;反向代理也不要为它开规则。
> - **不提交、不进镜像**:`.gitignore` 忽略了 `data/skland.db`、`-wal` / `-shm` 和 `skland-data/`;构建上下文只会把 `data/` 复制进镜像,所以容器部署要把库放在 `data/` 之外(compose 已经这么做)。
> - **备份要当密码处理**:`docker compose stop` 后再从卷里拷贝(WAL 写入中直接拷可能拿到不一致的副本),备份文件加密保存、限权,不要放进网盘 / 仓库 / 聊天记录;不再使用时删除卷(`docker compose down -v`)。
## 4. macOS / Linux 常驻
- 临时开服:`scripts/start.sh`(或 `npm start`),保持终端窗口打开。macOS 首次会询问是否允许 node 接受传入连接,选「允许」。
+5
View File
@@ -175,6 +175,11 @@ otherwise every "之后 / 后续的每场作战" card — e.g. 教鞭's 法术
A `choice:<effectId>` registry handler overrides the default application (§2.4).
### 1.3 Disconnects, AI takeover
* Account-required fork (§31.13, [skland.md](skland.md) §5): the selected UID + region can resume the same retained
seat on another device after explicit authenticated entry. Login/lookup alone does not replace a socket. The
transport rotates its token on successful attachment, while the match and its private state stay the same.
Account recovery does not extend the deadlines below or survive a server restart; an expired role's old seat goes
through the normal lobby onExpire/onLeave cleanup before a new session can reuse its stable playerId.
* Local multi-window recovery (#431, DESIGN §28.27) sends optional `hello.noReplace` for a tentative shared token,
and `hello.claimAt` for a previously welcomed token. Before binding a different socket, the server rejects a
connected seat when no replacement was allowed or the request has a later stamp than the attached holder
+31
View File
@@ -73,6 +73,37 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
---
### Required account binding at the callsign field (fork integration)
The title hides 博士代号 until binding, then displays the role nickname as plain text without an input
(`public/js/skland/entry.js`;
`SP_SKLAND=1`, [skland.md](../skland.md)). The primary button shows 绑定账号 until a named role is fetched, then 开始.
Multiple roles start with the default role and can be switched. Unbinding or an empty role list clears the callsign
and restores 绑定账号. The nickname retains its person icon. The scan tab fetches a QR automatically and refreshes it
every 10 s; the first awaiting-confirmation response starts a 60 s window. Refresh keeps the current image visible
until its replacement is ready and updates the image source in place. Manual refresh remains available, with
serial 2 s polling and paused timers outside the scan tab/visible page. Saved callsigns/entered flags and account recovery cannot skip the binding lookup. Skland QR
login supports official/Bilibili channels; phone/password uses the Hypergryph account. Every game-entry HTTP request
uses the existing game session token and a 30-day HttpOnly JWT cookie, without entering the lobby automatically.
The cookie restores the role in a new tab or after a server restart; role choice persists on the server. The callsign
never becomes an identity key: UID plus server region determine a stable game player id, allowing duplicate names.
Account UI, styles and the entry policy stay in independent module files; title, boot and recovery have small hooks
to reduce upstream merge conflicts. An optional server account policy also checks hello and room/game requests
against the JWT-owned role; editing the client's UUID, region or name cannot select another player's binding.
Unbind revokes remembered login. Owner request, 2026-10-14; §31.12 and [skland.md](../skland.md).
The title's 恢复账号对局 picker queries the selected role's live server-side seat, even on a device with no local match
history. It replaces the visible 本机 recovery buttons (including the obsolete lobby shortcut). Opening it or restoring
the login cookie does not disconnect an active device. 开始 or an explicit recovery confirmation asks for the role's
current token, reconnects with the JWT, and enters only after welcome confirms the expected playerId. The existing
socket is then replaced (4001, no auto-reconnect), and its old token cannot take the role back on reload. A duplicated
tab or stale saved entered flag stays at the title unless its own current role token really resumed. Any anonymous
welcome also clears an already-entered shell; the connection banner's 在此页面继续 uses checked account entry.
The picker shows
empty, loading and error states, a takeover notice and a refresh action; an expired/replaced selector cannot create a
new match. Room/match state is still memory-only with the existing disconnect windows; server restart ends it.
The UI and entry adapter remain in public/js/skland/ (§31.13).
## 13. Local-client art (optional, host-side)
`tools/local-extract/extract.py` (Python: UnityPy + lz4 + Pillow; decodes Arknights' custom **LZ4AK** bundle compression via `aklz4.py`) extracts the art the web download does not include (some of it, most official HUD sprites among it, is on the public mirror too — §22.5) from a locally installed client (CrossOver Windows build or PlayCover iOS build) into `public/assets/local/**` and writes `data/local-assets.json` (`{ groups: { '<subdir>': { name: { path, w, h, kind } } } }`). Everything here is **optional**: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art). The emotes and the guide pages below are on the public mirror too (§22.5): fetch-assets downloads them (data/assets.json `ui['emoticon/<dir>/<picId>']`, `ui['guide/<key>']`) and the client shows the local copy first, then that one (`data.js artUrls`); docs/DEPLOY.md §6 lists what still needs the client.
+22
View File
@@ -4,6 +4,26 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 8. Network protocol (shared/protocol.js is normative)
**Fork account policy (opt-in `SP_SKLAND=1`, owner request 2026-10-14).** The existing WebSocket messages are
unchanged. Hello may establish anonymous transport for account login, but room/game requests require the role
owned by a persistent, server-verified JWT cookie. The server ignores a client's claimed nickname/UUID for account
identity; it derives stable `playerId` from UID plus provider-reported server region (`channelName`). Nicknames
may repeat, and optional provider `gameId` metadata does not affect identity. The public id uses 128 SHA-256 digest
bits encoded as base64url after `a_` (24 characters), so normal field ids and per-player battle reports fit the
existing protocol limits. A reconnect token alone cannot resume
an account identity. A valid login plus the role's current reconnect token can resume its previous socket; each
game frame checks the current login record and selected role, so logout or a role switch invalidates its authority.
Cookie restoration and read-only recovery lookup never take over another device. An explicit authenticated
enter/resume POST returns the selected role's token; the existing hello then attaches it and resends room/match state.
Only after successful attachment is the account token rotated, so a stale device cannot silently retake the seat.
The client stages the entry token in memory until welcome; local multi-tab claims still protect normal reconnects.
Login lasts 30 days, survives process restart and is revoked on unbind. Room/match data remain in memory.
Recovery selectors are scoped to the selected role and the particular room/match; stale selectors return 409 without
creating a new game. The existing 10-minute / solo 24-hour disconnect windows are unchanged, and an expired seat is
removed through the lobby before its stable playerId can be reused. Account API ownership and private DB handling
are specified in [skland.md](../skland.md) §5/§9. The optional policy hooks leave module-off transport unchanged;
no simulation rule, WebSocket message or protocol-version change is involved.
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound (a compressed frame counts its inflated size); rate limit 40 msgs/s per connection (excess dropped with `error RATE`). Frames from 1 KB up are compressed with permessage-deflate (RFC 7692; `server/http/websocket.js WS_DEFLATE`: no server context takeover, a 2 KB server window, zlib memLevel 7 — about 72 KB of zlib state per connection); smaller frames go out plain, and a client that offers no compression, or caps the server window below 2 KB, gets plain frames. A measured solo session (rounds 3–5, `/healthz?traffic=1`): 173 KB of server frames → 60 KB on the wire.
### 8.1 Session & lobby
@@ -137,6 +157,8 @@ voting, refuses stale ids and cancels on disconnect/leave; DESIGN §28.21 / META
Explicit local recovery (§28.20) reuses saved hello tokens after a fresh live-tab claim check. Display names and room
codes are UI metadata only, never authentication. No new network message or nickname/IP matching is introduced.
The account-required fork replaces the visible local recovery entry with authenticated account recovery (§31.13);
the generic local helper and its module-off handshake behavior are retained.
Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name.
+65
View File
@@ -341,3 +341,68 @@ every vertex on a slab tile at its top or its foot; 战场#03 and #05(上半) ha
`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).
+371
View File
@@ -0,0 +1,371 @@
# skland.md — the opt-in Skland account binding
`server/skland/` reads a player's Arknights account bindings and associates the roles with their game session.
The title hides the 博士代号 label and nickname until binding, then displays them as plain text without an input.
Logging in fetches the callsign from the default role's `nickName`; choosing another role updates it immediately.
The primary button shows 绑定账号 until a named role is available, then 开始. Unbinding
or an empty role list clears the callsign and restores 绑定账号. Boot and account-match recovery recheck the current
server binding, so a cached manual callsign/entered flag cannot skip it. This fork requires binding for browser entry;
an operator must enable the module to let players enter. When enabled, the server also requires the authenticated
role for room/game requests. Login persists for 30 days through an HttpOnly JWT cookie, including across server restarts.
Password login uses the official Hypergryph account binding service directly; the existing QR/SMS paths use the
signed Skland API. The historical module name, URL paths and enable switch are retained for compatibility.
It is **off by default** and stays off unless an operator turns it on
(§2); an unconfigured server keeps no account credentials at all. It uses the public website's account endpoints and
the community-documented Skland endpoints (§10); enabling it and storing credentials is the operator's decision, not a
feature every server gets. The module reaches the network only when the server does; the tests inject a stub `fetchImpl`.
## 1. What it is, and what it is not
It is a per-player credential store plus a login client and a small JSON HTTP surface. It does: run the three account
login paths (扫码 / 验证码 / 密码), fetch the account's bound roles, refresh an expired credential once, and answer the
binding state to the game. It does **not** solve 人机验证 (§4), spend any credential, or post anything on the player's
behalf. The game entry uses a reconnect token for transport and a server-verified JWT for account ownership (§5).
The independent diagnostic routes are closed by default; an administrator key enables their caller-supplied ids,
excluding the game's reserved pending/account namespaces.
## 2. Turning it on
| Switch | Effect |
|---|---|
| `SP_SKLAND=1` | enables the module (exactly `1`; any other value leaves it off) |
| `SP_SKLAND_DB=<path>` | the SQLite file (default `<repo>/data/skland.db`) |
| `SP_AUTH_SECRET=<secret>` | optional HS256 signing secret, at least 32 bytes; otherwise generated once and persisted in the database |
| `SP_PUBLIC_ORIGIN=https://game.example.com` | optional canonical browser origin, useful behind a reverse proxy |
| `SP_SKLAND_DIAGNOSTIC_KEY=<secret>` | enables administrator diagnostic routes; callers must supply `X-SP-Diagnostic-Key` |
| `startServer({ skland: true })` | same as `SP_SKLAND=1` |
| `startServer({ skland: {…} })` | enables it and takes `storeFile`, `fetchImpl` (tests), `client`, `scanTimeoutMs`, `authSecret`, `publicOrigin`, `diagnosticKey`; `authNow` injects the test clock |
`startServer` returns the live service as `srv.skland`, or `null` when the module is off; it logs
`[skland] account binding on — <file>` once. On an unconfigured server no store is created and no credential is read or
written. The off state still owns its paths: `routes.js` mounts `createSklandOffHandler`, so **every** `/skland/*`
request answers one uniform JSON `404 { ok: false, error }` — without it a GET would fall through to the static mount
(a 404) and a POST to the GET/HEAD gate (a 405 HTML page), two shapes for one API.
## 3. The three login paths
QR/SMS converge on `grant → cred` (`user/oauth2/v2/grant` then `zonai .../generate_cred_by_code`), then
`finishLogin` persists the row (`server/skland/service.js`). Password login instead queries the official account
binding service and stores its result without obtaining a Skland credential.
The Skland QR path is retained for both official-server and Bilibili-server bindings; role extraction preserves
every Arknights channel returned by Skland. The phone/password form authenticates a Hypergryph account. Bilibili
players use Skland QR login and complete any authorization redirect in the provider's interface, as reported by the
user; the panel continues polling the same scan session. This integration does not assume that Bilibili offers a
phone/password endpoint, or that authorization is its only possible login method.
| Path | The player | The server calls | Comes back |
|---|---|---|---|
| 扫码 | scans the QR (`scanUrl`, a `hypergryph://scan_login?…` deep link), confirms on the phone | `gen_scan/login` → `scan_status` (polled) → `token_by_scan_code` | `token`, `hgId`, **`deviceToken`** |
| 验证码 | receives the SMS code (`send_phone_code`, type 2) and types it | `token_by_phone_code` | `token`, `hgId`, **`deviceToken`** |
| 密码 | types phone + password | `token_by_phone_password` → binding-service grant (type 1) → `binding_list` | HG `token` and Arknights bindings; **no Skland cred required** |
Password lookup follows the [official account SDK](https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js),
checked on 2026-10-14: `Account.API.grantApp('binding')` posts `{ token, appCode: 'be36d44aa36bfb5b', type: 1 }`
to `https://as.hypergryph.com/user/oauth2/v2/grant`, receives a service **token** (not a Skland grant code), then
GETs `https://binding-api-account-prod.hypergryph.com/account/binding/v1/binding_list` with that token and
`appCode=arknights`. A previous local QR/SMS login or stored `deviceToken` is not a prerequisite.
Only a real upstream status **109** (`NEED_DEVICE_VERIFY`) becomes `{ ok: false, error, needDevice: true }`;
an incorrect password remains its own error. The 密码 endpoint takes `phone` + `password` only; passwords are never stored.
The [Arknights account page](https://ak.hypergryph.com/user/home) also uses this SDK. Its `/user/api/role/login`
request consumes an opaque role token issued by `/account/binding/v1/u8_token_by_uid`; the website does not locally
encrypt the password into that token. Reading the binding list already supplies the requested account information,
so this module does not need that later website-session login request or its analytics fields.
`send_phone_code` uses **2** (`LOGIN_OR_REGISTER`) for both the initial send and a captcha retry, following the
[official account center login implementation](https://web.hycdn.cn/user-center/index.fa9aa713.js), checked on
2026-10-14. The previous type 24 came from an Endfield-specific example rather than this web login flow.
The endpoint may answer status 102 (发送频率太高) — a readable error, not a captcha.
For `general/v1/scan_status`, both status **100** (未扫码) and **101** (已扫码待确认) return
`state: 'waiting'`, with `awaitingConfirmation: false/true` respectively. The title's scan tab automatically fetches
a QR and replaces it every 10 s. The first 101 starts a 60 s refresh window; repeated 101 responses do not extend it.
The manual 重新获取二维码 action stays available. Automatic/manual replacement keeps the existing image visible
until the new QR is encoded, then updates its source without removing the image. The same QR session keeps polling every 2 s, including while the phone confirmation propagates;
only the response carrying `data.scanCode` advances to the token exchange. Treating 101 as an error stopped the panel
before it could observe the user's confirmation. This mapping follows the [official account center](https://user.hypergryph.com/)
and its [login implementation](https://web.hycdn.cn/user-center/index.fa9aa713.js), checked on 2026-10-14.
Other failure statuses (including 102, expired on this endpoint) still surface as errors rather than waiting forever.
QR generation and polling are serial: confirmation wins over a queued refresh, and the latest status is checked
before automatic replacement. Leaving the scan tab or hiding the page pauses the timers; returning resumes the
same QR and checks its status before replacing it. Closing the dialog or binding successfully stops the timers.
Failures remain visible and retry QR generation after 10 s. The independent diagnostic page retains manual QR
generation; its compatible `waiting` state is unchanged.
## 4. 人机验证 (geetest)
SMS sending, password authentication and the official account grant can request verification as `status: 1` with
`data.captcha`. The client raises `SklandCaptchaError`; the service returns `{ ok: false, needCaptcha: true, captcha }`,
plus `captchaStep: 'password' | 'accountGrant'` for password login.
The panel displays a verification button. After the user clicks it, `public/js/skland/captcha.js` lazily loads the
Geetest provider's widget. The user completes the challenge in that widget; cancellation and load errors leave a
retry button. No phone, password or account token is passed to Geetest by this module. Geetest v4 challenges use
`geetestId` and `riskType`; the provider's validation fields plus the original `challenge` are returned exactly as
the official HG SDK does. Legacy v3 challenges use `gt`, `challenge`, `new_captcha` and return the provider's three
`geetest_*` fields. The proof is sent in `captcha` on the original request, never generated by the server.
Password retries include `captchaStep`. A challenge on `accountGrant` temporarily retains the authenticated HG token
in server memory, usable for 120 seconds, so its retry uses the same token without repeating the password exchange.
Pending tokens are never sent to the browser or persisted before login completes; unbind discards them.
## 5. The HTTP surface
### Game entry
The title uses `/skland/session/<endpoint>` with `Authorization: Bearer <game reconnect token>` and same-origin
cookies on every request, including reads and SMS sending. It establishes the existing `hello`/`welcome` transport
without entering the lobby. Invalid or expired transport tokens receive JSON 401. Tokens never appear in URLs,
QR codes or provider requests. Supplied `player`, UUID, UID or server fields cannot choose another account's row.
Before login, the server uses a reserved `@session/<playerId>` pending row. After provider confirmation it copies
credentials to a random `@account/<subject>` row, clears the pending credentials and issues `sp_account`, a signed
HS256 JWT cookie: HttpOnly, SameSite=Strict, Path=/, Max-Age=2592000; HTTPS uses Secure. JWT contents are signed,
not encrypted, and contain only issuer/audience, random subject/login id, expiry and the selected role key.
Provider tokens, passwords and signing keys never enter it. Verification requires the fixed algorithm, signature,
issuer, audience, time bounds, matching server login record and an active binding.
The following endpoints have the same request/response shapes as the diagnostic table below, with no `player` needed:
`status`, `roles`, `scan/start`, `scan/poll`, `phone/send`, `phone/login`, `password/login`, `unbind`.
The additional POST `role/select` accepts `{ roleKey }` and selects only an owned, named role with a nonempty UID
and region. `status`/selection also return `loginId`, `selectedRoleKey` and `gamePlayerId`. Selection is persisted
in the server login record and JWT. On login the default valid role is selected, otherwise the first valid role;
refresh preserves a still-valid selection or chooses a replacement. An empty list cannot start the game.
Changing roles or unbinding while in a room is rejected; leave first. The selected nickname uses the existing
callsign sanitizer, but never determines ownership. Binding itself does not press Start.
`shared/accountIdentity.js` defines identity as the provider-reported server region (`channelName`) plus normalized
string UID. Optional `gameId` and mutable nickname are excluded, so QR and password answers may omit `gameId`
without changing identity. The game exposes a stable `a_` + base64url of the first 16 SHA-256 digest bytes of that tuple
as `playerId` (24 characters; `n:<playerId>` fits the existing 32-character field id limit), uses the existing
registry/lobby maps, and permits duplicate nicknames. Different regions with the same UID remain distinct.
Authenticated WebSocket hello ignores client-claimed name/identity and verifies that the supplied reconnect token
belongs to this role; every room/game frame rechecks the live login and selected role. A bare reconnect token cannot
resume an account identity, and a remembered cookie alone establishes only login transport. An anonymous socket can
establish login transport and ping, but cannot issue room/game requests. Explicit entry is described below.
New tabs and restarted servers restore through the persistent cookie/database without another provider login.
Unbind revokes the server login, clears its credentials/cookie and invalidates old JWTs, including open sockets;
expired cookies require binding again. Login/refresh replies arriving after unbind cannot restore cleared secrets.
Deleting the database or rotating the signing secret invalidates remembered logins. Room and match state remain
in memory and do not survive a server restart.
### Cross-device match recovery
The title's **恢复账号对局** reads server state for the selected role instead of the browser's recent-token ring.
A new device signs in independently and selects the same provider region + UID; it need not copy a reconnect token,
JWT or localStorage. Restoring the login cookie, opening the title and querying recovery do not take over the seat.
These additional account routes use the same live transport header, JWT, same-origin checks and rate limit:
| Method | Session endpoint | Request | Response |
|---|---|---|---|
| GET | `recovery` | none | `{ ok: true, recovery: null \| { id, name, code, mode, difficulty, inMatch, round, connected, spectating } }` |
| POST | `enter` | `{}` | `{ ok: true, gamePlayerId, token, name }`; starts a game transport or resumes the retained seat |
| POST | `resume` | `{ id }` from the recovery response | same as enter, but only while the selected role still owns that room/match |
The recovery id is a selector, not a credential. It is unique to the in-memory room and match count; a new room or
later match invalidates it. Supplied player/UID/region/nickname fields never select another role. A missing, foreign
or stale recovery returns JSON 409 with `code: 'resume_expired'`, without creating a game session. A read does not
extend a disconnect deadline and contains no token or private match view. Entry/recovery credentials stay in the
POST response body, never in a URL or persistent browser storage before the server acknowledges them.
After an explicit Start or recovery confirmation, the client stages that token, reconnects with the cookie and waits
for welcome to confirm the expected gamePlayerId before entering. The server keeps the same Match/PlayerState and
reuses the existing room/state resync. A successfully attached account socket rotates the reconnect token and closes
the old socket with 4001; obtaining the token without attaching does not kick the active device. Old tokens cannot
silently retake the seat. Automatic boot entry requires the tab's current token to have resumed the selected role;
a stale or duplicated tab stays at the title. Its user may still explicitly start/recover with a valid account login.
The connection banner's 在此页面继续 uses the same checked account entry, not an obsolete token. Any anonymous
welcome clears the entered flag, including a connection reset on an already open page.
Ordinary reconnects retain the existing live-tab claim checks. Module-off sessions do not rotate their tokens.
The existing lifetimes still apply: solo matches keep a disconnected seat for 24 hours; co-op matches for 10 minutes;
a waiting room also has its 60-second lobby grace. Actual expiration cleans the old seat through lobby.onExpire
before the stable role id can be reused. Changing devices does not save the match to SQLite, synchronize local UI
preferences/statistics, or make it survive server restart. The UI supplies loading, empty, stale/error and refresh
states and warns when continuing may disconnect another device.
### Independent diagnostic page
These routes require `SP_SKLAND_DIAGNOSTIC_KEY` and the matching `X-SP-Diagnostic-Key` header; otherwise they
return 403. Do not embed this administrator key in browser code. `skland.html` is a development diagnostic page;
the main title uses the account-scoped endpoints above. Reserved `@session/` and `@account/` ids are forbidden.
Every answer is JSON, including errors (`{ ok: false, error }`), so a client is never handed the module-level HTML
error page. Bodies are capped at 64 KiB. The handler is handed requests before the GET/HEAD gate, so it owns POST.
| Method | Path | Request | Response |
|---|---|---|---|
| GET | `/skland/status` | `?player=` | `{ playerId, status, bound, nickname, hgId, roles, error? }` |
| GET | `/skland/roles` | `?player=&refresh=1` | `{ ok, roles }` (200 even on failure) |
| POST | `/skland/scan/start` | `{ player }` | `{ ok, scanId, scanUrl, expiresInMs }` |
| POST | `/skland/scan/poll` | `{ player }` | `{ ok, state: none \| waiting \| confirmed \| expired \| error, scanUrl?, awaitingConfirmation?, status?, nickname?, error? }` |
| POST | `/skland/phone/send` | `{ player?, phone, captcha? }` | `{ ok }` or `{ ok: false, needCaptcha, captcha }` or `{ ok: false, error }` |
| POST | `/skland/phone/login` | `{ player, phone, code }` | `{ ok, nickname, roles }` or `{ ok: false, error }` |
| POST | `/skland/password/login` | `{ player, phone, password, captcha?, captchaStep? }` | `{ ok, nickname, roles }`, `{ ok: false, needCaptcha, captcha, captchaStep }` or `{ ok: false, error, needDevice? }` |
| POST | `/skland/unbind` | `{ player }` | `{ ok: true }` |
`status` is `unbound | scanning | bound | error`; `bound` is `status === 'bound'`. A missing `player` (or phone /
code / password, per endpoint) is a 400, an unknown `/skland/*` path a 404, a non-POST method on a POST path a 405, and a
malformed body a 400. One 扫码 poll both reports `confirmed` and completes the login; a poll with no live session is
`state: 'none'`.
```bash
curl -s 'http://127.0.0.1:3000/skland/status?player=p1' -H 'X-SP-Diagnostic-Key: <admin-secret>'
curl -s -X POST http://127.0.0.1:3000/skland/phone/login \
-H 'X-SP-Diagnostic-Key: <admin-secret>' -H 'content-type: application/json' -d '{"player":"p1","phone":"13800000000","code":"123456"}'
```
## 6. The database
One row per player in `skland_players` (`node:sqlite`, `journal_mode = WAL`, `synchronous = NORMAL`), file from
`SP_SKLAND_DB` / `storeFile` (default `data/skland.db`; the parent directory is created). The store is synchronous;
callers never await. The enabled module requires Node with `node:sqlite` (22.5 or newer); it does not silently
replace persistent authentication with an in-memory fallback.
| Column | Holds |
|---|---|
| `player_id` | reserved pending/account subject key, or administrator diagnostic id — primary key |
| `access_token` | the HG login token (refreshes official bindings or rebuilds a Skland `cred`) |
| `device_token` | the device token of a 扫码/验证码 login (optional on Skland grants; unused by official account lookup) |
| `hg_id` | the Hypergryph account id (`hgId`) |
| `cred` | the long-term API credential |
| `cred_token` | the HMAC signing key — never leaves the server |
| `user_id` | the Skland internal user id (the column exists; no code path writes it) |
| `d_id` | the device fingerprint the credentials are pinned to (`B` + 32 hex, per player) |
| `nickname` | the profile name from the binding answer |
| `binding` | the raw binding list from the selected provider (cache; parsed back on read) |
| `binding_source` | `skland` for QR/SMS, `hypergryph` for official password lookup; selects the refresh path |
| `status` | `unbound \| scanning \| bound \| error` |
| `error` | the last failure message, if any |
| `created_at`, `updated_at` | epoch ms |
`update()` merges only the named fields; `remove()` drops the row. **解绑 (`unbind`) clears every credential field —
including `device_token` and `d_id` — and marks the row `unbound`.** The row itself stays. Password login can be
attempted again directly. Existing databases receive the `binding_source` column automatically, defaulting their
rows to `skland` so the already working QR/SMS bindings continue to refresh through their original provider.
Successful password lookup clears any previous Skland credentials and stores only the HG account token and bindings.
The same SQLite file holds `account_meta` (the generated JWT signing secret) and `account_logins`
(`id`, `subject`, `role_key`, `expires`, `revoked`). These tables enforce expiry, selection and logout revocation.
Back up the file consistently with its WAL state and treat backups as credentials too.
## 7. Credential refresh
Official password bindings refresh by obtaining a new binding-service token from the stored HG account token and
querying the official binding list again. An expired HG token requires login again. QR/SMS `getBinding` retries
once on the two expiry codes, otherwise the player rebinds:
- **10000** — `cred_token` expired → `POST zonai /api/v1/auth/refresh` (header `cred`), retry with the new `token`.
- **10002** — `cred` expired → re-run `getGrantCode(access_token, device_token)` then `getCred`, retry. Needs a stored
`access_token` or `device_token`; without either it falls through to the error below.
- Any other code, or a failed retry: the upstream message is raised; one matching 未登录 reads as
`登录已过期,请重新绑定`. `service.roles(player, true)` re-queries only while `status === 'bound'` and persists the
refreshed credentials.
## 8. Signing
`server/skland/crypto.js` is pure and fully unit-tested against pinned vectors (`test/skland/crypto.test.js`) — the
composition is the part a refactor breaks silently. With `header_ca = {"platform":"3","timestamp":<ts>,"dId":<dId>,
"vName":"1.0.0"}` in that key order and `ts = floor(now/1000) - 2` (2 s behind):
```
GET: raw = path + query + ts + compact(header_ca)
POST: raw = path + compact(body ?? {}) + ts + compact(header_ca)
sign = md5( hmacSha256_hex(cred_token, raw) ) // SHA-256 HMAC, lowercase hex, then MD5 of that hex
```
The headers are `cred`, `dId`, `platform: 3`, `timestamp`, `vName: 1.0.0`, `sign` plus the Skland mobile client
identity (`User-Agent: Skland/…`, `X-Requested-With: com.hypergryph.skland`, `Accept-Encoding: gzip`). Only
`getBinding` / the exposed `signedGet` / `signedPost` are signed; `generate_cred_by_code` is not. The login endpoints
send `deviceHeaders()` instead — a `X-DeviceId` (10 hex chars), `X-DeviceModel` and `X-DeviceType: 2`, cached for the
process so the upstream sees one device.
## 9. 安全模型 (security model)
- **Ownership comes from server-verified credentials.** Account routes need a live transport token plus the JWT for
account operations. UUID/UID/region edits do not confer ownership; role selection is checked against stored
provider bindings. WebSocket identity and authorization are server-enforced too. Administrator diagnostic routes
are closed by default and cannot access reserved rows. No arbitrary upstream URL/proxy endpoint is exposed.
- **Same-origin requests and bounded traffic.** Cross-site Origin/Fetch Metadata requests are rejected. Account HTTP
requests share a per-address token bucket (3/s, burst 120); request bodies are capped at 64 KiB. Use HTTPS for
remote deployments. A fully stolen valid bearer/cookie remains a credential until expiry/revocation; JWT signing
prevents modification, not copying. HttpOnly keeps the JWT out of JavaScript storage.
- **A `cred` (with `cred_token`) is equivalent to account access.** It is a long-term API credential, not a session
cookie. Treat it as a secret.
- **The store keeps credentials in plaintext SQLite.** A deployment must treat the database file as a secret: restrict
its permissions to the server account, keep it out of backups and repositories, and **never serve or expose it over
HTTP**. The built-in static/media/pack handlers deny the configured DB, WAL and SHM paths, including realpath
aliases and Windows filename casing. A reverse proxy or other file server needs its own protection. This is why
the module is opt-in.
- A player can **解绑** after leaving their room; that clears the stored credentials. Changing the Hypergryph password invalidates
the stored credentials upstream, so the player rebinds.
## 10. Legal and project rules
The account flow follows the public official SDK; the legacy Skland flow also follows community descriptions.
These website endpoints have no compatibility guarantee and may change without notice. The `appCode` in `crypto.js` is a public SDK constant visible in a
decompiled client — it is **not** an authorisation or a permission. This project is non-commercial
([AGENTS.md](../AGENTS.md) forbids ads, payments and other monetisation) and stays that way. Enabling the module, and
the handling of the credentials it stores, is the operator's decision and their responsibility.
## 11. Tests
Account test files (`node --test 'test/skland/*.test.js'`), against a stub upstream or widget — no account network.
| File | Pins |
|---|---|
| `test/skland/crypto.test.js` (10) | the signature composition (GET/POST, with and without query/body), the primitives, stability under a single changed input, the client identity headers, `makeDeviceId`, the cached `deviceHeaders` |
| `test/skland/store.test.js` (10) | one row per player, full credential round-trip, merge-only update, unbind clearing credentials, WAL persistence, parent-dir creation, and migration preserving old QR credentials while persisting the official binding source |
| `test/skland/client.test.js` (21) | QR/SMS chains, 100→101→confirmed polling, captcha retry, password → official binding-service grant/list without Skland, real device challenge 109, malformed grant/list and upstream errors, signed Skland requests and credential refresh |
| `test/skland/http.test.js` (24) | server wiring, QR polling with official/Bilibili channel preservation, SMS/captcha, fresh password login without Skland credentials, password/grant captcha continuation on the same token, pending-login invalidation on unbind, actual device challenge and incorrect password, provider-aware role refresh and switching, player isolation and error responses |
| `test/skland/captcha.test.js` (4) | fake Geetest v3/v4 widgets: exact proof/challenge hand-off, cancellation, missing proof, unsupported challenge and provider load errors; no real challenge or network |
| `test/skland/docker.test.js` (8) | the one-command packaging: the compose service, the port, `SP_SKLAND`/`SP_SKLAND_DB`, the database path inside the mounted named volume (and outside every static mount), the Dockerfile's prepared+owned directory before `USER node` |
| `test/skland/panel.test.js` (10) | `public/skland.html`: every endpoint it calls, the QR encoding `scanUrl` (not the scanId), the 2 s poll with its cleanup, explicit poll errors, the `needCaptcha` path and the "not enabled" answer |
| `test/skland/api.test.js` (5) | the title adapter's token-only authorization header, error mapping, hello reuse and default/selected role choice |
| `test/skland/scan.test.js` (7) | deterministic 10/60 s refresh boundaries, serial polling, manual refresh, pause/resume, late responses, completion retry and failure recovery |
| `test/skland/session.test.js` (5) | JWT signature/claims, role ownership, cross-origin rejection, logout/replay, restart/expiry, DB download denial, late provider cancellation, same-name UID/region isolation and authenticated game transport |
| `test/skland/entry.browser.test.js` (1, opt-in) | the real title/modal with a stub provider: QR, role/channel selection, password CAPTCHA, SMS, hidden/plain-text callsign, empty roles, unbinding, cached-entry checks, reload persistence and short landscape layout; skipped unless `SP_E2E=1` |
| `test/skland/recovery.test.js` | independent logins restoring the same live Match/private state, UID/region and role-selection isolation, read-only lookup, explicit takeover/token rotation, stale selectors, unchanged solo/co-op windows, expired-seat cleanup and account-id compatibility with watch/kick/battle reports |
| `test/skland/recovery.browser.test.js` (1, opt-in) | two isolated browser contexts with no shared saves: real title/login/recovery, exact state restoration, manual continue, empty/expired recovery, old-device reload and duplicated-tab safety, desktop/landscape-phone layout; `SP_E2E=1` |
## 12. Files
| File | Holds |
|---|---|
| `server/skland/crypto.js` | the signature, the appCodes, the device id and the login device headers |
| `server/skland/client.js` | the three login chains, captcha, `getGrantCode`/`getCred`, the signed client, `getBinding` refresh |
| `server/skland/store.js` | the SQLite credential schema, migrations and merge-only store |
| `server/skland/service.js` | per-player orchestration, scan sessions, status/roles, `finishLogin`, `unbind` |
| `server/skland/http.js` | administrator diagnostic JSON routes and bounded body reader |
| `server/skland/auth.js`, `accountHttp.js` | persistent JWT/account ownership, revocation, stable game identity and scoped HTTP entry/recovery |
| `server/skland/recovery.js` | read-only selected-role seat metadata and room/match recovery selectors |
| `shared/accountIdentity.js` | shared UID + server-region key, independent of nickname and optional provider metadata |
| `public/js/skland/captcha.js` | user-operated Geetest widget, loaded only when verification is needed |
| `public/js/skland/entry.js`, `api.js` | title binding button/dialog, token-authenticated client, role selection and QR loading |
| `public/js/skland/gate.js`, `resume.js` | checked account entry, safe automatic reconnect and the cross-device recovery picker |
| `public/js/skland/scan.js` | automatic QR generation, 10/60 s refresh and serial 2 s confirmation polling |
| `public/css/skland/binding.css` | feature-scoped styles, loaded by the entry without editing global stylesheets |
| `server/skland/session.js` | reconnect-token resolver and the reserved game-binding namespace |
## 13. Keeping upstream merges small
Login logic, the callsign display, entry validation and CSS live under the module's own directories. The title
renders these components, owns the dialog-open state and chooses 绑定账号/开始 from the verified nickname. `main.js`
checks cached entry through `restoreBoundEntry`. The title mounts the account recovery picker; the obsolete local
shortcut is removed from the lobby (the generic `ui/resumeMatch.js` helper remains available upstream). The identity
helper stages explicit account tokens without persisting a claim before welcome. The server factory installs an
optional account policy: small hooks in `server/net.js` cover connection, hello, successful attachment/token rotation
and per-frame authorization; static handlers accept a private-file denylist. With no policy the upstream transport
behaves as before. No server lobby, match, simulation or WebSocket message changes are needed.
Language packs and this reference hold translations/documentation.
Keep future account changes in these module files and preserve the small title/boot/recovery hooks when merging
upstream. This reduces the conflict surface; edits to the same hooks/translations may still need a merge.
The Hypergryph and Bilibili brand images are referenced directly from the
[official account page bundle](https://web.hycdn.cn/arknights/user/index.842215.js):
[`hypergryph.d69547.png`](https://web.hycdn.cn/arknights/user/assets/imgs/hypergryph.d69547.png) and
[`bilibili.11810a.png`](https://web.hycdn.cn/arknights/user/assets/imgs/bilibili.11810a.png), checked on 2026-10-14.
Images are decorative beside text labels, so a failed image request leaves the controls usable. No game art is added
to git. The human-operated CAPTCHA temporarily releases the game's modal focus trap for the provider widget.