实现账号绑定、持久登录与跨设备对局恢复
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
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:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
@@ -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
@@ -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 接受传入连接,选「允许」。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Reference in new issue
Block a user