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

+7
View File
@@ -28,6 +28,13 @@ __pycache__/
# (the server answers /packs/index.json itself; tools/package.mjs writes this file into the release zips) ----------- # (the server answers /packs/index.json itself; tools/package.mjs writes this file into the release zips) -----------
packs/index.json packs/index.json
# --- 森空岛 account binding (docs/skland.md): the local credential database — a secret, never committed ------------
# SP_SKLAND_DB default (server/skland/store.js) plus the SQLite WAL side files; skland-data/ is the Docker volume path
data/skland.db
data/skland.db-wal
data/skland.db-shm
skland-data/
# --- tests & logs ---------------------------------------------------------------------------------- # --- tests & logs ----------------------------------------------------------------------------------
test/e2e/out/ test/e2e/out/
logs/ logs/
+14 -1
View File
@@ -12,8 +12,18 @@
# (public/fonts, data/assets.json and data/local-assets.json are copied from the build context when present) # (public/fonts, data/assets.json and data/local-assets.json are copied from the build context when present)
# Without any art the game still runs with placeholder visuals. # Without any art the game still runs with placeholder visuals.
# #
# Data: the opt-in Skland account binding (SP_SKLAND=1, docs/skland.md) is the only runtime state: one SQLite file of
# account credentials (SP_SKLAND_DB). It must survive a container restart, so it belongs on a volume — this image
# creates /app/skland-data and hands it to `node` below, and docker-compose.yml mounts the named volume `skland-data`
# there with SP_SKLAND_DB=/app/skland-data/skland.db. There is deliberately no `VOLUME` instruction: an anonymous
# volume would make `docker run` silently accumulate credentials a host cannot find, so the mount is the caller's
# explicit choice (`-v skland-data:/app/skland-data`). The default (data/skland.db, inside the container) is only for
# a throwaway run. The database is a secret: it holds account credentials, it sits outside every static mount, and it
# must never be committed, served over HTTP or copied around unencrypted.
#
# Run: docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol # Run: docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
# Env: PORT (3000), HOST (0.0.0.0), SP_COMBAT (client|server), SP_VERIFY (off|sample|all), TRUST_PROXY (auto|1|0), DEBUG # Env: PORT (3000), HOST (0.0.0.0), SP_COMBAT (client|server), SP_VERIFY (off|sample|all), TRUST_PROXY (auto|1|0), DEBUG,
# SP_SKLAND (off; 1 turns the Skland account binding on), SP_SKLAND_DB (data/skland.db)
ARG NODE_IMAGE=node:22-alpine ARG NODE_IMAGE=node:22-alpine
@@ -53,6 +63,9 @@ COPY --from=build /app/data ./data
COPY --from=build /app/public ./public COPY --from=build /app/public ./public
# research tables: read by server/sim/nodeData.js as a fallback # research tables: read by server/sim/nodeData.js as a fallback
COPY --from=build /app/docs/research ./docs/research COPY --from=build /app/docs/research ./docs/research
# the Skland credential database's directory (SP_SKLAND_DB): created before USER node and owned by it, so a fresh named
# volume inherits a writable mount point and the unprivileged user can create the .db and its -wal / -shm side files
RUN mkdir -p /app/skland-data && chown node:node /app/skland-data
USER node USER node
EXPOSE 3000 EXPOSE 3000
+36
View File
@@ -17,6 +17,7 @@ them (with `node_modules/`, which keeps each package's own licence file).
| [Preact](https://github.com/preactjs/preact) | 10.29.8 | MIT | UI — `public/vendor/preact.module.js`, `hooks.module.js` | no (npm) | yes | | [Preact](https://github.com/preactjs/preact) | 10.29.8 | MIT | UI — `public/vendor/preact.module.js`, `hooks.module.js` | no (npm) | yes |
| [htm](https://github.com/developit/htm) | 3.1.1 | Apache-2.0 | UI templates — `public/vendor/htm.module.js` | no (npm) | yes | | [htm](https://github.com/developit/htm) | 3.1.1 | Apache-2.0 | UI templates — `public/vendor/htm.module.js` | no (npm) | yes |
| [three.js](https://github.com/mrdoob/three.js) | 0.186.1 | MIT | official 3D board — `public/vendor/three.core.js`, `three.module.js` | no (npm) | yes | | [three.js](https://github.com/mrdoob/three.js) | 0.186.1 | MIT | official 3D board — `public/vendor/three.core.js`, `three.module.js` | no (npm) | yes |
| [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) | 2.0.4 | MIT | the 森空岛 binding panel renders the scan-login QR client-side — `public/vendor/qrcode.js` (a UMD build loaded as a classic script, so the global `qrcode`) | no (npm) | yes |
| [ws](https://github.com/websockets/ws) | 8.22.0 | MIT | WebSocket server (`server/`) | no (npm) | yes (`node_modules/`) | | [ws](https://github.com/websockets/ws) | 8.22.0 | MIT | WebSocket server (`server/`) | no (npm) | yes (`node_modules/`) |
| [Node.js](https://nodejs.org/) | v22.23.3 (pinned in `scripts/make-windows-bundle.mjs`) | MIT | the portable `node\node.exe` (**Windows portable package only** — the integration bundle in [Releases](../../releases/latest) ships no `node.exe`) | no (downloaded from nodejs.org at package time, sha256 verified) | **Windows portable package only** (`node\node.exe`, with `node\LICENSE-node.txt`) | | [Node.js](https://nodejs.org/) | v22.23.3 (pinned in `scripts/make-windows-bundle.mjs`) | MIT | the portable `node\node.exe` (**Windows portable package only** — the integration bundle in [Releases](../../releases/latest) ships no `node.exe`) | no (downloaded from nodejs.org at package time, sha256 verified) | **Windows portable package only** (`node\node.exe`, with `node\LICENSE-node.txt`) |
| [puppeteer-core](https://github.com/puppeteer/puppeteer) | 25.12.0 | Apache-2.0 | optional browser tests (dev dependency) | no (npm) | no | | [puppeteer-core](https://github.com/puppeteer/puppeteer) | 25.12.0 | Apache-2.0 | optional browser tests (dev dependency) | no (npm) | no |
@@ -177,6 +178,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
``` ```
### qrcode-generator — MIT
```text
QR Code Generator for JavaScript
Copyright (c) 2009 Kazuhiko Arase
URL: http://www.d-project.com/
Licensed under the MIT license:
http://www.opensource.org/licenses/mit-license.php
The word 'QR Code' is registered trademark of
DENSO WAVE INCORPORATED
http://www.denso-wave.com/qrcode/faqpatent-e.html
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
### ws — MIT ### ws — MIT
```text ```text
+32
View File
@@ -0,0 +1,32 @@
# 卫戍协议:盟约 · one command: `docker compose up -d` (docs/DEPLOY.md「Docker」).
# The service builds the repository's own Dockerfile and tags the image; no other service is needed.
services:
stronghold:
build:
context: .
# A) download the art while building (~550 MB, needs internet): uncomment
# args: { FETCH_ASSETS: "1" }
image: stronghold-protocol
restart: unless-stopped
ports:
- "3000:3000"
environment:
# Skland (森空岛) account binding: SP_SKLAND=1 turns the module on (docs/skland.md) and SP_SKLAND_DB points its
# SQLite credential store at the named volume below. To run without the module, comment out (or delete) both
# lines — the server then keeps no account credentials at all and the volume is never written.
SP_SKLAND: "1"
SP_SKLAND_DB: /app/skland-data/skland.db
volumes:
# The credential database must survive `docker compose down`, `docker compose up --build` and image rebuilds, so
# it lives in a named volume instead of the container's writable layer. A named volume behaves the same on Windows,
# macOS and Linux and needs no host path or permission setup; `docker compose down -v` is what deletes it.
# The database is a secret: never publish or serve it over HTTP (it is outside every static mount), and back it
# up carefully (docs/DEPLOY.md「Docker」).
- skland-data:/app/skland-data
# B) the host's art instead of the downloaded copy, read-only (prepare it with `node tools/setup.mjs`): uncomment
# - ./public/assets:/app/public/assets:ro
# One named volume, one runtime database. `docker volume inspect` shows where the host keeps it; it is private to the
# container user, so a bind mount (`./skland-data:/app/skland-data`) is only needed to read the file from the host.
volumes:
skland-data:
+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 - **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 `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 - **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`. `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 - **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/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/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/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/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
| `server/data.js` | loads `data/*.json` once (frozen) | | `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 | | `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/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/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/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/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 | | `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。 | | 磁盘 | 素材约 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`。 | | 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。 服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。唯一例外是打开可选的森空岛账号绑定(`SP_SKLAND=1`,第 3.2 节):它的凭据库需要持久化和备份。
## 1. Windows 小主机:一步步 ## 1. Windows 小主机:一步步
@@ -180,7 +180,7 @@ cloudflared tunnel --url http://localhost:3000
2. 路由器「虚拟服务器 / 端口转发」:外部端口 3000(或任意端口)→ 内部 `主机IP:3000`,TCP。 2. 路由器「虚拟服务器 / 端口转发」:外部端口 3000(或任意端口)→ 内部 `主机IP:3000`,TCP。
3. 朋友访问 `http://<你的公网 IP>:外部端口`。 3. 朋友访问 `http://<你的公网 IP>:外部端口`。
注意:游戏没有账号系统,知道地址的人都能进来。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),但仍建议不玩时关掉转发,或优先用 Tailscale。 本分支的标题入口要求绑定账号(第 3.2 节);启用模块后,服务器也会校验房间/对局操作对应的账号角色。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),建议不玩时关掉转发,或优先用 Tailscale。公网账号登录使用 HTTPS(2.4)。
### 2.4 反向代理与 HTTPS(有域名时) ### 2.4 反向代理与 HTTPS(有域名时)
@@ -231,8 +231,20 @@ https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://
## 3. Docker ## 3. Docker
### 3.1 一条命令(docker compose)
仓库根目录的 `docker-compose.yml` 已经把构建、端口、重启策略和数据卷写好,在项目根目录执行:
```bash ```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 build -t stronghold-protocol --build-arg FETCH_ASSETS=1 .
docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol 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 -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 ```yaml
services:
stronghold:
build:
context: .
args: { FETCH_ASSETS: "1" }
ports: ["3000:3000"]
restart: unless-stopped
environment: 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 常驻 ## 4. macOS / Linux 常驻
- 临时开服:`scripts/start.sh`(或 `npm start`),保持终端窗口打开。macOS 首次会询问是否允许 node 接受传入连接,选「允许」。 - 临时开服:`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). A `choice:<effectId>` registry handler overrides the default application (§2.4).
### 1.3 Disconnects, AI takeover ### 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, * 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 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 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) ## 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. `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) ## 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. 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 ### 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 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. 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. 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 `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 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). 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.
+947 -940
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -50,6 +50,7 @@
"pixi-spine": "^4.0.6", "pixi-spine": "^4.0.6",
"pixi.js": "^7.4.2", "pixi.js": "^7.4.2",
"preact": "^10.29.8", "preact": "^10.29.8",
"qrcode-generator": "^2.0.4",
"three": "^0.186.1", "three": "^0.186.1",
"ws": "^8.22.0" "ws": "^8.22.0"
}, },
+45
View File
@@ -0,0 +1,45 @@
/* Feature-local styles: keep upstream title/components styles untouched. */
.sp-binding-callsign { display: grid; gap: .1rem; margin: 0; }
.sp-binding-callsign__label { display: flex; align-items: baseline; gap: .1rem; color: var(--text-lo); font-size: calc(.15 * var(--t)); font-weight: 500; letter-spacing: .04em; }
.sp-binding-callsign__name { display: flex; align-items: center; gap: .12rem; margin: 0; color: var(--text-hi); font-size: calc(.26 * var(--t)); font-weight: 700; line-height: 1.4; overflow-wrap: anywhere; }
.sp-binding-callsign__icon { flex: none; color: var(--text-lo); }
.sp-binding-callsign__name > span { min-width: 0; }
.sp-binding-entry { display: flex; justify-content: flex-end; width: 4.9rem; margin-top: -.08rem; }
.sp-binding-entry:empty { display: none; }
.sp-binding-entry > .btn { max-width: 100%; white-space: normal; text-align: left; }
.sp-binding .modal__body { display: grid; gap: .2rem; font-size: calc(.18 * var(--t)); }
.sp-binding__intro { color: var(--text-lo); line-height: 1.6; margin: 0; }
.sp-binding__tabs, .sp-binding__channels, .sp-binding__actions { display: flex; flex-wrap: wrap; align-items: center; gap: .12rem; }
.sp-binding__channels { color: var(--text-lo); font-size: calc(.15 * var(--t)); }
.sp-binding__channels > span { display: inline-flex; align-items: center; gap: .08rem; }
.sp-binding__brand { width: .26rem; height: .26rem; object-fit: contain; flex: none; }
.sp-binding__scan { display: grid; justify-items: center; gap: .16rem; text-align: center; }
.sp-binding__scan p { margin: 0; }
.sp-binding__qr { width: 2.4rem; height: 2.4rem; max-width: 100%; image-rendering: pixelated; background: #fff; }
.sp-binding__form, .sp-binding__roles { display: grid; gap: .16rem; }
.sp-binding__form p, .sp-binding__roles p { margin: 0; line-height: 1.6; }
.sp-binding__code { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: .12rem; }
.sp-binding__field { min-width: 0; }
.sp-binding__role { justify-content: flex-start; gap: .14rem; height: auto; min-height: .65rem; text-align: left; white-space: normal; }
.sp-binding__role > span { display: grid; gap: .04rem; overflow-wrap: anywhere; }
.sp-binding__role small { color: var(--text-lo); font-size: calc(.14 * var(--t)); }
.sp-binding__error { color: var(--red); overflow-wrap: anywhere; }
.sp-binding__notice { color: var(--mint-500); }
.sp-binding__captcha { border-top: 1px solid var(--line-2); padding-top: .16rem; }
.sp-binding__verifying { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; background: rgba(0, 0, 0, .75); color: var(--text-hi); font-size: calc(.2 * var(--t)); }
/* Short landscape screens: the title scene can be taller than the browser's visual viewport. Anchor the account box
to the top of the overlay and let its body scroll, so the close action and role list remain reachable. */
@media (max-height: 600px) {
.sp-binding { align-self: start; max-height: calc(100dvh - .3rem); margin-top: .15rem; }
.sp-binding .modal__head { padding-top: .12rem; }
.sp-binding .modal__body { padding-top: .1rem; padding-bottom: .14rem; }
.sp-binding .modal__actions { padding-top: .1rem; padding-bottom: .1rem; }
}
/* Recovery remains readable on another device even at the game's smallest landscape root scale. */
.sp-account-resume { width: min(94vw, max(6.8rem, 420px)); max-height: calc(100dvh - 24px); }
.sp-account-resume .modal__title { font-size: max(18px, calc(.26 * var(--t))); }
.sp-account-resume .modal__body { display: grid; gap: max(.16rem, 10px); font-size: max(14px, calc(.18 * var(--t))); line-height: 1.7; }
.sp-account-resume .modal__body p { margin: 0; }
.sp-account-resume .btn { min-height: 44px; font-size: max(14px, calc(.17 * var(--t))); }
+54 -1
View File
@@ -1218,5 +1218,58 @@
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names} can only be deployed on the four tiles adjacent to the summoner and returned to the Bench", "{names}只能部署在召唤者相邻四格,已退回整备区": "{names} can only be deployed on the four tiles adjacent to the summoner and returned to the Bench",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names} can only be deployed on the four tiles adjacent to the summoner; the Bench is full, so they come back next round", "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names} can only be deployed on the four tiles adjacent to the summoner; the Bench is full, so they come back next round",
"装备库存不足,未获得该装备": "Equipment stock is insufficient; no equipment was received", "装备库存不足,未获得该装备": "Equipment stock is insufficient; no equipment was received",
"库存不足": "Out of stock" "库存不足": "Out of stock",
"需要人机验证,请点击下方按钮完成验证。": "Human verification is required. Use the button below to continue.",
"开始人机验证": "Start verification",
"正在加载验证组件…": "Loading verification…",
"验证已取消,请点击按钮重试。": "Verification cancelled. Use the button to try again.",
"验证通过,正在继续…": "Verified. Continuing…",
"暂不支持此验证类型,请使用验证码或扫码登录。": "This verification type is not supported. Use SMS or QR login.",
"验证组件加载失败,请重试。": "Could not load verification. Please try again.",
"验证未完成,请重试。": "Verification was not completed. Please try again.",
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "For the official server, you can log in with your phone number. For the Bilibili server, scan with Skland and follow the authorization prompts.",
"此服务器未启用账号绑定,请联系服主开启后再进入。": "Account binding is disabled on this server. Ask the host to enable it before entering.",
"游戏会话已失效,请关闭后重试。": "Your game session expired. Close this dialog and try again.",
"连接失败,请重试。": "Connection failed. Please try again.",
"二维码加载失败,请重试。": "Could not load the QR code. Please try again.",
"服务器响应异常,请重试。": "Unexpected server response. Please try again.",
"已绑定:{name}": "Bound: {name}",
"绑定明日方舟账号": "Bind Arknights account",
"登录成功,未找到明日方舟角色。": "Logged in, but no Arknights characters were found.",
"登录失败,请重试。": "Login failed. Please try again.",
"验证码已发送,请查看短信。": "Code sent. Check your text messages.",
"二维码已失效,请重新获取。": "The QR code expired. Get a new code.",
"请在验证窗口中完成人机验证。": "Complete human verification in the verification window.",
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "Your character nickname becomes your callsign after binding. You can start once it is retrieved.",
"绑定账号后自动获取": "Retrieved after binding your account",
"绑定账号": "Bind account",
"· 绑定账号并开始后将自动加入": "· Join automatically after binding your account and starting",
"选择明日方舟角色": "Select an Arknights character",
"刷新角色": "Refresh characters",
"解绑账号": "Unbind account",
"登录方式": "Login method",
"森空岛扫码": "Skland QR login",
"验证码": "Verification code",
"密码": "Password",
"官服": "Official server",
"B服:扫码后按提示完成授权": "Bilibili: scan and follow the authorization prompts",
"使用森空岛 App 扫描二维码": "Scan the QR code with the Skland app",
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "Scan and confirm with the Skland app. Supports the official and Bilibili servers.",
"重新获取二维码": "Get a new QR code",
"二维码每 10 秒自动刷新,也可手动刷新。": "The QR code refreshes every 10 seconds. You can also refresh it manually.",
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "Scanned. Waiting for confirmation; the QR code will refresh automatically after 1 minute.",
"获取二维码": "Get QR code",
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "Use a Hypergryph account for the official server. For Bilibili, choose Skland QR login.",
"手机号": "Phone number",
"{n} 秒后重发": "Resend in {n} seconds",
"发送验证码": "Send code",
"登录并绑定": "Log in and bind",
"恢复账号对局": "Resume Account Match",
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "Sign in to the same account and select the same server and character to continue an unexpired match on another device. Matches cannot be restored after a server restart.",
"正在查询账号对局…": "Checking for account matches…",
"此对局已结束或过期,请刷新后重试。": "This match has ended or expired. Refresh and try again.",
"当前角色没有可恢复的对局。": "There is no match to resume for the selected character.",
"对局进行中": "Match in progress",
"同盟等候中": "Waiting in the alliance",
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "If this character is still online on another device, resuming will disconnect that device."
} }
+54 -1
View File
@@ -1224,5 +1224,58 @@
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました", "{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます", "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます",
"装备库存不足,未获得该装备": "装備の在庫が不足しているため、獲得できませんでした", "装备库存不足,未获得该装备": "装備の在庫が不足しているため、獲得できませんでした",
"库存不足": "在庫不足" "库存不足": "在庫不足",
"需要人机验证,请点击下方按钮完成验证。": "本人確認が必要です。下のボタンから認証を完了してください。",
"开始人机验证": "認証を開始",
"正在加载验证组件…": "認証を読み込み中…",
"验证已取消,请点击按钮重试。": "認証をキャンセルしました。ボタンから再試行してください。",
"验证通过,正在继续…": "認証が完了しました。処理を続けています…",
"暂不支持此验证类型,请使用验证码或扫码登录。": "この認証形式には対応していません。SMSまたはQRコードでログインしてください。",
"验证组件加载失败,请重试。": "認証を読み込めませんでした。再試行してください。",
"验证未完成,请重试。": "認証が完了していません。再試行してください。",
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "公式サーバーは電話番号でログインできます。Bilibiliサーバーは森空島でQRコードを読み取り、画面の案内に従って連携を許可してください。",
"此服务器未启用账号绑定,请联系服主开启后再进入。": "このサーバーではアカウント連携が無効です。管理者に有効化を依頼してから入場してください。",
"游戏会话已失效,请关闭后重试。": "ゲームセッションが無効です。閉じてから再試行してください。",
"连接失败,请重试。": "接続に失敗しました。再試行してください。",
"二维码加载失败,请重试。": "QRコードを読み込めませんでした。再試行してください。",
"服务器响应异常,请重试。": "サーバーの応答が異常です。再試行してください。",
"已绑定:{name}": "連携済み:{name}",
"绑定明日方舟账号": "アークナイツのアカウントを連携",
"登录成功,未找到明日方舟角色。": "ログインしましたが、アークナイツのキャラクターが見つかりません。",
"登录失败,请重试。": "ログインに失敗しました。再試行してください。",
"验证码已发送,请查看短信。": "認証コードを送信しました。SMSを確認してください。",
"二维码已失效,请重新获取。": "QRコードの有効期限が切れました。再取得してください。",
"请在验证窗口中完成人机验证。": "認証ウィンドウで認証を完了してください。",
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "連携後、キャラクター名をコードネームとして自動取得します。取得できたら開始できます。",
"绑定账号后自动获取": "アカウント連携後に自動取得",
"绑定账号": "アカウント連携",
"· 绑定账号并开始后将自动加入": "· アカウント連携後、開始すると自動で参加します",
"选择明日方舟角色": "アークナイツのキャラクターを選択",
"刷新角色": "キャラクターを更新",
"解绑账号": "アカウント連携を解除",
"登录方式": "ログイン方法",
"森空岛扫码": "森空島でQRログイン",
"验证码": "認証コード",
"密码": "パスワード",
"官服": "公式サーバー",
"B服:扫码后按提示完成授权": "Bilibili:QR読取後に案内に従って連携を許可",
"使用森空岛 App 扫描二维码": "森空島アプリでQRコードを読み取る",
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "森空島アプリでQRコードを読み取り、確認してください。公式・Bilibiliサーバーに対応しています。",
"重新获取二维码": "QRコードを再取得",
"二维码每 10 秒自动刷新,也可手动刷新。": "QRコードは10秒ごとに自動更新されます。手動でも更新できます。",
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "読み取り済み、確認待ちです。QRコードは1分後に自動更新されます。",
"获取二维码": "QRコードを取得",
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "公式サーバーはHypergryphアカウントでログインしてください。Bilibiliは森空島のQRログインを選択してください。",
"手机号": "電話番号",
"{n} 秒后重发": "{n} 秒後に再送信",
"发送验证码": "認証コードを送信",
"登录并绑定": "ログインして連携",
"恢复账号对局": "アカウントの対局を再開",
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "同じアカウントでログインし、同じサーバーのキャラクターを選択すると、有効期限内の対局を別の端末で続行できます。サーバー再起動後は復元できません。",
"正在查询账号对局…": "アカウントの対局を確認中…",
"此对局已结束或过期,请刷新后重试。": "この対局は終了したか、有効期限が切れています。更新して再試行してください。",
"当前角色没有可恢复的对局。": "選択中のキャラクターには再開できる対局がありません。",
"对局进行中": "対局進行中",
"同盟等候中": "同盟で待機中",
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "このキャラクターが別の端末でオンラインの場合、再開すると以前の接続は切断されます。"
} }
+54 -1
View File
@@ -1220,5 +1220,58 @@
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다.", "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다.",
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다.", "装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다.",
"库存不足": "재고 부족", "库存不足": "재고 부족",
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다)." "渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다).",
"需要人机验证,请点击下方按钮完成验证。": "자동 입력 방지 인증이 필요합니다. 아래 버튼을 눌러 인증해 주세요.",
"开始人机验证": "인증 시작",
"正在加载验证组件…": "인증을 불러오는 중…",
"验证已取消,请点击按钮重试。": "인증이 취소되었습니다. 버튼을 눌러 다시 시도해 주세요.",
"验证通过,正在继续…": "인증이 완료되어 계속 진행합니다…",
"暂不支持此验证类型,请使用验证码或扫码登录。": "지원하지 않는 인증 방식입니다. SMS 또는 QR 코드로 로그인해 주세요.",
"验证组件加载失败,请重试。": "인증을 불러오지 못했습니다. 다시 시도해 주세요.",
"验证未完成,请重试。": "인증을 완료하지 못했습니다. 다시 시도해 주세요.",
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "공식 서버는 휴대폰 번호로 로그인할 수 있습니다. Bilibili 서버는 스카이랜드로 QR 코드를 스캔하고 화면 안내에 따라 연동을 승인해 주세요.",
"此服务器未启用账号绑定,请联系服主开启后再进入。": "이 서버에서는 계정 연동이 비활성화되어 있습니다. 서버 관리자에게 활성화를 요청한 뒤 입장하세요.",
"游戏会话已失效,请关闭后重试。": "게임 세션이 만료되었습니다. 창을 닫고 다시 시도해 주세요.",
"连接失败,请重试。": "연결하지 못했습니다. 다시 시도해 주세요.",
"二维码加载失败,请重试。": "QR 코드를 불러오지 못했습니다. 다시 시도해 주세요.",
"服务器响应异常,请重试。": "서버 응답에 문제가 있습니다. 다시 시도해 주세요.",
"已绑定:{name}": "연동됨: {name}",
"绑定明日方舟账号": "명일방주 계정 연동",
"登录成功,未找到明日方舟角色。": "로그인했으나 명일방주 캐릭터를 찾지 못했습니다.",
"登录失败,请重试。": "로그인하지 못했습니다. 다시 시도해 주세요.",
"验证码已发送,请查看短信。": "인증 코드를 보냈습니다. 문자 메시지를 확인하세요.",
"二维码已失效,请重新获取。": "QR 코드가 만료되었습니다. 새로 발급해 주세요.",
"请在验证窗口中完成人机验证。": "인증 창에서 사람 인증을 완료해 주세요.",
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "연동 후 캐릭터 닉네임을 코드네임으로 자동으로 가져옵니다. 가져오면 시작할 수 있습니다.",
"绑定账号后自动获取": "계정 연동 후 자동으로 가져옵니다",
"绑定账号": "계정 연동",
"· 绑定账号并开始后将自动加入": "· 계정 연동 후 시작하면 자동으로 참가합니다",
"选择明日方舟角色": "명일방주 캐릭터 선택",
"刷新角色": "캐릭터 새로고침",
"解绑账号": "계정 연동 해제",
"登录方式": "로그인 방법",
"森空岛扫码": "스카이랜드 QR 로그인",
"验证码": "인증 코드",
"密码": "비밀번호",
"官服": "공식 서버",
"B服:扫码后按提示完成授权": "Bilibili: 스캔 후 안내에 따라 연동 승인",
"使用森空岛 App 扫描二维码": "스카이랜드 앱으로 QR 코드 스캔",
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "스카이랜드 앱으로 QR 코드를 스캔하고 확인하세요. 공식 및 Bilibili 서버를 지원합니다.",
"重新获取二维码": "QR 코드 재발급",
"二维码每 10 秒自动刷新,也可手动刷新。": "QR 코드는 10초마다 자동으로 갱신됩니다. 직접 갱신할 수도 있습니다.",
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "스캔 완료, 확인을 기다리고 있습니다. QR 코드는 1분 후 자동으로 갱신됩니다.",
"获取二维码": "QR 코드 발급",
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "공식 서버는 Hypergryph 계정으로 로그인하세요. Bilibili 서버는 스카이랜드 QR 로그인을 선택하세요.",
"手机号": "휴대폰 번호",
"{n} 秒后重发": "{n}초 후 재전송",
"发送验证码": "인증 코드 전송",
"登录并绑定": "로그인 및 연동",
"恢复账号对局": "계정 대전 이어하기",
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "같은 계정으로 로그인하고 같은 서버의 캐릭터를 선택하면 다른 기기에서 만료되지 않은 대전을 이어할 수 있습니다. 서버가 재시작되면 복구할 수 없습니다.",
"正在查询账号对局…": "계정 대전 확인 중…",
"此对局已结束或过期,请刷新后重试。": "대전이 종료되었거나 만료되었습니다. 새로고침한 후 다시 시도하세요.",
"当前角色没有可恢复的对局。": "현재 캐릭터로 이어할 수 있는 대전이 없습니다.",
"对局进行中": "대전 진행 중",
"同盟等候中": "동맹 대기 중",
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "이 캐릭터가 다른 기기에 접속 중이라면 이어하기 후 기존 연결이 끊어집니다."
} }
+54 -1
View File
@@ -1222,5 +1222,58 @@
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區", "{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還", "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還",
"装备库存不足,未获得该装备": "裝備庫存不足,未獲得該裝備", "装备库存不足,未获得该装备": "裝備庫存不足,未獲得該裝備",
"库存不足": "庫存不足" "库存不足": "庫存不足",
"需要人机验证,请点击下方按钮完成验证。": "需要人機驗證,請點擊下方按鈕完成驗證。",
"开始人机验证": "開始人機驗證",
"正在加载验证组件…": "正在載入驗證元件…",
"验证已取消,请点击按钮重试。": "驗證已取消,請點擊按鈕重試。",
"验证通过,正在继续…": "驗證通過,正在繼續…",
"暂不支持此验证类型,请使用验证码或扫码登录。": "暫不支援此驗證類型,請使用驗證碼或掃碼登入。",
"验证组件加载失败,请重试。": "驗證元件載入失敗,請重試。",
"验证未完成,请重试。": "驗證未完成,請重試。",
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "官服可使用手機號登入;B服請使用森空島掃碼,並按頁面提示完成授權。",
"此服务器未启用账号绑定,请联系服主开启后再进入。": "此伺服器未啟用帳號綁定,請聯絡服主開啟後再進入。",
"游戏会话已失效,请关闭后重试。": "遊戲工作階段已失效,請關閉後重試。",
"连接失败,请重试。": "連線失敗,請重試。",
"二维码加载失败,请重试。": "二維碼載入失敗,請重試。",
"服务器响应异常,请重试。": "伺服器回應異常,請重試。",
"已绑定:{name}": "已綁定:{name}",
"绑定明日方舟账号": "綁定明日方舟帳號",
"登录成功,未找到明日方舟角色。": "登入成功,未找到明日方舟角色。",
"登录失败,请重试。": "登入失敗,請重試。",
"验证码已发送,请查看短信。": "驗證碼已傳送,請查看簡訊。",
"二维码已失效,请重新获取。": "二維碼已失效,請重新取得。",
"请在验证窗口中完成人机验证。": "請在驗證視窗中完成人機驗證。",
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "綁定後自動取得角色暱稱作為博士代號,取得成功後即可開始。",
"绑定账号后自动获取": "綁定帳號後自動取得",
"绑定账号": "綁定帳號",
"· 绑定账号并开始后将自动加入": "· 綁定帳號並開始後將自動加入",
"选择明日方舟角色": "選擇明日方舟角色",
"刷新角色": "重新整理角色",
"解绑账号": "解除帳號綁定",
"登录方式": "登入方式",
"森空岛扫码": "森空島掃碼",
"验证码": "驗證碼",
"密码": "密碼",
"官服": "官服",
"B服:扫码后按提示完成授权": "B服:掃碼後按提示完成授權",
"使用森空岛 App 扫描二维码": "使用森空島 App 掃描二維碼",
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "使用森空島 App 掃碼並確認,支援官服和 B 服。",
"重新获取二维码": "重新取得二維碼",
"二维码每 10 秒自动刷新,也可手动刷新。": "二維碼每 10 秒自動更新,也可手動更新。",
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "已掃碼,等待確認;二維碼將在 1 分鐘後自動更新。",
"获取二维码": "取得二維碼",
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "使用鷹角帳號登入官服。B服請選擇森空島掃碼。",
"手机号": "手機號碼",
"{n} 秒后重发": "{n} 秒後重傳",
"发送验证码": "傳送驗證碼",
"登录并绑定": "登入並綁定",
"恢复账号对局": "恢復帳號對局",
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "登入同一帳號並選擇同一區服角色,可在其他裝置繼續尚未過期的對局。伺服器重新啟動後無法恢復。",
"正在查询账号对局…": "正在查詢帳號對局…",
"此对局已结束或过期,请刷新后重试。": "此對局已結束或過期,請重新整理後重試。",
"当前角色没有可恢复的对局。": "目前角色沒有可恢復的對局。",
"对局进行中": "對局進行中",
"同盟等候中": "同盟等候中",
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "如果此角色仍在其他裝置上線,恢復後將中斷舊連線。"
} }
+12 -9
View File
@@ -8,13 +8,13 @@
// otherwise → Lobby // otherwise → Lobby
// Deep link `?room=CODE`: remembered at boot, auto-joined once the player has entered and the // Deep link `?room=CODE`: remembered at boot, auto-joined once the player has entered and the
// session is online (after a short grace period in case the server restores a room on resume). // session is online (after a short grace period in case the server restores a room on resume).
// Reloading a tab that already passed the title re-enters automatically (sessionStorage flag) and // Reloading a tab that already passed the title re-enters after checking its current role binding and
// resumes the server session with the saved token; stale room/match state is dropped if the // resumes the server session with the saved token; stale room/match state is dropped if the
// server does not re-push it within RESTORE_GRACE_MS after `welcome`. Boot waits for // server does not re-push it within RESTORE_GRACE_MS after `welcome`. Boot waits for
// `identity.init()` (which token this tab may use without stealing another live tab's session) // `identity.init()` (which token this tab may use without stealing another live tab's session)
// before the first connect; a page restored from the back/forward cache reloads. // before the first connect; a page restored from the back/forward cache reloads.
// A `welcome` with a NEW playerId (the server restarted / the session expired) while a room or match was on screen: // A `welcome` with a NEW playerId (the server restarted / the session expired) while a room or match was on screen:
// toast 「服务器会话已重置,上一局模拟已结束」 (store.js sessionResetNotice) and return to the lobby with nothing stale. // drop stale match state (store.js sessionResetNotice); an unauthenticated transport returns to the title for checked entry.
// //
// Shared modules are imported relatively ('../../shared/…' resolves to /shared/… in the browser). // Shared modules are imported relatively ('../../shared/…' resolves to /shared/… in the browser).
// Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation // Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation
@@ -41,6 +41,7 @@ import { store, useStore, emptyMatch, selectRoute, sessionResetNotice, isSpectat
import { data } from './data.js'; import { data } from './data.js';
import { GAME_FILES } from './ui/gameComponents.js'; import { GAME_FILES } from './ui/gameComponents.js';
import { TitleScreen, sanitizeName } from './screens/title.js'; import { TitleScreen, sanitizeName } from './screens/title.js';
import { acceptAccountWelcome, restoreBoundEntry } from './skland/gate.js';
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js'; import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
import { RoomScreen } from './screens/room.js'; import { RoomScreen } from './screens/room.js';
import { GameScreen } from './screens/game.js'; import { GameScreen } from './screens/game.js';
@@ -145,6 +146,7 @@ function backToLobby() {
} }
function onWelcome(msg) { function onWelcome(msg) {
const accountAccepted = acceptAccountWelcome(msg);
identity.saveToken(msg.token); identity.saveToken(msg.token);
identity.rememberMatch(null); identity.rememberMatch(null);
const prev = store.get(); const prev = store.get();
@@ -156,8 +158,8 @@ function onWelcome(msg) {
if (prevId != null && prevId !== msg.playerId) { if (prevId != null && prevId !== msg.playerId) {
// A brand-new server session (the server restarted — crashed / killed, so no room.closed arrived — or this session // A brand-new server session (the server restarted — crashed / killed, so no room.closed arrived — or this session
// expired on it): whatever we showed before is gone — back to the lobby cleanly and say why. // expired on it): clear the old snapshot. A login-only transport stays at the title until checked account entry.
const notice = sessionResetNotice(prev, msg.playerId); const notice = accountAccepted ? sessionResetNotice(prev, msg.playerId) : null;
backToLobby(); backToLobby();
if (notice) toast(t(notice), 'warn', { ttl: 7000 }); if (notice) toast(t(notice), 'warn', { ttl: 7000 });
} else if (prev.room || prev.match.public) { } else if (prev.room || prev.match.public) {
@@ -303,7 +305,7 @@ function App() {
return html`<div class="app-root"> return html`<div class="app-root">
<div class="app-bg" aria-hidden="true"></div> <div class="app-bg" aria-hidden="true"></div>
${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`} ${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`}
<${ConnectionBanner} /> <${ConnectionBanner} continueSession=${() => restoreBoundEntry(sanitizeName)} />
<${ToastHost} /> <${ToastHost} />
<${UiHosts} /> <${UiHosts} />
<${GuideHost} /> <${GuideHost} />
@@ -360,7 +362,7 @@ async function boot() {
const entered = identity.wasEntered() && !!savedName; const entered = identity.wasEntered() && !!savedName;
store.set((s) => ({ store.set((s) => ({
me: { ...s.me, name: savedName }, me: { ...s.me, name: savedName },
session: { entered }, session: { entered: false },
ui: { ...s.ui, pendingJoin }, ui: { ...s.ui, pendingJoin },
})); }));
@@ -378,9 +380,10 @@ async function boot() {
// Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13). // Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13).
data.load('local').catch(() => {}); data.load('local').catch(() => {});
const connectWhenReady = identityReady.then(() => { const connectWhenReady = identityReady.then((token) => {
if (entered) net.setName(savedName); if (entered && token) return restoreBoundEntry(sanitizeName, { automatic: true });
else net.connect(); identity.setEntered(false);
net.connect();
}); });
// the language (and its UI translations) before the first render: no Chinese flash for an English player // the language (and its UI translations) before the first render: no Chinese flash for an English player
const langReady = initLang().catch((err) => console.warn('[app] language setup failed', err)); const langReady = initLang().catch((err) => console.warn('[app] language setup failed', err));
+12
View File
@@ -746,6 +746,7 @@ export function createIdentity(deps = {}) {
let initPromise = null; let initPromise = null;
let initialized = false; let initialized = false;
let resumeBusy = false; let resumeBusy = false;
let explicitClaim = false; // account entry POST supplied the token; do not persist until welcome
let current = null; // token this tab uses (null = let the server create a session) let current = null; // token this tab uses (null = let the server create a session)
let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
let owned = sget(session, K_TOKEN); let owned = sget(session, K_TOKEN);
@@ -811,6 +812,7 @@ export function createIdentity(deps = {}) {
// The original page can reappear after resume's query but before its hello. Keep our old // The original page can reappear after resume's query but before its hello. Keep our old
// session in storage and give back the tentative selection immediately in that interval. // session in storage and give back the tentative selection immediately in that interval.
if (current && tokenHash(current) === hash) { if (current && tokenHash(current) === hash) {
explicitClaim = false;
if (current === owned) { if (current === owned) {
owned = null; welcomeAt = null; welcomed = false; owned = null; welcomeAt = null; welcomed = false;
sdel(session, K_WELCOME); sdel(session, K_WELCOME);
@@ -911,6 +913,13 @@ export function createIdentity(deps = {}) {
} catch { return false; } } catch { return false; }
finally { resumeBusy = false; } finally { resumeBusy = false; }
}, },
/** Select an authenticated account entry token without treating an unacknowledged hello as a saved session. */
stageToken(token) {
if (!isToken(token)) return false;
current = token;
explicitClaim = true;
return true;
},
/** @returns {string} remembered player name ('' if none) */ /** @returns {string} remembered player name ('' if none) */
loadName: () => (sget(local, K_NAME) || '').slice(0, 64), loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
/** @param {string} name */ /** @param {string} name */
@@ -924,6 +933,7 @@ export function createIdentity(deps = {}) {
}, },
/** A shared candidate must never replace a connected session, including a hello already in flight. */ /** A shared candidate must never replace a connected session, including a hello already in flight. */
getTokenClaim() { getTokenClaim() {
if (explicitClaim) return { noReplace: false };
return current === owned && owned return current === owned && owned
? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) } ? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) }
: { noReplace: true }; : { noReplace: true };
@@ -935,6 +945,7 @@ export function createIdentity(deps = {}) {
/** @param {string} token from `welcome` */ /** @param {string} token from `welcome` */
saveToken(token) { saveToken(token) {
if (!isToken(token)) return; if (!isToken(token)) return;
explicitClaim = false;
if (current !== token) rememberMatch(null); if (current !== token) rememberMatch(null);
if (owned !== token || welcomeAt == null) welcomeAt = now(); if (owned !== token || welcomeAt == null) welcomeAt = now();
owned = token; owned = token;
@@ -946,6 +957,7 @@ export function createIdentity(deps = {}) {
}, },
/** Forget this tab's token (e.g. the server said the session is invalid). */ /** Forget this tab's token (e.g. the server said the session is invalid). */
clearToken() { clearToken() {
explicitClaim = false;
rememberMatch(null); rememberMatch(null);
const t = current || sget(session, K_TOKEN); const t = current || sget(session, K_TOKEN);
current = null; current = null;
-2
View File
@@ -11,7 +11,6 @@
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the // Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
// config.json mode texts come localized from data.js. // config.json mode texts come localized from data.js.
import { ResumeMatchButton } from '../ui/resumeMatch.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js'; import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js'; import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
@@ -321,7 +320,6 @@ export function LobbyScreen() {
<h1 class="topbar__title">${t('选择模拟协议')}</h1> <h1 class="topbar__title">${t('选择模拟协议')}</h1>
</div> </div>
<div class="topbar__right"> <div class="topbar__right">
<${ResumeMatchButton} />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} /> <${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
<button type="button" class="me-chip me-chip--btn stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}> <button type="button" class="me-chip me-chip--btn stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} /> <${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
+25 -18
View File
@@ -1,8 +1,8 @@
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language menu // Title screen: season-style backdrop, big title 卫戍协议:盟约, bound role callsign, 开始, the language menu
// (中文 | English | every pack in public/i18n/, ui/lang.js; a title in an alphabetic script — English — is the big one and // (中文 | English | every pack in public/i18n/, ui/lang.js; a title in an alphabetic script — English — is the big one and
// the small wordmark above it hides). // the small wordmark above it hides).
// //
// Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it, // Binding reads the role nickname; pressing 开始 validates it (1..NAME_MAX_LEN chars, no control characters), stores it,
// marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which // marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which
// sends `hello` (now, or as soon as the socket is open). The router then shows the lobby. // sends `hello` (now, or as soon as the socket is open). The router then shows the lobby.
// //
@@ -10,17 +10,18 @@
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is // entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404. // pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
import { ResumeMatchButton } from '../ui/resumeMatch.js'; import { AccountResumeMatchButton } from '../skland/resume.js';
import { AccountBindingEntry, BoundCallsign } from '../skland/entry.js';
import { enterBoundAccount, restoreBoundEntry } from '../skland/gate.js';
import { useMemo, useState } from '../../vendor/hooks.module.js'; import { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js'; import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js'; import { html, Button, Icon, MicroLabel, PingPill } from '../ui/components.js';
import { GuideButton } from '../ui/guide.js'; import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js'; import { openStats } from './stats.js';
import { toast } from '../ui/toasts.js';
import { net, identity } from '../net.js'; import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js'; import { store, useStore, shallowEqual } from '../store.js';
import { data, useData } from '../data.js'; import { data, useData } from '../data.js';
import { FullscreenButton, PwaInstallButton, detectFeatures } from '../ui/device.js'; import { FullscreenButton, PwaInstallButton } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js'; import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js'; import { t, N_ } from '../../../shared/i18n.js';
import { scriptOf } from '../../../shared/i18nPacks.js'; import { scriptOf } from '../../../shared/i18nPacks.js';
@@ -192,8 +193,10 @@ export function TitleScreen() {
const conn = useStore((s) => s.connection, shallowEqual); const conn = useStore((s) => s.connection, shallowEqual);
const pendingJoin = useStore((s) => s.ui.pendingJoin); const pendingJoin = useStore((s) => s.ui.pendingJoin);
useLang(); // re-render on a language switch useLang(); // re-render on a language switch
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || ''); const [name, setName] = useState('');
const [bindingOpen, setBindingOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [entering, setEntering] = useState(false);
const assetsSettled = useData('assets'); const assetsSettled = useData('assets');
const assets = data.get('assets'); const assets = data.get('assets');
const backdrop = findUiAsset(assets, BACKDROP_KEYS); const backdrop = findUiAsset(assets, BACKDROP_KEYS);
@@ -209,16 +212,17 @@ export function TitleScreen() {
const cssRidges = assetsSettled && (!ridges || ridgesFailed); const cssRidges = assetsSettled && (!ridges || ridgesFailed);
const valid = isValidName(name); const valid = isValidName(name);
const start = () => { const start = async () => {
if (!valid) { toast(t('请输入博士代号'), 'warn'); return; } if (entering) return;
enterSession(name); if (!valid) { setBindingOpen(true); return; }
setEntering(true);
if (!await restoreBoundEntry(sanitizeName)) { setName(''); setBindingOpen(true); }
setEntering(false);
}; };
const online = conn.status === 'online' || conn.status === 'connected'; const online = conn.status === 'online' || conn.status === 'connected';
const dotClass = online ? 'is-on' : conn.status === 'reconnecting' || conn.status === 'connecting' || conn.status === 'handshaking' ? 'is-warn' : 'is-bad'; const dotClass = online ? 'is-on' : conn.status === 'reconnecting' || conn.status === 'connecting' || conn.status === 'handshaking' ? 'is-warn' : 'is-bad';
// touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view)
const touchUi = useMemo(() => detectFeatures().coarse, []);
// a title in an alphabetic script (English, French …) is the big one in the display face and the wordmark above it // a title in an alphabetic script (English, French …) is the big one in the display face and the wordmark above it
// hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag) // hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag)
const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic'; const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic';
@@ -267,12 +271,10 @@ export function TitleScreen() {
<div class="title-login"> <div class="title-login">
${pendingJoin ? html`<div class="title-invite"> ${pendingJoin ? html`<div class="title-invite">
<${Icon} name="key" /> <${Icon} name="key" />
<span>${t('收到同盟邀请')}</span><b class="num">${pendingJoin}</b><span class="t-lo">${t('· 输入代号后将自动加入')}</span> <span>${t('收到同盟邀请')}</span><b class="num">${pendingJoin}</b><span class="t-lo">${t('· 绑定账号并开始后将自动加入')}</span>
</div>` : null} </div>` : null}
<${TextField} label=${t('博士代号')} micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN} <${BoundCallsign} name=${name} />
placeholder=${t('输入你的代号(最多 {NAME_MAX_LEN} 字)', { NAME_MAX_LEN })} autoFocus=${!touchUi} <${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" data-testid="binding-start" disabled=${entering} onClick=${start}>${valid ? t('开始') : t('绑定账号')}<//>
onInput=${setName} onEnter=${start} />
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>${t('开始')}<//>
<div class="title-conn"> <div class="title-conn">
<span class=${`status-dot ${dotClass}`}></span> <span class=${`status-dot ${dotClass}`}></span>
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span> <span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
@@ -283,12 +285,17 @@ export function TitleScreen() {
<${FullscreenButton} class="title-fs" /> <${FullscreenButton} class="title-fs" />
</div> </div>
</div> </div>
<${AccountBindingEntry} name=${name} open=${bindingOpen} onOpen=${() => setBindingOpen(true)} onClose=${() => setBindingOpen(false)}
onNickname=${(nickname) => {
const value = sanitizeName(nickname); setName(value); identity.saveName(value);
if (!value) identity.setEntered(false);
}} />
</main> </main>
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} /> <${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
<footer class="title-foot"> <footer class="title-foot">
<${ResumeMatchButton} /> <${AccountResumeMatchButton} bound=${valid} onResume=${(id) => enterBoundAccount(sanitizeName, { recoveryId: id })} />
<${PwaInstallButton} /> <${PwaInstallButton} />
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span> <span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<span class="title-foot__meta"> <span class="title-foot__meta">
+70
View File
@@ -0,0 +1,70 @@
// Independent title adapter: the game reconnect token stays in a header, never a URL, QR code or provider request.
export class BindingError extends Error {
constructor(code, message = code) { super(message); this.code = code; }
}
export function createBindingApi({ token, fetchImpl = globalThis.fetch, signal } = {}) {
return async (path, body) => {
const credential = typeof token === 'function' ? token() : token;
if (!credential) throw new BindingError('session');
let response;
try {
response = await fetchImpl(`/skland/session/${path}`, {
method: body === undefined ? 'GET' : 'POST', cache: 'no-store', signal,
credentials: 'same-origin',
headers: { authorization: `Bearer ${credential}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
} catch (error) {
if (signal?.aborted) throw error;
throw new BindingError('network');
}
if (response.status === 404) throw new BindingError('off');
if (response.status === 401) throw new BindingError('session');
let result;
try { result = await response.json(); } catch { throw new BindingError('response'); }
if (!response.ok) throw new BindingError(result.code === 'resume_expired' ? 'resume_expired' : 'upstream', result.error);
return result;
};
}
/** Establish hello without pressing Start or changing the router's entered flag. */
export async function ensureBindingSession({ net, identity, name, timeoutMs = 10_000 }) {
await identity.init();
if (net.status === 'online' && identity.getToken()) return identity.getToken();
return new Promise((resolve, reject) => {
const clean = () => { clearTimeout(timer); offWelcome(); offError(); };
const offWelcome = net.on('welcome', (message) => { clean(); resolve(message.token); });
const offError = net.on('helloError', () => { clean(); reject(new BindingError('session')); });
const timer = setTimeout(() => { clean(); reject(new BindingError('network')); }, timeoutMs);
net.setName(name);
net.connect();
});
}
export { roleKey } from '../../../shared/accountIdentity.js';
import { roleKey, hasRoleIdentity } from '../../../shared/accountIdentity.js';
export function preferredRole(roles, selected) {
const named = roles?.filter((role) => hasRoleIdentity(role) && typeof role?.nickName === 'string' && role.nickName.trim()) || [];
return named.find((role) => roleKey(role) === selected) || named.find((role) => role.isDefault) || named[0] || null;
}
let qrLoader;
export async function qrDataUrl(value) {
if (typeof globalThis.qrcode !== 'function') {
qrLoader ||= new Promise((resolve, reject) => {
const script = document.createElement('script');
const fail = () => { clearTimeout(timer); script.remove(); qrLoader = null; reject(new BindingError('qr')); };
const timer = setTimeout(fail, 10_000);
script.src = '/vendor/qrcode.js';
script.onload = () => { clearTimeout(timer); if (typeof globalThis.qrcode === 'function') resolve(); else fail(); };
script.onerror = fail;
document.head.append(script);
});
await qrLoader;
}
const qr = globalThis.qrcode(0, 'M');
qr.addData(value); qr.make();
return qr.createDataURL(6, 4);
}
+69
View File
@@ -0,0 +1,69 @@
// Interactive Geetest hand-off, following the official HG account SDK. The user completes the provider's widget;
// only its proof is returned to the original login request. No phone, password or account token is sent here.
const scripts = new Map();
const PROVIDERS = {
v4: { src: 'https://static.geetest.com/v4/gt4.js', init: 'initGeetest4' },
v3: { src: 'https://static.geetest.com/static/tools/gt.js', init: 'initGeetest' },
};
async function loadProvider(version) {
const provider = PROVIDERS[version];
if (typeof globalThis[provider.init] === 'function') return;
if (!scripts.has(version)) {
const pending = new Promise((resolve, reject) => {
const script = document.createElement('script');
const fail = () => { clearTimeout(timer); script.remove(); reject(new Error('captcha_load')); };
const timer = setTimeout(fail, 20_000);
script.src = provider.src;
script.async = true;
script.onerror = fail;
script.onload = () => {
clearTimeout(timer);
if (typeof globalThis[provider.init] === 'function') resolve();
else fail();
};
document.head.append(script);
});
scripts.set(version, pending);
pending.catch(() => scripts.delete(version));
}
await scripts.get(version);
}
/**
* Called only by the user's captcha button. `scope` and `load` allow isolated widget contract tests without a
* network call or a real challenge. Resolves null on user cancellation; never completes a real puzzle itself.
*/
export async function solveCaptcha(challenge, { scope = globalThis, load = loadProvider } = {}) {
const version = challenge.geetestId ? 'v4' : challenge.gt && challenge.challenge ? 'v3' : null;
if (!version) throw new Error('captcha_type');
await load(version);
return new Promise((resolve, reject) => {
let settled = false;
const init = scope[PROVIDERS[version].init];
const options = version === 'v4'
? { captchaId: challenge.geetestId, riskType: challenge.riskType, product: 'bind', language: 'zho' }
: { gt: challenge.gt, challenge: challenge.challenge, new_captcha: challenge.new_captcha,
offline: false, product: 'bind', https: true, lang: 'zh-cn' };
init(options, (widget) => {
const finish = (proof, error) => {
if (settled) return;
settled = true;
widget.destroy?.();
if (error) reject(error); else resolve(proof);
};
widget.onSuccess(() => {
const proof = widget.getValidate();
if (!proof) { finish(null, new Error('captcha_validate')); return; }
finish(version === 'v4' ? { ...proof, challenge: challenge.challenge } : proof);
});
widget.onError((error) => {
if (error?.error_code !== 'error_01') finish(null, new Error('captcha_validate'));
});
widget.onClose(() => finish(null));
widget.onReady(() => {
if (version === 'v4') widget.showCaptcha(); else widget.verify();
});
});
});
}
+276
View File
@@ -0,0 +1,276 @@
// Login UI, callsign display and HTTP integration stay inside the account module.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal, Icon } from '../ui/components.js';
import { net, identity } from '../net.js';
import { useStore } from '../store.js';
import { useLang } from '../ui/lang.js';
import { t } from '../../../shared/i18n.js';
import { createBindingApi, ensureBindingSession, preferredRole, roleKey, qrDataUrl } from './api.js';
import { solveCaptcha } from './captcha.js';
import { selectedBoundRole, syncAccountSession, resetAccountSession } from './gate.js';
import { createQrLogin } from './scan.js';
import { hasRoleIdentity } from '../../../shared/accountIdentity.js';
// Brand images served by the official Arknights account page; no extracted game art is stored in this repository.
const LOGOS = {
hg: 'https://web.hycdn.cn/arknights/user/assets/imgs/hypergryph.d69547.png',
bili: 'https://web.hycdn.cn/arknights/user/assets/imgs/bilibili.11810a.png',
};
function Brand({ kind }) {
return html`<img class="sp-binding__brand" src=${LOGOS[kind]} alt="" referrerpolicy="no-referrer" onError=${(event) => { event.currentTarget.hidden = true; }} />`;
}
function errorText(error) {
if (error.code === 'off') return t('此服务器未启用账号绑定,请联系服主开启后再进入。');
if (error.code === 'session') return t('游戏会话已失效,请关闭后重试。');
if (error.code === 'network') return t('连接失败,请重试。');
if (error.code === 'qr') return t('二维码加载失败,请重试。');
if (error.code === 'response') return t('服务器响应异常,请重试。');
if (error.message === 'scan_response') return t('服务器响应异常,请重试。');
if (error.message === 'scan_expired') return t('二维码已失效,请重新获取。');
if (error.message === 'captcha_load') return t('验证组件加载失败,请重试。');
if (error.message === 'captcha_type') return t('暂不支持此验证类型,请使用验证码或扫码登录。');
if (error.message === 'captcha_validate') return t('验证未完成,请重试。');
return error.message;
}
export function BoundCallsign({ name }) {
if (!name) return null;
return html`<dl class="sp-binding-callsign">
<dt class="sp-binding-callsign__label">${t('博士代号')}<span class="micro">CALLSIGN</span></dt>
<dd class="sp-binding-callsign__name"><${Icon} name="user" class="sp-binding-callsign__icon" /><span data-testid="bound-callsign">${name}</span></dd>
</dl>`;
}
export function AccountBindingEntry({ name, open, onOpen, onClose, onNickname }) {
useLang();
const playerId = useStore((state) => state.me.playerId);
const checkedPlayer = useRef(playerId);
const nicknameRef = useRef(onNickname);
nicknameRef.current = onNickname;
useEffect(() => {
if (document.querySelector('link[data-sp-binding]')) return;
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/css/skland/binding.css'; link.dataset.spBinding = '';
document.head.append(link);
}, []);
// Restore from the authenticated status, never from the locally remembered callsign.
useEffect(() => {
if (checkedPlayer.current !== playerId) {
checkedPlayer.current = playerId;
if (!open) nicknameRef.current('');
}
if (open) return undefined;
let alive = true;
const abort = new AbortController();
(async () => {
try {
const token = await ensureBindingSession({ net, identity, name: t('博士') });
const state = await createBindingApi({ token, signal: abort.signal })('status');
if (alive) {
await syncAccountSession(state);
if (alive) nicknameRef.current(selectedBoundRole(state)?.nickName || '');
}
} catch { if (alive) nicknameRef.current(''); }
})();
return () => { alive = false; abort.abort(); };
}, [open, playerId]);
const choose = (role) => {
onNickname(role?.nickName || '');
};
return html`<div class="sp-binding-entry">
${name ? html`<${Button} variant="ghost" size="sm" icon="user" data-testid="account-binding-entry" onClick=${onOpen}>
${t('已绑定:{name}', { name })}<//>` : null}
${open ? html`<${BindingDialog} name=${name} onChoose=${choose} onUnbind=${() => choose(null)} onClose=${onClose} />` : null}
</div>`;
}
function BindingDialog({ name, onChoose, onUnbind, onClose }) {
const [api, setApi] = useState(null);
const [tab, setTab] = useState('scan');
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [roles, setRoles] = useState([]);
const [selected, setSelected] = useState('');
const [bound, setBound] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [qr, setQr] = useState(null);
const [scanBusy, setScanBusy] = useState(false);
const [scanAwaiting, setScanAwaiting] = useState(false);
const [challenge, setChallenge] = useState(null);
const [verifying, setVerifying] = useState(false);
const [cooldown, setCooldown] = useState(0);
const alive = useRef(true);
const scan = useRef(null);
const scanTab = useRef(tab);
scanTab.current = tab;
const requestLock = useRef(false);
const selectionKey = useRef('');
const selectedRef = useRef('');
const chooseRef = useRef(onChoose);
chooseRef.current = onChoose;
const showSelection = (role) => {
const key = roleKey(role);
selectedRef.current = key; setSelected(key); chooseRef.current(role);
try { if (selectionKey.current) sessionStorage.setItem(selectionKey.current, key); } catch { /* optional preference */ }
};
const accept = async (state) => {
await syncAccountSession(state);
if (!alive.current) return;
const list = state.roles || [];
setBound(!!state.bound); setRoles(list);
const role = state.bound ? preferredRole(list, state.selectedRoleKey || selectedRef.current) : null;
if (role) showSelection(role);
else { selectedRef.current = ''; setSelected(''); chooseRef.current(null); }
};
useEffect(() => {
const abort = new AbortController();
alive.current = true;
(async () => {
try {
const token = await ensureBindingSession({ net, identity, name: name || t('博士') });
if (!alive.current) return;
const request = createBindingApi({ token: () => identity.getToken() || token, signal: abort.signal });
const state = await request('status');
selectionKey.current = `sp.binding.role.${state.playerId}`;
try { selectedRef.current = sessionStorage.getItem(selectionKey.current) || ''; } catch { /* optional preference */ }
if (alive.current) { setApi(() => request); await accept(state); }
} catch (failure) { if (alive.current) setError(errorText(failure)); }
finally { if (alive.current) setBusy(false); }
})();
return () => { alive.current = false; abort.abort(); };
}, []);
useEffect(() => {
if (!cooldown) return undefined;
const timer = setTimeout(() => setCooldown((value) => Math.max(0, value - 1)), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
const run = async (work) => {
if (!api || requestLock.current) return;
requestLock.current = true; setBusy(true); setError(''); setNotice('');
try { await work(); } catch (failure) { if (alive.current) setError(errorText(failure)); }
finally { requestLock.current = false; if (alive.current) setBusy(false); }
};
const finish = async () => {
const state = await api('status');
if (!alive.current) return;
await accept(state); setPassword(''); setCode(''); setChallenge(null);
if (!state.roles?.length) setNotice(t('登录成功,未找到明日方舟角色。'));
};
const finishRef = useRef(finish);
finishRef.current = finish;
const login = async (path, body) => {
const result = await api(path, body);
if (!alive.current) return;
if (result.needCaptcha) { setChallenge({ result, path, body }); return; }
if (!result.ok) throw new Error(result.error || t('登录失败,请重试。'));
setChallenge(null);
if (path === 'phone/send') { setCooldown(60); setNotice(t('验证码已发送,请查看短信。')); }
else await finish();
};
const verify = () => run(async () => {
setVerifying(true);
try {
const proof = await solveCaptcha(challenge.result.captcha);
if (!alive.current) return;
if (!proof) { setNotice(t('验证已取消,请点击按钮重试。')); return; }
await login(challenge.path, { ...challenge.body, captcha: proof, captchaStep: challenge.result.captchaStep });
} finally { if (alive.current) setVerifying(false); }
});
useEffect(() => {
if (!api || bound) return undefined;
const controller = createQrLogin({ request: api, encode: qrDataUrl, onQr: setQr,
onPending: setScanAwaiting, onBusy: setScanBusy,
onError: (failure) => setError(failure ? errorText(failure) : ''),
onConfirmed: () => finishRef.current(),
});
scan.current = controller;
const visibility = () => controller.setActive(scanTab.current === 'scan' && !document.hidden);
visibility();
document.addEventListener('visibilitychange', visibility);
return () => { controller.stop(); scan.current = null; document.removeEventListener('visibilitychange', visibility); };
}, [api, bound]);
useEffect(() => { scan.current?.setActive(tab === 'scan' && !document.hidden); }, [tab, api, bound]);
const switchTab = (value) => {
setTab(value); setChallenge(null); setPassword(''); setError(''); setNotice('');
};
const field = (label, value, change, type, autocomplete) => html`<label class="field sp-binding__field">
<span class="field__label">${label}</span><span class="field__box brackets">
<input class="field__input" type=${type} value=${value} autocomplete=${autocomplete} required disabled=${busy}
inputmode=${type === 'tel' ? 'tel' : autocomplete === 'one-time-code' ? 'numeric' : undefined}
onInput=${(event) => { change(event.currentTarget.value); setChallenge(null); }} />
</span></label>`;
return html`
${verifying ? html`<div class="sp-binding__verifying" role="status">${t('请在验证窗口中完成人机验证。')}</div>` : null}
<${Modal} open=${!verifying} title=${t('绑定明日方舟账号')} micro="ARKNIGHTS ACCOUNT" width="6.8rem" class="sp-binding" onClose=${onClose}
actions=${html`<${Button} variant="ghost" onClick=${onClose}>${t('完成')}<//>`}>
<p class="sp-binding__intro">${t('绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。')}</p>
${error ? html`<p class="sp-binding__error" role="alert">${error}</p>` : null}
${notice ? html`<p class="sp-binding__notice" role="status">${notice}</p>` : null}
${bound ? html`<div class="sp-binding__roles">
<p>${t('选择明日方舟角色')}</p>
${roles.map((role) => html`<${Button} key=${roleKey(role)} block=${true} active=${selected === roleKey(role)}
class="sp-binding__role" disabled=${busy || !hasRoleIdentity(role) || !role.nickName?.trim()} onClick=${() => run(async () => {
const state = await api('role/select', { roleKey: roleKey(role) });
if (!state.ok) throw new Error(state.error);
if (alive.current) await accept(state);
})}>
<${Brand} kind=${/bilibili|b服|b站/i.test(role.channelName || '') ? 'bili' : 'hg'} />
<span><b>${role.nickName || role.uid}</b><small>${role.channelName || ''} · UID ${role.uid}</small></span><//>`)}
<div class="sp-binding__actions">
<${Button} size="sm" disabled=${busy} onClick=${() => run(async () => {
const result = await api('roles?refresh=1');
if (!result.ok) throw new Error(result.error);
await finish();
})}>${t('刷新角色')}<//>
<${Button} size="sm" variant="ghost" disabled=${busy} onClick=${() => run(async () => {
const result = await api('unbind', {});
if (!result.ok) throw new Error(result.error);
await resetAccountSession();
if (alive.current) {
setBound(false); setRoles([]); setSelected(''); selectedRef.current = '';
try { sessionStorage.removeItem(selectionKey.current); } catch { /* optional preference */ }
onUnbind();
}
})}>${t('解绑账号')}<//>
</div>
</div>` : html`
<div class="sp-binding__tabs" role="tablist" aria-label=${t('登录方式')}>
<${Button} size="sm" role="tab" aria-selected=${tab === 'scan'} active=${tab === 'scan'} disabled=${busy || scanBusy} onClick=${() => switchTab('scan')}>${t('森空岛扫码')}<//>
<${Button} size="sm" role="tab" aria-selected=${tab === 'sms'} active=${tab === 'sms'} disabled=${busy || scanBusy} onClick=${() => switchTab('sms')}>${t('验证码')}<//>
<${Button} size="sm" role="tab" aria-selected=${tab === 'password'} active=${tab === 'password'} disabled=${busy || scanBusy} onClick=${() => switchTab('password')}>${t('密码')}<//>
</div>
<div class="sp-binding__channels"><span><${Brand} kind="hg" />${t('官服')}</span><span><${Brand} kind="bili" />${t('B服:扫码后按提示完成授权')}</span></div>
${busy && !api ? html`<p role="status">${t('正在连接服务器')}</p>` : null}
${tab === 'scan' ? html`<div class="sp-binding__scan">
${qr ? html`<img class="sp-binding__qr" src=${qr} alt=${t('使用森空岛 App 扫描二维码')} />` : null}
<p>${t('使用森空岛 App 扫码并确认,支持官服和 B 服。')}</p>
<p class="t-lo" role="status">${scanAwaiting ? t('已扫码,等待确认;二维码将在 1 分钟后自动刷新。') : t('二维码每 10 秒自动刷新,也可手动刷新。')}</p>
<${Button} disabled=${!api || busy || scanBusy} loading=${busy || scanBusy} onClick=${() => scan.current?.refresh()}>${t('重新获取二维码')}<//>
</div>` : html`<form class="sp-binding__form" onSubmit=${(event) => {
event.preventDefault();
run(() => login(tab === 'sms' ? 'phone/login' : 'password/login', tab === 'sms' ? { phone, code } : { phone, password }));
}}>
<p class="t-lo">${t('使用鹰角账号登录官服。B服请选择森空岛扫码。')}</p>
${field(t('手机号'), phone, setPhone, 'tel', 'username')}
${tab === 'sms' ? html`<div class="sp-binding__code">
${field(t('验证码'), code, setCode, 'text', 'one-time-code')}
<${Button} size="sm" disabled=${!api || busy || !phone.trim() || cooldown > 0} onClick=${() => run(() => login('phone/send', { phone }))}>
${cooldown ? t('{n} 秒后重发', { n: cooldown }) : t('发送验证码')}<//>
</div>` : field(t('密码'), password, setPassword, 'password', 'current-password')}
<${Button} type="submit" block=${true} variant="primary" loading=${busy} disabled=${!api || busy || !phone.trim() || !(tab === 'sms' ? code.trim() : password)}>${t('登录并绑定')}<//>
</form>`}
${challenge ? html`<div class="sp-binding__captcha"><p>${t('需要人机验证,请点击下方按钮完成验证。')}</p>
<${Button} loading=${busy} onClick=${verify}>${t('开始人机验证')}<//></div>` : null}
`}
<//>`;
}
+78
View File
@@ -0,0 +1,78 @@
// Fork-local entry policy: saved callsigns/entered flags are hints, never proof of a role binding.
import { net, identity } from '../net.js';
import { store } from '../store.js';
import { BindingError, createBindingApi, ensureBindingSession, preferredRole } from './api.js';
export function selectedBoundRole(state) {
if (!state?.bound) return null;
let selected = '';
try { selected = sessionStorage.getItem('sp.binding.role.' + state.playerId) || ''; } catch { /* optional preference */ }
return preferredRole(state.roles, state.selectedRoleKey || selected);
}
/** Losing the authenticated game transport must never leave an anonymous socket inside the game shell. */
export function acceptAccountWelcome(message) {
if (typeof message.playerId === 'string' && message.playerId.startsWith('a_')) return true;
identity.setEntered(false);
store.patch('session', { entered: false });
return false;
}
/** A title lookup restores the login only. Changing roles releases an old, roomless game transport. */
export async function syncAccountSession(state) {
const current = store.get().me.playerId;
if (current?.startsWith('a_') && current !== state?.gamePlayerId) await resetAccountSession();
}
export async function resetAccountSession() {
identity.clearToken(); identity.setEntered(false);
store.patch('session', { entered: false });
net.reconnectNow('Doctor');
await ensureBindingSession({ net, identity, name: 'Doctor' });
}
export async function accountRecovery({ signal } = {}) {
await ensureBindingSession({ net, identity, name: identity.loadName() || 'Doctor' });
const state = await createBindingApi({ token: () => identity.getToken(), signal })('recovery');
if (!state.ok) throw new BindingError('response');
return state.recovery;
}
/** Explicit entry/recovery: ask for the selected role's token, then verify the WebSocket actually attached it. */
export async function enterBoundAccount(normalizeName, { recoveryId, automatic = false } = {}) {
identity.setEntered(false);
store.patch('session', { entered: false });
await ensureBindingSession({ net, identity, name: identity.loadName() || 'Doctor' });
const api = createBindingApi({ token: () => identity.getToken() });
const state = await api('status');
const name = normalizeName(selectedBoundRole(state)?.nickName || '');
if (!name || !state.gamePlayerId) throw new BindingError('session');
if (automatic) {
// A copied/stale local token must not silently take the role back from the device that now owns it.
if (net.playerId !== state.gamePlayerId) return false;
} else {
const entry = await api(recoveryId ? 'resume' : 'enter', recoveryId ? { id: recoveryId } : {});
if (!entry.ok || entry.gamePlayerId !== state.gamePlayerId || typeof entry.token !== 'string') throw new BindingError('response');
net.close();
if (!identity.stageToken(entry.token)) throw new BindingError('response');
try {
net.reconnectNow(name);
await ensureBindingSession({ net, identity, name });
if (net.playerId !== state.gamePlayerId) throw new BindingError('session');
} catch (error) {
net.close(); identity.clearToken();
throw error;
}
}
identity.saveName(name);
store.patch('me', { name });
net.setName(name);
identity.setEntered(true);
store.patch('session', { entered: true });
return true;
}
/** Boot/Start keep the title visible when the server cannot verify the selected role. */
export async function restoreBoundEntry(normalizeName, options) {
try { return await enterBoundAccount(normalizeName, options); } catch { return false; }
}
+67
View File
@@ -0,0 +1,67 @@
// Cross-device recovery uses authenticated server state, not the browser's recent-token list.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal } from '../ui/components.js';
import { useLang } from '../ui/lang.js';
import { t } from '../../../shared/i18n.js';
import { accountRecovery } from './gate.js';
function recoveryError(error) {
if (error.code === 'resume_expired') return t('此对局已结束或过期,请刷新后重试。');
if (error.code === 'session') return t('游戏会话已失效,请关闭后重试。');
return t('连接失败,请重试。');
}
export function AccountResumeMatchButton({ bound, onResume }) {
useLang();
const [open, setOpen] = useState(false);
const [item, setItem] = useState(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [revision, setRevision] = useState(0);
const lock = useRef(false);
const alive = useRef(true);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
if (!bound) { setOpen(false); setItem(null); return undefined; }
if (!open) return undefined;
const abort = new AbortController();
setLoading(true); setItem(null); setError('');
accountRecovery({ signal: abort.signal }).then((value) => {
if (!abort.signal.aborted) setItem(value);
}).catch((failure) => {
if (!abort.signal.aborted) setError(recoveryError(failure));
}).finally(() => { if (!abort.signal.aborted) setLoading(false); });
return () => abort.abort();
}, [open, bound, revision]);
if (!bound) return null;
const resume = async () => {
if (!item || lock.current) return;
lock.current = true; setBusy(true); setError('');
try {
await onResume(item.id);
if (alive.current) setOpen(false);
} catch (failure) {
if (alive.current) {
setError(recoveryError(failure));
if (failure.code === 'resume_expired') setItem(null);
}
} finally {
lock.current = false;
if (alive.current) setBusy(false);
}
};
return html`<${Button} variant="secondary" size="sm" data-testid="resume-account-match" onClick=${() => setOpen(true)}>${t('恢复账号对局')}<//>
<${Modal} open=${open} onClose=${() => !busy && setOpen(false)} title=${t('恢复账号对局')} class="sp-account-resume"
actions=${html`<${Button} variant="secondary" disabled=${busy || loading} onClick=${() => setRevision((value) => value + 1)}>${t('刷新')}<//>
<${Button} variant="ghost" disabled=${busy} onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
<p>${t('登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。')}</p>
${loading ? html`<p role="status">${t('正在查询账号对局…')}</p>` : null}
${error ? html`<p role="alert">${error}</p>` : null}
${!loading && !item && !error ? html`<p role="status">${t('当前角色没有可恢复的对局。')}</p>` : null}
${item ? html`<p>${item.inMatch ? t('对局进行中') : t('同盟等候中')}${item.inMatch && item.round > 0 ? ' · ' + t('第 {r} 回合', { r: item.round }) : ''}</p>
<p class="t-lo">${t('如果此角色仍在其他设备在线,恢复后将断开旧连接。')}</p>
<${Button} variant="primary" block=${true} disabled=${busy || loading} data-testid="confirm-account-resume" onClick=${resume}>
${t('{name} · 同盟 {code}', { name: item.name || t('博士'), code: item.code })}<//>` : null}
<//>`;
}
+70
View File
@@ -0,0 +1,70 @@
// Serial QR requests: a refresh cannot replace a scan while its confirmation poll is still in flight.
export const SCAN_POLL_MS = 2_000;
export const SCAN_REFRESH_MS = 10_000;
export const SCAN_CONFIRM_REFRESH_MS = 60_000;
export function createQrLogin({ request, encode, onQr, onPending, onError, onConfirmed, onBusy,
now = Date.now, timers = globalThis, isHidden = () => false }) {
let stopped = false, completed = false, confirmed = false, active = false, busy = false, timer;
let ready = false, awaiting = false, manual = true;
let refreshAt = Infinity, pollAt = Infinity;
const clear = () => { timers.clearTimeout(timer); timer = undefined; };
const schedule = () => {
clear();
if (stopped || completed || !active || busy) return;
const due = manual ? now() : ready ? Math.min(refreshAt, pollAt) : refreshAt;
timer = timers.setTimeout(tick, isHidden() ? SCAN_POLL_MS : Math.max(0, due - now()));
};
const generate = async () => {
manual = false; ready = false; awaiting = false;
// Keep the displayed image until its replacement is encoded; refreshing only changes the existing img's src.
onBusy(true); onPending(false); onError(null);
try {
const result = await request('scan/start', {});
if (stopped) return;
if (!result.ok || !result.scanUrl) throw new Error(result.error || 'scan_response');
const image = await encode(result.scanUrl);
if (stopped) return;
ready = true; refreshAt = now() + SCAN_REFRESH_MS; pollAt = now() + SCAN_POLL_MS;
onQr(image);
} finally { if (!stopped) onBusy(false); }
};
async function tick() {
clear();
if (stopped || completed || !active || busy) return;
if (isHidden()) { schedule(); return; }
busy = true;
try {
if (confirmed) { manual = false; await onConfirmed(); completed = true; }
else if (manual || !ready) await generate();
else {
// Poll once before an automatic refresh too: a just-scanned QR gets its full confirmation window.
const result = await request('scan/poll', {});
if (stopped) return;
if (!result.ok || result.error) throw new Error(result.error || 'scan_response');
if (result.state === 'confirmed') {
confirmed = true; onQr(null);
await onConfirmed();
completed = true;
return;
}
if (result.state !== 'waiting') throw new Error('scan_expired');
if (result.awaitingConfirmation && !awaiting) {
awaiting = true; refreshAt = now() + SCAN_CONFIRM_REFRESH_MS; onPending(true);
}
pollAt = now() + SCAN_POLL_MS;
if (active && !isHidden() && (manual || now() >= refreshAt)) await generate();
}
} catch (error) {
if (!stopped) {
ready = false; refreshAt = now() + SCAN_REFRESH_MS;
awaiting = false; onPending(false); onQr(null); onError(error);
}
} finally { busy = false; schedule(); }
}
return {
setActive(value) { active = value; schedule(); },
refresh() { manual = true; schedule(); },
stop() { stopped = true; clear(); },
};
}
+2 -2
View File
@@ -19,7 +19,7 @@ export function bannerVisible(conn, entered, restoring, buildStale = false) {
return true; return true;
} }
export function ConnectionBanner() { export function ConnectionBanner({ continueSession } = {}) {
const conn = useStore((s) => s.connection, shallowEqual); const conn = useStore((s) => s.connection, shallowEqual);
const entered = useStore((s) => s.session.entered); const entered = useStore((s) => s.session.entered);
const restoring = useStore((s) => s.ui.restoring); const restoring = useStore((s) => s.ui.restoring);
@@ -53,7 +53,7 @@ export function ConnectionBanner() {
: conn.status === 'closed' ? t('连接已关闭') : conn.status === 'closed' ? t('连接已关闭')
: rejected ? t(conn.lastError.text) : t('正在连接服务器'); : rejected ? t(conn.lastError.text) : t('正在连接服务器');
const action = conn.status === 'reconnecting' ? { label: t('立即重连'), run: () => net.retryNow() } const action = conn.status === 'reconnecting' ? { label: t('立即重连'), run: () => net.retryNow() }
: conn.status === 'closed' ? { label: replaced ? t('在此页面继续') : t('重新连接'), run: () => net.connect() } : conn.status === 'closed' ? { label: replaced ? t('在此页面继续') : t('重新连接'), run: continueSession || (() => net.connect()) }
: versionMismatch ? { label: t('刷新页面'), run: () => location.reload() } : versionMismatch ? { label: t('刷新页面'), run: () => location.reload() }
: rejected ? { label: t('重试'), run: () => net.reconnectNow() } : null; : rejected ? { label: t('重试'), run: () => net.reconnectNow() } : null;
return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert"> return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert">
+5 -3
View File
@@ -6,7 +6,7 @@ import { store } from '../store.js';
import { t } from '../../../shared/i18n.js'; import { t } from '../../../shared/i18n.js';
import { toast } from './toasts.js'; import { toast } from './toasts.js';
export function ResumeMatchButton() { export function ResumeMatchButton({ beforeEnter } = {}) {
const [items, setItems] = useState(() => identity.recoverable()); const [items, setItems] = useState(() => identity.recoverable());
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -22,13 +22,15 @@ export function ResumeMatchButton() {
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
if (await identity.resume(item.id)) { if (await identity.resume(item.id)) {
identity.setEntered(true);
store.patch('session', { entered: true });
// Keep the tentative token in this document until welcome persists it. Reloading here // Keep the tentative token in this document until welcome persists it. Reloading here
// would either lose the selection or falsely turn it into an established session claim. // would either lose the selection or falsely turn it into an established session claim.
net.close(); net.close();
net.setName(item.name); net.setName(item.name);
net.connect(); net.connect();
if (!beforeEnter || await beforeEnter()) {
identity.setEntered(true);
store.patch('session', { entered: true });
}
setOpen(false); setOpen(false);
setBusy(false); setBusy(false);
} else { } else {
+539
View File
@@ -0,0 +1,539 @@
<!doctype html>
<!--
public/skland.html — 森空岛(Skland)账号绑定的独立诊断 / 引导面板(server/skland/ 的浏览器端)。
这不是游戏 Preact UI 的一部分(public/js/screens/ 里的界面不动):一个单文件、无构建、无框架的页面,
三步走完绑定的三条路径 —— 扫码(QR)/ 验证码(SMS)/ 密码 —— 并随时显示绑定状态与角色列表。
身份:?player=<id> > localStorage('sp.skland.player') > 新生成的占位 id。这个 id 目前只是占位,
真正的游戏会话接入后应由会话自己的 player id 提供(server/skland/service.js 的头注释、docs/skland.md 安全模型)。
端点(全部 JSON;错误是 {ok:false,error} 或双语错误页;详见 server/skland/http.js):
GET /skland/status?player=… 绑定状态
POST /skland/scan/start {player} 开始扫码 → {scanId, scanUrl}(二维码编码 scanUrl 深链,不是 scanId)
POST /skland/scan/poll {player} 每 ~2s 轮询一次,confirmed 的那次调用同时完成登录
POST /skland/phone/send {player,phone,captcha?} 发短信验证码;可能要 geetest 人机验证
POST /skland/phone/login {player,phone,code} 验证码登录
POST /skland/password/login {player,phone,password} 密码登录;仅上游要求时才需要额外设备验证(needDevice)
POST /skland/unbind {player} 解绑
GET /skland/roles?player=…&refresh=1 重新拉取角色
模块默认关闭(SP_SKLAND=1 才挂载):此时 /skland/* 由占位处理器统一答 JSON 404。页面据此显示"该功能未启用",
而不是看起来坏掉。
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>森空岛绑定 · Skland · 卫戍协议:盟约</title>
<!-- 二维码库:qrcode-generator(MIT),由 tools/vendor.mjs 在安装期复制到 public/vendor/。
源码检出 / 未跑 npm install 时它可能不存在——脚本缺失不报错,renderQr() 回退为可选中的深链文本。 -->
<script src="/vendor/qrcode.js"></script>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #111614; color: #d8e3de; font: 15px/1.6 "Noto Sans SC", system-ui, sans-serif; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px 64px; }
h1 { font-size: 22px; margin: 0 0 4px; color: #4ed8af; letter-spacing: .04em; }
h2 { font-size: 15px; margin: 0 0 10px; color: #4ed8af; letter-spacing: .08em; }
.sub { opacity: .7; margin: 0 0 16px; font-size: 13px; }
.card { border: 1px solid #2c3a35; background: #0c0f0e; padding: 16px; margin: 0 0 16px; }
.tabs { display: flex; gap: 8px; margin: 0 0 12px; }
.tabs button { flex: 1; padding: 8px 10px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: inherit; cursor: pointer; }
.tabs button[aria-selected="true"] { border-color: #4ed8af; color: #4ed8af; }
.panel { border: 1px solid #2c3a35; background: #0c0f0e; padding: 16px; }
label { display: block; margin: 10px 0 4px; font-size: 13px; opacity: .8; }
input { width: 100%; padding: 8px 10px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: inherit; }
button.act { padding: 8px 14px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: inherit; cursor: pointer; }
button.act:hover:not(:disabled) { border-color: #4ed8af; color: #4ed8af; }
button.act:disabled { opacity: .45; cursor: not-allowed; }
.row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 2px 14px; margin: 0 0 10px; }
.kv dt { opacity: .6; font-size: 13px; }
.kv dd { margin: 0; word-break: break-all; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 8px 0; }
th, td { border: 1px solid #2c3a35; padding: 4px 8px; text-align: left; }
th { opacity: .6; font-weight: 400; }
.hint { font-size: 13px; opacity: .75; margin: 10px 0 0; white-space: pre-wrap; }
.hint.err, .err { color: #ff9d8a; }
.off, .warn { border: 1px solid #4ed8af; background: #12211c; padding: 10px 12px; margin: 0 0 12px; color: #4ed8af; font-size: 13px; }
.warn { border-color: #ffb454; background: #241c10; color: #ffb454; }
.deeplink { width: 100%; margin-top: 8px; padding: 8px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: 12px/1.5 monospace; }
#qr-box svg { width: 220px; max-width: 100%; height: auto; background: #fff; padding: 4px; }
code { background: #141816; padding: 1px 4px; word-break: break-all; }
a { color: #4ed8af; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>森空岛绑定 · Skland binding</h1>
<p class="sub">
绑定身份 · Player id:<code id="player-id">…</code><br />
这个 id 目前是占位(?player= 或浏览器本地保存),等游戏会话提供真实 id 后再由会话注入 ·
placeholder until the game session provides the real player id.
</p>
<section class="card" id="status-card">
<h2>绑定状态 · Binding status</h2>
<!-- 404 = 模块没挂载(默认关闭,SP_SKLAND=1 才启用)。 -->
<p class="off" id="module-off" hidden>
该功能未启用 · the module is off —— 服务器没有挂载 /skland/*(以 <code>SP_SKLAND=1</code> 启动后可用)·
the server was started without the module (404/405).
</p>
<dl class="kv">
<dt>状态 · Status</dt><dd id="st-status">—</dd>
<dt>昵称 · Nickname</dt><dd id="st-nickname">—</dd>
<dt>森空岛 ID · hgId</dt><dd id="st-hgid">—</dd>
</dl>
<table id="roles-table">
<thead><tr><th>uid</th><th>昵称 · nickName</th><th>服务器 · channelName</th><th>gameId</th></tr></thead>
<tbody id="roles-body"></tbody>
</table>
<p class="hint" id="roles-empty">还没有角色 · No roles yet.</p>
<!-- 每个 fetch 的失败都落在这一行上,不会有未处理的 rejection。 -->
<p class="err" id="status-error" hidden></p>
<div class="row">
<button class="act" id="btn-refresh" type="button">刷新 · Refresh</button>
<button class="act" id="btn-unbind" type="button" data-needs-module>解绑 · Unbind</button>
</div>
</section>
<nav class="tabs" role="tablist">
<button class="act" type="button" role="tab" data-tab="scan">扫码 · QR</button>
<button class="act" type="button" role="tab" data-tab="sms">验证码 · SMS</button>
<button class="act" type="button" role="tab" data-tab="pw">密码 · Password</button>
</nav>
<p class="hint" id="login-scope"></p>
<section class="panel" id="panel-scan" role="tabpanel">
<h2>扫码登录 · Scan to log in</h2>
<p class="hint">用森空岛 App 扫描二维码,或在 App 内打开深链;面板每 2 秒问一次状态 ·
scan the code with the Skland app; the panel polls every 2 s.</p>
<div class="row"><button class="act" id="btn-scan-start" type="button" data-needs-module>获取二维码 · Get QR code</button></div>
<div id="qr-box"></div>
<p class="hint" id="qr-note"></p>
<p class="hint" id="scan-state"></p>
</section>
<section class="panel" id="panel-sms" role="tabpanel" hidden>
<h2>验证码登录 · SMS code</h2>
<label for="sms-phone">手机号 · Phone</label>
<input id="sms-phone" type="tel" autocomplete="tel" inputmode="tel" placeholder="13800000000" />
<div class="row"><button class="act" id="btn-sms-send" type="button" data-needs-module>发送验证码 · Send code</button></div>
<label for="sms-code">验证码 · Code</label>
<input id="sms-code" type="text" autocomplete="one-time-code" inputmode="numeric" placeholder="123456" />
<div class="row"><button class="act" id="btn-sms-login" type="button" data-needs-module>登录并绑定 · Log in</button></div>
<p class="hint" id="sms-state"></p>
<div id="captcha-box" hidden>
<p class="hint" aria-live="polite"></p>
<button class="act" type="button"></button>
</div>
</section>
<section class="panel" id="panel-pw" role="tabpanel" hidden>
<h2>密码登录 · Phone + password</h2>
<label for="pw-phone">手机号 · Phone</label>
<input id="pw-phone" type="tel" autocomplete="tel" inputmode="tel" placeholder="13800000000" />
<label for="pw-password">密码 · Password</label>
<input id="pw-password" type="password" autocomplete="current-password" />
<div class="row"><button class="act" id="btn-pw-login" type="button" data-needs-module>登录并绑定 · Log in</button></div>
<p class="hint" id="pw-state"></p>
<div id="pw-captcha-box" hidden>
<p class="hint" aria-live="polite"></p>
<button class="act" type="button"></button>
</div>
</section>
<p class="hint"><a href="/">返回首页 · Back to home</a></p>
</main>
<script type="module">
import { t } from '/shared/i18n.js';
import { solveCaptcha } from '/js/skland/captcha.js';
// 面板逻辑:所有 /skland/* 调用都过 api(),失败一律显示成文字;二维码编码 scanUrl 深链。
/** 轮询间隔(契约:约 2 秒)。 */
const POLL_MS = 2000;
/** 玩家 id 的 localStorage 键。 */
const PLAYER_KEY = 'sp.skland.player';
/** 404 = 模块未启用(默认关闭)。 */
const NOT_ENABLED = '该功能未启用 · the module is off(服务器未以 SP_SKLAND=1 启动)· not enabled (404)';
/** 状态值 → 双语标签(server/skland/service.js 的 status)。 */
const STATUS_LABEL = { unbound: '未绑定 · unbound', scanning: '扫码中 · scanning', bound: '已绑定 · bound', error: '错误 · error' };
const $ = (sel) => document.querySelector(sel);
$('#login-scope').textContent = t('官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。');
/** 面板自己的错误类型:message 已经是给人看的一句话。 */
class PanelError extends Error {}
/** 玩家标识:?player= > localStorage > 新生成的占位 id。 */
function resolvePlayer() {
const fromUrl = new URLSearchParams(location.search).get('player');
let id = (fromUrl || '').trim();
try { id = id || localStorage.getItem(PLAYER_KEY) || ''; } catch { /* 隐私模式:localStorage 不可用时只用 URL/新 id */ }
if (!id) id = `web-${Math.random().toString(36).slice(2, 10)}`;
try { localStorage.setItem(PLAYER_KEY, id); } catch { /* 同上 */ }
return id;
}
const player = resolvePlayer();
/** 把非 JSON 的错误页 HTML 压成一行可读文本。 */
function brief(body) {
return String(body).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160);
}
/** 模块没挂载:亮出未启用提示,并停掉所有依赖模块的按钮。 */
let moduleOff = false;
function setModuleOff() {
if (moduleOff) return;
moduleOff = true;
$('#module-off').hidden = false;
for (const b of document.querySelectorAll('[data-needs-module]')) b.disabled = true;
}
/**
* 调用一个 /skland/* JSON 接口。网络 / 非 JSON / HTTP 错误都抛 PanelError,由调用方展示;
* 404 先于解析处理:模块关闭时 GET 由静态服务器答 404 错误页(不是 JSON)。
*/
async function api(path, { body } = {}) {
const init = { method: 'GET', cache: 'no-store', headers: {} };
if (body !== undefined) {
init.method = 'POST';
init.headers['content-type'] = 'application/json';
init.body = JSON.stringify(body);
}
let res;
try { res = await fetch(path, init); } catch (e) { throw new PanelError(`网络错误 · Network error: ${e.message}`); }
let text = '';
try { text = await res.text(); } catch { /* 读体失败也当空体处理 */ }
// 模块默认关闭时 /skland/* 由一个占位处理器统一答 JSON 404(server/skland/http.js createSklandOffHandler),
// 所以"未启用"只需认这一种应答;HTML 应答不是这个接口的形态,一并当作未启用兜底。
if ((res.status === 404 && !(res.headers.get('content-type') || '').includes('application/json'))
|| (res.headers.get('content-type') || '').includes('text/html')) {
setModuleOff();
throw new PanelError(NOT_ENABLED);
}
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = null; }
if (!res.ok) throw new PanelError(data?.error || `HTTP ${res.status} · ${brief(text)}`);
if (!data || typeof data !== 'object') throw new PanelError(`响应不是 JSON · Response is not JSON (HTTP ${res.status})`);
return data;
}
// ---------------------------------------------------------------------------------------------
// 状态区(常显)
// ---------------------------------------------------------------------------------------------
function showError(message) {
const el = $('#status-error');
el.hidden = !message;
el.textContent = message || '';
}
/** 一行提示:id 指向某个 .hint,note 非空时整体标红。 */
function msg(id, main, note = '') {
const el = $(id);
el.textContent = note ? `${main} —— ${note}` : main;
el.classList.toggle('err', !!note);
}
function text(id, value) { $(id).textContent = value == null || value === '' ? '—' : String(value); }
/** roles = {uid, nickName, channelName, gameId}(上游没给的字段以 — 显示)。 */
function renderRoles(roles) {
const body = $('#roles-body');
body.textContent = '';
const list = Array.isArray(roles) ? roles : [];
$('#roles-empty').hidden = list.length > 0;
for (const r of list) {
const tr = document.createElement('tr');
for (const v of [r?.uid, r?.nickName, r?.channelName, r?.gameId]) {
const td = document.createElement('td');
td.textContent = v == null || v === '' ? '—' : String(v);
tr.append(td);
}
body.append(tr);
}
}
function renderStatus(s) {
text('#st-status', STATUS_LABEL[s.status] || s.status);
text('#st-nickname', s.nickname);
text('#st-hgid', s.hgId);
renderRoles(s.roles);
showError(s.error || '');
}
/** GET /skland/status。返回状态对象(失败返回 null;错误已显示)。 */
async function refreshStatus() {
try {
const s = await api(`/skland/status?player=${encodeURIComponent(player)}`);
renderStatus(s);
return s;
} catch (e) { showError(e.message); return null; }
}
/** 刷新按钮:先状态;已绑定再 ?refresh=1 让服务器重新问一次森空岛要角色列表。 */
async function refreshAll() {
const s = await refreshStatus();
if (moduleOff || !s?.bound) return;
try {
const r = await api(`/skland/roles?player=${encodeURIComponent(player)}&refresh=1`);
if (r.ok) renderRoles(r.roles);
else showError(r.error || '查询角色失败 · Roles lookup failed');
} catch (e) { showError(e.message); }
}
async function unbind() {
showError('');
try {
await api('/skland/unbind', { body: { player } });
await refreshStatus();
} catch (e) { showError(e.message); }
}
// ---------------------------------------------------------------------------------------------
// 扫码:编码 scanUrl → 每 2s 轮询直到 confirmed / expired
// ---------------------------------------------------------------------------------------------
/** 扫码会话是否还在进行(离开页面/Tab 只停表,不清会话)。 */
let scanLive = false;
let pollTimer = null;
let pollBusy = false;
/** 停表:唯一清 clearInterval 的地方。 */
function pollStop() {
if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null; }
}
/** 起表:只有会话还在、扫码 Tab 在前台时才轮询。 */
function pollStart() {
pollStop();
if (!scanLive || activeTab !== 'scan' || document.hidden) return;
pollTimer = setInterval(() => { void pollOnce(); }, POLL_MS);
}
/** 二维码库缺失 / 绘制失败时的回退:把 scanUrl 深链作为可选中的文本放出来,绝不抛错。 */
function renderQrFallback(scanUrl) {
const box = $('#qr-box');
box.textContent = '';
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '二维码库 /vendor/qrcode.js 不可用(未执行 npm install / npm run vendor)——请手动打开下面的深链 · QR library unavailable; open the deep link manually.';
const link = document.createElement('textarea');
link.className = 'deeplink';
link.readOnly = true;
link.rows = 3;
link.value = String(scanUrl);
link.addEventListener('focus', () => link.select());
box.append(note, link);
$('#qr-note').textContent = '';
}
/**
* 二维码:编码 scanUrl(hypergryph:// 深链),不是会话标识。库缺失(源码检出没有 vendor 文件)或调用失败时
* 回退为可选中的深链文本,绝不让缺库抛出去。
*/
function renderQr(scanUrl) {
if (typeof qrcode !== 'function') { renderQrFallback(scanUrl); return; }
const box = $('#qr-box');
box.textContent = '';
try {
const qr = qrcode(0, 'M');
qr.addData(scanUrl);
qr.make();
box.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2 });
$('#qr-note').textContent = '用森空岛 App 扫码 · Scan with the Skland app';
} catch { renderQrFallback(scanUrl); }
}
async function startScan() {
scanLive = false;
pollStop();
showError('');
msg('#scan-state', '正在获取二维码… · Requesting a QR code…');
try {
const r = await api('/skland/scan/start', { body: { player } });
if (!r.ok) { msg('#scan-state', '获取二维码失败 · Could not start', r.error || ''); return; }
renderQr(r.scanUrl);
const secs = Number(r.expiresInMs) > 0 ? Math.round(Number(r.expiresInMs) / 1000) : 0;
msg('#scan-state', secs ? `等待手机确认…(${secs}s 内有效 · valid for ${secs}s)` : '等待手机确认… · Waiting for the phone…');
scanLive = true;
pollStart();
await refreshStatus();
} catch (e) {
msg('#scan-state', '获取二维码失败 · Could not start', e.message);
showError(e.message);
}
}
/** 一次轮询。confirmed / expired / none 都是终态:停表、刷新状态。 */
async function pollOnce() {
if (pollBusy || !scanLive) return;
pollBusy = true;
try {
const r = await api('/skland/scan/poll', { body: { player } });
// 服务端把上游失败答成 HTTP 200 + { ok: false, error }(没有 state)——先认它,否则真正的原因会被
// 下面那条 `state === undefined` 的兜底吞成"未知状态"。
if (r.ok === false) {
scanLive = false;
pollStop();
msg('#scan-state', '轮询失败 · Poll failed', r.error || '');
showError(r.error || '');
return;
}
if (r.state === 'waiting') { msg('#scan-state', '等待手机确认… · Waiting for the phone…'); return; }
scanLive = false;
pollStop();
if (r.state === 'confirmed') msg('#scan-state', '已确认 · Confirmed', r.error || '');
else if (r.state === 'expired') msg('#scan-state', '二维码已过期,请重新获取 · Expired — start again');
else if (r.state === 'none') msg('#scan-state', '没有进行中的扫码会话 · No scan session');
else if (r.state === 'error') msg('#scan-state', '轮询失败 · Poll failed', r.error || '');
else msg('#scan-state', `未知状态 · Unknown state: ${String(r.state)}`, `响应 · response: ${brief(JSON.stringify(r))}`);
await refreshStatus();
} catch (e) {
scanLive = false;
pollStop();
msg('#scan-state', '轮询已停止 · Polling stopped', e.message);
showError(e.message);
} finally { pollBusy = false; }
}
// ---------------------------------------------------------------------------------------------
// 验证码
// ---------------------------------------------------------------------------------------------
/** Send SMS, optionally retrying with the proof from the interactive widget. */
async function sendCode(extra) {
const phone = $('#sms-phone').value.trim();
if (!phone) { msg('#sms-state', '请填写手机号 · Enter a phone number'); return; }
showError('');
msg('#sms-state', '正在发送… · Sending…');
try {
const body = { player, phone };
if (extra) body.captcha = extra;
const r = await api('/skland/phone/send', { body });
if (r.ok) { $('#captcha-box').hidden = true; msg('#sms-state', '验证码已发送 · Code sent'); return; }
if (r.needCaptcha) { renderCaptcha(r.captcha || {}, '#captcha-box', (proof) => sendCode(proof)); return; }
msg('#sms-state', '发送失败 · Send failed', r.error || '');
showError(r.error || '发送失败 · Send failed');
} catch (e) {
msg('#sms-state', '发送失败 · Send failed', e.message);
showError(e.message);
}
}
/** Load and open the provider's widget only after the user clicks its button. */
function renderCaptcha(challenge, selector, retry) {
const box = $(selector);
const state = box.querySelector('p');
const button = box.querySelector('button');
box.hidden = false;
state.textContent = t('需要人机验证,请点击下方按钮完成验证。');
button.textContent = t('开始人机验证');
button.disabled = false;
button.onclick = async () => {
button.disabled = true;
state.textContent = t('正在加载验证组件…');
try {
const proof = await solveCaptcha(challenge);
if (!proof) { state.textContent = t('验证已取消,请点击按钮重试。'); return; }
state.textContent = t('验证通过,正在继续…');
box.hidden = true;
await retry(proof);
} catch (e) {
state.textContent = e.message === 'captcha_type'
? t('暂不支持此验证类型,请使用验证码或扫码登录。')
: e.message === 'captcha_load' ? t('验证组件加载失败,请重试。') : t('验证未完成,请重试。');
} finally { button.disabled = false; }
};
box.scrollIntoView({ block: 'nearest' });
}
async function smsLogin() {
const phone = $('#sms-phone').value.trim();
const code = $('#sms-code').value.trim();
if (!phone || !code) { msg('#sms-state', '请填写手机号与验证码 · Enter the phone and the code'); return; }
showError('');
msg('#sms-state', '正在登录… · Logging in…');
try {
const r = await api('/skland/phone/login', { body: { player, phone, code } });
if (r.ok) {
$('#captcha-box').hidden = true;
msg('#sms-state', `绑定成功 · Bound${r.nickname ? `:${r.nickname}` : ''}`);
} else {
msg('#sms-state', '登录失败 · Login failed', r.error || '');
showError(r.error || '登录失败 · Login failed');
}
await refreshStatus();
} catch (e) {
msg('#sms-state', '登录失败 · Login failed', e.message);
showError(e.message);
}
}
// ---------------------------------------------------------------------------------------------
// 密码
// ---------------------------------------------------------------------------------------------
async function passwordLogin(extra = {}) {
const phone = $('#pw-phone').value.trim();
const password = $('#pw-password').value;
if (!phone || !password) { msg('#pw-state', '请填写手机号与密码 · Enter the phone and the password'); return; }
showError('');
msg('#pw-state', '正在登录… · Logging in…');
try {
const r = await api('/skland/password/login', { body: { player, phone, password, ...extra } });
if (r.ok) {
$('#pw-captcha-box').hidden = true;
msg('#pw-state', `绑定成功 · Bound${r.nickname ? `:${r.nickname}` : ''}`);
} else if (r.needCaptcha) {
renderCaptcha(r.captcha || {}, '#pw-captcha-box', (proof) => passwordLogin({ captcha: proof, captchaStep: r.captchaStep }));
return;
} else {
msg('#pw-state', '登录失败 · Login failed', r.error || '');
showError(r.error || '登录失败 · Login failed');
}
await refreshStatus();
} catch (e) {
msg('#pw-state', '登录失败 · Login failed', e.message);
showError(e.message);
}
}
// ---------------------------------------------------------------------------------------------
// Tabs 与启动
// ---------------------------------------------------------------------------------------------
let activeTab = 'scan';
function switchTab(name) {
activeTab = name;
for (const b of document.querySelectorAll('[data-tab]')) b.setAttribute('aria-selected', String(b.dataset.tab === name));
for (const p of document.querySelectorAll('.panel')) p.hidden = p.id !== `panel-${name}`;
// 离开扫码区只停表:会话还在时切回来会继续问(scanLive 不由 Tab 决定)。
if (name !== 'scan') pollStop();
else pollStart();
}
$('#player-id').textContent = player;
for (const b of document.querySelectorAll('[data-tab]')) b.addEventListener('click', () => switchTab(b.dataset.tab));
$('#btn-refresh').addEventListener('click', () => { void refreshAll(); });
$('#btn-unbind').addEventListener('click', () => { void unbind(); });
$('#btn-scan-start').addEventListener('click', () => { void startScan(); });
$('#btn-sms-send').addEventListener('click', () => { void sendCode(); });
$('#btn-sms-login').addEventListener('click', () => { void smsLogin(); });
$('#btn-pw-login').addEventListener('click', () => { void passwordLogin(); });
// 页面隐藏 / 离开时停表,不给后台标签页留定时器;回到前台且扫码会话还在就继续。
document.addEventListener('visibilitychange', () => { if (document.hidden) pollStop(); else pollStart(); });
window.addEventListener('pagehide', () => pollStop());
switchTab('scan');
void refreshAll();
</script>
</body>
</html>
+4 -2
View File
@@ -19,9 +19,10 @@ import { hashedFileOpts } from './assetIndex.js';
* `/media/~<hash>/bgm/act1` (a served manifest's `/assets/~<hash>/audio/bgm/act1.mp3`, public/js/media.js): static.js * `/media/~<hash>/bgm/act1` (a served manifest's `/assets/~<hash>/audio/bgm/act1.mp3`, public/js/media.js): static.js
* strips the segment and hands it over as `urlHash`; the answer is `immutable` when it names the resolved file's bytes * strips the segment and hands it over as `urlHash`; the answer is `immutable` when it names the resolved file's bytes
* (assetIndex.js), else `no-cache`, and the ETag is the file's content hash when the index has it. * (assetIndex.js), else `no-cache`, and the ETag is the file's content hash when the index has it.
* @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null }} [hashing] * @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
* isPrivate?: (file: string) => Promise<boolean> }} [hashing]
*/ */
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null } = {}) { export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null, isPrivate = async () => false } = {}) {
const root = path.join(path.resolve(publicDir), 'assets', 'audio'); const root = path.join(path.resolve(publicDir), 'assets', 'audio');
const segments = String(rest || '').split('/').filter((s) => s.length > 0); const segments = String(rest || '').split('/').filter((s) => s.length > 0);
if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; } if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
@@ -48,6 +49,7 @@ export async function serveMedia(req, res, rest, query, publicDir, gzipCache, lo
stat = await fsp.stat(absPath); stat = await fsp.stat(absPath);
} catch { continue; } } catch { continue; }
if (!stat.isFile()) continue; if (!stat.isFile()) continue;
if (await isPrivate(absPath)) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
// serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared. // serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared.
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day), // Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day),
// or that of its content-hashed form. // or that of its content-hashed form.
+11 -2
View File
@@ -2,6 +2,8 @@
// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md) // (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md)
// //
// * a URL longer than 4096 characters → 414; one that does not parse → 400; // * a URL longer than 4096 characters → 414; one that does not parse → 400;
// * /skland/* → the Skland module's own listener when it is enabled (server/skland/http.js; SP_SKLAND=1). It owns
// its methods (POST), so it runs before the GET/HEAD gate below;
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`; // * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions, // * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions,
// rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the // rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the
@@ -48,16 +50,23 @@ const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.tes
* The request listener for `http.createServer`. * The request listener for `http.createServer`.
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, * @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
* rawPath: string, query: string) => Promise<void>, * rawPath: string, query: string) => Promise<void>,
* health: Parameters<typeof healthReport>[0], log: object, trustProxy?: 'auto' | boolean }} deps * health: Parameters<typeof healthReport>[0], log: object, trustProxy?: 'auto' | boolean,
* skland?: ((req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
* parts: { rawPath: string, query: string }) => Promise<boolean>) | null }} deps
* trustProxy: whose forwarding headers name the client (net.js clientAddress; the server's TRUST_PROXY) * trustProxy: whose forwarding headers name the client (net.js clientAddress; the server's TRUST_PROXY)
* skland: the /skland/* handler when the module is enabled (server/skland/http.js; SP_SKLAND=1). It owns its paths
* and its methods (POST), so it runs before the GET/HEAD method gate.
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void} * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void}
*/ */
export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto' }) { export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto', skland = null }) {
async function handleRequest(req, res) { async function handleRequest(req, res) {
const url = req.url || '/'; const url = req.url || '/';
if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; } if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; }
const parts = splitUrl(url); const parts = splitUrl(url);
if (!parts) { sendError(req, res, 400, '请求地址无效 · Bad request'); return; } if (!parts) { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
if (skland && (parts.rawPath === '/skland' || parts.rawPath.startsWith('/skland/'))) {
if (await skland(req, res, parts)) return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') { if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Allow', 'GET, HEAD'); res.setHeader('Allow', 'GET, HEAD');
sendError(req, res, 405, '不支持的请求方法 · Method not allowed'); sendError(req, res, 405, '不支持的请求方法 · Method not allowed');
+14 -4
View File
@@ -29,6 +29,7 @@
// Files go out through files.js serveFile. // Files go out through files.js serveFile.
import fsp from 'node:fs/promises'; import fsp from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { MEDIA_PREFIX } from '../../shared/media.js'; import { MEDIA_PREFIX } from '../../shared/media.js';
import { splitHashedPath } from '../../shared/assetPaths.js'; import { splitHashedPath } from '../../shared/assetPaths.js';
@@ -62,7 +63,7 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
/** /**
* Create the static request handler. * Create the static request handler.
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string, * @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string,
* packs?: ReturnType<typeof createPackRegistry>, log?: object, * packs?: ReturnType<typeof createPackRegistry>, log?: object, privateFiles?: string[],
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null, * assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} dirs * views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} dirs
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs); * packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs);
@@ -70,7 +71,14 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>} * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>}
*/ */
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog, export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog,
assetIndex = null, views = null }) { assetIndex = null, views = null, privateFiles = [] }) {
const canonical = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
const privatePaths = new Set(privateFiles.map(canonical));
for (const file of privateFiles) {
try { privatePaths.add(canonical(realpathSync(file))); } catch { /* Side files may not exist yet. */ }
}
const isPrivate = async (file) => privatePaths.size > 0 && (privatePaths.has(canonical(file))
|| privatePaths.has(canonical(await fsp.realpath(file))));
const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) }); const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) });
const mounts = [ const mounts = [
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) }, { prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
@@ -107,13 +115,13 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
if (parts.length === 1 && parts[0] === PACK_INDEX_FILE) { sendJson(req, res, 200, registry.index()); return; } if (parts.length === 1 && parts[0] === PACK_INDEX_FILE) { sendJson(req, res, 200, registry.index()); return; }
const abs = parts.length >= 2 && !parts.some((s) => s === '' || s === '.' || s === '..') ? registry.servable(parts[0], parts.slice(1).join('/')) : null; const abs = parts.length >= 2 && !parts.some((s) => s === '' || s === '.' || s === '..') ? registry.servable(parts[0], parts.slice(1).join('/')) : null;
const st = abs ? await fsp.stat(abs).catch(() => null) : null; const st = abs ? await fsp.stat(abs).catch(() => null) : null;
if (!st || !st.isFile()) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; } if (!st || !st.isFile() || await isPrivate(abs)) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; }
await serveFile(req, res, abs, st, 'packs', parts, query, gzipCache, log); await serveFile(req, res, abs, st, 'packs', parts, query, gzipCache, log);
return; return;
} }
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3 // Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
if (decoded.startsWith(MEDIA_PREFIX)) { if (decoded.startsWith(MEDIA_PREFIX)) {
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex }); await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex, isPrivate });
return; return;
} }
// The served manifests: every art URL with its content hash (assetViews.js), once the index is ready // The served manifests: every art URL with its content hash (assetViews.js), once the index is ready
@@ -137,6 +145,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
return; return;
} }
let absPath = path.join(mount.dir, ...segments); let absPath = path.join(mount.dir, ...segments);
if (privatePaths.has(canonical(absPath))) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
if (absPath !== mount.dir && !absPath.startsWith(mount.dir + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; } if (absPath !== mount.dir && !absPath.startsWith(mount.dir + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
let stat; let stat;
@@ -183,6 +192,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
} }
const indexed = mount.name === 'public' && segments.length > 1 && ASSET_INDEX_ROOTS.includes(segments[0]); const indexed = mount.name === 'public' && segments.length > 1 && ASSET_INDEX_ROOTS.includes(segments[0]);
const opts = indexed ? hashedFileOpts(assetIndex, segments.join('/'), stat, urlHash) : (urlHash ? { cacheControl: 'no-cache' } : {}); const opts = indexed ? hashedFileOpts(assetIndex, segments.join('/'), stat, urlHash) : (urlHash ? { cacheControl: 'no-cache' } : {});
if (await isPrivate(absPath)) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log, opts); await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log, opts);
}; };
} }
+55 -3
View File
@@ -17,6 +17,9 @@
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static // http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
// http/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the // http/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the
// WebSocket, since the start (GET /healthz?traffic=1) // WebSocket, since the start (GET /healthz?traffic=1)
// skland/ the opt-in Skland account binding (SP_SKLAND=1): /skland/* — QR / 验证码 / 密码 login, one
// SQLite row per player. server/skland/http.js is handed to routes.js as its own listener (it
// owns POST); docs/skland.md
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400 // http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against // http/boot.js a pending update package first (update.js: old files deleted, the install verified against
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT / // MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
@@ -29,6 +32,7 @@
// The server only auto-listens when this file is the process entry point. // The server only auto-listens when this file is the process entry point.
import http from 'node:http'; import http from 'node:http';
import path from 'node:path';
import { getData, loadData } from './data.js'; import { getData, loadData } from './data.js';
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.js'; import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.js';
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js'; import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
@@ -42,6 +46,13 @@ import { createAssetIndex } from './http/assetIndex.js';
import { createManifestViews } from './http/assetViews.js'; import { createManifestViews } from './http/assetViews.js';
import { answerClientError } from './http/common.js'; import { answerClientError } from './http/common.js';
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js'; import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
import { createSklandStore } from './skland/store.js';
import { createSklandService } from './skland/service.js';
import { createSklandHttp, createSklandOffHandler } from './skland/http.js';
import { createSessionPlayerResolver } from './skland/session.js';
import { createAccountAuth } from './skland/auth.js';
import { createAccountHttp } from './skland/accountHttp.js';
import { createAccountRecovery } from './skland/recovery.js';
// The public API of this module (tests and tools import it from here); the code lives in ./http/. // The public API of this module (tests and tools import it from here); the code lives in ./http/.
export { export {
@@ -60,8 +71,12 @@ export {
* maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number, * maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number,
* heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number, * heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number,
* assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number }, * assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number },
* skland?: boolean | { storeFile?: string, fetchImpl?: typeof fetch, client?: object, scanTimeoutMs?: number,
* authSecret?: string, publicOrigin?: string, diagnosticKey?: string, authNow?: () => number },
* }} [opts] * }} [opts]
* assetIndex: the content-hash index (default on; false or SP_ASSET_INDEX=0: off — config.js assetIndexOptionsFrom) * assetIndex: the content-hash index (default on; false or SP_ASSET_INDEX=0: off — config.js assetIndexOptionsFrom)
* skland: the Skland account binding (server/skland/), off by default (SP_SKLAND=1 turns it on). `storeFile` picks
* the SQLite file (SP_SKLAND_DB, default data/skland.db); `fetchImpl` replaces the upstream HTTP (tests).
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer, * @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network, * lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>, * registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
@@ -83,14 +98,20 @@ export async function startServer(opts = {}) {
const indexOptions = assetIndexOptionsFrom(opts, publicDir); const indexOptions = assetIndexOptionsFrom(opts, publicDir);
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null; const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null;
const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null; const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null;
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views }); // The Skland account binding (server/skland/): off unless asked for (SP_SKLAND=1, or opts.skland). Its own SQLite
// file (SP_SKLAND_DB, default <dataDir>/skland.db) holds one row per player; the routes are /skland/* (POST).
const skland = createSklandModule(opts, { log, registry, network, lobby });
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views,
privateFiles: skland?.privateFiles || [] });
// Off still owns /skland/*: one uniform JSON 404 beats a static 404 for GET and a 405 for POST (skland/http.js).
const sklandHttp = skland ? skland.http : createSklandOffHandler();
const startedAt = Date.now(); const startedAt = Date.now();
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz. // The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
resetBuildTag(); resetBuildTag();
buildTag(); buildTag();
const traffic = createTrafficCounter(); const traffic = createTrafficCounter();
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy })); const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy, skland: sklandHttp }));
server.on('clientError', answerClientError); server.on('clientError', answerClientError);
const wss = attachWebSocket(server, { network, log, traffic }); const wss = attachWebSocket(server, { network, log, traffic });
@@ -142,6 +163,7 @@ export async function startServer(opts = {}) {
try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); } try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); }
network.close(); network.close();
assetIndex?.close(); assetIndex?.close();
try { skland?.close(); } catch (e) { log.error('[shutdown] skland', e); }
await new Promise((resolve) => { await new Promise((resolve) => {
server.close(() => resolve()); server.close(() => resolve());
server.closeIdleConnections?.(); server.closeIdleConnections?.();
@@ -152,7 +174,37 @@ export async function startServer(opts = {}) {
return closing; return closing;
} }
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, close }; return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, skland: skland ? skland.service : null, close };
}
/**
* The Skland module (server/skland/), or null when it is off. Off is the default: an unconfigured server keeps no
* account credentials at all. Turn it on with `SP_SKLAND=1` (or `opts.skland`), which every caller of startServer can
* pass — the HTTP tests inject their own store file and a stub `fetchImpl`, so nothing here reaches the network.
* @param {Record<string, any>} opts
* @param {{ log: object, registry: import('./net.js').SessionRegistry, network: import('./net.js').Network }} deps
* @returns {{ service: any, http: any, privateFiles: string[], close: () => void } | null}
*/
function createSklandModule(opts, { log, registry, network, lobby }) {
const cfg = opts.skland;
const enabled = !!cfg || process.env.SP_SKLAND === '1';
if (!enabled) return null;
const conf = typeof cfg === 'object' ? cfg : {};
const file = conf.storeFile || process.env.SP_SKLAND_DB || path.join(ROOT, 'data', 'skland.db');
const store = createSklandStore({ file, log });
const service = createSklandService({ store, fetchImpl: conf.fetchImpl, client: conf.client, scanTimeoutMs: conf.scanTimeoutMs });
const auth = createAccountAuth({ file: store.file, service, registry, secret: conf.authSecret || process.env.SP_AUTH_SECRET,
publicOrigin: conf.publicOrigin || process.env.SP_PUBLIC_ORIGIN, diagnosticKey: conf.diagnosticKey || process.env.SP_SKLAND_DIAGNOSTIC_KEY,
trustProxy: network.opts.trustProxy, now: conf.authNow, onExpire: (session) => lobby.onExpire(session) });
network.accountPolicy = auth.gamePolicy;
log.info?.(`[skland] account binding on — ${store.file}`);
return {
service,
http: createAccountHttp({ service, auth, registry, recovery: createAccountRecovery({ registry, lobby }),
diagnostic: createSklandHttp({ service, sessionPlayer: createSessionPlayerResolver(registry) }) }),
privateFiles: store.file === ':memory:' ? [] : [store.file, store.file + '-wal', store.file + '-shm'],
close: () => { auth.close(); store.close(); },
};
} }
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js). // `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
+22
View File
@@ -184,6 +184,16 @@ export class SessionRegistry {
if (this.byTokenMap.get(s.token) === s) this.byTokenMap.delete(s.token); if (this.byTokenMap.get(s.token) === s) this.byTokenMap.delete(s.token);
} }
/** Rotate an authenticated session's bearer after attaching a new socket. @param {Session} s */
rotateToken(s) {
const previous = s.token;
if (this.byTokenMap.get(previous) === s) this.byTokenMap.delete(previous);
let token;
do token = newToken(); while (token === previous || this.byTokenMap.has(token));
s.token = token;
this.byTokenMap.set(token, s);
}
/** /**
* The reconnect window of one session: the registry's, or the session's own `resumeWindowMs` when that is longer * The reconnect window of one session: the registry's, or the session's own `resumeWindowMs` when that is longer
* (the lobby may only extend it, never shorten it). * (the lobby may only extend it, never shorten it).
@@ -526,6 +536,8 @@ export class Network {
/** @type {Map<string, number>} open sockets per client network key */ /** @type {Map<string, number>} open sockets per client network key */
this.connsPerKey = new Map(); this.connsPerKey = new Map();
this.closed = false; this.closed = false;
// Optional fork account policy; transport and the upstream game protocol remain usable when it is absent.
this.accountPolicy = null;
this.heartbeatTimer = setInterval(() => this.heartbeat(), this.opts.heartbeatMs); this.heartbeatTimer = setInterval(() => this.heartbeat(), this.opts.heartbeatMs);
this.heartbeatTimer.unref?.(); this.heartbeatTimer.unref?.();
const sweepMs = Math.max(20, Math.min(15_000, Math.floor(this.opts.reconnectWindowMs / 4))); const sweepMs = Math.max(20, Math.min(15_000, Math.floor(this.opts.reconnectWindowMs / 4)));
@@ -559,6 +571,7 @@ export class Network {
if (this.closed) { try { ws.close(CLOSE.SHUTDOWN, 'server shutdown'); } catch { /* ignore */ } return; } if (this.closed) { try { ws.close(CLOSE.SHUTDOWN, 'server shutdown'); } catch { /* ignore */ } return; }
const conn = new Connection(ws, clientAddress(req, this.opts.trustProxy), this.now(), this.opts); const conn = new Connection(ws, clientAddress(req, this.opts.trustProxy), this.now(), this.opts);
this.conns.set(ws, conn); this.conns.set(ws, conn);
if (this.accountPolicy) conn.account = this.accountPolicy.connection(req);
if (conn.key) this.connsPerKey.set(conn.key, (this.connsPerKey.get(conn.key) || 0) + 1); if (conn.key) this.connsPerKey.set(conn.key, (this.connsPerKey.get(conn.key) || 0) + 1);
ws.on('message', (data, isBinary) => { ws.on('message', (data, isBinary) => {
try { this.onFrame(conn, data, isBinary); } catch (e) { this.log.error('[net] frame handler crashed', e); } try { this.onFrame(conn, data, isBinary); } catch (e) { this.log.error('[net] frame handler crashed', e); }
@@ -612,6 +625,9 @@ export class Network {
} }
if (msg.t === 'hello') { this.onHelloMsg(conn, msg, now); return; } if (msg.t === 'hello') { this.onHelloMsg(conn, msg, now); return; }
if (!conn.session) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'hello required')); return; } if (!conn.session) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'hello required')); return; }
if (this.accountPolicy && !this.accountPolicy.authorized(conn)) {
this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account binding required')); return;
}
if (HEAVY_TYPES.has(msg.t) && !conn.heavy.take(now)) { this.reply(conn, errorMsg(ERR.RATE, rid, `${msg.t} too often`)); return; } if (HEAVY_TYPES.has(msg.t) && !conn.heavy.take(now)) { this.reply(conn, errorMsg(ERR.RATE, rid, `${msg.t} too often`)); return; }
let res; let res;
@@ -640,6 +656,11 @@ export class Network {
this.reply(conn, errorMsg(ERR.BAD_MSG, rid, `version mismatch: server ${PROTOCOL_VERSION}`)); this.reply(conn, errorMsg(ERR.BAD_MSG, rid, `version mismatch: server ${PROTOCOL_VERSION}`));
return; return;
} }
if (this.accountPolicy) {
const account = this.accountPolicy.hello(conn, msg);
if (!account) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account authentication required')); return; }
msg = { ...msg, ...account };
}
const name = sanitizeName(msg.name); const name = sanitizeName(msg.name);
if (!name) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'bad field name')); return; } if (!name) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'bad field name')); return; }
@@ -670,6 +691,7 @@ export class Network {
session.lastSeen = now; session.lastSeen = now;
session.addr = conn.ip; session.addr = conn.ip;
session.limitKey = conn.key; session.limitKey = conn.key;
this.accountPolicy?.attached?.(conn, { repeat });
let extra = null; let extra = null;
try { extra = this.handler.welcomeInfo?.() ?? null; } catch (e) { this.log.error('[net] welcomeInfo crashed', e); } try { extra = this.handler.welcomeInfo?.() ?? null; } catch (e) { this.log.error('[net] welcomeInfo crashed', e); }
+108
View File
@@ -0,0 +1,108 @@
// i18n-ignore-file: API errors are diagnostics; the account UI supplies translated labels.
import { sendJson } from '../http/common.js';
import { readJsonBody } from './http.js';
import { ACCOUNT_PREFIX } from './auth.js';
import { SESSION_BINDING_PREFIX } from './session.js';
export function createAccountHttp({ service, auth, registry, recovery, diagnostic }) {
const endpoints = { status: 'GET', roles: 'GET', recovery: 'GET', enter: 'POST', resume: 'POST', 'scan/start': 'POST', 'scan/poll': 'POST',
'phone/send': 'POST', 'phone/login': 'POST', 'password/login': 'POST', 'role/select': 'POST', unbind: 'POST' };
return async (req, res, parts) => {
const fail = (status, error, code) => { sendJson(req, res, status, { ok: false, error, ...(code ? { code } : {}) }); return true; };
if (!auth.originAllowed(req)) return fail(403, '跨站请求已拒绝 · Cross-origin request denied');
if (auth.limited(req)) return fail(429, '请求过于频繁 · Too many requests');
if (!parts.rawPath.startsWith('/skland/session/')) {
if (!auth.diagnosticAllowed(req)) return fail(403, '诊断接口需要管理员密钥 · Diagnostic API requires an admin key');
return diagnostic(req, res, parts);
}
const endpoint = parts.rawPath.slice('/skland/session/'.length);
if (!Object.hasOwn(endpoints, endpoint)) return fail(404, '未知接口 · Unknown endpoint');
if (req.method !== endpoints[endpoint]) return fail(405, '不支持的请求方法 · Method not allowed');
const match = /^Bearer ([a-f0-9]{32})$/i.exec(req.headers.authorization || '');
const session = match && registry.byToken(match[1]);
if (!session || registry.isExpired(session, registry.now())) return fail(401, '游戏会话已失效 · Game session expired');
const query = new URLSearchParams(parts.query);
let body = {};
if (req.method === 'POST') {
try { body = await readJsonBody(req); } catch { return fail(400, '请求体无效 · Bad request'); }
if (!body || typeof body !== 'object' || Array.isArray(body)) return fail(400, '请求体无效 · Bad request');
}
// Reading a POST body yields: logout, expiry or a device takeover may invalidate either credential meanwhile.
if (registry.byToken(match[1]) !== session) return fail(401, '游戏会话已失效 · Game session expired');
let login;
try { login = auth.authenticate(req); } catch { auth.setCookie(req, res, null); return fail(401, '登录已过期,请重新绑定 · Login expired'); }
if (session.playerId.startsWith('a_') && (!login || auth.state(login).gamePlayerId !== session.playerId)) {
return fail(403, '身份不匹配 · Identity mismatch');
}
// Claimed identity fields never choose the target row; all ownership comes from authenticated credentials.
const pendingPlayer = SESSION_BINDING_PREFIX + session.playerId;
const issue = () => {
login = auth.publish(pendingPlayer);
if (login) auth.setCookie(req, res, login);
};
if (!login && service.status(pendingPlayer).bound) issue(); // Migrate an existing binding owned by this transport.
const player = login ? ACCOUNT_PREFIX + login.subject : pendingPlayer;
const syncSelection = () => {
if (!login) return;
const updated = auth.ensureSelection(login);
if (updated !== login) { login = updated; auth.setCookie(req, res, login); }
};
try {
let answer;
if (endpoint === 'status') { syncSelection(); answer = login ? auth.state(login) : service.status(player); }
else if (endpoint === 'recovery' || endpoint === 'enter' || endpoint === 'resume') {
if (!login) return fail(401, '请先绑定账号 · Account binding required');
syncSelection();
const playerId = auth.state(login).gamePlayerId;
if (!playerId) return fail(409, '请选择有效角色 · A playable role is required');
const item = recovery.describe(playerId);
if (endpoint === 'recovery') answer = { ok: true, recovery: item };
else {
if (endpoint === 'resume' && (!item || typeof body.id !== 'string' || body.id !== item.id)) {
return fail(409, '此对局已结束或过期,请刷新后重试 · Recovery is no longer available', 'resume_expired');
}
const target = auth.gameSession(login);
answer = { ok: true, gamePlayerId: target.playerId, token: target.token, name: target.name };
}
} else if (endpoint === 'roles') {
if (!login) return fail(401, '请先绑定账号 · Account binding required');
answer = { ok: true, roles: await service.roles(player, query.get('refresh') === '1') };
syncSelection();
} else if (endpoint === 'role/select') {
if (!login) return fail(401, '请先绑定账号 · Account binding required');
const selected = auth.select(login, body.roleKey);
if (!selected) return fail(403, '无法选择该角色 · Role not owned or currently in a room');
login = selected; auth.setCookie(req, res, login); answer = { ok: true, ...auth.state(login) };
} else if (endpoint === 'unbind') {
if (login) {
const role = auth.roleOf(login), current = role && registry.byId(auth.state(login).gamePlayerId);
if (current?.roomCode) return fail(409, '请先离开同盟 · Leave your room first');
auth.revoke(login);
}
service.unbind(pendingPlayer); auth.setCookie(req, res, null); answer = { ok: true };
} else {
if (login) return fail(409, '请先解绑当前账号 · Unbind the current account first');
if (endpoint === 'scan/start') answer = { ok: true, ...await service.startScan(player) };
else if (endpoint === 'scan/poll') {
answer = { ok: true, ...await service.pollScan(player) };
if (answer.state === 'confirmed' && !answer.error) issue();
} else if (endpoint === 'phone/send') {
if (!body.phone) return fail(400, '缺少手机号 · Missing phone');
answer = await service.sendPhoneCode(player, String(body.phone), body.captcha);
} else if (endpoint === 'phone/login') {
if (!body.phone || !body.code) return fail(400, '缺少登录信息 · Missing login fields');
answer = await service.loginByPhoneCode(player, String(body.phone), String(body.code));
if (answer.ok) issue();
} else if (endpoint === 'password/login') {
if (!body.phone || !body.password) return fail(400, '缺少登录信息 · Missing login fields');
answer = await service.loginByPhonePassword(player, String(body.phone), String(body.password), body.captcha, body.captchaStep);
if (answer.ok) issue();
}
}
sendJson(req, res, 200, answer);
} catch (error) {
sendJson(req, res, 200, { ok: false, ...(endpoint === 'scan/poll' ? { state: 'error' } : {}), error: error.message });
}
return true;
};
}
+187
View File
@@ -0,0 +1,187 @@
// Persistent account authentication. Provider credentials stay in SQLite; the browser receives only a signed JWT.
import { DatabaseSync } from 'node:sqlite';
import { createHmac, createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { TokenBucket, isLocalIp, sanitizeName } from '../net.js';
import { roleKey, hasRoleIdentity } from '../../shared/accountIdentity.js';
export const ACCOUNT_PREFIX = '@account/';
export const ACCOUNT_COOKIE = 'sp_account';
export const LOGIN_TTL = 30 * 24 * 60 * 60;
const ISSUER = 'stronghold-account', AUDIENCE = 'stronghold-game';
const json64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const equal = (a, b) => a.length === b.length && timingSafeEqual(a, b);
// 128 digest bits in base64url keep `n:<playerId>` within g.watch's existing 32-character field limit.
export const accountPlayerId = (role) => 'a_' + createHash('sha256').update(roleKey(role)).digest().subarray(0, 16).toString('base64url');
export function createAccountAuth({ file, service, registry, secret, publicOrigin, diagnosticKey, trustProxy = 'auto', now = Date.now, onExpire }) {
const db = new DatabaseSync(file);
db.exec(`PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS account_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS account_logins (
id TEXT PRIMARY KEY, subject TEXT NOT NULL, role_key TEXT, expires INTEGER NOT NULL, revoked INTEGER NOT NULL DEFAULT 0
);`);
if (secret && Buffer.byteLength(secret) < 32) throw new Error('SP_AUTH_SECRET must contain at least 32 bytes');
db.prepare('INSERT OR IGNORE INTO account_meta(key,value) VALUES (?,?)').run('signing_key', randomBytes(48).toString('base64url'));
const key = Buffer.from(secret || db.prepare('SELECT value FROM account_meta WHERE key=?').get('signing_key').value);
const configuredOrigin = publicOrigin ? new URL(publicOrigin).origin : null;
const signature = (value) => createHmac('sha256', key).update(value).digest();
const playable = (role) => hasRoleIdentity(role) && !!sanitizeName(role.nickName);
const roleOf = (login) => service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === login.role_key) || null;
const active = (login) => login && !login.revoked && login.expires > Math.floor(now() / 1000)
&& service.status(ACCOUNT_PREFIX + login.subject).bound;
const buckets = new Map();
function limited(req) {
const id = req.socket?.remoteAddress || '?';
if (!buckets.has(id)) {
if (buckets.size >= 4096) buckets.delete(buckets.keys().next().value);
buckets.set(id, new TokenBucket(3, 120, now()));
}
return !buckets.get(id).take(now());
}
function originAllowed(req) {
if (req.headers['sec-fetch-site'] === 'cross-site') return false;
if (!req.headers.origin) return true; // Non-browser clients still need both credentials.
const secure = req.socket?.encrypted || (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress))
&& req.headers['x-forwarded-proto'] === 'https');
return req.headers.origin === (configuredOrigin || `${secure ? 'https' : 'http'}://${req.headers.host}`);
}
function cookieValue(req) {
const entries = (req.headers.cookie || '').split(';').map((part) => part.trim()).filter((part) => part.startsWith(ACCOUNT_COOKIE + '='));
if (entries.length > 1) throw new Error('invalid account cookie');
return entries.length ? entries[0].slice(ACCOUNT_COOKIE.length + 1) : null;
}
function verify(token) {
try {
if (typeof token !== 'string' || token.length > 4096) return null;
const parts = token.split('.');
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return null;
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
if (header.alg !== 'HS256' || header.typ !== 'JWT' || Object.keys(header).length !== 2) return null;
if (!equal(signature(parts[0] + '.' + parts[1]), Buffer.from(parts[2], 'base64url'))) return null;
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
const at = Math.floor(now() / 1000);
if (claims.iss !== ISSUER || claims.aud !== AUDIENCE || !Number.isInteger(claims.iat) || claims.iat > at
|| !Number.isInteger(claims.exp) || claims.exp <= at || claims.exp <= claims.iat || claims.exp - claims.iat > LOGIN_TTL
|| typeof claims.sub !== 'string' || typeof claims.jti !== 'string') return null;
const login = db.prepare('SELECT * FROM account_logins WHERE id=?').get(claims.jti);
return active(login) && login.subject === claims.sub && login.expires === claims.exp && login.role_key === claims.role ? login : null;
} catch { return null; }
}
function authenticate(req) {
const token = cookieValue(req);
if (!token) return null;
const login = verify(token);
if (!login) throw new Error('invalid account cookie');
return login;
}
function setCookie(req, res, login) {
const secure = req.socket?.encrypted || configuredOrigin?.startsWith('https:') || req.headers.origin?.startsWith('https:')
|| (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress)) && req.headers['x-forwarded-proto'] === 'https');
let value = '', maxAge = 0;
if (login) {
const at = Math.floor(now() / 1000);
const head = json64({ alg: 'HS256', typ: 'JWT' });
const body = json64({ iss: ISSUER, aud: AUDIENCE, sub: login.subject, jti: login.id,
iat: at, exp: login.expires, role: login.role_key });
value = head + '.' + body + '.' + signature(head + '.' + body).toString('base64url');
maxAge = Math.max(0, login.expires - at);
}
res.setHeader('Set-Cookie', `${ACCOUNT_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? '; Secure' : ''}`);
}
function publish(player) {
const row = service._store.get(player);
if (!row || row.status !== 'bound') return null;
const roles = service.status(player).roles.filter(playable);
const selected = roles.find((role) => role.isDefault) || roles[0];
const login = { id: randomUUID(), subject: randomUUID(), role_key: selected ? roleKey(selected) : null,
expires: Math.floor(now() / 1000) + LOGIN_TTL, revoked: 0 };
const { player_id: _player, created_at: _created, updated_at: _updated, ...credentials } = row;
service._store.update(ACCOUNT_PREFIX + login.subject, credentials);
db.prepare('INSERT INTO account_logins(id,subject,role_key,expires) VALUES (?,?,?,?)')
.run(login.id, login.subject, login.role_key, login.expires);
service.unbind(player); // A replay of the old transport token cannot issue a fresh login after logout.
return login;
}
function select(login, selectedKey) {
const role = service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === selectedKey);
if (!role) return null;
const old = roleOf(login), session = old && registry.byId(accountPlayerId(old));
if (session?.roomCode && selectedKey !== login.role_key) return null;
db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id);
return { ...login, role_key: selectedKey };
}
function ensureSelection(login) {
if (roleOf(login)) return login;
const roles = service.status(ACCOUNT_PREFIX + login.subject).roles.filter(playable);
const role = roles.find((item) => item.isDefault) || roles[0];
const selectedKey = role ? roleKey(role) : null;
if (selectedKey === login.role_key) return login;
db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id);
return { ...login, role_key: selectedKey };
}
function state(login) {
const status = service.status(ACCOUNT_PREFIX + login.subject), role = roleOf(login);
return { ...status, loginId: login.id, selectedRoleKey: login.role_key,
gamePlayerId: role ? accountPlayerId(role) : null };
}
function roleSession(role) {
const id = accountPlayerId(role);
let session = registry.byId(id);
if (session && registry.isExpired(session, registry.now())) {
registry.remove(session);
onExpire?.(session); // Remove the old seat before reusing its stable account playerId.
session = null;
}
if (!session) {
session = registry.create(sanitizeName(role.nickName));
if (!session) throw new Error('server full');
registry.byPlayerId.delete(session.playerId); session.playerId = id; registry.byPlayerId.set(id, session);
}
return session;
}
function revoke(login) {
db.prepare('UPDATE account_logins SET revoked=1 WHERE subject=?').run(login.subject);
service.unbind(ACCOUNT_PREFIX + login.subject);
}
const gamePolicy = {
connection(req) {
let login = null;
try { login = authenticate(req); } catch { /* Expired cookies can still establish a login-only transport. */ }
return { login, allowed: originAllowed(req) };
},
hello(conn, message) {
if (!conn.account.allowed) return null;
const login = conn.account.login;
if (login) {
const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(login.id);
const role = active(current) && current.role_key === login.role_key && roleOf(current);
const target = conn.session || (message.token && registry.byToken(message.token));
// Merely restoring the cookie or viewing the title must not take over another device. A current role token
// resumes normally; the authenticated enter/resume POST supplies it after an explicit user action.
if (role && target?.playerId === accountPlayerId(role)) return { ...message, name: sanitizeName(role.nickName), token: target.token };
}
// Anonymous hellos may establish transport for login, but never resume an authenticated game identity.
if (conn.session?.playerId.startsWith('a_')) return null;
const target = message.token && registry.byToken(message.token);
return { ...message, token: target?.playerId.startsWith('a_') ? undefined : message.token };
},
attached(conn, { repeat }) {
if (!repeat && conn.session?.playerId.startsWith('a_')) registry.rotateToken(conn.session);
},
authorized(conn) {
const captured = conn.account?.login;
if (!captured) return false;
const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(captured.id);
const role = active(current) && current.role_key === captured.role_key && roleOf(current);
return !!role && conn.session?.playerId === accountPlayerId(role);
},
};
return { authenticate, setCookie, publish, select, ensureSelection, state, roleOf, revoke, gamePolicy, originAllowed, limited,
gameSession(login) { const role = roleOf(login); return role ? roleSession(role) : null; },
diagnosticAllowed(req) {
const supplied = req.headers['x-sp-diagnostic-key'];
return !!diagnosticKey && typeof supplied === 'string' && equal(Buffer.from(supplied), Buffer.from(diagnosticKey));
},
close() { db.close(); },
};
}
+333
View File
@@ -0,0 +1,333 @@
// server/skland/client.js — HG account bindings and the legacy Skland login/signed API client. Every call uses an
// (i18n-ignore-file: what this file holds is not UI text — the Chinese strings are the internal chain-step labels of
// errors and logs, or the message of an upstream failure the HTTP layer turns into `{ ok: false, error }`, exactly the
// "Error messages are not player text" case of docs/I18N.md; the panel owns the wording a player reads)
// injected `fetchImpl` (default: globalThis.fetch), so tests run the whole chain against a stub and nothing here
// touches the network unless the server does. Account bindings follow the official account SDK; the legacy Skland
// endpoints/signing also follow community documentation (3mua.cn and TNXG/skland-api). Signing lives in ./crypto.js.
//
// The existing QR/SMS paths use Skland; password lookup uses the official HG account binding service:
// 扫码 genScan → pollScan → tokenByScanCode → (token + deviceToken) → grant → cred
// 验证码 sendPhoneCode → tokenByPhoneCode → (token + hgId + deviceToken) → grant → cred
// 密码 tokenByPhonePassword → token → binding-service grant (type 1) → Arknights binding_list
//
// A deviceToken is optional in the official account SDK's grant request. Password login must attempt the grant
// even on a fresh local player; only upstream status 109 (NEED_DEVICE_VERIFY) requires extra verification.
// Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (checked 2026-10-14).
//
// SMS/password/account grants may ask for Geetest (status 1). The browser displays the interactive widget and
// returns its proof to the challenged step; the server never solves the challenge or exposes account tokens.
//
// Cred refresh: code 10000 → cred_token expired → POST zonai /auth/refresh, retry once; code 10002 → cred expired →
// re-run grant → cred from the stored access_token (+ deviceToken), retry once; otherwise the player re-binds.
import { SKLAND_APP_CODE, SKLAND_UA, signedHeaders, deviceHeaders } from './crypto.js';
const AS_BASE = 'https://as.hypergryph.com';
const ZONAI_BASE = 'https://zonai.skland.com';
const BINDING_BASE = 'https://binding-api-account-prod.hypergryph.com';
// Official account SDK: Account.API.grantApp('binding'), app registry entry (checked 2026-10-14).
const BINDING_APP_CODE = 'be36d44aa36bfb5b';
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 120_000;
/** Error carrying the upstream's message; `code` is the upstream status/code field, `step` the chain step. */
export class SklandError extends Error {
/**
* @param {string} message @param {number | string} [code] @param {string} [step]
*/
constructor(message, code, step) {
super(message);
this.name = 'SklandError';
this.code = code;
this.step = step;
}
}
/** The upstream requested an interactive Geetest challenge; the browser completes it and retries that step. */
export class SklandCaptchaError extends SklandError {
/**
* @param {{ gt?: string, challenge?: string, new_captcha?: boolean, geetestId?: string, riskType?: string }} captcha
*/
constructor(captcha, step = 'sendPhoneCode') {
super('需要人机验证,请完成验证后重试', 'captcha', step);
this.name = 'SklandCaptchaError';
this.captcha = captcha;
}
}
/** The upstream requested device verification (109); absence of a local deviceToken alone is not an error. */
export class SklandNeedDeviceError extends SklandError {
constructor(message = '当前设备需要额外验证', step = 'getGrantCode') {
super(`${message},请使用验证码或扫码完成登录`, 109, step);
this.name = 'SklandNeedDeviceError';
}
}
async function readJson(res, step) {
try { return await res.json(); } catch {
throw new SklandError(`${step}:响应不是 JSON (HTTP ${res.status})`, res.status, step);
}
}
/** as.hypergryph.com answers { status: 0, msg?, data? }; returns `data`. */
function assertAs(json, step, captchaStep = step) {
if (json.status === 1 && json.data?.captcha) throw new SklandCaptchaError(json.data.captcha, captchaStep);
if (json.status === 109) throw new SklandNeedDeviceError(json.msg || undefined, step);
if (json.status !== 0) throw new SklandError(json.msg || `${step}失败`, json.status, step);
return json.data || {};
}
/** zonai.skland.com answers { code: 0, message?, data }; returns `data`. */
function assertZonai(json, step) {
if (json.code !== 0) throw new SklandError(json.message || json.msg || `${step}失败`, json.code, step);
return json.data || {};
}
/**
* @param {{
* fetchImpl?: typeof fetch,
* baseHeaders?: Record<string, string>,
* pollIntervalMs?: number,
* pollTimeoutMs?: number,
* sleep?: (ms: number) => Promise<void>,
* }} [opts]
*/
export function createSklandClient(opts = {}) {
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
const pollTimeoutMs = opts.pollTimeoutMs ?? POLL_TIMEOUT_MS;
const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
const baseHeaders = { 'User-Agent': SKLAND_UA, Accept: 'application/json', ...(opts.baseHeaders || {}) };
async function post(url, body, extraHeaders) {
return fetchImpl(url, {
method: 'POST',
headers: { ...baseHeaders, 'Content-Type': 'application/json', ...(extraHeaders || {}) },
body: JSON.stringify(body ?? {}),
});
}
// ── 扫码登录 ──────────────────────────────────────────────────────────────────────────────
/**
* Step 1: request a QR scan session. The QR code must encode `scanUrl` (a `hypergryph://scan_login?…` deep link),
* not the scanId — the 森空岛 app's scanner reads the deep link.
* @returns {Promise<{ scanId: string, scanUrl: string }>}
*/
async function genScan() {
const res = await post(`${AS_BASE}/general/v1/gen_scan/login`, { appCode: SKLAND_APP_CODE });
const json = await readJson(res, '获取扫码会话');
const data = assertAs(json, '获取扫码会话');
return { scanId: data.scanId, scanUrl: data.scanUrl || `hypergryph://scan_login?scanId=${data.scanId}` };
}
/**
* Step 2: one status check (the HTTP route polls per request; `pollScan` below loops it).
* The official account-center login keeps polling for 100 (unscanned) and 101 (awaiting phone confirmation).
* Source: https://web.hycdn.cn/user-center/index.fa9aa713.js (general/v1/scan_status, checked 2026-10-14).
* Only status 0 + data.scanCode completes confirmation; 102 and other failures remain errors.
* @returns {Promise<{ state: 'waiting' | 'confirmed', scanCode?: string, awaitingConfirmation?: boolean }>}
*/
async function checkScan(scanId) {
const res = await fetchImpl(`${AS_BASE}/general/v1/scan_status?scanId=${encodeURIComponent(scanId)}`, { headers: baseHeaders });
const json = await readJson(res, '查询扫码状态');
if (json.data && json.data.scanCode) return { state: 'confirmed', scanCode: json.data.scanCode };
if (json.status === 100 || json.status === 101) return { state: 'waiting', awaitingConfirmation: json.status === 101 };
throw new SklandError(json.msg || '查询扫码状态失败', json.status, 'checkScan');
}
/**
* Step 2 (looping): poll until the phone confirms, or the timeout passes.
* @param {string} scanId
* @param {{ onWait?: (state: string) => void }} [o]
* @returns {Promise<string>} the scanCode
*/
async function pollScan(scanId, o = {}) {
const deadline = Date.now() + pollTimeoutMs;
for (;;) {
const { state, scanCode } = await checkScan(scanId);
if (state === 'confirmed') return scanCode;
if (Date.now() > deadline) throw new SklandError('扫码超时,请重试', 'timeout', 'pollScan');
o.onWait?.(state);
await sleep(pollIntervalMs);
}
}
/** Step 3: scanCode → { token, hgId?, deviceToken? }. */
async function tokenByScanCode(scanCode) {
const res = await post(`${AS_BASE}/user/auth/v1/token_by_scan_code`,
{ appCode: SKLAND_APP_CODE, from: 0, scanCode }, deviceHeaders());
const json = await readJson(res, '扫码换取 token');
const data = assertAs(json, '扫码换取 token');
return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null };
}
// ── 验证码登录 ────────────────────────────────────────────────────────────────────────────
/**
* Send a login/register SMS (type 2, LOGIN_OR_REGISTER), matching the official account center:
* https://web.hycdn.cn/user-center/index.fa9aa713.js (checked 2026-10-14).
* Rate limited (status 102) and 人机验证 (status 1 + geetest) surface as errors;
* retry the latter with the solved `captcha` fields.
* @param {string} phone
* @param {Record<string, string>} [captcha] Geetest v3/v4 proof supplied by the browser
* @returns {Promise<{ ok: true }>}
*/
async function sendPhoneCode(phone, captcha) {
const body = { phone, type: 2 };
if (captcha) body.captcha = captcha;
const res = await post(`${AS_BASE}/general/v1/send_phone_code`, body);
const json = await readJson(res, '发送验证码');
if (json.status === 1 && json.data && json.data.captcha) throw new SklandCaptchaError(json.data.captcha);
if (json.status === 102) throw new SklandError(json.msg || '发送频率太高,请稍后再试', 102, 'sendPhoneCode');
assertAs(json, '发送验证码');
return { ok: true };
}
/** 验证码 → { token, hgId, deviceToken } (this endpoint always returns the deviceToken). */
async function tokenByPhoneCode(phone, code) {
const res = await post(`${AS_BASE}/user/auth/v2/token_by_phone_code`,
{ appCode: SKLAND_APP_CODE, code, phone }, deviceHeaders());
const json = await readJson(res, '验证码换取 token');
const data = assertAs(json, '验证码换取 token');
return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null };
}
// ── 密码登录 ──────────────────────────────────────────────────────────────────────────────
/** 密码 → { token }; a deviceToken is not required locally to try the following grant. */
async function tokenByPhonePassword(phone, password, captcha) {
const body = { phone, password };
if (captcha) body.captcha = captcha;
const res = await post(`${AS_BASE}/user/auth/v1/token_by_phone_password`, body, deviceHeaders());
const json = await readJson(res, '密码换取 token');
const data = assertAs(json, '密码换取 token', 'password');
if (!data.token) throw new SklandError('密码登录未返回 token', 'no_token', 'tokenByPhonePassword');
return { token: data.token };
}
/**
* Query Arknights account bindings without a Skland account or cred.
* Official SDK: Account.API.grantApp('binding') → Role.API.getServerRoleList('ak').
* Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (2026-10-14).
*/
async function getAccountBinding(accessToken, captcha) {
const body = { token: accessToken, appCode: BINDING_APP_CODE, type: 1 };
if (captcha) body.captcha = captcha;
const res = await post(`${AS_BASE}/user/oauth2/v2/grant`,
body, deviceHeaders());
const grant = assertAs(await readJson(res, '授权查询账号绑定'), '授权查询账号绑定', 'accountGrant');
if (!grant.token) throw new SklandError('账号绑定授权未返回 token', 'no_token', 'getAccountBinding');
const query = new URLSearchParams({ token: grant.token, appCode: 'arknights' });
const response = await fetchImpl(`${BINDING_BASE}/account/binding/v1/binding_list?${query}`, { headers: baseHeaders });
const binding = assertAs(await readJson(response, '查询明日方舟账号绑定'), '查询明日方舟账号绑定');
if (!Array.isArray(binding.list)) throw new SklandError('账号绑定响应缺少角色列表', 'bad_binding', 'getAccountBinding');
return binding;
}
// ── token → 授权码 → cred → 绑定 ──────────────────────────────────────────────────────────
/**
* access_token (+ deviceToken when the login path provided one) → grant code.
* @param {string} accessToken
* @param {string | null} [deviceToken]
* @returns {Promise<string>} the grant code
*/
async function getGrantCode(accessToken, deviceToken = null) {
const body = { appCode: SKLAND_APP_CODE, token: accessToken, type: 0 };
if (deviceToken) body.deviceToken = deviceToken;
const res = await post(`${AS_BASE}/user/oauth2/v2/grant`, body);
const json = await readJson(res, '换取授权码');
return assertAs(json, '换取授权码').code;
}
/** grant code → { cred, credToken } (this endpoint takes no signature). */
async function getCred(code) {
const res = await post(`${ZONAI_BASE}/api/v1/user/auth/generate_cred_by_code`, { code, kind: 1 });
const json = await readJson(res, '换取 cred');
const data = assertZonai(json, '换取 cred');
return { cred: data.cred, credToken: data.token };
}
/** A signed GET on zonai (the signature lives in crypto.js). */
async function signedGet(path, query, cred, credToken, dId) {
const { headers } = signedHeaders({ cred, credToken, dId, method: 'GET', path, query });
const res = await fetchImpl(`${ZONAI_BASE}${path}${query ? `?${query}` : ''}`, { headers: { ...baseHeaders, ...headers } });
return readJson(res, path);
}
/** A signed POST on zonai (attendance etc.). */
async function signedPost(path, body, cred, credToken, dId) {
const { headers } = signedHeaders({ cred, credToken, dId, method: 'POST', path, body });
const res = await post(`${ZONAI_BASE}${path}`, body, headers);
return readJson(res, path);
}
/**
* The account's bound games / roles, refreshing the credential once when the upstream says it expired.
* @param {{ cred: string, credToken: string, dId: string, accessToken?: string | null, deviceToken?: string | null }} c
* @returns {Promise<{ binding: object, cred: string, credToken: string }>} the (possibly refreshed) credentials
*/
async function getBinding(c) {
let { cred, credToken } = c;
const dId = c.dId;
let json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
if (json.code === 10000) {
const res = await post(`${ZONAI_BASE}/api/v1/auth/refresh`, {}, { cred });
const data = assertZonai(await readJson(res, '刷新 cred_token'), '刷新 cred_token');
credToken = data.token;
json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
}
if (json.code === 10002 && (c.accessToken || c.deviceToken)) {
const grantCode = await getGrantCode(c.accessToken, c.deviceToken);
const fresh = await getCred(grantCode);
cred = fresh.cred;
credToken = fresh.credToken;
json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
}
if (json.code !== 0) {
const message = json.message || json.msg || '';
if (/未登录/.test(message)) throw new SklandError('登录已过期,请重新绑定', json.code, 'getBinding');
throw new SklandError(message || '查询绑定失败', json.code, 'getBinding');
}
return { binding: json.data, cred, credToken };
}
// ── 整链快捷方式(service.js 用它们组装每个玩家的登录) ──────────────────────────────────
/** 扫码整链: scanUrl → scanCode → token (+deviceToken) → grant → cred. */
async function loginByScan({ onWait } = {}) {
const { scanId, scanUrl } = await genScan();
const scanCode = await pollScan(scanId, { onWait });
const auth = await tokenByScanCode(scanCode);
const grantCode = await getGrantCode(auth.token, auth.deviceToken);
const { cred, credToken } = await getCred(grantCode);
return { scanId, scanUrl, ...auth, cred, credToken };
}
/** 验证码整链: phone + code → token (+hgId+deviceToken) → grant → cred. */
async function loginByPhoneCode(phone, code) {
const auth = await tokenByPhoneCode(phone, code);
const grantCode = await getGrantCode(auth.token, auth.deviceToken);
const { cred, credToken } = await getCred(grantCode);
return { ...auth, cred, credToken };
}
/**
* Password → HG account token → binding-service token (type 1) → Arknights roles.
* This flow does not acquire a Skland cred or require a stored deviceToken.
*/
async function loginByPhonePassword(phone, password) {
const { token } = await tokenByPhonePassword(phone, password);
const binding = await getAccountBinding(token);
return { token, binding };
}
return {
genScan, checkScan, pollScan, tokenByScanCode,
sendPhoneCode, tokenByPhoneCode, tokenByPhonePassword,
getGrantCode, getCred, getBinding, getAccountBinding, signedGet, signedPost,
loginByScan, loginByPhoneCode, loginByPhonePassword,
};
}
+105
View File
@@ -0,0 +1,105 @@
// server/skland/crypto.js — the Skland API request signature (docs/PACKS.md §4 keeps packs data-only; this module is
// server code, not a pack). Same algorithm the community clients use, reconstructed from the public descriptions:
//
// header_ca = { platform: '3', timestamp: '<seconds>', dId: '<device id>', vName: '1.0.0' }
// GET: raw = path + query + timestamp + compactJson(header_ca)
// POST: raw = path + compactJson(body) + timestamp + compactJson(header_ca)
// sign = md5( hmacSha256Hex(credToken, raw) )
//
// Only Web Crypto / node:crypto are used; no third-party dependency. Pure functions — fully unit-tested in
// test/skland/crypto.test.js against pinned vectors.
import { createHmac, createHash, randomUUID, randomBytes } from 'node:crypto';
/** The Skland mobile client's appCode (public SDK constant, visible in decompiled clients). */
export const SKLAND_APP_CODE = '4ca99fa6b56cc2ba';
/** User-Agent the upstream expects (a Skland Android client). */
export const SKLAND_UA = 'Skland/1.32.1 (com.hypergryph.skland; build:103201004; Android 33; ) Okhttp/4.11.0';
/** Compact JSON: no spaces, key order preserved (the upstream signs the exact body bytes). */
export function compactJson(value) {
return JSON.stringify(value);
}
/**
* Build the signed header set for one Skland API request.
* @param {{
* cred: string, credToken: string, dId: string,
* method: 'GET' | 'POST', path: string,
* query?: string, body?: object | string,
* timestampSeconds?: number,
* }} p
* @returns {{ headers: Record<string, string>, signInput: string }}
*/
export function signedHeaders(p) {
const timestampSeconds = p.timestampSeconds ?? Math.floor(Date.now() / 1000) - 2;
const ts = String(timestampSeconds);
const headerCa = { platform: '3', timestamp: ts, dId: p.dId, vName: '1.0.0' };
const caJson = compactJson(headerCa);
const method = p.method.toUpperCase();
const bodyJson = method === 'POST'
? compactJson(p.body ?? {})
: '';
const query = p.query || '';
const signInput = method === 'POST'
? `${p.path}${bodyJson}${ts}${caJson}`
: `${p.path}${query}${ts}${caJson}`;
const sign = md5(hmacSha256Hex(p.credToken, signInput));
return {
signInput,
headers: {
'User-Agent': SKLAND_UA,
'Accept-Encoding': 'gzip',
Connection: 'close',
'X-Requested-With': 'com.hypergryph.skland',
cred: p.cred,
dId: p.dId,
platform: '3',
timestamp: ts,
vName: '1.0.0',
sign,
},
};
}
/** HMAC-SHA256, lowercase hex digest. */
export function hmacSha256Hex(key, message) {
return createHmac('sha256', key).update(message, 'utf8').digest('hex');
}
/** MD5, lowercase hex digest. */
export function md5(text) {
return createHash('md5').update(text, 'utf8').digest('hex');
}
/**
* A device fingerprint (`dId`). The upstream accepts any stable opaque value here; the community clients derive
* one from a random UUID, cache it and reuse it for the account's lifetime. `B` + 32 hex mirrors their format.
* @returns {string}
*/
export function makeDeviceId() {
return `B${randomBytes(16).toString('hex')}`;
}
/** A fresh scan session id placeholder (not a real upstream value — tests only). */
export function randomId() {
return randomUUID().replace(/-/g, '');
}
/**
* The account/login device headers (as.hypergryph.com 验证码 / 密码 / 扫码登录都要求):
* `X-DeviceId` random ~10 chars, `X-DeviceModel` a device name, `X-DeviceType: 2` (mobile).
* The device id is stable per process so the upstream sees one device for the server's lifetime.
* @returns {{ 'X-DeviceId': string, 'X-DeviceModel': string, 'X-DeviceType': string }}
*/
export function deviceHeaders() {
if (!deviceHeaders.cache) {
deviceHeaders.cache = Object.freeze({
'X-DeviceId': randomBytes(5).toString('hex'), // 10 hex chars
'X-DeviceModel': 'Stronghold-Protocol Server',
'X-DeviceType': '2',
});
}
return deviceHeaders.cache;
}
deviceHeaders.cache = null;
+158
View File
@@ -0,0 +1,158 @@
// server/skland/http.js — the /skland/* HTTP surface (JSON). Wired into the main request listener by routes.js when
// (i18n-ignore-file: the answers are bilingual by design, 中文 · English, the same convention as server/http/*
// — docs/I18N.md; the panel renders its own labels and shows these strings verbatim as the diagnostic)
// the skland module is enabled (opts.skland / env SP_SKLAND=1). **Every** answer of this surface is JSON, including
// the errors: a JSON client must never be handed the module-level HTML error page (http/common.js sendError), so a
// local `fail()` writes { ok: false, error } with the status instead. POST bodies are read with a size cap; the
// security headers come from the outer listener.
//
// Endpoints (docs/skland.md):
// GET /skland/status?player=… the player's binding state
// POST /skland/scan/start {player} start a scan session → { scanId, scanUrl }
// POST /skland/scan/poll {player} one poll; finishes the login when the phone confirmed
// POST /skland/phone/send {player, phone, captcha?} send the SMS code
// POST /skland/phone/login {player, phone, code} 验证码 login → bind
// POST /skland/password/login {player, phone, password} 密码 login → bind
// POST /skland/unbind {player} forget the credentials
// GET /skland/roles?player=…&refresh=1 the bound arknights roles (cached)
import { sendJson } from '../http/common.js';
import { SESSION_BINDING_PREFIX } from './session.js';
/** The cap on a POST body (a phone + code + player id is small). */
const MAX_BODY = 64 * 1024;
/** Read the request body as JSON (async iterable; node:http IncomingMessage supports it). */
export async function readJsonBody(req) {
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY) throw new Error('body too large');
chunks.push(chunk);
}
if (!chunks.length) return {};
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
/**
* @param {{ service: ReturnType<typeof import('./service.js').createSklandService>, sessionPlayer?: Function }} deps
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
* parts: { rawPath: string, query: string }) => Promise<boolean>}
* resolves true when the request was a /skland/* route (handled here).
*/
export function createSklandHttp({ service, sessionPlayer }) {
const queryParam = (parts, name) => {
for (const pair of parts.query.split('&')) {
const [k, v] = pair.split('=');
if (decodeURIComponent(k || '') === name) return decodeURIComponent(v || '');
}
return '';
};
/** Every failure of this surface is JSON — never the module-level HTML error page. */
const fail = (req, res, status, error) => sendJson(req, res, status, { ok: false, error });
async function handle(req, res, parts) {
if (parts.rawPath !== '/skland' && !parts.rawPath.startsWith('/skland/')) return false;
const scoped = parts.rawPath.startsWith('/skland/session/');
const path = scoped ? parts.rawPath.replace('/skland/session/', '/skland/') : parts.rawPath;
const ownedPlayer = scoped ? sessionPlayer?.(req) : null;
if (scoped && !ownedPlayer) {
fail(req, res, 401, '游戏会话已失效,请重新连接 · Game session expired'); return true;
}
const allowed = (player) => scoped || (!player.startsWith(SESSION_BINDING_PREFIX) && !player.startsWith('@account/'));
try {
if (req.method === 'GET' && path === '/skland/status') {
const player = ownedPlayer || queryParam(parts, 'player') || '';
if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; }
if (!player) { fail(req, res, 400, '缺少 player 参数 · Missing player'); return true; }
sendJson(req, res, 200, service.status(player));
return true;
}
if (req.method === 'GET' && path === '/skland/roles') {
const player = ownedPlayer || queryParam(parts, 'player') || '';
if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; }
const refresh = queryParam(parts, 'refresh') === '1';
if (!player) { fail(req, res, 400, '缺少 player 参数 · Missing player'); return true; }
try {
sendJson(req, res, 200, { ok: true, roles: await service.roles(player, refresh) });
} catch (e) {
sendJson(req, res, 200, { ok: false, error: e.message });
}
return true;
}
if (req.method !== 'POST') { fail(req, res, 405, '不支持的请求方法 · Method not allowed'); return true; }
let body;
try { body = await readJsonBody(req); } catch {
fail(req, res, 400, '请求体无效 · Bad request'); return true;
}
const player = ownedPlayer || String(body.player || '');
if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; }
if (path === '/skland/scan/start') {
if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; }
try {
const qr = await service.startScan(player);
sendJson(req, res, 200, { ok: true, ...qr });
} catch (e) { sendJson(req, res, 200, { ok: false, error: e.message }); }
return true;
}
if (path === '/skland/scan/poll') {
if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; }
try {
sendJson(req, res, 200, { ok: true, ...(await service.pollScan(player)) });
} catch (e) {
// A poll that fails still names a terminal state: a caller stops on anything but 'waiting', and a bare
// { ok: false } would leave it reading `state === undefined` (the panel showed "未知状态" for that).
sendJson(req, res, 200, { ok: false, state: 'error', error: e.message });
}
return true;
}
if (path === '/skland/phone/send') {
if (!player || !body.phone) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; }
try {
sendJson(req, res, 200, await service.sendPhoneCode(player, String(body.phone), body.captcha));
} catch { fail(req, res, 500, '服务器内部错误 · Internal error'); }
return true;
}
if (path === '/skland/phone/login') {
if (!player || !body.phone || !body.code) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; }
sendJson(req, res, 200, await service.loginByPhoneCode(player, String(body.phone), String(body.code)));
return true;
}
if (path === '/skland/password/login') {
if (!player || !body.phone || !body.password) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; }
sendJson(req, res, 200, await service.loginByPhonePassword(player, String(body.phone), String(body.password),
body.captcha, typeof body.captchaStep === 'string' ? body.captchaStep : undefined));
return true;
}
if (path === '/skland/unbind') {
if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; }
sendJson(req, res, 200, service.unbind(player));
return true;
}
fail(req, res, 404, '未知的 /skland 接口 · Unknown endpoint');
} catch {
fail(req, res, 500, '服务器内部错误 · Internal error');
}
return true;
}
return handle;
}
/**
* The handler mounted when the module is OFF. Without it the path would fall through to the static mount (a 404 for
* GET) and to routes.js's GET/HEAD gate (a **405 HTML** for POST) — an inconsistent surface for one API. This one
* answers every /skland/* request the same way, JSON like the enabled surface, so a client can always parse the
* "not enabled" answer.
* @returns {(req, res, parts: { rawPath: string }) => Promise<boolean>}
*/
export function createSklandOffHandler() {
return async (req, res, parts) => {
if (parts.rawPath !== '/skland' && !parts.rawPath.startsWith('/skland/')) return false;
sendJson(req, res, 404, { ok: false, error: '未启用森空岛绑定 · the Skland module is off (set SP_SKLAND=1)' });
return true;
};
}
+22
View File
@@ -0,0 +1,22 @@
// Account recovery describes only the selected role's live server-side seat. It never reads browser caches.
import { randomUUID } from 'node:crypto';
export function createAccountRecovery({ registry, lobby }) {
const roomIds = new WeakMap();
return {
describe(playerId) {
const session = playerId && registry.byId(playerId);
if (!session || registry.isExpired(session, registry.now())) return null;
const room = lobby.roomOf(session);
if (!room || room.disposed) return null;
if (!roomIds.has(room)) roomIds.set(room, randomUUID());
return {
// A selector, not a credential: a replaced room or a later match must not satisfy a stale recovery request.
id: roomIds.get(room) + ':' + room.matchCount,
name: session.name, code: room.code, mode: room.mode, difficulty: room.difficulty,
inMatch: !!room.match, round: room.match?.round ?? 0,
connected: session.connected, spectating: !!room.spectatorOf(playerId),
};
},
};
}
+285
View File
@@ -0,0 +1,285 @@
// server/skland/service.js — per-player Arknights account bindings on top of the client (client.js) and the
// (i18n-ignore-file: the Chinese strings are error/diagnostic text (an upstream failure's message, a login that did
// not finish) returned as `{ ok: false, error }` — docs/I18N.md's "Error messages are not player text"; the panel
// supplies the wording a player reads)
// store (store.js). One instance per server. The scan sessions live in memory (a QR code is transient); everything
// durable lives in the store.
//
// HTTP uses transport-scoped pending rows, then JWT-owned account rows after provider confirmation.
// Diagnostic ids require an administrator key and cannot access either reserved namespace (docs/skland.md §5).
import { createSklandClient, SklandError, SklandCaptchaError, SklandNeedDeviceError } from './client.js';
import { makeDeviceId } from './crypto.js';
/** Extracts a readable profile name from the binding answer (an arknights channel first, then any). */
function nicknameFromBinding(binding) {
const lists = binding?.list || [];
const ordered = [...lists.filter((l) => l?.appCode === 'arknights'), ...lists.filter((l) => l?.appCode !== 'arknights')];
for (const list of ordered) {
for (const b of list?.bindingList || []) {
if (b.nickName) return b.nickName;
}
}
return null;
}
/** The arknights characters of the binding answer, as a stable shape (every field always present, so JSON keeps it).
* `uid` is the in-game account id, `channelName` the channel, `isDefault` its flag, `gameId` the game's own id. */
function arknightsRoles(binding) {
const roles = [];
for (const list of binding?.list || []) {
if (list?.appCode !== 'arknights') continue;
for (const b of list?.bindingList || []) {
roles.push({
uid: b.uid == null ? null : String(b.uid),
nickName: b.nickName ?? null,
channelName: b.channelName ?? null,
isDefault: b.is_default === true || b.isDefault === true,
gameId: b.gameId ?? null,
});
}
}
return roles;
}
/**
* @param {{
* store: ReturnType<typeof import('./store.js').createSklandStore>,
* client?: ReturnType<typeof import('./client.js').createSklandClient>,
* fetchImpl?: typeof fetch,
* scanTimeoutMs?: number,
* }} p
*/
export function createSklandService({ store, client, fetchImpl, scanTimeoutMs = 130_000 }) {
const skland = client || createSklandClient({ fetchImpl });
/** @type {Map<string, { scanId: string, scanUrl: string, createdAt: number, attempt: object, polling: boolean }>} in-memory scan sessions by player */
const scans = new Map();
// Resume the same grant after its captcha; never cache passwords or repeat a successful password exchange.
const pendingPasswordGrants = new Map();
const attempts = new Map();
function begin(playerId) {
const attempt = {};
attempts.set(playerId, attempt); scans.delete(playerId);
return attempt;
}
const current = (playerId, attempt) => attempts.get(playerId) === attempt;
function checkCurrent(playerId, attempt) {
if (!current(playerId, attempt)) {
const error = new Error('登录已取消,请重试');
throw error;
}
}
function retire(playerId, attempt) { if (current(playerId, attempt)) attempts.delete(playerId); }
function now() { return Date.now(); }
/** The player's row, created when missing. */
function row(playerId) {
store.ensure(playerId);
return store.get(playerId);
}
/** GET /skland/status body. */
function status(playerId) {
const r = row(playerId);
const roles = r.binding ? arknightsRoles(r.binding) : [];
return {
playerId,
status: r.status,
bound: r.status === 'bound',
nickname: r.nickname,
hgId: r.hg_id,
roles,
error: r.error || undefined,
};
}
/** Starts (or restarts) a QR login session for the player. The QR code encodes `scanUrl`. */
async function startScan(playerId) {
const attempt = begin(playerId);
pendingPasswordGrants.delete(playerId);
try {
const { scanId, scanUrl } = await skland.genScan();
checkCurrent(playerId, attempt);
scans.set(playerId, { scanId, scanUrl, createdAt: now(), attempt, polling: false });
store.update(playerId, { status: 'scanning', error: null });
return { scanId, scanUrl, expiresInMs: scanTimeoutMs };
} catch (error) { retire(playerId, attempt); throw error; }
}
/**
* One poll of the player's scan session. When the phone confirmed, the rest of the chain runs and the player is
* bound — the same call that reports `confirmed` also completes the login.
* @returns {Promise<{ state: 'none' | 'waiting' | 'confirmed' | 'expired', scanUrl?: string, error?: string,
* awaitingConfirmation?: boolean, status?: string, nickname?: string | null }>}
*/
async function pollScan(playerId) {
const session = scans.get(playerId);
if (!session) return { state: 'none' };
if (now() - session.createdAt > scanTimeoutMs) {
scans.delete(playerId);
retire(playerId, session.attempt);
store.update(playerId, { status: 'unbound', error: '扫码超时' });
return { state: 'expired', error: '扫码超时,请重新获取二维码' };
}
if (session.polling) return { state: 'waiting', scanUrl: session.scanUrl };
session.polling = true;
let confirmed = false;
try {
const { state, scanCode, awaitingConfirmation } = await skland.checkScan(session.scanId);
checkCurrent(playerId, session.attempt);
if (state === 'waiting') return { state, scanUrl: session.scanUrl, awaitingConfirmation: !!awaitingConfirmation };
confirmed = true;
const auth = await skland.tokenByScanCode(scanCode);
await finishLogin(playerId, auth, { attempt: session.attempt });
const r = row(playerId);
scans.delete(playerId); retire(playerId, session.attempt);
return { state: 'confirmed', status: r.status, nickname: r.nickname };
} catch (e) {
if (!current(playerId, session.attempt)) return { state: 'none' };
scans.delete(playerId); retire(playerId, session.attempt);
store.update(playerId, { status: 'error', error: e.message });
if (!confirmed) throw e;
return { state: 'confirmed', error: e.message };
} finally { session.polling = false; }
}
/**
* Shared tail of every login path: grant → cred → binding, persisted per player.
* @param {string} playerId
* @param {{ token: string, hgId?: string | null, deviceToken?: string | null }} auth
* @param {{ dId?: string, attempt?: object }} [o]
*/
async function finishLogin(playerId, auth, o = {}) {
const existing = row(playerId);
const dId = existing.d_id || o.dId || makeDeviceId();
const grantCode = await skland.getGrantCode(auth.token, auth.deviceToken);
const { cred, credToken } = await skland.getCred(grantCode);
const { binding, cred: cred2, credToken: credToken2 } = await skland.getBinding({
cred, credToken, dId, accessToken: auth.token, deviceToken: auth.deviceToken,
});
if (o.attempt) checkCurrent(playerId, o.attempt);
store.update(playerId, {
access_token: auth.token, device_token: auth.deviceToken || existing.device_token || null,
hg_id: auth.hgId || existing.hg_id || null,
cred: cred2, cred_token: credToken2, d_id: dId, binding, binding_source: 'skland',
nickname: nicknameFromBinding(binding), status: 'bound', error: null,
});
return row(playerId);
}
/** 发送短信验证码. `captcha` = the solved geetest fields when the first attempt asked for one. */
async function sendPhoneCode(playerId, phone, captcha) {
try {
await skland.sendPhoneCode(phone, captcha);
store.update(playerId, { error: null });
return { ok: true };
} catch (e) {
if (e instanceof SklandCaptchaError) return { ok: false, needCaptcha: true, captcha: e.captcha };
if (e instanceof SklandError) return { ok: false, error: e.message };
throw e;
}
}
/** 验证码登录: phone + code → bind. Uses the step methods so `finishLogin` is the only grant→cred→binding place. */
async function loginByPhoneCode(playerId, phone, code) {
const attempt = begin(playerId);
pendingPasswordGrants.delete(playerId);
try {
const auth = await skland.tokenByPhoneCode(phone, code);
const r = await finishLogin(playerId, auth, { attempt });
return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) };
} catch (e) {
if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' };
const message = e instanceof SklandError ? e.message : '登录失败';
store.update(playerId, { status: 'error', error: message });
return { ok: false, error: message };
} finally { retire(playerId, attempt); }
}
/**
* Password login queries the official HG account binding service directly, without acquiring a Skland cred.
* A previous QR/SMS login and its deviceToken are not needed for this flow.
*/
async function loginByPhonePassword(playerId, phone, password, captcha, captchaStep = 'password') {
const attempt = begin(playerId);
let auth;
try {
if (captchaStep === 'accountGrant' && captcha) {
const pending = pendingPasswordGrants.get(playerId);
pendingPasswordGrants.delete(playerId);
if (!pending || pending.phone !== phone || now() - pending.createdAt > 120_000) {
throw new SklandError('验证已过期,请重新点击登录', 'captcha_expired', 'accountGrant');
}
auth = { token: pending.token };
} else {
pendingPasswordGrants.delete(playerId);
auth = await skland.tokenByPhonePassword(phone, password, captcha);
}
const { token } = auth;
const binding = await skland.getAccountBinding(token, captchaStep === 'accountGrant' ? captcha : undefined);
checkCurrent(playerId, attempt);
store.update(playerId, {
access_token: token, device_token: null, hg_id: null, cred: null, cred_token: null,
user_id: null, d_id: null, binding, binding_source: 'hypergryph',
nickname: nicknameFromBinding(binding), status: 'bound', error: null,
});
const r = row(playerId);
return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) };
} catch (e) {
if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' };
if (e instanceof SklandCaptchaError) {
if (e.step === 'accountGrant' && auth) pendingPasswordGrants.set(playerId, { token: auth.token, phone, createdAt: now() });
return { ok: false, needCaptcha: true, captcha: e.captcha, captchaStep: e.step };
}
if (e instanceof SklandNeedDeviceError) {
store.update(playerId, { status: 'error', error: e.message });
return { ok: false, error: e.message, needDevice: true };
}
const message = e instanceof SklandError ? e.message : '登录失败';
store.update(playerId, { status: 'error', error: message });
return { ok: false, error: message };
} finally { retire(playerId, attempt); }
}
/** 解绑: forget every credential, including the deviceToken; later logins are checked by the upstream again. */
function unbind(playerId) {
store.unbind(playerId);
scans.delete(playerId);
pendingPasswordGrants.delete(playerId);
attempts.delete(playerId);
return { ok: true };
}
/** The cached arknights roles (a fresh /player/binding round-trip when `refresh`). */
async function roles(playerId, refresh = false) {
const r = row(playerId);
if (!refresh || r.status !== 'bound') return arknightsRoles(r.binding);
const attempt = begin(playerId);
try {
if (r.binding_source === 'hypergryph') {
const binding = await skland.getAccountBinding(r.access_token);
checkCurrent(playerId, attempt);
store.update(playerId, { binding, nickname: nicknameFromBinding(binding), error: null });
return arknightsRoles(binding);
}
const { binding, cred, credToken } = await skland.getBinding({
cred: r.cred, credToken: r.cred_token, dId: r.d_id, accessToken: r.access_token, deviceToken: r.device_token,
});
checkCurrent(playerId, attempt);
store.update(playerId, { binding, cred, credToken, nickname: nicknameFromBinding(binding) });
return arknightsRoles(binding);
} catch (e) {
if (!current(playerId, attempt)) throw e;
const message = e instanceof SklandError ? e.message : '查询失败';
store.update(playerId, { status: 'error', error: message });
throw e;
} finally { retire(playerId, attempt); }
}
return {
status, startScan, pollScan, sendPhoneCode, loginByPhoneCode, loginByPhonePassword, unbind, roles,
/** test hooks */
_store: store, _client: skland, _scans: scans,
};
}
+10
View File
@@ -0,0 +1,10 @@
// The title integration reuses the game's existing reconnect token. No new WebSocket messages or session fields.
export const SESSION_BINDING_PREFIX = '@session/';
export function createSessionPlayerResolver(registry) {
return (req) => {
const match = /^Bearer ([a-f0-9]{32})$/i.exec(req.headers.authorization || '');
const session = match ? registry.byToken(match[1]) : null;
return session ? SESSION_BINDING_PREFIX + session.playerId : null;
};
}
+179
View File
@@ -0,0 +1,179 @@
// server/skland/store.js — the per-player Skland credential store (docs/PACKS.md keeps packs data-only; this is
// server code, not a pack). A local, high-performance embedded database: node:sqlite (bundled with Node ≥ 22) in
// WAL mode — no native build step, no extra dependency, survives restarts, and the Docker image mounts one file.
//
// One row per pending transport, JWT account subject or administrator diagnostic id, holding the login chain state:
// access_token the HG account token (refreshes official bindings or rebuilds a Skland cred)
// cred the long-term API credential
// cred_token the HMAC signing key (never leaves the server)
// user_id the Skland internal user id
// d_id the device fingerprint the credentials are pinned to
// nickname / binding what the account reports (cache; refreshed on demand)
// binding_source selects official HG account lookup or the legacy Skland refresh path
//
// The store is synchronous (SQLite is), so callers never await; it is created per server instance (tests pass their
// own file, `:memory:` for pure in-memory). Reserved keys and account ownership are enforced by accountHttp.js.
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS skland_players (
player_id TEXT PRIMARY KEY,
access_token TEXT,
device_token TEXT,
hg_id TEXT,
cred TEXT,
cred_token TEXT,
user_id TEXT,
d_id TEXT,
nickname TEXT,
binding TEXT, -- the raw binding list from the selected provider (cache)
binding_source TEXT NOT NULL DEFAULT 'skland', -- skland | hypergryph
status TEXT NOT NULL DEFAULT 'unbound', -- unbound | scanning | bound | error
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_skland_status ON skland_players(status);
`;
/**
* Node ≥ 22.5 has node:sqlite; an older 22.x has not. The store falls back to an in-memory map so the server still
* boots (the credentials then do not survive a restart — logged once).
* @param {{ file?: string, log?: object }} [opts] file: the database path (default data/skland.db; ':memory:' for
* tests). The parent directory is created when missing.
*/
export function createSklandStore(opts = {}) {
const file = opts.file || join(process.cwd(), 'data', 'skland.db');
const log = opts.log || { warn: () => {} };
if (typeof DatabaseSync !== 'function') {
log.warn?.('[skland] node:sqlite unavailable on this Node — using an in-memory store (credentials lost on restart)');
return createMemoryStore(file);
}
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
const db = new DatabaseSync(file);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA synchronous = NORMAL;');
db.exec('PRAGMA foreign_keys = ON;');
db.exec(SCHEMA);
// Preserve existing QR/SMS bindings when opening a database created before official account lookup was added.
if (!db.prepare('PRAGMA table_info(skland_players)').all().some((column) => column.name === 'binding_source')) {
db.exec("ALTER TABLE skland_players ADD COLUMN binding_source TEXT NOT NULL DEFAULT 'skland'");
}
const now = () => Date.now();
/** @returns {Record<string, any> | null} the row as an object, or null */
function rowToObject(row) {
if (!row) return null;
return { ...row, binding: row.binding ? JSON.parse(row.binding) : null };
}
return {
/** The database file (or ':memory:'). */
file,
/** The SQLite journal mode actually in force ('wal' on a file-backed store; 'memory' for the fallback). */
journalMode() {
try { return String(db.prepare('PRAGMA journal_mode').get().journal_mode).toLowerCase(); } catch { return 'unknown'; }
},
close() { db.close(); },
/** Ensure a row exists for the player (idempotent). */
ensure(playerId) {
db.prepare(
'INSERT INTO skland_players (player_id, status, created_at, updated_at) VALUES (?, ?, ?, ?) ' +
'ON CONFLICT(player_id) DO NOTHING',
).run(playerId, 'unbound', now(), now());
},
get(playerId) {
const row = db.prepare('SELECT * FROM skland_players WHERE player_id = ?').get(playerId);
return rowToObject(row);
},
/** All rows (admin/debug views). */
list() {
return db.prepare('SELECT * FROM skland_players ORDER BY updated_at DESC').all().map(rowToObject);
},
/**
* Merge fields into a player's row (created when missing). `binding` may be an object — stored as JSON.
* @param {string} playerId
* @param {Partial<{access_token: string, device_token: string, hg_id: string, cred: string, cred_token: string,
* user_id: string, d_id: string, nickname: string, binding: object | null, binding_source: string, status: string,
* error: string | null}>} fields
*/
update(playerId, fields) {
this.ensure(playerId);
const allowed = ['access_token', 'device_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname', 'binding_source', 'status', 'error'];
const sets = [];
const values = [];
for (const key of allowed) {
if (key in fields) { sets.push(`${key} = ?`); values.push(fields[key] ?? null); }
}
if ('binding' in fields) { sets.push('binding = ?'); values.push(fields.binding == null ? null : JSON.stringify(fields.binding)); }
if (!sets.length) return;
sets.push('updated_at = ?');
values.push(now(), playerId);
db.prepare(`UPDATE skland_players SET ${sets.join(', ')} WHERE player_id = ?`).run(...values);
},
/** Remove a player's credentials (解绑). The row stays, marked unbound. */
unbind(playerId) {
this.update(playerId, {
access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
});
},
/** Delete a player's row entirely (e.g. session cleanup). */
remove(playerId) {
db.prepare('DELETE FROM skland_players WHERE player_id = ?').run(playerId);
},
/** For tests: raw row count. */
count() {
return db.prepare('SELECT COUNT(*) AS n FROM skland_players').get().n;
},
};
}
/** The in-memory fallback (same API; a pre-22.5 Node). Credentials do not survive a restart. */
function createMemoryStore(file) {
/** @type {Map<string, Record<string, any>>} */
const rows = new Map();
const now = () => Date.now();
const blank = () => ({
player_id: null, access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
created_at: 0, updated_at: 0,
});
return {
file,
journalMode() { return 'memory'; },
close() { rows.clear(); },
ensure(playerId) {
if (!rows.has(playerId)) rows.set(playerId, { ...blank(), player_id: playerId, created_at: now(), updated_at: now() });
},
get(playerId) { const row = rows.get(playerId); return row ? { ...row } : null; },
list() { return [...rows.values()].map((r) => ({ ...r })); },
update(playerId, fields) {
this.ensure(playerId);
const row = rows.get(playerId);
for (const [key, value] of Object.entries(fields)) {
row[key] = key === 'binding' && value != null ? JSON.parse(JSON.stringify(value)) : (value ?? null);
}
row.updated_at = now();
},
unbind(playerId) {
this.update(playerId, {
access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
});
},
remove(playerId) { rows.delete(playerId); },
count() { return rows.size; },
};
}
+5
View File
@@ -0,0 +1,5 @@
// A role's UID is unique within its provider-reported server region. Display names are never identity keys.
// gameId is optional provider metadata (the official password API may omit it), so it cannot be part of identity.
export const roleKey = (role) => JSON.stringify([String(role.channelName || '').trim(), String(role.uid).trim()]);
export const hasRoleIdentity = (role) => role?.uid != null && String(role.uid).trim() !== ''
&& String(role.channelName || '').trim() !== '';
+21
View File
@@ -141,3 +141,24 @@ test('live token ownership survives a duplicated tab, reload and reconnect', { t
assert.equal(welcome.playerId, a.welcome.playerId); assert.equal(welcome.playerId, a.welcome.playerId);
await assertOnline(h, [a2, b]); await assertOnline(h, [a2, b]);
}); });
test('explicit account tokens are tentative until welcome and do not overwrite the last established local identity', async () => {
const old = '1'.repeat(32), selected = '2'.repeat(32), attached = '3'.repeat(32);
const local = storage(), session = storage({ 'sp.token': old });
const id = createIdentity({ local, session, channel: null });
await id.init();
id.saveToken(old);
assert.equal(id.stageToken(selected), true);
assert.equal(id.getToken(), selected);
assert.deepEqual(id.getTokenClaim(), { noReplace: false });
assert.equal(session.getItem('sp.token'), old);
assert.deepEqual(JSON.parse(local.getItem('sp.tokens')), [old]);
assert.equal(id.stageToken(''), false);
assert.equal(id.getToken(), selected);
id.saveToken(attached);
assert.equal(session.getItem('sp.token'), attached);
assert.equal(id.getToken(), attached);
assert.ok(Number.isFinite(id.getTokenClaim().claimAt));
assert.equal(JSON.parse(local.getItem('sp.tokens')).includes(selected), false, 'only the rotated welcome token is remembered');
});
+14 -1
View File
@@ -1,6 +1,7 @@
// .gitignore keeps per-machine state out of the repository (and so out of the release packages, which ship tracked // .gitignore keeps per-machine state out of the repository (and so out of the release packages, which ship tracked
// files only: tools/package.mjs). GitHub #258: a local npm cache (`npm_config_cache=./.npm-cache`, `npm ci --cache // files only: tools/package.mjs). GitHub #258: a local npm cache (`npm_config_cache=./.npm-cache`, `npm ci --cache
// .npm-cache`) must never be committed by any contributor. // .npm-cache`) must never be committed by any contributor. The opt-in Skland credential database (docs/skland.md) is
// the other per-machine secret: `data/skland.db` and the Docker volume path `skland-data/`.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -29,3 +30,15 @@ test('GitHub #258: the local npm cache (.npm-cache/) is ignored, like node_modul
} }
assert.notEqual(ignored('package.json'), true, 'a tracked file is not'); assert.notEqual(ignored('package.json'), true, 'a tracked file is not');
}); });
test('the Skland credential database (docs/skland.md): the file, its WAL side files and the Docker volume path', () => {
for (const entry of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/']) {
assert.ok(lines.includes(entry), `.gitignore lists ${entry}`);
}
// the store's default path, the two side files node:sqlite/WAL writes next to it, and the bind-mount form a
// deployment may use instead of docker-compose.yml's named volume (docker.test.js pins the volume itself)
for (const rel of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/skland.db', 'skland-data/skland.db-wal']) {
const v = ignored(rel);
if (v !== null) assert.equal(v, true, `${rel} is ignored`);
}
});
+68
View File
@@ -0,0 +1,68 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createBindingApi, ensureBindingSession, preferredRole, roleKey } from '../../public/js/skland/api.js';
test('the title client puts its game token only in an authorization header and never sends a player id', async () => {
const calls = [];
const request = createBindingApi({ token: 'private-game-token', fetchImpl: async (url, init) => {
calls.push({ url, init }); return { ok: true, status: 200, json: async () => ({ ok: true }) };
} });
await request('status'); await request('password/login', { phone: '13800000000', password: 'local-input' });
assert.equal(calls[0].url, '/skland/session/status');
assert.equal(calls[0].init.headers.authorization, 'Bearer private-game-token');
assert.equal(calls[0].init.method, 'GET');
assert.equal(calls[1].init.method, 'POST');
assert.deepEqual(JSON.parse(calls[1].init.body), { phone: '13800000000', password: 'local-input' });
assert.ok(calls.every(({ url, init }) => !url.includes('private-game-token') && !init.body?.includes('private-game-token')));
});
test('the title client distinguishes disabled module, expired session, network and malformed responses', async () => {
for (const [status, code] of [[404, 'off'], [401, 'session']]) {
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status }) })('status'), { code });
}
await assert.rejects(createBindingApi()('status'), { code: 'session' });
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => { throw new Error('offline'); } })('status'), { code: 'network' });
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 200, ok: true, json: async () => { throw new Error(); } }) })('status'), { code: 'response' });
});
test('binding starts hello without entering the game, reuses an online identity, and cleans up on failure', async () => {
const listeners = new Map();
let current = null, connects = 0, sentName;
const identity = { init: async () => {}, getToken: () => current };
const net = {
status: 'connected',
on: (event, fn) => { listeners.set(event, fn); return () => listeners.delete(event); },
setName: (name) => { sentName = name; },
connect: () => { connects++; setTimeout(() => { current = 'T'; listeners.get('welcome')?.({ token: 'T' }); }, 0); },
};
assert.equal(await ensureBindingSession({ net, identity, name: '博士代号' }), 'T');
assert.equal(sentName, '博士代号'); assert.equal(listeners.size, 0);
net.status = 'online';
assert.equal(await ensureBindingSession({ net, identity, name: '另一个名字' }), 'T');
assert.equal(connects, 1);
net.status = 'connected'; net.connect = () => {};
await assert.rejects(ensureBindingSession({ net, identity, name: '博士', timeoutMs: 1 }), { code: 'network' });
assert.equal(listeners.size, 0);
});
test('nickname selection uses the default role, preserves a selected channel, and supports no roles', () => {
const a = { uid: '123', channelName: '官服', gameId: 1, nickName: '官服博士' };
const b = { uid: '123', channelName: 'B服', gameId: 2, nickName: 'B服博士', isDefault: true };
assert.equal(preferredRole([a, b]), b);
assert.equal(preferredRole([a, b], roleKey(a)), a);
assert.equal(preferredRole([a, b], 'removed-role'), b);
assert.equal(preferredRole([a]), a);
assert.equal(preferredRole([]), null);
assert.equal(preferredRole([{ uid: 'missing-name', isDefault: true }, { ...b, nickName: ' ' }, a]), a);
assert.equal(preferredRole([{ uid: 'missing-name' }]), null);
assert.equal(preferredRole([{ uid: null, channelName: '官服', nickName: '缺少身份' }]), null);
assert.equal(roleKey(a), roleKey({ ...a, uid: 123, gameId: null, nickName: '改名以后' }), 'nickname and optional gameId never change identity');
});
test('an expired recovery is distinguishable from a transport error and never becomes a successful entry', async () => {
const request = createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 409, ok: false,
json: async () => ({ ok: false, code: 'resume_expired', error: 'expired' }),
}) });
await assert.rejects(request('resume', { id: 'stale' }), { code: 'resume_expired' });
});
+66
View File
@@ -0,0 +1,66 @@
// Provider contract tests use a fake widget and fake proof only; no real captcha or network request is performed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setImmediate } from 'node:timers/promises';
import { solveCaptcha } from '../../public/js/skland/captcha.js';
function fakeProvider(proof) {
const events = {};
const calls = { shown: 0, destroyed: 0, options: null };
const widget = {
onReady(fn) { events.ready = fn; }, onSuccess(fn) { events.success = fn; },
onClose(fn) { events.close = fn; }, onError(fn) { events.error = fn; },
getValidate() { return proof; },
showCaptcha() { calls.shown++; }, verify() { calls.shown++; },
destroy() { calls.destroyed++; events.close?.(); },
};
const init = (options, ready) => { calls.options = options; ready(widget); };
return { events, calls, scope: { initGeetest4: init, initGeetest: init }, load: async () => {} };
}
test('captcha v4 opens the provider widget and preserves its proof plus the upstream challenge', async () => {
const proof = { captcha_id: 'TEST', lot_number: 'TEST-LOT', captcha_output: 'TEST-PROOF', pass_token: 'TEST-PASS', gen_time: '1' };
const provider = fakeProvider(proof);
const pending = solveCaptcha({ geetestId: 'TEST', challenge: 'TEST-CHALLENGE', riskType: 'slide' }, provider);
await setImmediate();
assert.equal(provider.calls.shown, 0, 'initialization does not submit a proof');
provider.events.ready();
assert.equal(provider.calls.shown, 1);
assert.deepEqual(provider.calls.options, { captchaId: 'TEST', riskType: 'slide', product: 'bind', language: 'zho' });
provider.events.success();
assert.deepEqual(await pending, { ...proof, challenge: 'TEST-CHALLENGE' });
assert.equal(provider.calls.destroyed, 1);
});
test('captcha v3 preserves the three provider fields for the SMS retry', async () => {
const proof = { geetest_challenge: 'TEST-CH', geetest_validate: 'TEST-VALIDATE', geetest_seccode: 'TEST-SEC' };
const provider = fakeProvider(proof);
const pending = solveCaptcha({ gt: 'TEST', challenge: 'TEST-CH', new_captcha: true }, provider);
await setImmediate();
provider.events.ready();
provider.events.success();
assert.deepEqual(await pending, proof);
assert.equal(provider.calls.options.https, true);
});
test('captcha cancellation never yields a proof, including after a provider retry event', async () => {
const provider = fakeProvider({ captcha_output: 'UNUSED-TEST-PROOF' });
const pending = solveCaptcha({ geetestId: 'TEST' }, provider);
await setImmediate();
provider.events.error({ error_code: 'error_01' });
provider.events.close();
provider.events.success();
assert.equal(await pending, null);
assert.equal(provider.calls.destroyed, 1);
});
test('unsupported or failed captchas are rejected so the UI can offer retry', async () => {
await assert.rejects(() => solveCaptcha({}), /captcha_type/);
await assert.rejects(() => solveCaptcha({ geetestId: 'TEST' }, { load: async () => { throw new Error('captcha_load'); } }), /captcha_load/);
const provider = fakeProvider(null);
const pending = solveCaptcha({ geetestId: 'TEST' }, provider);
const rejected = assert.rejects(pending, /captcha_validate/);
await setImmediate();
provider.events.success();
await rejected;
});
+324
View File
@@ -0,0 +1,324 @@
// The Skland login chains (server/skland/client.js): 扫码 / 验证码 / 密码, the 人机验证 hand-off, and the two
// credential-refresh paths.
//
// Every case runs against a stub upstream whose answers are the shapes the community documentation records (the
// 3mua.cn write-up and TNXG/skland-api), so the chain's *order* and the *fields on the wire* are pinned without
// touching the network. The stub records each call, and the assertions read that record — a chain that skipped a step
// or sent the wrong body fails here rather than in production.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createSklandClient, SklandError, SklandCaptchaError, SklandNeedDeviceError } from '../../server/skland/client.js';
/**
* A stub upstream. `overrides` replaces individual answers by endpoint key.
* Keys: genScan, scanStatus, scanToken, sendCode, phoneToken, passwordToken, grant, cred, binding, refresh.
*/
function stubUpstream(overrides = {}) {
const calls = [];
const j = (o) => ({ status: 200, json: async () => o });
const answers = {
genScan: () => j({ status: 0, msg: 'OK', data: { scanId: 'SCAN1', scanUrl: 'hypergryph://scan_login?scanId=SCAN1' } }),
scanStatus: () => j({ status: 0, msg: 'OK', data: { scanCode: 'SCANCODE1' } }),
scanToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-SCAN', hgId: 'HG1', deviceToken: 'DEV1' } }),
sendCode: () => j({ status: 0, msg: 'OK' }),
phoneToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-CODE', hgId: 'HG1', deviceToken: 'DEV1' } }),
passwordToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-PW' } }),
grant: ({ body }) => j({ status: 0, msg: 'OK', data: body.appCode === 'be36d44aa36bfb5b'
? { token: 'BINDINGTOKEN' } : { uid: 'U1', code: 'GRANTCODE' } }),
cred: () => j({ code: 0, data: { cred: 'CRED', token: 'CREDTOKEN' } }),
binding: () => j({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士' }] }] } }),
accountBinding: () => j({ status: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', isDefault: true }] }] } }),
refresh: () => j({ code: 0, data: { token: 'CREDTOKEN2' } }),
};
const routes = [
['gen_scan/login', 'genScan'], ['scan_status', 'scanStatus'], ['token_by_scan_code', 'scanToken'],
['send_phone_code', 'sendCode'], ['token_by_phone_code', 'phoneToken'],
['token_by_phone_password', 'passwordToken'], ['oauth2/v2/grant', 'grant'],
['generate_cred_by_code', 'cred'], ['auth/refresh', 'refresh'], ['player/binding', 'binding'],
['account/binding/v1/binding_list', 'accountBinding'],
];
const fetchImpl = async (url, init = {}) => {
const key = routes.find(([frag]) => url.includes(frag))?.[1];
const answer = overrides[key] ?? answers[key];
calls.push({
key, url, method: init.method || 'GET',
body: init.body ? JSON.parse(init.body) : null,
headers: init.headers || {},
});
if (!answer) throw new Error(`stub: no route for ${url}`);
return answer(calls.at(-1));
};
return { fetchImpl, calls, byKey: (key) => calls.filter((c) => c.key === key) };
}
test('扫码登录: gen_scan → scan_status → token_by_scan_code → grant → cred', async () => {
const up = stubUpstream();
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 });
const r = await client.loginByScan();
assert.equal(r.scanId, 'SCAN1');
assert.equal(r.scanUrl, 'hypergryph://scan_login?scanId=SCAN1', 'the QR encodes scanUrl, not the scanId');
assert.equal(r.token, 'T-SCAN');
assert.equal(r.deviceToken, 'DEV1');
assert.equal(r.cred, 'CRED');
assert.equal(r.credToken, 'CREDTOKEN');
assert.deepEqual(up.calls.map((c) => c.key), [
'genScan', 'scanStatus', 'scanToken', 'grant', 'cred',
]);
assert.deepEqual(up.byKey('genScan')[0].body, { appCode: '4ca99fa6b56cc2ba' });
assert.deepEqual(up.byKey('scanToken')[0].body, { appCode: '4ca99fa6b56cc2ba', from: 0, scanCode: 'SCANCODE1' });
assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1', 'the grant carries the scan deviceToken');
});
test('扫码登录: the QR session carries the device headers the upstream requires', async () => {
const up = stubUpstream();
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 });
await client.loginByScan();
const headers = up.byKey('scanToken')[0].headers;
assert.match(headers['X-DeviceId'], /^[0-9a-f]{10}$/);
assert.equal(headers['X-DeviceType'], '2');
assert.ok(headers['X-DeviceModel']);
});
test('扫码登录: 未扫码 (100) and 已扫码待确认 (101) keep polling until confirmed', async () => {
const pending = [
{ status: 100, msg: '未扫码' },
{ status: 101, msg: '已扫码待确认' },
{ status: 101, msg: '已扫码待确认' },
];
const up = stubUpstream({
scanStatus: () => ({ status: 200, json: async () => pending.shift()
?? { status: 0, msg: 'OK', data: { scanCode: 'SCANCODE1' } } }),
});
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 });
const onWait = [];
const r = await client.loginByScan({ onWait: (s) => onWait.push(s) });
assert.equal(r.token, 'T-SCAN');
assert.equal(up.byKey('scanStatus').length, 4, 'polled through phone confirmation');
assert.deepEqual(onWait, ['waiting', 'waiting', 'waiting']);
assert.equal(up.byKey('scanToken').length, 1, 'the confirmed code is exchanged once');
});
test('扫码登录: terminal and unknown statuses still fail instead of polling indefinitely', async () => {
for (const status of [102, 999]) {
const up = stubUpstream({
scanStatus: () => ({ status: 200, json: async () => ({ status, msg: '扫码已失效' }) }),
});
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await assert.rejects(() => client.loginByScan(), (e) => {
assert.ok(e instanceof SklandError);
assert.equal(e.code, status);
assert.equal(e.step, 'checkScan');
return true;
});
assert.equal(up.byKey('scanStatus').length, 1);
assert.equal(up.byKey('scanToken').length, 0);
}
});
test('扫码登录: a timeout stops the poll with a readable error', async () => {
const up = stubUpstream({ scanStatus: () => ({ status: 200, json: async () => ({ status: 100, msg: '未扫码' }) }) });
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1, pollTimeoutMs: 0 });
await assert.rejects(() => client.loginByScan(), (e) => {
assert.ok(e instanceof SklandError);
assert.equal(e.step, 'pollScan');
assert.match(e.message, /超时/);
return true;
});
});
test('验证码登录: send_phone_code(type 2, LOGIN_OR_REGISTER) → token_by_phone_code → grant → cred', async () => {
const up = stubUpstream();
const client = createSklandClient({ fetchImpl: up.fetchImpl });
assert.deepEqual(await client.sendPhoneCode('13800000000'), { ok: true });
const r = await client.loginByPhoneCode('13800000000', '123456');
assert.equal(r.token, 'T-CODE');
assert.equal(r.hgId, 'HG1');
assert.equal(r.deviceToken, 'DEV1');
assert.equal(r.cred, 'CRED');
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2 });
assert.deepEqual(up.byKey('phoneToken')[0].body, { appCode: '4ca99fa6b56cc2ba', code: '123456', phone: '13800000000' });
assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1');
assert.equal(up.byKey('phoneToken')[0].headers['X-DeviceType'], '2');
});
test('验证码登录: 需要人机验证 surfaces the geetest challenge', async () => {
const captcha = { gt: 'GT', challenge: 'CH', new_captcha: true };
const up = stubUpstream({
sendCode: () => ({ status: 200, json: async () => ({ status: 1, msg: '需要人机验证', type: 'A', data: { captcha } }) }),
});
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await assert.rejects(() => client.sendPhoneCode('13800000000'), (e) => {
assert.ok(e instanceof SklandCaptchaError);
assert.equal(e.captcha.gt, 'GT');
assert.equal(e.captcha.challenge, 'CH');
assert.equal(e.captcha.new_captcha, true);
return true;
});
});
test('验证码登录: a solved captcha is echoed into the resend', async () => {
const up = stubUpstream();
const client = createSklandClient({ fetchImpl: up.fetchImpl });
const solved = { geetest_challenge: 'a', geetest_validate: 'b', geetest_seccode: 'c|d' };
await client.sendPhoneCode('13800000000', solved);
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2, captcha: solved });
});
test('验证码登录: 发送频率太高 (status 102) is a readable error', async () => {
const up = stubUpstream({
sendCode: () => ({ status: 200, json: async () => ({ status: 102, msg: '发送频率太高,请稍后再试', type: 'C1' }) }),
});
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await assert.rejects(() => client.sendPhoneCode('13800000000'), (e) => {
assert.ok(e instanceof SklandError);
assert.equal(e.code, 102);
assert.match(e.message, /频率太高/);
return true;
});
});
test('密码登录: password → account-service grant → official Arknights bindings without Skland', async () => {
const up = stubUpstream();
const client = createSklandClient({ fetchImpl: up.fetchImpl });
const r = await client.loginByPhonePassword('13800000000', 'pw');
assert.equal(r.token, 'T-PW');
assert.equal(r.binding.list[0].bindingList[0].nickName, '博士');
assert.deepEqual(up.byKey('passwordToken')[0].body, { phone: '13800000000', password: 'pw' });
assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 });
const url = new URL(up.byKey('accountBinding')[0].url);
assert.equal(url.origin, 'https://binding-api-account-prod.hypergryph.com');
assert.equal(url.pathname, '/account/binding/v1/binding_list');
assert.equal(url.searchParams.get('token'), 'BINDINGTOKEN');
assert.equal(url.searchParams.get('appCode'), 'arknights');
assert.equal(up.byKey('cred').length, 0);
assert.equal(up.byKey('binding').length, 0);
});
test('密码登录: without a deviceToken the chain still attempts grant and succeeds', async () => {
const up = stubUpstream();
const client = createSklandClient({ fetchImpl: up.fetchImpl });
const r = await client.loginByPhonePassword('13800000000', 'pw');
assert.equal(r.binding.list[0].bindingList.length, 1);
assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 });
});
test('密码登录: only an upstream status 109 requires device verification', async () => {
const up = stubUpstream({
grant: () => ({ status: 200, json: async () => ({ status: 109, msg: '需要设备验证' }) }),
});
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await assert.rejects(() => client.loginByPhonePassword('13800000000', 'pw'), (e) => {
assert.ok(e instanceof SklandNeedDeviceError);
assert.equal(e.code, 109);
return true;
});
assert.equal(up.byKey('grant').length, 1, 'the upstream decides whether verification is needed');
assert.equal(up.byKey('cred').length, 0, 'a rejected grant never advances to credentials');
assert.equal(up.byKey('accountBinding').length, 0);
});
test('account bindings: a grant missing its service token never queries with an undefined token', async () => {
const up = stubUpstream({ grant: () => ({ status: 200, json: async () => ({ status: 0, data: {} }) }) });
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await assert.rejects(() => client.getAccountBinding('T'), (e) => e.code === 'no_token');
assert.equal(up.byKey('accountBinding').length, 0);
});
test('account bindings: upstream errors and malformed lists do not become empty successful bindings', async () => {
for (const answer of [{ status: 3, msg: '登录已过期' }, { status: 0, data: {} }]) {
const up = stubUpstream({ accountBinding: () => ({ status: 200, json: async () => answer }) });
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await assert.rejects(() => client.getAccountBinding('T'), SklandError);
}
});
test('grant: the deviceToken is sent only when the login path produced one', async () => {
const up = stubUpstream();
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await client.getGrantCode('T', null);
assert.ok(!('deviceToken' in up.byKey('grant')[0].body));
await client.getGrantCode('T', 'DEV');
assert.equal(up.byKey('grant')[1].body.deviceToken, 'DEV');
});
test('getBinding: the signed request carries cred, dId, timestamp, vName and a signature', async () => {
const up = stubUpstream();
const client = createSklandClient({ fetchImpl: up.fetchImpl });
const r = await client.getBinding({ cred: 'CRED', credToken: 'CREDTOKEN', dId: 'DEV' });
assert.equal(r.binding.list[0].appCode, 'arknights');
const call = up.byKey('binding')[0];
assert.equal(call.method, 'GET');
assert.equal(call.headers.cred, 'CRED');
assert.equal(call.headers.dId, 'DEV');
assert.equal(call.headers.platform, '3');
assert.equal(call.headers.vName, '1.0.0');
assert.match(call.headers.sign, /^[0-9a-f]{32}$/, 'sign = md5(hmacSha256(...))');
assert.ok(Number(call.headers.timestamp) > 0);
});
test('getBinding: code 10000 refreshes the cred_token once and retries', async () => {
let first = true;
const up = stubUpstream({
binding: () => {
if (first) { first = false; return { status: 200, json: async () => ({ code: 10000, message: 'cred_token 过期' }) }; }
return { status: 200, json: async () => ({ code: 0, data: { list: [] } }) };
},
});
const client = createSklandClient({ fetchImpl: up.fetchImpl });
const r = await client.getBinding({ cred: 'CRED', credToken: 'OLD', dId: 'DEV' });
assert.equal(r.credToken, 'CREDTOKEN2', 'the refreshed cred_token is returned to the caller');
assert.equal(up.byKey('refresh').length, 1);
assert.equal(up.byKey('binding').length, 2, 'retried once');
});
test('getBinding: code 10002 rebuilds the cred from the stored access_token', async () => {
let first = true;
const up = stubUpstream({
binding: () => {
if (first) { first = false; return { status: 200, json: async () => ({ code: 10002, message: 'cred 过期' }) }; }
return { status: 200, json: async () => ({ code: 0, data: { list: [] } }) };
},
});
const client = createSklandClient({ fetchImpl: up.fetchImpl });
const r = await client.getBinding({ cred: 'OLD', credToken: 'OLD', dId: 'DEV', accessToken: 'AT', deviceToken: 'DEV1' });
assert.equal(r.cred, 'CRED', 'the rebuilt cred is returned to the caller');
assert.equal(r.credToken, 'CREDTOKEN');
assert.equal(up.byKey('grant').length, 1);
assert.equal(up.byKey('cred').length, 1);
});
test('getBinding: 用户未登录 reads as an expired session', async () => {
const up = stubUpstream({
binding: () => ({ status: 200, json: async () => ({ code: 10002, message: '用户未登录' }) }),
});
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await assert.rejects(() => client.getBinding({ cred: 'C', credToken: 'CT', dId: 'D' }), (e) => {
assert.ok(e instanceof SklandError);
assert.match(e.message, /过期|未登录/);
return true;
});
});
test('an upstream failure carries the step that failed', async () => {
const up = stubUpstream({ genScan: () => ({ status: 200, json: async () => ({ status: 1, msg: '系统繁忙' }) }) });
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await assert.rejects(() => client.genScan(), (e) => {
assert.ok(e instanceof SklandError);
assert.equal(e.message, '系统繁忙');
assert.equal(e.step, '获取扫码会话');
return true;
});
});
test('a non-JSON answer is reported as such instead of throwing a parse error', async () => {
const up = stubUpstream({ genScan: () => ({ status: 502, json: async () => { throw new Error('bad json'); } }) });
const client = createSklandClient({ fetchImpl: up.fetchImpl });
await assert.rejects(() => client.genScan(), (e) => {
assert.ok(e instanceof SklandError);
assert.match(e.message, /响应不是 JSON/);
return true;
});
});
+92
View File
@@ -0,0 +1,92 @@
// The Skland request signature (server/skland/crypto.js): the composition of the signed header set.
//
// The upstream's algorithm has two independent references in the community (the 3mua.cn write-up on the account login
// surface and TNXG/skland-api's crypto module). This file pins the *composition* — which bytes go into the HMAC, and
// in what order — because that is the part a refactor can silently break. The digest itself is checked against
// node:crypto's own HMAC-SHA256 and MD5, so a change of either primitive shows up here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHmac, createHash } from 'node:crypto';
import { signedHeaders, hmacSha256Hex, md5, compactJson, makeDeviceId, deviceHeaders } from '../../server/skland/crypto.js';
const ref = (key, msg) => createHash('md5').update(createHmac('sha256', key).update(msg, 'utf8').digest('hex'), 'utf8').digest('hex');
test('compactJson: no whitespace, insertion order kept (the upstream signs the exact body bytes)', () => {
assert.equal(compactJson({ b: 1, a: 2 }), '{"b":1,"a":2}');
assert.equal(compactJson({ gameId: 1, uid: '123' }), '{"gameId":1,"uid":"123"}');
assert.equal(compactJson({}), '{}');
});
test('hmacSha256Hex / md5 are the plain primitives', () => {
assert.equal(hmacSha256Hex('key', 'msg'), createHmac('sha256', 'key').update('msg').digest('hex'));
assert.equal(md5('abc'), '900150983cd24fb0d6963f7d28e17f72');
});
test('a GET sign input is path + query + timestamp + compact(header_ca)', () => {
const { signInput, headers } = signedHeaders({
cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'GET', path: '/api/v1/game/player/binding',
query: 'a=1', timestampSeconds: 1700000000,
});
const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}';
assert.equal(signInput, `/api/v1/game/player/bindinga=11700000000${ca}`);
assert.equal(headers.sign, ref('CTOK', signInput));
assert.equal(headers.cred, 'CRED');
assert.equal(headers.platform, '3');
assert.equal(headers.vName, '1.0.0');
assert.equal(headers.dId, 'DEV');
});
test('a GET without a query signs the bare path', () => {
const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 });
assert.equal(signInput, `/p1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`);
});
test('a POST sign input is path + compact(body) + timestamp + compact(header_ca)', () => {
const body = { gameId: 1, uid: '123' };
const { signInput, headers } = signedHeaders({
cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'POST', path: '/api/v1/game/attendance',
body, timestampSeconds: 1700000000,
});
const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}';
assert.equal(signInput, `/api/v1/game/attendance{"gameId":1,"uid":"123"}1700000000${ca}`);
assert.equal(headers.sign, ref('CTOK', signInput));
});
test('a POST without a body signs "{}"', () => {
const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', timestampSeconds: 1 });
assert.equal(signInput, `/p{}1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`);
});
test('the signature is stable for equal inputs and moves with any single input', () => {
const base = { cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', body: { x: 1 }, timestampSeconds: 5 };
const a = signedHeaders(base).headers.sign;
assert.equal(signedHeaders({ ...base }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, credToken: 'k2' }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, dId: 'd2' }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, timestampSeconds: 6 }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, body: { x: 2 } }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, path: '/q' }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, method: 'GET', query: '' }).headers.sign, a);
});
test('the mobile client identity headers the upstream expects', () => {
const h = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 }).headers;
assert.match(h['User-Agent'], /^Skland\//);
assert.equal(h['X-Requested-With'], 'com.hypergryph.skland');
assert.equal(h['Accept-Encoding'], 'gzip');
});
test('makeDeviceId: stable shape, a fresh value each call', () => {
const a = makeDeviceId();
const b = makeDeviceId();
assert.match(a, /^B[0-9a-f]{32}$/);
assert.notEqual(a, b);
});
test('deviceHeaders: the login device headers, cached for the process', () => {
const h = deviceHeaders();
assert.match(h['X-DeviceId'], /^[0-9a-f]{10}$/);
assert.equal(h['X-DeviceType'], '2');
assert.ok(h['X-DeviceModel'].length > 0);
assert.equal(deviceHeaders(), h, 'one device per process (the upstream sees a stable device)');
});
+147
View File
@@ -0,0 +1,147 @@
// The Docker packaging of the opt-in Skland account binding: docker-compose.yml, the Dockerfile's runtime stage and
// .gitignore form one contract — the credentials live in one SQLite file on a named volume that the unprivileged
// `node` user can write, and that file is never committed and never reachable over HTTP.
//
// No Docker daemon is needed (and CI has none): the files are read as text and the facts that make `docker compose up
// -d` work are asserted. docker-compose.yml is parsed with a few lines of indentation-based reading rather than a YAML
// dependency, so the assertions rest on the structure (which service, which key) instead of one loose regex.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
const linesOf = (text) => text.split(/\r?\n/).map((l) => l.replace(/\r$/, ''));
/** The indentation of a line (a YAML block is defined by it). */
const indentOf = (l) => l.length - l.replace(/^[ \t]*/, '').length;
/**
* The lines of the block under `key:` at exactly `indent` — comments and blank lines dropped (they are prose, not
* structure), a dedent ends it. Returns [] when the key is absent. Enough YAML to read this one file honestly.
* @param {string[]} lines
* @param {string} key
* @param {number} indent
*/
function block(lines, key, indent) {
const head = new RegExp(`^ {${indent}}${key}:`);
const out = [];
let inside = false;
for (const l of lines) {
if (!l.trim() || l.trim().startsWith('#')) continue;
if (!inside) { if (indentOf(l) === indent && head.test(l)) inside = true; continue; }
if (indentOf(l) <= indent) break;
out.push(l);
}
return out;
}
/** The scalar of `key: value` inside a block, with surrounding quotes removed; null when unset. */
function scalar(blockLines, key) {
for (const l of blockLines) {
const m = l.match(new RegExp(`^[ \\t]*${key}:[ \\t]*(.*)$`));
if (m) return m[1].trim().replace(/^(["'])(.*)\1$/, '$2');
}
return null;
}
/** The `- item` entries of the list under `key:` inside a block (quotes removed). */
function items(blockLines, key, indent) {
return block(blockLines, key, indent)
.map((l) => l.match(/^[ \t]*-[ \t]*(.*)$/)?.[1] ?? '')
.filter(Boolean)
.map((v) => v.trim().replace(/^(["'])(.*)\1$/, '$2'));
}
const compose = linesOf(read('docker-compose.yml'));
const services = block(compose, 'services', 0);
const service = block(services, 'stronghold', 2);
test('docker-compose.yml: one stronghold service, built locally, tagged and restarted', () => {
assert.deepEqual(services.filter((l) => indentOf(l) === 2 && /:$/.test(l.trim())).map((l) => l.trim()),
['stronghold:'], 'exactly one service');
const build = block(service, 'build', 4);
assert.equal(scalar(build, 'context'), '.', 'builds the repository Dockerfile');
assert.equal(scalar(service, 'image'), 'stronghold-protocol', 'tags the image');
assert.equal(scalar(service, 'restart'), 'unless-stopped');
// the art is opt-in: the compose file must not force a 550 MB download unless the operator uncomments it
assert.equal(scalar(build, 'args'), null, 'FETCH_ASSETS stays the caller\'s choice');
});
test('docker-compose.yml: publishes 3000:3000', () => {
const ports = items(service, 'ports', 4);
assert.ok(ports.includes('3000:3000'), `publishes 3000:3000 (got ${JSON.stringify(ports)})`);
});
test('docker-compose.yml: SP_SKLAND=1 and SP_SKLAND_DB inside the mounted named volume', () => {
const env = block(service, 'environment', 4);
assert.equal(scalar(env, 'SP_SKLAND'), '1', 'the Skland module is on');
const db = scalar(env, 'SP_SKLAND_DB');
assert.ok(db, 'SP_SKLAND_DB is set');
// the database must sit inside a mount, and that mount must be the named volume (survives down / rebuilds)
const mounts = items(service, 'volumes', 4);
const named = mounts.find((m) => m.startsWith('skland-data:'));
assert.ok(named, `the named volume skland-data is mounted (got ${JSON.stringify(mounts)})`);
const target = named.slice(named.indexOf(':') + 1);
assert.ok(target.startsWith('/'), `the volume has an absolute container path (${target})`);
assert.ok(db.startsWith(`${target}/`), `SP_SKLAND_DB ${db} is inside the mounted volume ${target}`);
// the volume is declared, so `docker compose up` creates it rather than failing on an unknown name
assert.ok(block(compose, 'volumes', 0).some((l) => indentOf(l) === 2 && l.trim().startsWith('skland-data')),
'the top-level volumes: section declares skland-data');
});
test('docker-compose.yml: the credential file is outside every static mount (/public, /data)', () => {
const db = scalar(block(service, 'environment', 4), 'SP_SKLAND_DB');
for (const dir of ['/app/public', '/app/data', '/app/shared']) {
assert.ok(!db.startsWith(`${dir}/`), `${db} is not under ${dir}: the static handler would serve it over HTTP`);
}
});
test('Dockerfile: /app/skland-data exists and belongs to node before USER node', () => {
const df = linesOf(read('Dockerfile'));
const user = df.findIndex((l) => /^USER[ \t]+node[ \t]*$/.test(l));
assert.ok(user > 0, 'the runtime stage still runs as USER node');
const mkdir = df.findIndex((l) => /^RUN[^\n]*mkdir -p[^\n]*\/app\/skland-data\b/.test(l));
assert.ok(mkdir >= 0, 'the database directory is created');
assert.ok(mkdir < user, `mkdir (line ${mkdir + 1}) precedes USER node (line ${user + 1})`);
assert.match(df[mkdir], /chown[ \t]+node:node[ \t]+\/app\/skland-data\b/, 'owned by node, so node can write it');
// a VOLUME instruction would hand `docker run` an anonymous volume the host cannot find; the mount is explicit
assert.ok(!df.some((l) => /^VOLUME\b/.test(l) && l.includes('/app/skland-data')),
'no anonymous VOLUME for the credential directory');
assert.match(read('Dockerfile'), /SP_SKLAND_DB/, 'the top comment documents the data path');
});
test('the compose volume target is the directory the image prepares', () => {
const named = items(service, 'volumes', 4).find((m) => m.startsWith('skland-data:'));
const target = named.slice(named.indexOf(':') + 1);
const df = linesOf(read('Dockerfile'));
const mkdir = df.find((l) => l.includes('mkdir -p') && l.includes(target));
assert.ok(mkdir, `the image creates ${target} before USER node, the directory compose mounts`);
assert.match(mkdir, new RegExp(`mkdir -p[ \\t]+${target.replace(/\//g, '\\/')}\\b`));
});
test('.gitignore: the runtime database and its SQLite side files are never committed', () => {
const lines = linesOf(read('.gitignore')).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
for (const entry of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/']) {
assert.ok(lines.includes(entry), `.gitignore lists ${entry}`);
}
});
test('docs/DEPLOY.md: the Docker section documents SP_SKLAND, SP_SKLAND_DB, the volume and the secret warning', () => {
const doc = read('docs/DEPLOY.md');
const start = doc.indexOf('## 3. Docker');
const end = doc.indexOf('## 4. ', start);
assert.ok(start >= 0 && end > start, 'the Docker section is still there');
const docker = doc.slice(start, end);
assert.match(docker, /`SP_SKLAND`/, 'names SP_SKLAND');
assert.match(docker, /`SP_SKLAND_DB`/, 'names SP_SKLAND_DB');
assert.match(docker, /docker compose up -d/, 'the one-command path');
assert.match(docker, /skland-data/, 'names the volume');
assert.match(docker, /密钥|凭据/, 'warns that the database holds credentials');
assert.match(docker, /chmod|权限/, 'tells the operator to restrict the file mode');
assert.match(docker, /备份/, 'tells the operator to back it up carefully');
assert.match(docker, /HTTP/, 'warns against exposing it over HTTP');
});
+211
View File
@@ -0,0 +1,211 @@
// Real title and modal, stub account provider. No real account, SMS, authorization or CAPTCHA is exercised.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { startServer } from '../../server/index.js';
import { SklandCaptchaError } from '../../server/skland/client.js';
const chrome = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const enabled = process.env.SP_E2E === '1' && fs.existsSync(chrome);
test('title requires binding, reads the nickname, keeps channel choice and handles CAPTCHA, SMS and restored entry', {
skip: enabled ? false : 'set SP_E2E=1 and CHROME_PATH (needs Chrome)', timeout: 60000,
}, async () => {
const binding = { list: [{ appCode: 'arknights', bindingList: [
{ uid: '123', nickName: '官服博士', channelName: '官服', gameId: 1 },
{ uid: '123', nickName: 'B服博士', channelName: 'B服', gameId: 2, isDefault: true },
] }] };
let polls = 0, scans = 0, passwordProof, smsSends = 0;
const client = {
genScan: async () => { scans++; return { scanId: `SCAN${scans}`, scanUrl: `hypergryph://scan_login?scanId=SCAN${scans}` }; },
checkScan: async () => (++polls > 1 ? { state: 'confirmed', scanCode: 'CODE' } : { state: 'waiting', awaitingConfirmation: true }),
tokenByScanCode: async () => ({ token: 'SCAN-TOKEN' }),
getGrantCode: async () => 'GRANT',
getCred: async () => ({ cred: 'CRED', credToken: 'SIGN' }),
getBinding: async () => ({ binding, cred: 'CRED', credToken: 'SIGN' }),
sendPhoneCode: async () => { smsSends++; },
tokenByPhoneCode: async () => ({ token: 'SMS-TOKEN' }),
tokenByPhonePassword: async (_phone, _password, captcha) => {
if (!captcha) throw new SklandCaptchaError({ geetestId: 'FAKE', challenge: 'ORIGINAL' }, 'password');
passwordProof = captcha; return { token: 'PASSWORD-TOKEN' };
},
getAccountBinding: async () => binding,
};
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client } });
let browser;
try {
const puppeteer = (await import('puppeteer-core')).default;
browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] });
let page;
const errors = [];
const newPage = async () => {
const tab = await browser.newPage();
await tab.setViewport({ width: 1440, height: 1000 });
tab.on('pageerror', (error) => errors.push(error.message));
await tab.setRequestInterception(true);
tab.on('request', (request) => {
if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort();
else request.continue();
});
return tab;
};
page = await newPage();
await page.goto(server.url);
await page.waitForSelector('[data-testid="binding-start"]');
const clickText = async (text) => {
await page.waitForFunction((value) => [...document.querySelectorAll('.sp-binding button')]
.some((button) => button.textContent.trim() === value && !button.disabled), {}, text);
await page.evaluate((value) => [...document.querySelectorAll('.sp-binding button')]
.find((button) => button.textContent.trim() === value).click(), text);
};
const open = async () => {
await page.click(await page.$('[data-testid="account-binding-entry"]') ? '[data-testid="account-binding-entry"]' : '[data-testid="binding-start"]');
await page.waitForSelector('.sp-binding');
await page.waitForFunction(() => window.__SP__?.net.status === 'online' && !document.querySelector('.sp-binding .btn[aria-busy="true"]'));
};
const callsign = () => page.evaluate(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent || '');
const expectName = async (name) => {
try {
await page.waitForFunction((value) => (document.querySelector('[data-testid="bound-callsign"]')?.textContent || '') === value, { timeout: 12000 }, name);
assert.equal(await page.$('.title-login input'), null, 'the callsign is never an input');
assert.equal(!!await page.$('.sp-binding-callsign'), !!name, 'the callsign area only exists after binding');
if (name) assert.ok(await page.$('.sp-binding-callsign__icon'), 'the nickname keeps its person icon');
} catch (error) {
console.error('Callsign state:', await page.evaluate(() => ({
callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent,
action: document.querySelector('[data-testid="binding-start"]')?.textContent,
playerId: window.__SP__?.store.get().me.playerId, connection: window.__SP__?.net.status,
alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent),
})), 'page errors:', errors);
throw error;
}
};
const expectAction = async (text) => page.waitForFunction((value) => document.querySelector('[data-testid="binding-start"]')?.textContent.trim() === value, {}, text);
await expectAction('绑定账号');
await expectName('');
assert.equal(await page.$('.title-login label'), null);
// Old manual callsigns and a cached entered flag must not bypass the server binding check.
await page.evaluate(() => { localStorage.setItem('sp.name', '旧的手填代号'); sessionStorage.setItem('sp.entered', '1'); });
await page.reload();
await expectAction('绑定账号');
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
await open();
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
await page.waitForSelector('.sp-binding__qr');
assert.equal(scans, 1, 'opening the scan tab automatically fetches a QR');
const firstQr = await page.$eval('.sp-binding__qr', (image) => image.src);
await clickText('重新获取二维码');
await page.waitForFunction((previous) => {
const image = document.querySelector('.sp-binding__qr'); return image && image.src !== previous;
}, {}, firstQr);
assert.equal(scans, 2, 'manual QR refresh remains available');
await page.waitForFunction(() => document.querySelector('.sp-binding__scan [role="status"]')?.textContent.includes('已扫码'));
await expectName('B服博士');
await expectAction('开始');
assert.ok(polls >= 2);
await clickText('官服博士官服 · UID 123');
await expectName('官服博士');
await clickText('刷新角色');
await expectName('官服博士');
await clickText('完成');
await open();
assert.equal(await callsign(), '官服博士');
await clickText('解绑账号');
await expectName('');
await expectAction('绑定账号');
await clickText('密码');
await page.type('.sp-binding input[type="tel"]', '13800000000');
await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD');
await clickText('登录并绑定');
await page.waitForSelector('.sp-binding__captcha');
await page.evaluate(() => {
window.initGeetest4 = (_options, callback) => {
let success;
callback({
onSuccess(fn) { success = fn; }, onError() {}, onClose() {},
onReady(fn) { setTimeout(fn, 0); }, showCaptcha() { success(); },
getValidate() { return { captcha_id: 'FAKE', lot_number: 'TEST' }; }, destroy() {},
});
};
});
await clickText('开始人机验证');
await expectName('B服博士');
assert.equal(passwordProof.challenge, 'ORIGINAL');
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false, 'binding never presses Start');
fs.mkdirSync('.cache/skland-preview', { recursive: true });
await new Promise((resolve) => setTimeout(resolve, 350));
await page.screenshot({ path: '.cache/skland-preview/bound.png' });
await clickText('解绑账号');
await clickText('验证码');
await clickText('发送验证码');
await page.waitForFunction(() => document.querySelector('.sp-binding__notice')?.textContent.includes('验证码已发送'));
assert.equal(smsSends, 1);
await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456');
await clickText('登录并绑定');
await expectName('B服博士');
await clickText('完成');
await new Promise((resolve) => setTimeout(resolve, 350));
await page.screenshot({ path: '.cache/skland-preview/entry-bound.png' });
await page.reload();
await expectName('B服博士');
const accountId = () => page.evaluate(async () => {
const response = await fetch('/skland/session/status', { headers: { authorization: 'Bearer ' + window.__SP__.store.get().me.token } });
return (await response.json()).gamePlayerId;
});
const previousId = await accountId();
await page.close(); page = await newPage(); await page.goto(server.url);
await expectName('B服博士'); await expectAction('开始');
assert.equal(await accountId(), previousId, 'a fresh tab restores the JWT-owned role without logging in or entering');
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
await open();
// Keep the existing target alive while checking the landscape layout; device emulation can reload the app and
// would test browser boot behavior rather than this feature's responsive CSS.
await page.setViewport({ width: 844, height: 390 });
await new Promise((resolve) => setTimeout(resolve, 350));
await page.screenshot({ path: '.cache/skland-preview/mobile.png' });
const fits = await page.$eval('.sp-binding', (element) => {
const rect = element.getBoundingClientRect();
const overlay = element.parentElement?.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, height: rect.height,
overlay: overlay && { top: overlay.top, bottom: overlay.bottom, height: overlay.height }, viewport: [innerWidth, innerHeight] };
});
assert.equal(fits.left >= 0 && fits.right <= fits.viewport[0] && fits.top >= 0 && fits.bottom <= fits.viewport[1], true,
`the modal fits a landscape phone: ${JSON.stringify(fits)}`);
await clickText('解绑账号');
await expectName('');
await expectAction('绑定账号');
await clickText('完成');
await page.click('[data-testid="binding-start"]');
await page.waitForSelector('.sp-binding');
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
// A login without a named role still cannot start.
const namedRoles = binding.list[0].bindingList;
binding.list[0].bindingList = [];
await clickText('验证码');
await page.type('.sp-binding input[type="tel"]', '13800000000');
await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456');
await clickText('登录并绑定');
await page.waitForSelector('.sp-binding__roles');
await expectAction('绑定账号');
await expectName('');
binding.list[0].bindingList = namedRoles;
await clickText('刷新角色');
await expectName('B服博士');
await clickText('完成');
await page.click('[data-testid="binding-start"]');
await page.waitForSelector('.lobby-screen');
assert.equal(await page.evaluate(() => window.__SP__.store.get().me.name), 'B服博士');
await page.reload();
await page.waitForSelector('.lobby-screen');
// Removing the binding on the server invalidates the cached entry on the next reload.
await page.evaluate(async () => {
await fetch('/skland/session/unbind', { method: 'POST', headers: {
authorization: `Bearer ${window.__SP__.store.get().me.token}`, 'content-type': 'application/json',
}, body: '{}' });
});
await page.reload();
await expectAction('绑定账号');
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
assert.deepEqual(errors, []);
} finally { await browser?.close(); await server.close(); }
});
+415
View File
@@ -0,0 +1,415 @@
// The /skland/* HTTP surface and its per-player orchestration (server/skland/service.js, server/skland/http.js).
//
// The server is started for real (startServer with `skland: { storeFile: ':memory:', fetchImpl }`), so the wiring —
// routes.js handing /skland/* to the module before its GET/HEAD gate, the JSON bodies, the store — is what is under
// test; only the upstream HTTP is a stub. Each case drives one login path the way the panel would.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../../server/index.js';
/** The stub upstream (the shapes the community documentation records). */
function stubUpstream(overrides = {}) {
const calls = [];
const j = (o) => ({ status: 200, json: async () => o });
const answers = {
genScan: () => j({ status: 0, msg: 'OK', data: { scanId: 'SCAN1', scanUrl: 'hypergryph://scan_login?scanId=SCAN1' } }),
scanStatus: () => j({ status: 100, msg: '未扫码' }),
scanToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-SCAN', hgId: 'HG1', deviceToken: 'DEV1' } }),
sendCode: () => j({ status: 0, msg: 'OK' }),
phoneToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-CODE', hgId: 'HG1', deviceToken: 'DEV1' } }),
passwordToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-PW' } }),
grant: ({ body }) => j({ status: 0, msg: 'OK', data: body.appCode === 'be36d44aa36bfb5b'
? { token: 'BINDINGTOKEN' } : { uid: 'U1', code: 'GRANTCODE' } }),
cred: () => j({ code: 0, data: { cred: 'CRED', token: 'CREDTOKEN' } }),
binding: () => j({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', channelName: '官服', gameId: 1 }] }] } }),
accountBinding: () => j({ status: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', channelName: '官服', isDefault: true }] }] } }),
refresh: () => j({ code: 0, data: { token: 'CREDTOKEN2' } }),
};
const routes = [
['gen_scan/login', 'genScan'], ['scan_status', 'scanStatus'], ['token_by_scan_code', 'scanToken'],
['send_phone_code', 'sendCode'], ['token_by_phone_code', 'phoneToken'],
['token_by_phone_password', 'passwordToken'], ['oauth2/v2/grant', 'grant'],
['generate_cred_by_code', 'cred'], ['auth/refresh', 'refresh'], ['player/binding', 'binding'],
['account/binding/v1/binding_list', 'accountBinding'],
];
const fetchImpl = async (url, init = {}) => {
const key = routes.find(([frag]) => url.includes(frag))?.[1];
calls.push({ key, url, method: init.method || 'GET', body: init.body ? JSON.parse(init.body) : null });
const answer = overrides[key] ?? answers[key];
if (!answer) throw new Error(`stub: no route for ${url}`);
return answer(calls.at(-1));
};
return { fetchImpl, calls, byKey: (key) => calls.filter((c) => c.key === key) };
}
/** Start a server with the stubbed Skland module. */
async function withServer(overrides, fn) {
const up = stubUpstream(overrides);
const srv = await startServer({
port: 0, host: '127.0.0.1', quiet: true,
skland: { storeFile: ':memory:', fetchImpl: up.fetchImpl, diagnosticKey: 'test-admin-only' },
});
const post = (path, body) => fetch(srv.url + path, {
method: 'POST', headers: { 'content-type': 'application/json', 'x-sp-diagnostic-key': 'test-admin-only' }, body: JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json() }));
const get = (path) => fetch(srv.url + path, { headers: { 'x-sp-diagnostic-key': 'test-admin-only' } }).then(async (r) => ({ status: r.status, body: await r.json() }));
try { await fn({ srv, post, get, up }); } finally { await srv.close(); }
}
test('the server exposes the module when skland is configured', async () => {
await withServer({}, async ({ srv }) => {
assert.ok(srv.skland, 'startServer returns the service');
});
});
test('the module is off by default, and /skland/* still answers one uniform JSON 404', async () => {
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
try {
assert.equal(srv.skland, null, 'no account store on an unconfigured server');
const got = await fetch(srv.url + '/skland/status?player=p1');
assert.equal(got.status, 404, 'GET /skland/* is not routed to a store');
assert.equal((await got.json()).ok, false, 'the off-surface is JSON, not the HTML error page');
// A POST must not reach routes.js's GET/HEAD gate and answer "405 method not allowed" for a feature that is
// simply off — the panel would show a cryptic method error instead of "not enabled".
const posted = await fetch(srv.url + '/skland/scan/start', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ player: 'p1' }),
});
assert.equal(posted.status, 404, 'POST is 404 too, not 405');
assert.match((await posted.json()).error, /SP_SKLAND|未启用/);
} finally { await srv.close(); }
});
test('GET /skland/status: an unknown player reads as unbound', async () => {
await withServer({}, async ({ get }) => {
const { status, body } = await get('/skland/status?player=p1');
assert.equal(status, 200);
assert.deepEqual(body, { playerId: 'p1', status: 'unbound', bound: false, nickname: null, hgId: null, roles: [] });
});
});
test('GET /skland/status without a player is a JSON 400', async () => {
await withServer({}, async ({ get }) => {
const { status, body } = await get('/skland/status');
assert.equal(status, 400);
assert.equal(body.ok, false, 'a JSON client must never be handed the HTML error page');
assert.match(body.error, /player/);
});
});
test('扫码: start → unscanned → awaiting phone confirmation → confirmed binds the same session', async () => {
const pending = [
{ status: 100, msg: '未扫码' },
{ status: 101, msg: '已扫码待确认' },
{ status: 101, msg: '已扫码待确认' },
];
await withServer({
scanStatus: () => ({ status: 200, json: async () => pending.shift()
?? { status: 0, msg: 'OK', data: { scanCode: 'SC1' } } }),
binding: () => ({ status: 200, json: async () => ({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [
{ uid: '123', nickName: '博士', channelName: '官服', gameId: 1 },
{ uid: '456', nickName: 'B服博士', channelName: 'B服', gameId: 2 },
] }] } }) }),
}, async ({ post, get, up }) => {
const start = await post('/skland/scan/start', { player: 'p1' });
assert.equal(start.status, 200);
assert.equal(start.body.ok, true);
assert.equal(start.body.scanUrl, 'hypergryph://scan_login?scanId=SCAN1');
for (let i = 0; i < 3; i++) {
const waiting = await post('/skland/scan/poll', { player: 'p1' });
assert.equal(waiting.body.ok, true);
assert.equal(waiting.body.state, 'waiting');
assert.equal(waiting.body.awaitingConfirmation, i > 0, 'status 101 is preserved for the longer QR refresh window');
assert.equal(waiting.body.scanUrl, start.body.scanUrl, 'the QR URL survives every pending poll');
const scanning = await get('/skland/status?player=p1');
assert.equal(scanning.body.status, 'scanning');
assert.equal(scanning.body.bound, false);
assert.equal(up.byKey('scanToken').length, 0, 'no token exchange before confirmation');
}
const done = await post('/skland/scan/poll', { player: 'p1' });
assert.equal(done.body.state, 'confirmed');
assert.equal(done.body.status, 'bound');
const status = await get('/skland/status?player=p1');
assert.equal(status.body.bound, true);
assert.equal(status.body.nickname, '博士');
assert.deepEqual(status.body.roles, [
{ uid: '123', nickName: '博士', channelName: '官服', isDefault: false, gameId: 1 },
{ uid: '456', nickName: 'B服博士', channelName: 'B服', isDefault: false, gameId: 2 },
]);
assert.equal(up.byKey('scanToken')[0].body.scanCode, 'SC1');
assert.equal(up.byKey('scanToken').length, 1);
assert.equal(up.byKey('grant').length, 1);
assert.equal(up.byKey('accountBinding').length, 0, 'QR login keeps the Skland binding provider');
assert.equal(up.byKey('genScan').length, 1, 'no replacement QR is needed after status 101');
});
});
test('扫码: a poll without a session answers state none', async () => {
await withServer({}, async ({ post }) => {
const r = await post('/skland/scan/poll', { player: 'never-started' });
assert.equal(r.body.ok, true);
assert.equal(r.body.state, 'none');
});
});
test('扫码: a failing poll still names a state, so the client never reads `undefined`', async () => {
// The panel showed "未知状态 · Unknown state: undefined" when the upstream died mid-scan: the answer was a bare
// { ok: false } with no `state`, and a caller that only switches on `state` fell through to its unknown branch.
await withServer({ scanStatus: () => { throw new Error('upstream down'); } }, async ({ post }) => {
await post('/skland/scan/start', { player: 'p1' });
const { body } = await post('/skland/scan/poll', { player: 'p1' });
assert.equal(body.ok, false);
assert.equal(body.state, 'error', 'every poll answer carries a terminal state');
assert.match(body.error, /upstream down/);
});
});
test('验证码: send → login binds the player', async () => {
await withServer({}, async ({ post, get, up }) => {
const sent = await post('/skland/phone/send', { player: 'p1', phone: '13800000000' });
assert.equal(sent.body.ok, true);
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2 });
const login = await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' });
assert.equal(login.body.ok, true);
assert.equal(login.body.nickname, '博士');
assert.equal(login.body.roles.length, 1);
const status = await get('/skland/status?player=p1');
assert.equal(status.body.bound, true);
assert.equal(status.body.hgId, 'HG1', 'the hgId from the login is kept');
assert.equal(up.byKey('grant').length, 1, 'exactly one grant per login');
assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1');
});
});
test('验证码: 需要人机验证 is passed through with the geetest challenge', async () => {
await withServer({
sendCode: () => ({ status: 200, json: async () => ({ status: 1, msg: '需要人机验证', data: { captcha: { gt: 'GT', challenge: 'CH', new_captcha: true } } }) }),
}, async ({ post }) => {
const r = await post('/skland/phone/send', { player: 'p1', phone: '13800000000' });
assert.equal(r.body.ok, false);
assert.equal(r.body.needCaptcha, true);
assert.deepEqual(r.body.captcha, { gt: 'GT', challenge: 'CH', new_captcha: true });
});
});
test('验证码: a solved captcha travels back to the upstream', async () => {
await withServer({}, async ({ post, up }) => {
const solved = { geetest_challenge: 'a', geetest_validate: 'b', geetest_seccode: 'c|d' };
await post('/skland/phone/send', { player: 'p1', phone: '13800000000', captcha: solved });
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2, captcha: solved });
});
});
test('密码: a fresh player can bind without a previous 验证码/扫码 login', async () => {
await withServer({}, async ({ post, get, up }) => {
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
assert.equal(r.body.ok, true);
assert.equal(r.body.nickname, '博士');
assert.equal((await get('/skland/status?player=p1')).body.bound, true);
assert.equal(up.byKey('phoneToken').length, 0);
assert.equal(up.byKey('scanToken').length, 0);
assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 });
assert.equal(r.body.roles[0].isDefault, true, 'official isDefault is preserved');
assert.equal(up.byKey('cred').length, 0, 'no Skland credentials are acquired');
assert.equal(up.byKey('binding').length, 0);
});
});
test('密码: upstream device verification (109) is explained after attempting login', async () => {
await withServer({
grant: () => ({ status: 200, json: async () => ({ status: 109, msg: '需要设备验证' }) }),
}, async ({ post, up, srv }) => {
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
assert.equal(r.body.ok, false);
assert.equal(r.body.needDevice, true);
assert.match(r.body.error, /验证码|扫码/);
assert.equal(up.byKey('passwordToken').length, 1);
assert.equal(up.byKey('grant').length, 1);
assert.equal(up.byKey('cred').length, 0);
assert.equal(srv.skland._store.get('p1').access_token, null, 'failed login credentials are not persisted');
});
});
test('密码: an incorrect password stays a password error without requiring a prior login', async () => {
await withServer({
passwordToken: () => ({ status: 200, json: async () => ({ status: 100, msg: '手机号或密码错误' }) }),
}, async ({ post, up }) => {
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'wrong' });
assert.equal(r.body.ok, false);
assert.equal(r.body.needDevice, undefined);
assert.equal(r.body.error, '手机号或密码错误');
assert.equal(up.byKey('passwordToken').length, 1);
assert.equal(up.byKey('grant').length, 0);
});
});
test('密码: interactive captcha proofs resume the password and account-grant steps separately', async () => {
const challenge = { geetestId: 'TEST-ID', challenge: 'TEST-CHALLENGE', riskType: 'slide' };
const passwordProof = { captcha_output: 'TEST-PASSWORD-PROOF', challenge: 'TEST-CHALLENGE' };
const grantProof = { captcha_output: 'TEST-GRANT-PROOF', challenge: 'TEST-CHALLENGE' };
const answer = (data) => ({ status: 200, json: async () => data });
await withServer({
passwordToken: ({ body }) => answer(body.captcha
? { status: 0, data: { token: 'T-AFTER-CAPTCHA' } } : { status: 1, data: { captcha: challenge } }),
grant: ({ body }) => answer(body.captcha
? { status: 0, data: { token: 'BINDINGTOKEN' } } : { status: 1, data: { captcha: challenge } }),
}, async ({ post, up, srv }) => {
const login = { player: 'p1', phone: '13800000000', password: 'pw' };
const first = await post('/skland/password/login', login);
assert.equal(first.body.needCaptcha, true);
assert.equal(first.body.captchaStep, 'password');
assert.deepEqual(first.body.captcha, challenge);
assert.equal(up.byKey('grant').length, 0);
const second = await post('/skland/password/login', { ...login, captcha: passwordProof, captchaStep: 'password' });
assert.equal(second.body.needCaptcha, true);
assert.equal(second.body.captchaStep, 'accountGrant');
assert.deepEqual(up.byKey('passwordToken')[1].body.captcha, passwordProof);
assert.equal(srv.skland._store.get('p1'), null, 'pending credentials are not persisted');
const third = await post('/skland/password/login', { ...login, captcha: grantProof, captchaStep: 'accountGrant' });
assert.equal(third.body.ok, true);
assert.equal(third.body.roles.length, 1);
assert.equal(up.byKey('passwordToken').length, 2, 'the successful password exchange is not repeated');
assert.deepEqual(up.byKey('grant').map((c) => c.body.token), ['T-AFTER-CAPTCHA', 'T-AFTER-CAPTCHA']);
assert.deepEqual(up.byKey('grant')[1].body.captcha, grantProof);
});
});
test('密码: unbind discards a pending grant captcha and cannot resurrect its login', async () => {
await withServer({
grant: () => ({ status: 200, json: async () => ({ status: 1, data: { captcha: { geetestId: 'TEST-ID' } } }) }),
}, async ({ post, up }) => {
const login = { player: 'p1', phone: '13800000000', password: 'pw' };
assert.equal((await post('/skland/password/login', login)).body.captchaStep, 'accountGrant');
await post('/skland/unbind', { player: 'p1' });
const retry = await post('/skland/password/login', { ...login, captcha: { captcha_output: 'TEST' }, captchaStep: 'accountGrant' });
assert.equal(retry.body.ok, false);
assert.match(retry.body.error, /过期/);
assert.equal(up.byKey('grant').length, 1);
assert.equal(up.byKey('accountBinding').length, 0);
});
});
test('密码: a previous Skland binding switches to official account lookup without reusing its deviceToken', async () => {
await withServer({}, async ({ post, up, srv }) => {
await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' });
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
assert.equal(r.body.ok, true);
assert.equal(r.body.nickname, '博士');
assert.deepEqual(up.byKey('passwordToken')[0].body, { phone: '13800000000', password: 'pw' },
'the password endpoint takes phone + password only');
const grant = up.byKey('grant').at(-1);
assert.equal(grant.body.deviceToken, undefined, 'official account lookup does not need the Skland deviceToken');
assert.equal(grant.body.appCode, 'be36d44aa36bfb5b');
assert.equal(grant.body.type, 1);
assert.equal(grant.body.token, 'T-PW');
const row = srv.skland._store.get('p1');
assert.equal(row.binding_source, 'hypergryph');
assert.equal(row.cred, null);
assert.equal(row.cred_token, null);
assert.equal(row.device_token, null);
});
});
test('密码: role refresh uses official account bindings and rebind via SMS restores the Skland source', async () => {
await withServer({}, async ({ post, get, up, srv }) => {
await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
assert.equal((await get('/skland/roles?player=p1')).body.roles.length, 1);
assert.equal(up.byKey('accountBinding').length, 1, 'cached lookup does not call upstream');
const refreshed = await get('/skland/roles?player=p1&refresh=1');
assert.equal(refreshed.body.ok, true);
assert.equal(refreshed.body.roles[0].isDefault, true);
assert.equal(up.byKey('accountBinding').length, 2);
assert.equal(up.byKey('cred').length, 0);
await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' });
assert.equal(srv.skland._store.get('p1').binding_source, 'skland');
await get('/skland/roles?player=p1&refresh=1');
assert.equal(up.byKey('binding').length, 2);
assert.equal(up.byKey('accountBinding').length, 2);
});
});
test('解绑 clears the credentials and the status reads unbound again', async () => {
await withServer({}, async ({ post, get }) => {
await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' });
assert.equal((await get('/skland/status?player=p1')).body.bound, true);
assert.equal((await post('/skland/unbind', { player: 'p1' })).body.ok, true);
const after = await get('/skland/status?player=p1');
assert.equal(after.body.bound, false);
assert.equal(after.body.status, 'unbound');
assert.deepEqual(after.body.roles, []);
});
});
test('players are isolated: one binding never leaks into another', async () => {
await withServer({}, async ({ post, get }) => {
await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' });
assert.equal((await get('/skland/status?player=p1')).body.bound, true);
assert.equal((await get('/skland/status?player=p2')).body.bound, false, 'p2 stays unbound');
});
});
test('GET /skland/roles returns the cached binding, and ?refresh=1 re-queries upstream', async () => {
await withServer({}, async ({ post, get, up }) => {
await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' });
const cached = await get('/skland/roles?player=p1');
assert.equal(cached.body.ok, true);
assert.equal(cached.body.roles.length, 1);
assert.equal(up.byKey('binding').length, 1, 'the cached read does not hit the upstream');
const refreshed = await get('/skland/roles?player=p1&refresh=1');
assert.equal(refreshed.body.ok, true);
assert.equal(up.byKey('binding').length, 2, 'refresh re-queries');
});
});
test('a failed 验证码 login records the error on the status', async () => {
await withServer({
phoneToken: () => ({ status: 200, json: async () => ({ status: 1, msg: '验证码错误' }) }),
}, async ({ post, get }) => {
const r = await post('/skland/phone/login', { player: 'p1', phone: '138', code: '000000' });
assert.equal(r.body.ok, false);
assert.equal(r.body.error, '验证码错误');
const status = await get('/skland/status?player=p1');
assert.equal(status.body.status, 'error');
assert.equal(status.body.error, '验证码错误');
assert.equal(status.body.bound, false);
});
});
test('an unknown /skland endpoint is a JSON 404 and a bad method a JSON 405', async () => {
await withServer({}, async ({ post, srv }) => {
const unknown = await post('/skland/nope', { player: 'p1' });
assert.equal(unknown.status, 404);
assert.equal(unknown.body.ok, false);
const del = await fetch(srv.url + '/skland/status?player=p1', { method: 'DELETE', headers: { 'x-sp-diagnostic-key': 'test-admin-only' } });
assert.equal(del.status, 405);
const body = await del.json();
assert.equal(body.ok, false, 'the method error is JSON too');
});
});
test('a malformed JSON body is a 400, not a crash', async () => {
await withServer({}, async ({ srv }) => {
const res = await fetch(srv.url + '/skland/phone/login', {
method: 'POST', headers: { 'content-type': 'application/json', 'x-sp-diagnostic-key': 'test-admin-only' }, body: '{not json',
});
assert.equal(res.status, 400);
assert.equal((await res.json()).ok, false);
});
});
test('the rest of the server is unaffected: /healthz and / still answer', async () => {
await withServer({}, async ({ srv }) => {
const health = await fetch(srv.url + '/healthz');
assert.equal(health.status, 200);
assert.equal((await health.json()).ok, true);
const home = await fetch(srv.url + '/');
assert.ok([200, 404].includes(home.status), 'the static mount still owns /');
});
});
+115
View File
@@ -0,0 +1,115 @@
// test/skland/panel.test.js — public/skland.html, the standalone Skland binding panel (scan / SMS / password).
//
// The panel is one self-contained HTML file with an inline module script (no build step, no browser test), so this
// test reads it as TEXT and pins only the contract facts that would silently rot: the endpoints it calls, that the QR
// encodes the scanUrl deep link (NOT the scan session id), that captcha challenges are handled, that a 404 reads as
// "the module is off", and that the 2 s polling loop always gets cleared.
// String checks like these are deliberately few and each one names the failure it prevents.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const HTML_PATH = path.join(ROOT, 'public', 'skland.html');
describe('the Skland binding panel (public/skland.html)', () => {
assert.ok(existsSync(HTML_PATH), 'the panel is a real file the static server can serve');
const html = readFileSync(HTML_PATH, 'utf8');
test('is one whole document: a doctype, an inline module script and a closed html tag', () => {
// A stray `</script>` or an unclosed tag is exactly the kind of damage a text-only test can catch.
assert.match(html, /^<!doctype html>/i);
assert.equal((html.match(/<script\b/g) || []).length, (html.match(/<\/script>/g) || []).length, 'every script opens and closes once');
assert.match(html, /<script(?![^>]*\bsrc=)[^>]*type="module"/i, 'the panel logic is an inline module (no build step)');
assert.match(html, /<\/body>\s*<\/html>\s*$/, 'the document ends where it should');
});
test('every endpoint the panel needs is referenced', () => {
// Each is a distinct login/status step of server/skland/http.js; dropping one breaks that path silently.
for (const endpoint of [
'/skland/status', '/skland/scan/start', '/skland/scan/poll', '/skland/phone/send',
'/skland/phone/login', '/skland/password/login', '/skland/unbind', '/skland/roles',
]) {
assert.ok(html.includes(endpoint), `the panel calls ${endpoint}`);
}
assert.match(html, /\/skland\/roles\?player=.*refresh=1/, 'a bound refresh re-queries the roles');
});
test('the QR code encodes scanUrl — never the scanId', () => {
// The upstream deep link is `hypergryph://scan_login?…` (server/skland/service.js startScan). Encoding the opaque
// scanId would render a QR that no phone can act on, and nothing else in the flow would fail loudly.
assert.ok(html.includes('/vendor/qrcode.js'), 'the vendored library is loaded as a classic script');
const addData = html.match(/qr\.addData\(\s*([^)]*)\)/);
assert.ok(addData, 'the generator is fed one payload');
assert.match(addData[1], /scanUrl/, 'the payload is the scanUrl deep link');
assert.doesNotMatch(addData[1], /scanId/, 'never the scan session id');
assert.match(html, /renderQr\(\s*r\.scanUrl\s*\)/, 'the value comes from the scan/start answer');
assert.match(html, /typeof qrcode !== 'function'/, 'a missing vendor file is detected…');
assert.match(html, /createSvgTag|createImgTag/, '…and the present library renders a tag');
assert.match(html, /textContent\s*=\s*String\(scanUrl\)|value\s*=\s*String\(scanUrl\)/, '…with the deep link as selectable text');
});
test('the QR frame step is named exactly as the server sends it', () => {
// service.js pollScan answers state ∈ none|waiting|confirmed|expired; 'none' and 'expired' mean start over.
for (const state of ["'waiting'", "'confirmed'", "'expired'", "'none'"]) {
assert.ok(html.includes(`state === ${state}`), `the poll handles state ${state}`);
}
});
test('a 404 (or the 405 a POST gets) on /skland/* reads as "the module is off", not as a broken page', () => {
// The module is opt-in (SP_SKLAND=1). With it off /skland/* is not routed at all: a GET falls through to the
// static server's 404 page, but a POST hits routes.js's GET/HEAD gate first and gets a 405 HTML page. Both are
// the bilingual error page, while an enabled module always answers JSON (errors included) — so the panel keys on
// the HTML content type as well, and only a plain 404 check would show a confusing "method not allowed".
assert.match(html, /res\.status === 404/);
assert.match(html, /text\/html/, 'an HTML answer is recognised as the wrong server…');
assert.match(html, /该功能未启用\s*·\s*the module is off/);
assert.ok(html.includes('id="module-off"'), 'the notice has a place to render');
assert.match(html, /setModuleOff\(\)/, 'the notice is raised, not just logged');
});
test('needCaptcha is handled', () => {
// needCaptcha: send → geetest challenge, then resend with the solved fields (service.js sendPhoneCode).
assert.ok(html.includes('needCaptcha'));
assert.match(html, /needCaptcha[\s\S]{0,200}renderCaptcha\(/, 'the challenge data is displayed');
assert.ok(html.includes('/js/skland/captcha.js'), 'the shared interactive widget supplies the proof');
assert.ok(html.includes('pw-captcha-box'), 'password login has its own challenge control');
});
test('a poll answer is never read as `undefined`: ok:false is honoured and `state` is always named', () => {
// The panel once showed "未知状态 · Unknown state: undefined" because the server answered a failed poll with a
// bare { ok: false } and pollOnce only switched on `state`. Both halves are pinned here.
assert.match(html, /r\.ok === false/, 'pollOnce checks ok:false before it reads state');
assert.match(html, /r\.state === 'error'/, "the server's terminal error state is handled");
assert.match(html, /未知状态[\s\S]{0,160}brief\(JSON\.stringify\(r\)\)/, 'the unknown branch shows the real body');
});
test('the poll runs about every 2 s and its timer is always cleared', () => {
// A leaked interval keeps asking the server after the tab is hidden or the section is left (service.js deletes the
// scan session on the first non-waiting answer, so a leaked poll can only ever report state 'none' — noise).
const ms = html.match(/POLL_MS\s*=\s*(\d+)/);
assert.ok(ms, 'the interval is a named constant');
assert.equal(Number(ms[1]), 2000, 'the documented ~2 s');
assert.match(html, /setInterval\([\s\S]{0,80}?POLL_MS\s*\)/, 'the timer uses that constant');
assert.match(html, /clearInterval\(pollTimer\)/, 'the single cleanup path clears the interval');
assert.match(html, /document\.hidden\)\s*return/, 'the poll never starts on a hidden tab');
assert.match(html, /document\.hidden\)\s*pollStop\(\)/, 'a tab that goes hidden stops the poll');
assert.ok(html.includes("addEventListener('visibilitychange'"), 'visibility changes are watched');
assert.ok(html.includes("addEventListener('pagehide'"), 'leaving the page stops the poll too');
});
test('the identity shown is the ?player= / localStorage one, labelled as a placeholder', () => {
assert.ok(html.includes('URLSearchParams'), 'the player id is read from the URL');
assert.ok(html.includes('sp.skland.player'), 'and kept in localStorage under a clear key');
assert.match(html, /placeholder/i, 'the page says the id is a placeholder until the session provides one');
});
test('no external network calls and no inline secrets', () => {
// The page must work on an offline LAN box: only same-origin /vendor and /skland URLs are allowed. No tokens,
// passwords or phone numbers are baked in either.
assert.doesNotMatch(html, /https?:\/\//, 'no absolute http(s) URL anywhere');
assert.doesNotMatch(html, /\b(?:token|secret|password)\s*[:=]\s*['"][A-Za-z0-9]{8,}['"]/i, 'no hardcoded credential');
});
});
+167
View File
@@ -0,0 +1,167 @@
// Two isolated browser contexts exercise real title/login/recovery UI against an entirely stubbed provider.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { startServer } from '../../server/index.js';
const chrome = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const enabled = process.env.SP_E2E === '1' && fs.existsSync(chrome);
test('a separately logged-in device restores the match without local saves, and stale or duplicated tabs do not take it back',
{ skip: !enabled, timeout: 90_000 }, async () => {
const binding = { list: [{ appCode: 'arknights', bindingList: [
{ uid: '123', channelName: '官服', nickName: '跨设备博士', isDefault: true },
] }] };
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false,
skland: { storeFile: ':memory:', client: {
genScan: async () => ({ scanId: 'TEST', scanUrl: 'hypergryph://scan_login?scanId=TEST' }),
checkScan: async () => ({ state: 'waiting' }),
tokenByPhonePassword: async () => ({ token: 'FAKE' }),
getAccountBinding: async () => binding,
} } });
let browser;
const errors = [];
try {
const puppeteer = (await import('puppeteer-core')).default;
browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] });
const firstContext = await browser.createBrowserContext(), secondContext = await browser.createBrowserContext();
const pageIn = async (context) => {
const page = await context.newPage();
await page.setViewport({ width: 1440, height: 1000 });
page.on('pageerror', (error) => errors.push(error.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort();
else request.continue();
});
return page;
};
const clickDialog = async (page, label) => {
// QR loading can disable the tabs between separate readiness and click calls. Check/click atomically.
await page.waitForFunction((text) => {
const button = [...document.querySelectorAll('.sp-binding button')]
.find((element) => element.textContent.trim() === text && !element.disabled);
if (!button) return false;
button.click(); return true;
}, {}, label);
};
const login = async (page) => {
await page.goto(server.url);
await page.waitForSelector('[data-testid="binding-start"]');
await page.click('[data-testid="binding-start"]');
await clickDialog(page, '密码');
await page.waitForSelector('.sp-binding input[type="tel"]:not([disabled])');
await page.type('.sp-binding input[type="tel"]', '13800000000');
await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD');
await clickDialog(page, '登录并绑定');
await page.waitForFunction(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent === '跨设备博士');
await clickDialog(page, '完成');
};
const a = await pageIn(firstContext), b = await pageIn(secondContext);
await login(a);
await a.click('[data-testid="resume-account-match"]');
await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="status"]')?.textContent === '当前角色没有可恢复的对局。');
await a.keyboard.press('Escape');
await a.waitForSelector('.sp-account-resume', { hidden: true });
await a.click('[data-testid="binding-start"]');
await a.waitForSelector('.lobby-screen');
await a.evaluate(async () => {
await window.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
await window.__SP__.net.request('room.start');
});
await a.waitForFunction(() => window.__SP__.store.get().match.private && window.__SP__.store.get().room?.inMatch);
const previous = await a.evaluate(() => {
const s = window.__SP__.store.get();
return { code: s.room.code, playerId: s.me.playerId, token: s.me.token, private: s.match.private };
});
await login(b);
assert.equal(await b.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('sp.matches') || '{}')).length), 0);
assert.equal(await b.evaluate(() => window.__SP__.store.get().match.private), null);
assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false);
assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online');
assert.equal(await b.$('[data-testid="resume-local-match"]'), null, 'account UI no longer offers a local-token recovery');
await b.click('[data-testid="resume-account-match"]');
await b.waitForSelector('[data-testid="confirm-account-resume"]');
assert.match(await b.$eval('[data-testid="confirm-account-resume"]', (el) => el.textContent), new RegExp(previous.code));
assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online', 'opening the picker does not take over');
fs.mkdirSync('.cache/skland-preview', { recursive: true });
await b.$eval('.modal', (element) => Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)));
await b.screenshot({ path: '.cache/skland-preview/account-recovery.png' });
await b.setViewport({ width: 844, height: 390 });
const fits = await b.$eval('.sp-account-resume', (element) => {
const rect = element.getBoundingClientRect();
return rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight;
});
assert.equal(fits, true, 'the recovery picker fits a landscape phone');
const readable = await b.$eval('.sp-account-resume', (element) => ({
font: parseFloat(getComputedStyle(element.querySelector('.modal__body')).fontSize),
hit: element.querySelector('[data-testid="confirm-account-resume"]').getBoundingClientRect().height,
}));
assert.ok(readable.font >= 14 && readable.hit >= 44, 'small-screen recovery stays readable and tappable');
await b.screenshot({ path: '.cache/skland-preview/account-recovery-mobile.png' });
await b.setViewport({ width: 1440, height: 1000 });
await b.click('[data-testid="confirm-account-resume"]');
await b.waitForFunction((code) => {
const s = window.__SP__.store.get();
return s.session.entered && s.room?.code === code && !!s.match.private;
}, {}, previous.code);
const restored = await b.evaluate(() => window.__SP__.store.get());
assert.equal(restored.me.playerId, previous.playerId);
assert.notEqual(restored.me.token, previous.token);
for (const key of ['funds', 'shop', 'hand', 'board']) assert.deepEqual(restored.match.private[key], previous.private[key]);
await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED' && window.__SP__.net.status === 'closed');
const continueHere = async (page) => {
await page.waitForFunction(() => {
const button = [...document.querySelectorAll('.conn-banner button')]
.find((element) => element.textContent.trim() === '在此页面继续' && !element.disabled);
if (!button) return false;
button.click(); return true;
});
await page.waitForFunction((code) => {
const s = window.__SP__.store.get();
return window.__SP__.net.status === 'online' && s.session.entered && s.room?.code === code;
}, {}, previous.code);
};
await continueHere(a);
await b.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED');
await continueHere(b);
const heldToken = await b.evaluate(() => window.__SP__.store.get().me.token);
await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED');
await a.reload();
await a.waitForSelector('[data-testid="bound-callsign"]');
assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false, 'an old saved entry cannot silently retake the role');
assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online');
const duplicate = await pageIn(secondContext);
await duplicate.evaluateOnNewDocument((token) => {
sessionStorage.setItem('sp.token', token); sessionStorage.setItem('sp.entered', '1');
}, heldToken);
await duplicate.goto(server.url);
await duplicate.waitForSelector('[data-testid="bound-callsign"]');
assert.equal(await duplicate.evaluate(() => window.__SP__.store.get().session.entered), false);
assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online', 'a duplicated tab does not take over either');
// The match can end while the other device has its recovery dialog open.
await a.click('[data-testid="resume-account-match"]');
await a.waitForSelector('[data-testid="confirm-account-resume"]');
await b.evaluate(() => window.__SP__.net.request('g.leave'));
await a.click('[data-testid="confirm-account-resume"]');
await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="alert"]')?.textContent === '此对局已结束或过期,请刷新后重试。');
assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false);
assert.equal(await a.$('[data-testid="confirm-account-resume"]'), null);
// An active page that loses its server session must not stay in an anonymous, unusable lobby.
const current = server.registry.byToken(heldToken);
server.registry.remove(current);
await b.evaluate(() => window.__SP__.net.reconnectNow());
await b.waitForSelector('[data-testid="bound-callsign"]');
assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false);
assert.deepEqual(errors, []);
} catch (error) {
const pages = browser ? await browser.pages() : [];
console.error('Recovery browser state:', await Promise.all(pages.map((page) => page.evaluate(() => ({
status: window.__SP__?.net.status, entered: window.__SP__?.store.get().session.entered,
playerId: window.__SP__?.store.get().me.playerId,
callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent,
alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent),
})).catch(() => null))), 'page errors:', errors);
throw error;
} finally { await browser?.close(); await server.close(); }
});
+195
View File
@@ -0,0 +1,195 @@
// Independent devices have independent login cookies and no shared browser storage. No provider network calls.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { startServer } from '../../server/index.js';
import { TestClient } from '../helpers/wsClient.js';
import { roleKey } from '../../shared/accountIdentity.js';
import { validateC2S } from '../../shared/protocol.js';
import { accountPlayerId } from '../../server/skland/auth.js';
const role = (uid = '123', channelName = '官服', extra = {}) => ({ uid, channelName, nickName: '同名博士', ...extra });
async function fixture(t) {
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false,
skland: { storeFile: ':memory:' } });
const clients = [];
t.after(async () => { await Promise.all(clients.map((client) => client.close())); await server.close(); });
const connect = async (cookie, token) => {
const client = await TestClient.connect(server.url.replace('http:', 'ws:') + '/ws', {
wsOptions: { headers: cookie ? { cookie } : {} },
});
clients.push(client);
const welcome = await client.hello('Transport', token);
return { client, token: welcome.token, welcome };
};
const request = async (device, endpoint, body, headers = {}) => {
const response = await fetch(server.url + '/skland/session/' + endpoint, {
method: body === undefined ? 'GET' : 'POST',
headers: { authorization: 'Bearer ' + device.token, ...(device.cookie ? { cookie: device.cookie } : {}),
'content-type': 'application/json', ...headers },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const cookie = response.headers.get('set-cookie');
if (cookie) device.cookie = cookie.split(';')[0];
return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') };
};
const device = async (roles = [role()]) => {
const d = await connect();
server.skland._store.update('@session/' + d.welcome.playerId, { status: 'bound', nickname: roles[0].nickName,
binding: { list: [{ appCode: 'arknights', bindingList: roles }] } });
const result = await request(d, 'status');
assert.equal(result.status, 200);
d.state = result.body;
return d;
};
const enter = async (d, id) => {
const result = await request(d, id ? 'resume' : 'enter', id ? { id } : {});
assert.equal(result.status, 200, JSON.stringify(result.body));
await d.client.close();
Object.assign(d, await connect(d.cookie, result.body.token));
assert.equal(d.welcome.playerId, result.body.gamePlayerId);
assert.notEqual(d.token, result.body.token, 'the accepted socket rotates the account token');
return d;
};
const start = async (d, mode = 'solo') => {
await enter(d);
assert.equal((await d.client.request({ t: 'room.create', mode, difficulty: 'NORMAL' })).t, 'ok');
const state = await d.client.waitFor('room.state');
assert.equal((await d.client.request({ t: 'room.start' })).t, 'ok');
const room = server.lobby.rooms.get(state.code);
assert.ok(room.match, 'uses the real Match implementation');
await d.client.waitFor('m.public');
return room;
};
const disconnect = async (d) => {
const session = server.registry.byToken(d.token);
const closed = once(session.ws, 'close');
await d.client.close();
await closed;
};
return { server, connect, request, device, enter, start, disconnect };
}
test('a fresh device queries the selected role and explicitly resumes the same live match and private state', async (t) => {
const h = await fixture(t), a = await h.device();
const room = await h.start(a), match = room.match, player = match.players.get(a.welcome.playerId);
player.funds = 37;
player.shop.freeRefreshes = 2;
const before = player.privateView(), oldToken = a.token;
const b = await h.device([role(123, '官服', { nickName: '已改名', gameId: null })]);
assert.notEqual(a.cookie, b.cookie, 'the device logs in independently, not by copying a JWT');
assert.equal(a.state.gamePlayerId, b.state.gamePlayerId, 'nickname and optional metadata do not select another identity');
assert.equal(a.client.isOpen, true, 'login alone does not take over the active device');
assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false);
const result = await h.request(b, 'recovery');
const item = result.body.recovery;
assert.equal(result.status, 200);
assert.match(result.cache, /no-store/);
assert.equal(item.code, room.code); assert.equal(item.inMatch, true); assert.equal(item.connected, true);
assert.equal(Object.hasOwn(item, 'token'), false, 'the read-only summary exposes no reconnect credential');
assert.equal(a.client.isOpen, true, 'querying a match is not resuming it');
await h.enter(b, item.id);
assert.equal((await a.client.closed).code, 4001, 'only successful explicit recovery replaces the old device');
assert.equal(b.welcome.playerId, a.welcome.playerId);
assert.equal(room.match, match);
assert.equal((await b.client.waitFor('room.state')).code, room.code);
assert.deepEqual(await b.client.waitFor('m.private'), before);
assert.equal(h.server.registry.byToken(oldToken), null, 'the old device token was revoked');
const stale = await h.connect(a.cookie, oldToken);
assert.notEqual(stale.welcome.playerId, b.welcome.playerId, 'an old device cannot silently take the role back');
assert.equal(b.client.isOpen, true);
assert.equal((await stale.client.request({ t: 'g.ready' })).t, 'error');
});
test('disconnected solo and co-op matches use the existing account-independent reconnect windows', async (t) => {
for (const mode of ['solo', 'coop']) await t.test(mode, async (t) => {
const h = await fixture(t), a = await h.device();
const room = await h.start(a, mode), session = h.server.registry.byId(a.welcome.playerId);
await h.disconnect(a);
const at = session.disconnectedAt;
assert.ok(at != null);
assert.equal(h.server.registry.windowOf(session), mode === 'solo' ? 86_400_000 : 600_000);
const b = await h.device();
const item = (await h.request(b, 'recovery')).body.recovery;
assert.equal(item.connected, false);
assert.equal(session.disconnectedAt, at, 'looking at a recovery never renews its deadline');
await h.enter(b, item.id);
assert.equal((await b.client.waitFor('room.state')).code, room.code);
assert.equal(session.disconnectedAt, null);
assert.equal(session.connected, true);
});
});
test('recovery never follows a supplied player, nickname, UID, region or foreign recovery selector', async (t) => {
const h = await fixture(t), a = await h.device(), room = await h.start(a);
const item = (await h.request(a, 'recovery')).body.recovery;
const strangers = [await h.device([role('456')]), await h.device([role('123', 'B服')]),
await h.device([role('123'), role('456', '官服', { isDefault: true })])];
for (const b of strangers) {
assert.equal((await h.request(b, 'recovery?player=' + a.welcome.playerId)).body.recovery, null);
const result = await h.request(b, 'resume', { id: item.id, player: a.welcome.playerId,
uid: '123', channelName: '官服', name: a.welcome.name });
assert.equal(result.status, 409); assert.equal(result.body.code, 'resume_expired');
const stolen = await h.connect(b.cookie, a.token);
assert.notEqual(stolen.welcome.playerId, a.welcome.playerId);
assert.equal(stolen.client.log.some((frame) => frame.t === 'm.private'), false);
}
const multi = strangers[2];
assert.equal((await h.request(multi, 'role/select', { roleKey: roleKey(role('123')) })).status, 200);
assert.equal((await h.request(multi, 'recovery')).body.recovery.code, room.code);
const anonymous = await h.connect();
assert.equal((await h.request(anonymous, 'recovery')).status, 401);
assert.equal((await h.request(anonymous, 'resume', { id: item.id })).status, 401);
for (const endpoint of ['enter', 'resume']) {
assert.equal((await h.request(multi, endpoint, { id: item.id }, { origin: 'https://attacker.invalid' })).status, 403);
}
assert.equal(a.client.isOpen, true);
});
test('a stale or expired recovery is refused without creating a session; later entry disposes the old seat first', async (t) => {
const h = await fixture(t), a = await h.device(), room = await h.start(a);
const session = h.server.registry.byId(a.welcome.playerId), item = (await h.request(a, 'recovery')).body.recovery;
await h.disconnect(a);
const at = session.disconnectedAt + h.server.registry.windowOf(session) + 1;
h.server.registry.now = () => at; h.server.network.now = () => at;
const b = await h.device();
assert.equal((await h.request(b, 'recovery')).body.recovery, null);
const stale = await h.request(b, 'resume', { id: item.id });
assert.equal(stale.status, 409); assert.equal(stale.body.code, 'resume_expired');
assert.equal(h.server.registry.byId(session.playerId), session, 'a failed restore does not mint a fresh game');
await h.enter(b);
assert.notEqual(h.server.registry.byId(session.playerId), session);
assert.equal(session.roomCode, null);
assert.equal(room.disposed, true, 'the expired solo match is abandoned, not orphaned under the stable playerId');
assert.equal((await h.request(b, 'recovery')).body.recovery, null);
assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false);
});
test('a room that starts a later match invalidates the old selector without stealing its connected seat', async (t) => {
const h = await fixture(t), a = await h.device(), b = await h.device();
await h.enter(a);
assert.equal((await a.client.request({ t: 'room.create', mode: 'solo', difficulty: 'NORMAL' })).t, 'ok');
const first = (await h.request(b, 'recovery')).body.recovery;
assert.equal(first.inMatch, false);
assert.equal((await a.client.request({ t: 'room.start' })).t, 'ok');
assert.equal((await h.request(b, 'resume', { id: first.id })).status, 409);
const current = (await h.request(b, 'recovery')).body.recovery;
assert.notEqual(current.id, first.id); assert.equal(current.code, first.code);
assert.equal(a.client.isOpen, true);
await h.enter(b, current.id);
assert.equal((await a.client.closed).code, 4001);
});
test('stable account identities and normal fields fit every existing client message limit', () => {
const playerId = accountPlayerId(role()), fieldId = 'n:' + playerId;
const perPlayer = { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, unitsEnd: [] };
for (const message of [
{ t: 'room.kick', seat: 1, playerId },
{ t: 'room.removeSpectator', playerId },
{ t: 'g.watch', fieldId, playerId },
{ t: 'b.result', battleId: 'match.1', result: { reason: 'cleared', time: 1, perPlayer: { [playerId]: perPlayer } } },
]) assert.equal(validateC2S(message), null, message.t + ' accepts the account identity');
assert.equal(playerId, accountPlayerId(role(123, ' 官服 ', { nickName: '新名字', gameId: 2 })));
});
+120
View File
@@ -0,0 +1,120 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createQrLogin } from '../../public/js/skland/scan.js';
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
function fakeClock() {
let time = 0, id = 0;
const tasks = new Map();
return {
now: () => time,
timers: {
setTimeout: (fn, ms) => { tasks.set(++id, { fn, at: time + ms }); return id; },
clearTimeout: (key) => tasks.delete(key),
},
count: () => tasks.size,
async advance(ms) {
const target = time + ms;
for (;;) {
const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0];
if (!next || next[1].at > target) break;
time = next[1].at; tasks.delete(next[0]); next[1].fn(); await flush();
}
time = target; await flush();
},
};
}
function fixture(poll = () => ({ ok: true, state: 'waiting' }), onConfirmed) {
const clock = fakeClock(), calls = [], images = [], pending = [], errors = [];
let confirmed = 0, starts = 0;
const scan = createQrLogin({ ...clock,
request: async (path) => {
calls.push({ path, at: clock.now() });
return path === 'scan/start' ? { ok: true, scanUrl: `QR${++starts}` } : poll();
},
encode: async (url) => `image:${url}`, onQr: (value) => images.push(value),
onPending: (value) => pending.push(value), onError: (value) => { if (value) errors.push(value); },
onBusy: () => {}, onConfirmed: async () => { confirmed++; await onConfirmed?.(); },
});
return { scan, clock, calls, images, pending, errors, starts: () => starts, confirmed: () => confirmed };
}
test('QR is fetched automatically, polled every 2 s and replaced every 10 s', async () => {
const f = fixture(); f.scan.setActive(true); await f.clock.advance(0);
assert.deepEqual(f.images, ['image:QR1']);
await f.clock.advance(9999); assert.equal(f.starts(), 1);
await f.clock.advance(1); assert.equal(f.starts(), 2);
assert.deepEqual(f.calls.filter((c) => c.path === 'scan/poll').map((c) => c.at), [2000, 4000, 6000, 8000, 10000]);
await f.clock.advance(10000); assert.equal(f.starts(), 3);
f.scan.stop(); assert.equal(f.clock.count(), 0);
});
test('a failed status fetch after confirmation retries completion without replacing the authenticated QR', async () => {
let attempts = 0;
const f = fixture(() => ({ ok: true, state: 'confirmed' }), async () => {
if (++attempts === 1) throw new Error('status offline');
});
f.scan.setActive(true); await f.clock.advance(2000);
assert.equal(f.confirmed(), 1); assert.equal(f.errors[0].message, 'status offline');
await f.clock.advance(10000);
assert.equal(f.confirmed(), 2); assert.equal(f.starts(), 1); assert.equal(f.clock.count(), 0);
f.scan.stop();
});
test('the first awaiting-confirmation event starts a 60 s window; repeated polls do not extend it', async () => {
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
f.scan.setActive(true); await f.clock.advance(2000);
assert.equal(f.pending.at(-1), true);
await f.clock.advance(59999); assert.equal(f.starts(), 1);
await f.clock.advance(1); assert.equal(f.starts(), 2);
assert.equal(f.calls.filter((c) => c.path === 'scan/start')[1].at, 62000);
assert.equal(f.pending.at(-1), false, 'a new QR returns to the ordinary interval');
f.scan.refresh(); await f.clock.advance(0); assert.equal(f.starts(), 3, 'manual refresh is immediate too');
f.scan.stop();
});
test('a scan detected at the 10 s refresh boundary keeps the QR for confirmation', async () => {
let f;
f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: f.clock.now() >= 10000 }));
f.scan.setActive(true); await f.clock.advance(10000);
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
await f.clock.advance(59999); assert.equal(f.starts(), 1);
await f.clock.advance(1); assert.equal(f.starts(), 2);
f.scan.stop();
});
test('pausing stops requests and resuming checks confirmation before replacing an overdue QR', async () => {
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
f.scan.setActive(true); await f.clock.advance(0);
f.scan.setActive(false); assert.equal(f.clock.count(), 0);
await f.clock.advance(50000); assert.equal(f.calls.length, 1);
f.scan.setActive(true); await f.clock.advance(0);
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
f.scan.stop(); await f.clock.advance(120000); assert.equal(f.calls.length, 2);
});
test('confirmation wins over a manual refresh queued during an in-flight poll', async () => {
let resolvePoll;
const f = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
f.scan.setActive(true); await f.clock.advance(2000);
f.scan.refresh(); assert.equal(f.starts(), 1, 'a refresh never overlaps its poll');
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
assert.equal(f.confirmed(), 1); assert.equal(f.clock.count(), 0);
await f.clock.advance(120000); assert.equal(f.starts(), 1);
f.scan.stop();
});
test('stopping ignores late replies, and a failed poll can recover with a fresh QR', async () => {
let resolvePoll;
const stopped = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
stopped.scan.setActive(true); await stopped.clock.advance(2000); stopped.scan.stop();
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
assert.equal(stopped.confirmed(), 0); assert.equal(stopped.clock.count(), 0);
const retry = fixture(() => { throw new Error('offline'); });
retry.scan.setActive(true); await retry.clock.advance(2000);
assert.equal(retry.errors[0].message, 'offline');
await retry.clock.advance(9999); assert.equal(retry.starts(), 1);
await retry.clock.advance(1); assert.equal(retry.starts(), 2);
retry.scan.stop();
});
+156
View File
@@ -0,0 +1,156 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHmac } from 'node:crypto';
import { WebSocket } from 'ws';
import { startServer } from '../../server/index.js';
import { ACCOUNT_COOKIE, LOGIN_TTL } from '../../server/skland/auth.js';
import { roleKey } from '../../shared/accountIdentity.js';
import { PROTOCOL_VERSION } from '../../shared/constants.js';
const role = (uid = '123', channelName = '官服', gameId = 1) => ({ uid, channelName, gameId, nickName: '同名博士' });
async function request(server, endpoint, { token, cookie, body, headers = {} } = {}) {
const response = await fetch(server.url + endpoint, { method: body === undefined ? 'GET' : 'POST',
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(cookie ? { cookie } : {}),
'content-type': 'application/json', ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return { status: response.status, body: await response.json(), setCookie: response.headers.get('set-cookie') };
}
async function login(server, roles = [role()]) {
const session = server.registry.create('temporary');
server.skland._store.update('@session/' + session.playerId, { status: 'bound', nickname: roles[0].nickName,
binding: { list: [{ appCode: 'arknights', bindingList: roles }] } });
const result = await request(server, '/skland/session/status', { token: session.token });
assert.equal(result.status, 200);
return { token: session.token, cookie: result.setCookie.split(';')[0], state: result.body, setCookie: result.setCookie };
}
async function socket(server, account = {}) {
const ws = new WebSocket(server.url.replace('http:', 'ws:') + '/ws', { headers: account.cookie ? { cookie: account.cookie } : {} });
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
let rid = 0;
const send = (message) => new Promise((resolve, reject) => {
const id = ++rid;
const timer = setTimeout(() => { ws.off('message', receive); reject(new Error('socket reply timeout')); }, 3000);
const receive = (data) => { const reply = JSON.parse(data);
if (reply.rid === id) { clearTimeout(timer); ws.off('message', receive); resolve(reply); } };
ws.on('message', receive); ws.send(JSON.stringify({ ...message, rid: id }));
});
const entry = account.cookie && await request(server, '/skland/session/enter', { ...account, body: {} });
const welcome = await send({ t: 'hello', name: '伪造昵称', token: entry?.body.token || account.token, version: PROTOCOL_VERSION });
return { ws, send, welcome };
}
test('account HTTP rejects forged JWT/roles and cross-origin access; logout revokes replay and diagnostics are closed', async () => {
const authSecret = 'test-only-key-never-a-real-deployment-123456789';
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', authSecret } });
try {
const a = await login(server), b = await login(server, [role('456')]);
assert.match(a.setCookie, /HttpOnly/); assert.match(a.setCookie, /SameSite=Strict/); assert.match(a.setCookie, /Max-Age=2592000/);
assert.equal((await request(server, '/skland/session/status', { cookie: a.cookie })).status, 401);
assert.equal((await request(server, '/skland/session/status?token=' + a.token, { cookie: a.cookie })).status, 401);
assert.equal((await request(server, '/skland/session/status?player=' + encodeURIComponent(b.state.playerId), a)).body.playerId, a.state.playerId);
assert.equal((await request(server, '/skland/session/role/select', { ...a, body: { roleKey: roleKey(role('456')) } })).status, 403);
for (const headers of [{ origin: 'https://attacker.invalid' }, { 'sec-fetch-site': 'cross-site' }]) {
assert.equal((await request(server, '/skland/session/status', { ...a, headers })).status, 403);
}
for (const url of ['/skland/status?player=' + a.state.playerId, '/skland/roles?player=someone']) {
assert.equal((await request(server, url, a)).status, 403);
}
const jwt = a.cookie.slice(ACCOUNT_COOKIE.length + 1).split('.'), claims = JSON.parse(Buffer.from(jwt[1], 'base64url'));
assert.ok(!JSON.stringify(claims).includes('access_token'));
for (const forged of [
[jwt[0], Buffer.from(JSON.stringify({ ...claims, sub: b.state.playerId })).toString('base64url'), jwt[2]],
[Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'), jwt[1], jwt[2]],
]) assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${forged.join('.')}` })).status, 401);
for (const invalid of [{ iss: 'another-server' }, { aud: 'another-app' }, { iat: claims.exp + 1 }, { role: roleKey(role('456')) }]) {
const body = Buffer.from(JSON.stringify({ ...claims, ...invalid })).toString('base64url');
const input = jwt[0] + '.' + body;
const signed = input + '.' + createHmac('sha256', authSecret).update(input).digest('base64url');
assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${signed}` })).status, 401);
}
await request(server, '/skland/session/unbind', { ...b, body: { player: a.state.playerId } });
assert.equal((await request(server, '/skland/session/status', a)).body.bound, true, 'B only unbinds B');
await request(server, '/skland/session/unbind', { ...a, body: {} });
assert.equal((await request(server, '/skland/session/status', a)).status, 401);
assert.equal((await request(server, '/skland/session/status', { token: a.token })).body.bound, false, 'old transport cannot reissue JWT');
} finally { await server.close(); }
});
test('late provider replies cannot restore credentials after unbind or replace a newer scan', async () => {
let resolveScan, resolveBinding, announceBinding, scans = 0, tokenExchanges = 0;
const binding = { list: [{ appCode: 'arknights', bindingList: [role()] }] };
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client: {
genScan: async () => ({ scanId: String(++scans), scanUrl: 'test:qr' }),
checkScan: async () => new Promise((resolve) => { resolveScan = resolve; }),
tokenByScanCode: async () => { tokenExchanges++; return { token: 'TEST' }; },
tokenByPhonePassword: async () => ({ token: 'TEST' }),
getAccountBinding: async () => { announceBinding(); return new Promise((resolve) => { resolveBinding = resolve; }); },
} } });
try {
const service = server.skland;
for (const action of [() => service.unbind('pending'), () => service.startScan('pending')]) {
await service.startScan('pending');
const pending = service.pollScan('pending');
await action(); resolveScan({ state: 'confirmed', scanCode: 'OLD' });
assert.equal((await pending).state, 'none');
assert.equal(service.status('pending').bound, false);
}
assert.equal(tokenExchanges, 0);
const reachedBinding = new Promise((resolve) => { announceBinding = resolve; });
const password = service.loginByPhonePassword('pending', '13800000000', 'test');
await reachedBinding; service.unbind('pending'); resolveBinding(binding);
assert.equal((await password).ok, false);
assert.equal(service._store.get('pending').access_token, null);
assert.equal(service.status('pending').bound, false);
} finally { await server.close(); }
});
test('JWT and bindings survive restart, expire after 30 days, and credential files cannot be downloaded', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-account-'));
let at = Date.now(), server;
const options = { port: 0, host: '127.0.0.1', quiet: true, publicDir: dir,
skland: { storeFile: path.join(dir, 'private.db'), authNow: () => at } };
try {
server = await startServer(options); const a = await login(server);
for (const name of ['private.db', 'PRIVATE.DB', 'private.db-wal', 'private.db-shm']) assert.equal((await fetch(server.url + '/' + name)).status, 404);
try { fs.symlinkSync(path.join(dir, 'private.db'), path.join(dir, 'alias.txt'));
assert.equal((await fetch(server.url + '/alias.txt')).status, 404);
} catch (error) { if (!['EPERM', 'EACCES'].includes(error.code)) throw error; }
await server.close(); server = await startServer(options);
const fresh = server.registry.create('new transport');
const restored = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie });
assert.equal(restored.body.gamePlayerId, a.state.gamePlayerId); assert.equal(restored.body.playerId, a.state.playerId);
assert.equal(restored.body.bound, true);
at += (LOGIN_TTL + 1) * 1000;
const expired = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie });
assert.equal(expired.status, 401); assert.match(expired.setCookie, /Max-Age=0/);
} finally { await server?.close(); fs.rmSync(dir, { recursive: true, force: true }); }
});
test('game identity uses UID plus region, permits duplicate nicknames, and blocks anonymous game requests', async () => {
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:' } });
const sockets = [];
try {
const accounts = [await login(server), await login(server, [role('456')]), await login(server, [role('123', 'B服', 2)])];
for (const account of accounts) sockets.push(await socket(server, account));
const welcomes = sockets.map((client) => client.welcome);
assert.ok(welcomes.every((welcome) => welcome.name === '同名博士'));
assert.equal(new Set(welcomes.map((welcome) => welcome.playerId)).size, 3);
assert.equal((await sockets[0].send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'ok');
const anonymous = await socket(server, { token: welcomes[0].token }); sockets.push(anonymous);
assert.notEqual(anonymous.welcome.playerId, welcomes[0].playerId);
assert.equal((await anonymous.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error');
assert.equal((await request(server, '/skland/session/status', { token: welcomes[0].token, cookie: accounts[1].cookie })).status, 403);
const reconnect = await socket(server, accounts[1]); sockets.push(reconnect);
assert.equal(reconnect.welcome.playerId, welcomes[1].playerId);
await request(server, '/skland/session/unbind', { token: reconnect.welcome.token, cookie: accounts[1].cookie, body: {} });
assert.equal((await reconnect.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error');
} finally { for (const client of sockets) client.ws.terminate(); await server.close(); }
});
test('a disabled server keeps the account surface off', async () => {
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
try { assert.equal((await fetch(server.url + '/skland/session/status')).status, 404); }
finally { await server.close(); }
});
+168
View File
@@ -0,0 +1,168 @@
// The per-player credential store (server/skland/store.js): node:sqlite in WAL mode, one row per player.
//
// The store is what survives a restart, so the round-trip of every column and the 解绑 semantics are pinned here.
// The file-backed cases also assert the WAL side files exist, which is what makes the SQLite path a "high-performance
// local database" rather than a map that happens to be persisted.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { createSklandStore } from '../../server/skland/store.js';
const tempDir = () => mkdtempSync(join(tmpdir(), 'sp-skland-'));
test('a player row starts unbound and ensure() is idempotent', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.ensure('p1');
store.ensure('p1');
const row = store.get('p1');
assert.equal(store.count(), 1);
assert.equal(row.player_id, 'p1');
assert.equal(row.status, 'unbound');
assert.equal(row.cred, null);
assert.equal(row.binding, null);
assert.ok(row.created_at > 0);
} finally { store.close(); }
});
test('get() of an unknown player is null; update() creates the row', () => {
const store = createSklandStore({ file: ':memory:' });
try {
assert.equal(store.get('nobody'), null);
store.update('p2', { status: 'bound', cred: 'C', cred_token: 'CT' });
assert.equal(store.get('p2').status, 'bound');
assert.equal(store.count(), 1, 'update() creates exactly one row');
} finally { store.close(); }
});
test('the whole login chain round-trips, including the binding JSON', () => {
const store = createSklandStore({ file: ':memory:' });
try {
const binding = { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士' }] }] };
store.update('p1', {
access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT',
user_id: 'U', d_id: 'D', nickname: '博士', binding, status: 'bound',
});
const row = store.get('p1');
assert.equal(row.access_token, 'AT');
assert.equal(row.device_token, 'DT');
assert.equal(row.hg_id, 'HG');
assert.equal(row.cred, 'C');
assert.equal(row.cred_token, 'CT');
assert.equal(row.d_id, 'D');
assert.equal(row.nickname, '博士');
assert.deepEqual(row.binding, binding, 'binding is stored as JSON and parsed back');
} finally { store.close(); }
});
test('update() merges only the named fields', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.update('p1', { cred: 'C', status: 'bound', nickname: 'N' });
store.update('p1', { cred_token: 'CT' });
const row = store.get('p1');
assert.equal(row.cred, 'C');
assert.equal(row.nickname, 'N');
assert.equal(row.status, 'bound');
assert.equal(row.cred_token, 'CT');
} finally { store.close(); }
});
test('unbind() forgets every credential and the binding source but keeps the row', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.update('p1', {
access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT',
user_id: 'U', d_id: 'D', nickname: 'N', binding: { list: [] }, binding_source: 'hypergryph', status: 'bound',
});
store.unbind('p1');
const row = store.get('p1');
assert.equal(row.status, 'unbound');
for (const field of ['access_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname']) {
assert.equal(row[field], null, `${field} is cleared`);
}
assert.equal(row.binding, null);
assert.equal(row.device_token, null, 'the device token is cleared with the rest of the credentials');
assert.equal(row.binding_source, 'skland', 'unbound rows return to the legacy default');
assert.equal(row.player_id, 'p1', 'the row stays so a rebind is cheap');
} finally { store.close(); }
});
test('a legacy database migrates without losing QR bindings and persists the official account source', () => {
const dir = tempDir();
const file = join(dir, 'legacy.db');
try {
const initial = createSklandStore({ file });
initial.update('old-player', { cred: 'OLD-CRED', device_token: 'OLD-DEVICE', status: 'bound', binding: { list: [] } });
initial.close();
const legacy = new DatabaseSync(file);
legacy.exec('ALTER TABLE skland_players DROP COLUMN binding_source');
legacy.close();
const migrated = createSklandStore({ file });
assert.equal(migrated.get('old-player').cred, 'OLD-CRED');
assert.equal(migrated.get('old-player').device_token, 'OLD-DEVICE');
assert.equal(migrated.get('old-player').binding_source, 'skland');
migrated.update('password-player', { access_token: 'HG-TOKEN', binding_source: 'hypergryph', status: 'bound', binding: { list: [] } });
migrated.close();
const reopened = createSklandStore({ file });
assert.equal(reopened.get('password-player').binding_source, 'hypergryph');
assert.equal(reopened.get('password-player').access_token, 'HG-TOKEN');
assert.equal(reopened.get('password-player').cred, null);
assert.equal(reopened.get('old-player').status, 'bound');
reopened.close();
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('remove() drops the row entirely', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.update('p1', { status: 'bound' });
store.remove('p1');
assert.equal(store.get('p1'), null);
assert.equal(store.count(), 0);
} finally { store.close(); }
});
test('list() returns every row, newest update first', () => {
const store = createSklandStore({ file: ':memory:' });
try {
store.update('a', { status: 'bound' });
store.update('b', { status: 'unbound' });
const ids = store.list().map((r) => r.player_id).sort();
assert.deepEqual(ids, ['a', 'b']);
} finally { store.close(); }
});
test('a file-backed store persists across instances and uses WAL', () => {
const dir = tempDir();
const file = join(dir, 'skland.db');
try {
const first = createSklandStore({ file });
first.update('p1', { cred: 'C', status: 'bound', binding: { list: [{ appCode: 'arknights', bindingList: [] }] } });
assert.equal(first.journalMode(), 'wal', 'WAL mode is on (readers do not block the writer)');
first.close();
assert.ok(existsSync(file), 'the database file exists on disk');
const second = createSklandStore({ file });
const row = second.get('p1');
assert.equal(row.cred, 'C', 'the credentials survive a restart');
assert.equal(row.status, 'bound');
assert.deepEqual(row.binding.list[0].appCode, 'arknights');
second.close();
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('the parent directory of the database file is created', () => {
const dir = tempDir();
const file = join(dir, 'nested', 'deeper', 'skland.db');
try {
const store = createSklandStore({ file });
store.update('p1', { status: 'bound' });
assert.ok(existsSync(file));
store.close();
} finally { rmSync(dir, { recursive: true, force: true }); }
});
+3
View File
@@ -40,6 +40,9 @@ export const VENDOR_FILES = Object.freeze([
['node_modules/htm/dist/htm.module.js', 'htm.module.js'], ['node_modules/htm/dist/htm.module.js', 'htm.module.js'],
['node_modules/three/build/three.core.js', 'three.core.js', true], ['node_modules/three/build/three.core.js', 'three.core.js', true],
['node_modules/three/build/three.module.js', 'three.module.js', true], ['node_modules/three/build/three.module.js', 'three.module.js', true],
// the 森空岛 binding panel (public/skland.html) renders the scan login QR client-side; the UMD build sets a global
// `qrcode(typeNumber, errorCorrectionLevel)` when loaded as a classic script, so this one is not an ES module.
['node_modules/qrcode-generator/dist/qrcode.js', 'qrcode.js'],
]); ]);
export function vendor({ log = console.log, warn = console.warn } = {}) { export function vendor({ log = console.log, warn = console.warn } = {}) {