Compare commits
11
Commits
a1f0d1bb6e
...
7c25c89992
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c25c89992 | ||
|
|
488db49f11 | ||
|
|
205f3bb7f7 | ||
|
|
d8aab0a2db | ||
|
|
566f3a7cd6 | ||
|
|
b5d0c3f96f | ||
|
|
9a0af9e891 | ||
|
|
347343d739 | ||
|
|
1df530f2b9 | ||
|
|
2b685011e9 | ||
|
|
cc3c210779 |
No files matched your search
@@ -28,6 +28,13 @@ __pycache__/
|
||||
# (the server answers /packs/index.json itself; tools/package.mjs writes this file into the release zips) -----------
|
||||
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 ----------------------------------------------------------------------------------
|
||||
test/e2e/out/
|
||||
logs/
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
## 0.2.6-dev — 未发布
|
||||
## 0.2.6 — 2026-10-13
|
||||
|
||||
### 开服流量
|
||||
- 静音或把背景音乐音量调到 0 时不再下载背景音乐,重新打开声音后才下载当前该放的曲子(关音乐的玩家每局少下约 7–14 MB)。
|
||||
@@ -9,8 +9,12 @@
|
||||
- 流量统计:`/healthz?traffic=1` 显示服务器发出的各类流量(只对本机和局域网的访问显示);「问题反馈」的诊断信息增加本页下载量。
|
||||
- 部署指南:固定访问地址、反代开 HTTP/2、Cloudflare 缓存规则。
|
||||
|
||||
### 规则(按官方)
|
||||
- 维什戴尔的魂灵之影只召唤到场上的部署格(含队友的半场):首领战中不会再落到整备区,单人首领战也不会落到空着的另一半场;范围内没有空位时不召唤(#557、PR #558,感谢 @IceCodeNew)。
|
||||
|
||||
### 界面
|
||||
- 官方战场场景的光照改回提取出的官方值:直射光和烘焙光不再被压暗,场景不再明显偏暗(#549,PR #554,感谢 @xcdoge)。
|
||||
- 显示官方场景时,射击台、特制水上平台和封印的地面重新画出来:此前它们只画在被场景替换掉的棋盘层里,战场#03 蓝门旁的高台是平的、战场#05(上半) 的水上平台看起来像一片空水面,干员却站在抬高的位置上(#569,感谢 @A1muE4aY)。
|
||||
- 最后一个还没选策略的玩家不再看到可点的「跳过」按钮:已经选完的玩家仍显示在顺序里,但不算可以让过去的人(PR #546,感谢 @Xinglan233)。
|
||||
- 每回合开始时,自己棋盘上的干员按部署顺序一个接一个重播入场动画并播放落地音效(维护者按官方实机确认);买到干员、干员上场或换到另一格时也会播放一次,同一次落地只响一声,音量跟随「音效」(PR #416,感谢 @Cromemadnd)。
|
||||
- 设置新增「弹药数量」,是可选项、默认关闭(官方界面没有这个数字):打开后弹药类技能的黄色弹药条下方显示剩余弹药 / 总弹药数(如 13/14),格子密到画不出分隔线时也能读出(PR #552,感谢 @IceCodeNew)。
|
||||
|
||||
+14
-1
@@ -12,8 +12,18 @@
|
||||
# (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.
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@@ -53,6 +63,9 @@ COPY --from=build /app/data ./data
|
||||
COPY --from=build /app/public ./public
|
||||
# research tables: read by server/sim/nodeData.js as a fallback
|
||||
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
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# 卫戍协议:盟约 · Stronghold Protocol: Alliance
|
||||
|
||||
> **开发版(dev 分支):不稳定,请勿用于公开服务器**。当前为 0.2.6-dev,尚未发布;稳定版请使用 Releases 中的 0.2.5。
|
||||
|
||||
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -39,7 +37,7 @@ English summary: [below](#english).
|
||||
|
||||
- **独立模拟**(单人)与**同盟模拟**(1–4 人**合作**,没有 PvP;空位可以加 AI 队友)。
|
||||
- 服务器是一个 Node.js 程序,**战斗在各玩家的浏览器里模拟**(和官方一样),服务器只管经济与回合,一台低功耗小主机就能开服。
|
||||
- 当前开发版 0.2.6-dev(未发布):减轻开服电脑的流量——静音时不下载背景音乐、WebSocket 消息压缩、素材地址带内容哈希长期缓存、brotli 压缩。最近发布版 0.2.5:3D 棋盘按战场显示官方场景,新增「官方伤害数字」选项,首领每局随机配对,固定赠品和已展示的干员牌不再被库存阻断,并修复了 0.2.4 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
- 当前版本 0.2.6:减轻开服电脑的流量(静音时不下载背景音乐、WebSocket 消息压缩、素材地址带内容哈希长期缓存、brotli 压缩、教程图改用 WebP),官方场景光照改回官方值,每回合开始重播干员入场动画,新增「弹药数量」选项,并修复了单位多时模型被裁切、留下残影等 0.2.5 发布后反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
|
||||
## 功能一览
|
||||
|
||||
@@ -66,7 +64,7 @@ English summary: [below](#english).
|
||||
|
||||
[Releases](../../releases/latest) 里有两种整合包,代码和运行依赖完全相同,二选一;已经装好 0.2.x 的,升级时只下载更新包即可:
|
||||
|
||||
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 550 MB,解压后约 780 MB):附带全部美术 / 音频(含中文、日文两套干员语音、官方 3D 棋盘贴图和各战场的官方场景),解压就能玩,不需要再下载任何东西。**推荐。**
|
||||
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 560 MB,解压后约 790 MB):附带全部美术 / 音频(含中文、日文两套干员语音、官方 3D 棋盘贴图和各战场的官方场景),解压就能玩,不需要再下载任何东西。**推荐。**
|
||||
- **精简包** `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB):不带素材,第一次启动时自动从公开镜像下载美术、Spine 模型、音频、字体、表情和「玩法说明」教程图(约 550 MB,可中断,再次启动会续传);官方 3D 棋盘等本地客户端素材不在其中(见下面的「本地客户端素材」;各战场的官方场景可以用 `node tools/setup.mjs --scenes` 在本机下载并提取)。适合下载大文件不方便的情况。
|
||||
- **更新包** `Stronghold-Protocol-v<版本>-update.zip`(0.2.1 起提供,大小看改动多少,通常只有几 MB):只含比之前的 0.2.x 版本改动过的文件,用来把已经装好的 0.2.x(完整包或精简包装的都行)升级到新版本,不用重新下载整个包。用法:先停止服务器(关掉窗口;装了开机自启的运行 `scripts\install-service-windows.ps1 -Stop`),把 zip 里 `Stronghold-Protocol` 文件夹的全部内容合并到安装文件夹、覆盖同名文件(Windows 资源管理器里复制粘贴即可;macOS 不要用访达拖放,它会整个替换文件夹,请用 `unzip -o`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 第 1.5 节),再照常启动。启动时会先核对全部程序文件、删除新版本不再用的旧文件,然后正常运行;如果这个文件夹不是更新包对应的版本(例如 0.1.x,或者程序文件被改过),会提示下载完整包,服务器不启动。全新安装请用完整包或精简包。
|
||||
|
||||
@@ -102,7 +100,7 @@ npm start # 启动服务器:http://localhost:3000
|
||||
|
||||
| 项目 | 要求 |
|
||||
|---|---|
|
||||
| 开服的电脑 | Windows / macOS / Linux,Node.js 22 或 24(LTS);磁盘约 700–850 MB(素材、依赖与本地提取贴图:完整包解压后约 710 MB);内存空闲约 100 MB,每局再加几 MB |
|
||||
| 开服的电脑 | Windows / macOS / Linux,Node.js 22 或 24(LTS);磁盘约 700–850 MB(素材、依赖与本地提取贴图:完整包解压后约 790 MB);内存空闲约 100 MB,每局再加几 MB |
|
||||
| 玩家 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑、手机或平板(横屏) |
|
||||
| 网络 | 首次进入游戏时,每位玩家要从开服的电脑下载几十 MB 素材(之后走浏览器缓存);对局中流量很小 |
|
||||
|
||||
@@ -253,7 +251,7 @@ node tools/perfbench.mjs --cpu 1,4,6 --profile # 真实战斗的帧
|
||||
|
||||
An **unofficial, non-commercial fan remake** of Arknights' seasonal auto-chess tower-defense mode *Stronghold Protocol: Alliance*, played in the browser: solo, or 1–4 player co-op (AI teammates can fill seats). Combat is simulated in each player's browser, so a low-power PC can host.
|
||||
|
||||
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~550 MB, all the art inside, the Chinese and Japanese operator voices and the official battlefield scenes included) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~550 MB) on its first start; from 0.2.1 on, `…-update.zip` holds only the files changed since the earlier 0.2.x releases: stop the server, extract it over an existing 0.2.x folder and start again (the first start deletes the files the new version dropped and verifies the install) — install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~550 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
|
||||
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~560 MB, all the art inside, the Chinese and Japanese operator voices and the official battlefield scenes included) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~550 MB) on its first start; from 0.2.1 on, `…-update.zip` holds only the files changed since the earlier 0.2.x releases: stop the server, extract it over an existing 0.2.x folder and start again (the first start deletes the files the new version dropped and verifies the install) — install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~550 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
|
||||
- **Languages:** Chinese (the default), English, 日本語, 한국어 and 繁體中文 — switch on the title screen or in Settings. Game texts come from the official clients; the Japanese, Korean and Traditional Chinese interface strings are machine translations (corrections welcome: [docs/I18N.md](docs/I18N.md)).
|
||||
- **Play with friends:** create a co-op room and share the 4-letter key or the `?room=KEY` link. On a LAN, use the address printed at start; otherwise use a virtual-LAN tool, a tunnel or a VPS — see [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||
- **Disclaimer:** not affiliated with or endorsed by Hypergryph or Yostar. All Arknights names, art, audio, text and data are © their respective owners and are **not** covered by this project's GPL licence. For study and personal non-commercial use only — no selling, paid distribution, paid servers or monetisation of any kind. Content will be removed on request of the rights holders. Provided "as is", without warranty.
|
||||
|
||||
@@ -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 |
|
||||
| [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 |
|
||||
| [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/`) |
|
||||
| [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 |
|
||||
@@ -177,6 +178,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
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
|
||||
|
||||
```text
|
||||
|
||||
@@ -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:
|
||||
@@ -25,7 +25,9 @@ effects), [DATA.md](DATA.md) (generated data), [ASSETS.md](ASSETS.md) (art and a
|
||||
|
||||
- **The server** is one Node process: plain `node:http` plus `ws`, no framework (`server/index.js` wires
|
||||
`server/http/`). It serves the static client, the generated data and the sim's source files, and runs every room and
|
||||
match in memory — rounds, shop, economy, bots, validation. Nothing is written to disk.
|
||||
match in memory — rounds, shop, economy, bots, validation. Optional account binding (`SP_SKLAND=1`) persists
|
||||
credentials and JWT login records in SQLite ([skland.md](skland.md)); a selected role can resume its retained seat
|
||||
across devices through authenticated account recovery. Match state remains in memory, not in SQLite.
|
||||
- **The browser** loads native ES modules with no bundler and no build step; the libraries are vendored into
|
||||
`public/vendor/` by `tools/vendor.mjs` on `npm install`.
|
||||
- **The battle simulation** (`server/sim/`) is pure ESM without any Node API, served read-only at `/sim/`. Both sides
|
||||
@@ -79,6 +81,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
||||
| `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` |
|
||||
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`, permessage-deflate), `static.js` (the mounts, the `~<hash>` asset routes), `media.js`, `files.js` (MIME, brotli / gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `traffic.js` (bytes sent by category and on the WebSocket, `/healthz?traffic=1`), `assetIndex.js` (the content hashes of `public/assets/` and `public/fonts/`, cached in `.cache/asset-hashes.json`), `assetViews.js` (the served `/data/assets.json` and `/data/local-assets.json`: every art URL with its hash segment; ASSETS.md), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
|
||||
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
|
||||
| `server/skland/` | opt-in provider binding and SQLite login records; `auth.js` verifies account ownership, `accountHttp.js` handles login/entry/recovery, `recovery.js` describes only the selected role's retained room or match |
|
||||
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
|
||||
| `server/data.js` | loads `data/*.json` once (frozen) |
|
||||
| `server/packs.js` | the content packs (PACKS.md): finds the language packs of `public/i18n/` and the pack folders of `packs/`, validates them, answers `/packs/index.json` and which pack files may be served; re-reads the folders when they change |
|
||||
@@ -102,6 +105,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
||||
|---|---|
|
||||
| `public/index.html`, `public/js/main.js` | the page and its entry: boot, the router (title → lobby → room → game) |
|
||||
| `public/js/net.js`, `public/js/store.js`, `public/js/data.js` | the socket client, the observable store, the data loader (`/data/*.json`, with the English overlay) |
|
||||
| `public/js/skland/` | the account dialog, role selection, entry gate and server-backed cross-device recovery picker; no local match-token list is used for account recovery |
|
||||
| `public/js/battle/` | `runner.js` (the local battle: loads `/sim/`, steps it, reports), `observe.js` (who may watch which field) |
|
||||
| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `cultivation.js` (its 潜能 / 练度 controls), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` |
|
||||
| `public/js/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node |
|
||||
|
||||
+55
-15
@@ -10,10 +10,10 @@
|
||||
| 服务器 CPU | 战斗在各玩家浏览器里模拟(DESIGN §14),服务器只负责回合、经济和校验:**每个房间每个作战回合约 1 ms CPU**。AI 队友 / 掉线玩家的战场由服务器模拟:作战开始时 3 个 AI 战场在开发机上约 0.2–0.5 s CPU,小主机上可能要几秒(分成 8 ms 小片执行,不会卡住其他房间)。`SP_VERIFY=all` 会复算每个真人战场,CPU 明显增加,小主机建议保持 `off` 或 `sample`。 |
|
||||
| 服务器内存 | 空闲约 100 MB,每个进行中的对局再增加几 MB。 |
|
||||
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测;0.2.6 起 1 KB 以上的 WebSocket 消息用 permessage-deflate 压缩,实测一局单人对局的服务器消息从 173 KB 降到 60 KB,约为原来的三分之一)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。0.2.6 起服务器启动后在后台给素材算内容哈希(第一次约 1–20 秒,看机器和硬盘;之后只检查大小和修改时间),下发的素材清单里每个地址带上哈希:浏览器把图片、模型和音频缓存一年,服务器更新、重新解压或 Docker 重建之后,玩家只重新下载内容真的变了的文件。哈希存在 `.cache/asset-hashes.json`,删掉会重新计算;目录不可写(例如只读的容器)时只在内存里算。 |
|
||||
| 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 710 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`。 |
|
||||
|
||||
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。
|
||||
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。唯一例外是打开可选的森空岛账号绑定(`SP_SKLAND=1`,第 3.2 节):它的凭据库需要持久化和备份。
|
||||
|
||||
## 1. Windows 小主机:一步步
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
```
|
||||
装完**关闭并重新打开**终端,`node -v` 应显示 v22 或更高(winget 的 LTS 目前是 v24.x,同样可用)。没有 winget 时从 <https://nodejs.org/zh-cn/download> 和 <https://git-scm.com/download/win> 下载安装。
|
||||
2. 下载,三选一。建议放在一个固定、短、**不在 OneDrive 同步范围内**的目录,例如 `C:\Stronghold-Protocol`:
|
||||
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 550 MB,解压后约 780 MB;已含运行依赖、前端库和全部素材,包括中文、日文两套干员语音和官方 3D 棋盘、各战场官方场景等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
|
||||
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 560 MB,解压后约 790 MB;已含运行依赖、前端库和全部素材,包括中文、日文两套干员语音和官方 3D 棋盘、各战场官方场景等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
|
||||
- **精简包**:同一页面的 `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB)。代码、运行依赖和前端库与完整包相同,但不带素材:美术、Spine 模型、音频(含两套干员语音)、字体、表情和「玩法说明」教程图在首次启动时由 setup 从公开镜像下载(约 550 MB,显示进度,可中断续传;镜像设置见下面的「国内镜像下载」)。官方 3D 棋盘等本地客户端素材需要用本机客户端提取,或从同一版本的完整包复制(第 6 节)。适合下载大文件不方便、或想先下一个小包的情况;放置方式同完整包。
|
||||
- **源码**:
|
||||
```powershell
|
||||
@@ -180,7 +180,7 @@ cloudflared tunnel --url http://localhost:3000
|
||||
2. 路由器「虚拟服务器 / 端口转发」:外部端口 3000(或任意端口)→ 内部 `主机IP:3000`,TCP。
|
||||
3. 朋友访问 `http://<你的公网 IP>:外部端口`。
|
||||
|
||||
注意:游戏没有账号系统,知道地址的人都能进来。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),但仍建议不玩时关掉转发,或优先用 Tailscale。
|
||||
本分支的标题入口要求绑定账号(第 3.2 节);启用模块后,服务器也会校验房间/对局操作对应的账号角色。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),建议不玩时关掉转发,或优先用 Tailscale。公网账号登录使用 HTTPS(2.4)。
|
||||
|
||||
### 2.4 反向代理与 HTTPS(有域名时)
|
||||
|
||||
@@ -231,8 +231,20 @@ https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://
|
||||
|
||||
## 3. Docker
|
||||
|
||||
### 3.1 一条命令(docker compose)
|
||||
|
||||
仓库根目录的 `docker-compose.yml` 已经把构建、端口、重启策略和数据卷写好,在项目根目录执行:
|
||||
|
||||
```bash
|
||||
# A) 构建时下载素材(需要联网,约 550 MB)
|
||||
docker compose up -d # 构建镜像并后台启动(首次会构建;HTTP 服务在 http://<主机>:3000)
|
||||
docker compose logs -f # 跟随日志;docker compose ps 看状态
|
||||
docker compose down # 停止并删除容器(数据卷 skland-data 保留)
|
||||
```
|
||||
|
||||
不用 compose 时等价的原始命令(`FETCH_ASSETS=1` 在构建时下载素材,约 550 MB,需要联网;不下载则用占位图,或改用 B 挂载宿主机素材):
|
||||
|
||||
```bash
|
||||
# A) 构建时下载素材
|
||||
docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 .
|
||||
docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
|
||||
|
||||
@@ -242,22 +254,50 @@ docker run -d --name stronghold -p 3000:3000 --restart unless-stopped \
|
||||
-v "$PWD/public/assets:/app/public/assets:ro" stronghold-protocol
|
||||
```
|
||||
|
||||
镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等)。健康检查:`GET /healthz`。
|
||||
镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`,也不含 `docker-compose.yml`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等),另有第 3.2 节的森空岛两项。健康检查:`GET /healthz`。
|
||||
|
||||
docker compose 示例:
|
||||
### 3.2 森空岛账号绑定与数据卷
|
||||
|
||||
可选的**账号绑定**(`server/skland/`,见 [skland.md](skland.md))把账号凭据、JWT 签名密钥和可撤销的登录记录存进同一个 SQLite 文件;这是服务器需要跨重启保留的账号数据(房间和对局仍然只在内存里,第 0 节)。JWT 以 HttpOnly Cookie 保存 30 天,刷新、重新开页面或重启服务不用重新绑定;解绑会使旧 JWT 失效。不设置 `SP_SKLAND` 时模块不启用、不建库,`/skland/*` 统一返回 JSON 404;本分支的标题入口仍要求绑定,因此开服需要启用它。需要含 `node:sqlite` 的 Node 22.5 或更新版本。
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `SP_SKLAND` | 空(关闭) | 设为 `1` 时打开账号绑定模块 |
|
||||
| `SP_SKLAND_DB` | `data/skland.db` | SQLite 凭据库的路径(相对路径相对项目根目录);父目录不存在时自己创建 |
|
||||
| `SP_AUTH_SECRET` | 自动生成并存入数据库 | 可选的 JWT 签名密钥,至少 32 字节;不要每次启动随机改变,轮换会使现有登录失效 |
|
||||
| `SP_PUBLIC_ORIGIN` | 按请求来源判定 | 可选的固定访问地址,例如 `https://game.example.com`,用于同源校验和 HTTPS Cookie |
|
||||
| `SP_SKLAND_DIAGNOSTIC_KEY` | 空(关闭) | 仅管理员诊断接口使用;调用需 `X-SP-Diagnostic-Key`,不要填进前端或发给玩家 |
|
||||
|
||||
`docker-compose.yml` 里这两个变量都指向挂载的具名卷:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
stronghold:
|
||||
build:
|
||||
context: .
|
||||
args: { FETCH_ASSETS: "1" }
|
||||
ports: ["3000:3000"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SP_VERIFY: "off"
|
||||
SP_SKLAND: "1"
|
||||
SP_SKLAND_DB: /app/skland-data/skland.db
|
||||
volumes:
|
||||
- skland-data:/app/skland-data
|
||||
```
|
||||
|
||||
- **为什么用卷**:容器以 `USER node` 运行,删除容器、`docker compose down` 或重建镜像都会丢掉镜像层里的文件;数据库放在具名卷 `skland-data` 上,重启、`docker compose down` 和 `docker compose up --build` 之后绑定关系都还在。`docker compose down -v`(或 `docker volume rm`)才会删除它。
|
||||
- **为什么用具名卷而不是绑定挂载**:Windows / macOS / Linux 上行为一致,不需要先建宿主机目录、也不用管容器内 `node`(uid 1000)对宿主机目录的权限。要在宿主机上直接读这个文件时,把挂载换成 `- ./skland-data:/app/skland-data`(目录已在 `.gitignore` 里)。
|
||||
- **关掉模块**:把 `SP_SKLAND` 和 `SP_SKLAND_DB` 两行删掉或注释掉即可(卷可以留着不用),服务器就不再保存任何账号凭据。手工 `docker run` 时对应地去掉 `-e SP_SKLAND=1 -e SP_SKLAND_DB=…` 两个参数。
|
||||
- **改用别的路径**:`SP_SKLAND_DB` 换成卷里的其它位置(例如 `/app/skland-data/skland-2.db`)。内置静态服务会拒绝下载当前配置的库及 WAL/SHM 文件,但仍建议放在所有静态目录之外,避免其它文件服务器或代理规则泄露。
|
||||
|
||||
公网使用 HTTPS;代理要保留 Host 并传递 `X-Forwarded-Proto`(2.4),也可显式设置 `SP_PUBLIC_ORIGIN`。
|
||||
HTTPS 下 Cookie 带 Secure;本地 `127.0.0.1` 开发使用 HTTP。游戏内用角色 UID + 区服标识账号,昵称可以重复。
|
||||
登录记录不保存密码,JWT 也不携带鹰角/森空岛凭据。
|
||||
|
||||
**随账号跨设备恢复**:新设备登录同一账号并选择同一区服角色后,可在标题页点击「恢复账号对局」,无需复制
|
||||
本机缓存。查询对局或自动恢复登录不会挤掉旧设备;点击「开始」或确认恢复后才接续原席位,旧设备断开且不会
|
||||
自动抢回连接。恢复仍受原有断线期限限制(独立模拟 24 小时、同盟对局 10 分钟,等候房间另受 60 秒宽限期限制),
|
||||
**不包含服务器重启后的对局存档**。只会查询当前角色,昵称相同或其他区服不会串号。
|
||||
|
||||
> **这个数据库是凭据,按密钥对待。** `skland.db` 里是明文保存的 `access_token`、长期 `cred` 和签名密钥 `cred_token`,拿到文件等于拿到对应账号的接口权限(skland.md §9):
|
||||
> - **限制权限**:只让运行服务器的账号可读,例如 `chmod 700 skland-data && chmod 600 skland-data/skland.db*`(WAL 模式下还会有 `-wal` / `-shm` 两个附属文件,同样处理)。在宿主机上运行而非容器里时默认路径是 `data/skland.db`,权限同理。
|
||||
> - **绝不通过 HTTP 暴露**:卷挂在 `/app/skland-data`,不在 `public/`、`data/`、`shared/` 任何静态挂载之下;反向代理也不要为它开规则。
|
||||
> - **不提交、不进镜像**:`.gitignore` 忽略了 `data/skland.db`、`-wal` / `-shm` 和 `skland-data/`;构建上下文只会把 `data/` 复制进镜像,所以容器部署要把库放在 `data/` 之外(compose 已经这么做)。
|
||||
> - **备份要当密码处理**:`docker compose stop` 后再从卷里拷贝(WAL 写入中直接拷可能拿到不一致的副本),备份文件加密保存、限权,不要放进网盘 / 仓库 / 聊天记录;不再使用时删除卷(`docker compose down -v`)。
|
||||
|
||||
## 4. macOS / Linux 常驻
|
||||
|
||||
- 临时开服:`scripts/start.sh`(或 `npm start`),保持终端窗口打开。macOS 首次会询问是否允许 node 接受传入连接,选「允许」。
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ This is the **single source of truth** for every implementer. Research lives in
|
||||
|
||||
Language: player-facing text is **Simplified Chinese** by default, with an English switch since 0.2.0 (§25.2, docs/I18N.md: UI strings through `t('中文')`, game texts from the official Chinese data or the official EN client's). Code, comments and identifiers are English.
|
||||
|
||||
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. **0.2.5** (2026-10-12) fixed gifts and already displayed operator offers under the shared stock, random boss pairing, the official battlefield scenes on the 3D board (optional local art: the full bundle includes them when extracted, a missing scene keeps the metal board; §15, §30.21), the official damage-number option, the 驭械术师 / hit-tile / bot ports and the community fixes after 0.2.4 — §30; its rules supersede the corresponding 0.2.4 ones. **0.2.6-dev** adds the host-bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket compression, content-hashed asset URLs with long caching, brotli) — §31, the current development state described by this document. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
|
||||
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. **0.2.5** (2026-10-12) fixed gifts and already displayed operator offers under the shared stock, random boss pairing, the official battlefield scenes on the 3D board (optional local art: the full bundle includes them when extracted, a missing scene keeps the metal board; §15, §30.21), the official damage-number option, the 驭械术师 / hit-tile / bot ports and the community fixes after 0.2.4 — §30; its rules supersede the corresponding 0.2.4 ones. **0.2.6** (2026-10-13) the host-bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket compression, content-hashed asset URLs with long caching, brotli, WebP guide pages), the official scene light gains, the impostor bounds, the deploy-animation replays, the optional ammo count and the community fixes after 0.2.5 — §31, the state described by this document; its rules supersede the corresponding 0.2.5 ones. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,4 +37,4 @@ normative lines it rewrote).
|
||||
| §28 | [history/0.2.3.md](history/0.2.3.md) | 0.2.3 — after the 0.2.2 release (2026-10-10) |
|
||||
| §29 | [history/0.2.4.md](history/0.2.4.md) | 0.2.4 — after the 0.2.3 release (2026-10-11) |
|
||||
| §30 | [history/0.2.5.md](history/0.2.5.md) | 0.2.5 — after the 0.2.4 release (2026-10-12) |
|
||||
| §31 | [history/0.2.6.md](history/0.2.6.md) | 0.2.6-dev — after the 0.2.5 release |
|
||||
| §31 | [history/0.2.6.md](history/0.2.6.md) | 0.2.6 — after the 0.2.5 release (2026-10-13) |
|
||||
@@ -175,6 +175,11 @@ otherwise every "之后 / 后续的每场作战" card — e.g. 教鞭's 法术
|
||||
A `choice:<effectId>` registry handler overrides the default application (§2.4).
|
||||
|
||||
### 1.3 Disconnects, AI takeover
|
||||
* Account-required fork (§31.13, [skland.md](skland.md) §5): the selected UID + region can resume the same retained
|
||||
seat on another device after explicit authenticated entry. Login/lookup alone does not replace a socket. The
|
||||
transport rotates its token on successful attachment, while the match and its private state stay the same.
|
||||
Account recovery does not extend the deadlines below or survive a server restart; an expired role's old seat goes
|
||||
through the normal lobby onExpire/onLeave cleanup before a new session can reuse its stable playerId.
|
||||
* Local multi-window recovery (#431, DESIGN §28.27) sends optional `hello.noReplace` for a tentative shared token,
|
||||
and `hello.claimAt` for a previously welcomed token. Before binding a different socket, the server rejects a
|
||||
connected seat when no replacement was allowed or the request has a later stamp than the attached holder
|
||||
|
||||
@@ -1617,3 +1617,7 @@ general hook guard. The queue is emptied in `finally`; it introduces no timer or
|
||||
- Healing skill activation includes elemental-only injury when the operator's heal profile can cure it. Pinecone S2 growth is deployment-local. Skadi S1 and Stead preserve redirect ancestry and take both shares from the pre-redirect hit, so the total damage is conserved.
|
||||
|
||||
`champagneHold` includes the internal `isolated` ally-selection flag (§29.11): device-type champagne bombs are excluded from ordinary allied auras/inspiration in both creation paths. Other unhealable summons retain their own selection behavior.
|
||||
|
||||
维什戴尔's `shadowTile` requires `Battle.onFieldBoard` before ranking summon candidates: a board of this field (a
|
||||
teammate's half), never a boss field's hand / 临时整备区 rows or an empty half (§31.10). `grid.inRect` alone also admits
|
||||
those bench rows. See [DESIGN §3](design/geometry-time.md#3-coordinates-fields-and-geometry) for the deployment boundary.
|
||||
+32
-1
@@ -73,6 +73,37 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
|
||||
|
||||
---
|
||||
|
||||
### Required account binding at the callsign field (fork integration)
|
||||
|
||||
The title hides 博士代号 until binding, then displays the role nickname as plain text without an input
|
||||
(`public/js/skland/entry.js`;
|
||||
`SP_SKLAND=1`, [skland.md](../skland.md)). The primary button shows 绑定账号 until a named role is fetched, then 开始.
|
||||
Multiple roles start with the default role and can be switched. Unbinding or an empty role list clears the callsign
|
||||
and restores 绑定账号. The nickname retains its person icon. The scan tab fetches a QR automatically and refreshes it
|
||||
every 10 s; the first awaiting-confirmation response starts a 60 s window. Refresh keeps the current image visible
|
||||
until its replacement is ready and updates the image source in place. Manual refresh remains available, with
|
||||
serial 2 s polling and paused timers outside the scan tab/visible page. Saved callsigns/entered flags and account recovery cannot skip the binding lookup. Skland QR
|
||||
login supports official/Bilibili channels; phone/password uses the Hypergryph account. Every game-entry HTTP request
|
||||
uses the existing game session token and a 30-day HttpOnly JWT cookie, without entering the lobby automatically.
|
||||
The cookie restores the role in a new tab or after a server restart; role choice persists on the server. The callsign
|
||||
never becomes an identity key: UID plus server region determine a stable game player id, allowing duplicate names.
|
||||
Account UI, styles and the entry policy stay in independent module files; title, boot and recovery have small hooks
|
||||
to reduce upstream merge conflicts. An optional server account policy also checks hello and room/game requests
|
||||
against the JWT-owned role; editing the client's UUID, region or name cannot select another player's binding.
|
||||
Unbind revokes remembered login. Owner request, 2026-10-14; §31.12 and [skland.md](../skland.md).
|
||||
|
||||
The title's 恢复账号对局 picker queries the selected role's live server-side seat, even on a device with no local match
|
||||
history. It replaces the visible 本机 recovery buttons (including the obsolete lobby shortcut). Opening it or restoring
|
||||
the login cookie does not disconnect an active device. 开始 or an explicit recovery confirmation asks for the role's
|
||||
current token, reconnects with the JWT, and enters only after welcome confirms the expected playerId. The existing
|
||||
socket is then replaced (4001, no auto-reconnect), and its old token cannot take the role back on reload. A duplicated
|
||||
tab or stale saved entered flag stays at the title unless its own current role token really resumed. Any anonymous
|
||||
welcome also clears an already-entered shell; the connection banner's 在此页面继续 uses checked account entry.
|
||||
The picker shows
|
||||
empty, loading and error states, a takeover notice and a refresh action; an expired/replaced selector cannot create a
|
||||
new match. Room/match state is still memory-only with the existing disconnect windows; server restart ends it.
|
||||
The UI and entry adapter remain in public/js/skland/ (§31.13).
|
||||
|
||||
## 13. Local-client art (optional, host-side)
|
||||
|
||||
`tools/local-extract/extract.py` (Python: UnityPy + lz4 + Pillow; decodes Arknights' custom **LZ4AK** bundle compression via `aklz4.py`) extracts the art the web download does not include (some of it, most official HUD sprites among it, is on the public mirror too — §22.5) from a locally installed client (CrossOver Windows build or PlayCover iOS build) into `public/assets/local/**` and writes `data/local-assets.json` (`{ groups: { '<subdir>': { name: { path, w, h, kind } } } }`). Everything here is **optional**: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art). The emotes and the guide pages below are on the public mirror too (§22.5): fetch-assets downloads them (data/assets.json `ui['emoticon/<dir>/<picId>']`, `ui['guide/<key>']`) and the client shows the local copy first, then that one (`data.js artUrls`); docs/DEPLOY.md §6 lists what still needs the client.
|
||||
@@ -94,7 +125,7 @@ Findings (2026-09-28): 卫戍协议 levels have no scene bundle (`mapId` null; t
|
||||
|
||||
Correction (2026-10-11, GitHub #532; PR #533 by @andrew05060414): the first finding above was made off season and is wrong. While a season runs, the official hot-update list carries **one prebuilt scene per battlefield** — `scenes/activities/act1autochess/level_act1autochess_m01…m07` and `scenes/activities/act2autochess/level_act2autochess_m01…m04` (each with `…/lightingdata.ab` and `…_lighting_settings.ab`; Android versions `25-11-21-15-21-44_ee1197`, `26-03-13-06-11-47_14fb99`, `26-04-13-12-31-42_e9b560`); the lists of 2026-09-13 and 2026-10-08 have none, and no 卫戍协议 level data either. The asset index of those versions (`<hash>.idx`: each bundle's dependencies) gives each scene's art: act1 m01–m07 and act2 m02 / m04 depend on `arts/maps/map_autochesssand` (m05 and act2 m04 also on `map_doss_shore`), act2 m01 on `map_lakegarden` + `map_syracuse_metro`, act2 m03 on `map_kxmr` + `map_lm_center`. **No battlefield scene depends on `arts/maps/map_autochess`** (the metal theme the procedural board below draws; in the current client the 矢量突破 scenes `act3break` use it). A scene holds the logic tiles (`(row,col)#tileKey` at anchor-local (col, row, −height), high ground 0.4), the art under `GraphicHolder` (static parts merged into "Combined Mesh (root: scene)" objects, lightmapped: Shadowmask mode, one directional key light), and three sub-graphics `HIDDEN@1…3` (`MapSubGraphicHolder`) that the game switches on: the left normal field, the right normal field and the boss field — the areas of `layout.js AREAS`.
|
||||
|
||||
Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/<stageId>`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Chunks of those shown parts that share a material and a lightmap page are one mesh when the area is built (`load.js mergeSceneChunks`); water shares a mesh across pages because its shader has no lightmap. A switched-off part stays out of that mesh. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. `SCENE_LIGHT` `direct` / `indirect` are 1, the scene's own key light and baked light (0.2.6, GitHub #549, PR #554 by @xcdoge; 0.2.5 had 0.8 / 0.4 tuned by eye); **[ASSUMED]** `ambient` 0.2 (a mesh without a lightmap page; the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files.
|
||||
Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/<stageId>`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below, and so do the device slabs — 射击台, 特制水上平台, 封印的地面 (`layout.js buckets.raised`, their sides reaching 0.08 under the scene's ground): they are runtime devices the scenes do not contain, while the scenes do model the 'h' high ground (act2 m01 / m02), which is not repeated (0.2.6, GitHub #569). The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Chunks of those shown parts that share a material and a lightmap page are one mesh when the area is built (`load.js mergeSceneChunks`); water shares a mesh across pages because its shader has no lightmap. A switched-off part stays out of that mesh. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. `SCENE_LIGHT` `direct` / `indirect` are 1, the scene's own key light and baked light (0.2.6, GitHub #549, PR #554 by @xcdoge; 0.2.5 had 0.8 / 0.4 tuned by eye); **[ASSUMED]** `ambient` 0.2 (a mesh without a lightmap page; the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files.
|
||||
|
||||
Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer:
|
||||
- Geometry generated from `data/stages.json` like the official builder: LOW tiles = top quads; HIGH tiles = raised blocks with side walls; forbidden/void, fences, gates (red), objective (blue), hand/temp pads, special terrain; UVs mapped onto the official atlas regions (reuse/extend `public/assets/local/map/autochess/tiles.json`); normal + emission + metallic maps; lighting tuned to match official screenshots (key directional light, ambient/hemisphere, soft contact shadows); official background meshes & texture; device meshes (OBJ exported by `tools/local-extract`) with their textures; animated gate/objective effects.
|
||||
|
||||
@@ -9,7 +9,7 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
- **Normal field** (own board): rows 9–12, cols 2–10. Enemy gates `S` at (9,10) and (12,10) (R1–3 only the lower one); protection objective `E` at (9,2). Col 9 lane is not deployable (tile type decides).
|
||||
- **Hand (整备区)**: row 7, cols 0–9 (10 regular slots, index = col). **Temp slots**: row 8, cols 4–8 (5 slots). Hand/temp tiles are never part of a battle.
|
||||
- **Partner display** cols 11–18 of rows 9–12 (the unite field uses them).
|
||||
- **Boss field**: rows 1–5; left half cols 2–10, right half cols 10–18.
|
||||
- **Boss field**: combat deployment rows 2–5; left half cols 2–10, right half cols 10–18. The mapped hand / temp rows are 0 / 1, inside the simulation rect but not deployment tiles (`Battle.onFieldBoard` excludes them): 维什戴尔's 魂灵之影 goes only on a board of the field — a teammate's half, never those rows or an empty half — and without a free one in range no shadow is summoned (§31.10).
|
||||
- Enemy preview pen rows 14–18 × cols 7–13 (row 16 empty): during prep the next round's enemies idle there (upper-gate enemies rows 17–18, lower-gate rows 14–15, ≤ 50 models, spawn-time order) — research 08 §4 / 09 §2.
|
||||
- Tile semantics come from `data/stages.json` legend: height (LOW/HIGH), buildable (ALL/MELEE/RANGED/NONE — the **effective** deploy type: the 深水区 `tile_deepsea` refuses deployment, NONE with the level's `buildableType` ALL kept, PRTS 深水区 地形信息 "拒绝部署(待补充)"; an active 特制水上平台 makes its tile deployable in the prep deploy map only, §21.3), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). The fenced tiles (围墙 `tile_fence_bound` / 围栏 `tile_fence`, legend `b`: LOW, buildable ALL, passable to flyers only) take units, which attack normally from there but block no ground enemy (PRTS 地形机制, §21.23). Melee chess may stand only on LOW tiles with buildable ALL/MELEE — except a melee chess whose trait reads 「可以放置于远程位」 (the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, with any module or none), which may use the ranged tiles too (`shared/highGround.js`; the owner's decision of 2026-10-05, following PRTS — it reverses the 2026-10-04 one that allowed only elite 歌蕾蒂娅 with HOK-Y; on a 高台 it attacks and blocks nothing); ranged chess on ALL/RANGED **and** on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their own `position`; a summon marked `ownerRange` (狼群, 流形: "只能部署在召唤者攻击范围内"; Mon3tr's 重构体 by her talent) goes only on a tile of its owner's attack range — the loadout grid rotated by the owner's facing around its tile (`PlayerState.summonRange`, §21.3) —, one marked `ownerRangeOutside` / `rangedTilesOnly` (凯尔希·思衡托's 战术锚点: "仅可以部署在凯尔希·思衡托攻击范围外的远程位") only on a 高台 outside it (`summonExcluded`, placement class `high`; 0.2.0).
|
||||
- **Facing (corrected, see research 09 §1.2):** like standard Arknights, every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT` chosen with the 4-direction deploy wheel after dropping it on a tile (also re-orientable in place); default `RIGHT`; persists across rounds. Range grid entries are `[dRow, dCol]` relative to facing RIGHT and are rotated: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)` (row 0 = bottom); `rangeExtend` applies along +dCol before rotating — to a running skill's range too, unless that skill ignores 攻击距离 (`targeting.noRangeExtend`, 信仰搅拌机 S3, §21.16). The right-side Final Assault player is mirrored col `c→20−c` with RIGHT↔LEFT (UP/DOWN unchanged).
|
||||
|
||||
@@ -4,6 +4,26 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
|
||||
## 8. Network protocol (shared/protocol.js is normative)
|
||||
|
||||
**Fork account policy (opt-in `SP_SKLAND=1`, owner request 2026-10-14).** The existing WebSocket messages are
|
||||
unchanged. Hello may establish anonymous transport for account login, but room/game requests require the role
|
||||
owned by a persistent, server-verified JWT cookie. The server ignores a client's claimed nickname/UUID for account
|
||||
identity; it derives stable `playerId` from UID plus provider-reported server region (`channelName`). Nicknames
|
||||
may repeat, and optional provider `gameId` metadata does not affect identity. The public id uses 128 SHA-256 digest
|
||||
bits encoded as base64url after `a_` (24 characters), so normal field ids and per-player battle reports fit the
|
||||
existing protocol limits. A reconnect token alone cannot resume
|
||||
an account identity. A valid login plus the role's current reconnect token can resume its previous socket; each
|
||||
game frame checks the current login record and selected role, so logout or a role switch invalidates its authority.
|
||||
Cookie restoration and read-only recovery lookup never take over another device. An explicit authenticated
|
||||
enter/resume POST returns the selected role's token; the existing hello then attaches it and resends room/match state.
|
||||
Only after successful attachment is the account token rotated, so a stale device cannot silently retake the seat.
|
||||
The client stages the entry token in memory until welcome; local multi-tab claims still protect normal reconnects.
|
||||
Login lasts 30 days, survives process restart and is revoked on unbind. Room/match data remain in memory.
|
||||
Recovery selectors are scoped to the selected role and the particular room/match; stale selectors return 409 without
|
||||
creating a new game. The existing 10-minute / solo 24-hour disconnect windows are unchanged, and an expired seat is
|
||||
removed through the lobby before its stable playerId can be reused. Account API ownership and private DB handling
|
||||
are specified in [skland.md](../skland.md) §5/§9. The optional policy hooks leave module-off transport unchanged;
|
||||
no simulation rule, WebSocket message or protocol-version change is involved.
|
||||
|
||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound (a compressed frame counts its inflated size); rate limit 40 msgs/s per connection (excess dropped with `error RATE`). Frames from 1 KB up are compressed with permessage-deflate (RFC 7692; `server/http/websocket.js WS_DEFLATE`: no server context takeover, a 2 KB server window, zlib memLevel 7 — about 72 KB of zlib state per connection); smaller frames go out plain, and a client that offers no compression, or caps the server window below 2 KB, gets plain frames. A measured solo session (rounds 3–5, `/healthz?traffic=1`): 173 KB of server frames → 60 KB on the wire.
|
||||
|
||||
### 8.1 Session & lobby
|
||||
@@ -137,6 +157,8 @@ voting, refuses stale ids and cancels on disconnect/leave; DESIGN §28.21 / META
|
||||
|
||||
Explicit local recovery (§28.20) reuses saved hello tokens after a fresh live-tab claim check. Display names and room
|
||||
codes are UI metadata only, never authentication. No new network message or nickname/IP matching is introduced.
|
||||
The account-required fork replaces the visible local recovery entry with authenticated account recovery (§31.13);
|
||||
the generic local helper and its module-off handshake behavior are retained.
|
||||
|
||||
Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name.
|
||||
|
||||
|
||||
+119
-3
@@ -1,10 +1,10 @@
|
||||
# DESIGN §31 — 0.2.6-dev
|
||||
# DESIGN §31 — 0.2.6
|
||||
|
||||
Part of [DESIGN.md](../DESIGN.md). Development after the 0.2.5 release.
|
||||
Part of [DESIGN.md](../DESIGN.md). This chapter records 0.2.6, released on 2026-10-13.
|
||||
|
||||
## 31. Host bandwidth (layers 0 and 1) and community follow-up
|
||||
|
||||
Unreleased. The maintainer's decision of 2026-10-12 on the bandwidth study (its measurements: a new player's first match costs the
|
||||
The maintainer's decision of 2026-10-12 on the bandwidth study (its measurements: a new player's first match costs the
|
||||
host about 52 MB, audio ≈ 40 % of it; returning players re-download after a re-extraction, a `setup --force`, a Docker
|
||||
rebuild or a changed tunnel address): do layer 0 (small fixes) and layer 1 (content-hashed URLs, long caching, brotli)
|
||||
first. Later layers (WebP / Opus variants, an asset-only service worker, a desktop client mode, importing an asset pack in
|
||||
@@ -290,3 +290,119 @@ inlined the readout into `_updateHud`; here it is a method like the purse's (`_u
|
||||
spending, refills, capacity changes and dense magazines, hidden at the skill's end, in the prep view, after death and for
|
||||
enemies, the live toggle), `test/ui/ammo-count.test.js` (normalisation, the toggle and its hint in every language pack, the
|
||||
view's live setting, the diagnostics line), `test/ui/gameLogic.test.js` (sanitizeSettings). Golden: unchanged.
|
||||
|
||||
### 31.10 魂灵之影 stays on the field's boards (#557, PR #558)
|
||||
|
||||
Issue #557; PR #558 by @IceCodeNew, adapted.
|
||||
|
||||
**Seen.** The boss / hidden simulation rect includes rows 0–1, the mapped hand and 临时整备区. `shadowTile` accepted any
|
||||
standable tile of her range there, so a bottom-row 维什戴尔 summoned 魂灵之影 onto the bench through her deployment talent
|
||||
or S3 when her nearer tiles were taken.
|
||||
|
||||
**Now.** A candidate must also be on a board of this field (`Battle.onFieldBoard`, the "deployable" of the other summons
|
||||
and of the 突袭 landing): a teammate's half on a pair field counts; a boss field's hand / 临时整备区 rows and the empty half
|
||||
of a solo boss field do not. With no free candidate the summon is skipped. Range, occupancy and the order (nearest to her,
|
||||
then the lower row, then the left column) are unchanged. The PR cut the bench rows by number; that left a standable tile
|
||||
of an empty half legal, so the port uses the field's boards instead (the Raid-only empty-half exception, §29.13, stays
|
||||
Raid's).
|
||||
|
||||
Sources: [PRTS 维什戴尔](https://prts.wiki/w/维什戴尔), the talent's nearest / lower-row / left-column deployable-tile rule;
|
||||
[issue #557](https://github.com/sganggs/Stronghold-Protocol/issues/557). No new [ASSUMED] rule.
|
||||
|
||||
**Tests.** `test/content/op_wisdel.test.js`: the PR's case (boss and hidden fields, both sides, a full range and one free
|
||||
combat tile, through the deployment talent and an actual S3 activation), and a solo / pair case — her own free tiles
|
||||
filled, the empty half takes no shadow and the teammate's half does. Normative: DESIGN §3
|
||||
([geometry-time.md](../design/geometry-time.md)); API note: [SIM.md](../SIM.md). Golden: unchanged.
|
||||
|
||||
### 31.11 Device slabs on the official scenes (#569)
|
||||
|
||||
Issue #569 by @A1muE4aY.
|
||||
|
||||
**Seen.** With an official scene on the 3D board, 战场#03's two raised ranged tiles beside the blue gate were drawn flat,
|
||||
while the operators placed on them stood at their raised height. The scene replaced the whole procedural board
|
||||
(`scene.js setStage` skipped its board, glass, decal and pipe buckets), and the raised slabs of the stage devices —
|
||||
射击台 (`platform`, 0.26), 特制水上平台 (`waterPlatform`, 0.2) and 封印的地面 (`sealedFloor`, 0.05, a hazard decal
|
||||
top) — exist only in that board bucket (`layout.js slabDevice`). The scenes do not contain them: they are runtime
|
||||
devices of the stage (`stageDevices`), and the scene geometry has no raised top on those tiles. Affected while a scene
|
||||
is shown: 战场#03 (射击台), 战场#05(上半) (the 16 water platforms of the normal field, 32 on the boss field — the pool read
|
||||
as empty water) and 战场#06(上半) (射击台 and 封印的地面).
|
||||
|
||||
**Now.** `buildBoard` repeats each device slab into `buckets.raised` (tops and sides; a sealed floor's hazard top into
|
||||
`buckets.raisedDecal`), with the side faces reaching `RAISED_FOOT` (0.08) below the tile so no gap shows against the
|
||||
scene's ground (about z 0). With an official scene `setStage` draws only those two buckets of the procedural board, in
|
||||
the board's own materials; without one the board draws the slabs itself as before. The 'h' high ground is not repeated:
|
||||
the scenes model it (act2 m01's plates and act2 m02's sandstone blocks, checked side by side); an earlier draft that
|
||||
also drew our 'h' plates covered them. Units already stood at `tiles.js heightAt`, which includes the device heights.
|
||||
|
||||
**[ASSUMED]** The slabs keep the metal board's plate and side textures: no official prefab of these devices is extracted.
|
||||
|
||||
**Tests.** `test/render/board3d.test.js` (every stage and area: one plate or decal top and four sides per slab device,
|
||||
every vertex on a slab tile at its top or its foot; 战场#03 and #05(上半) have them; act2 m01 / m02 repeat nothing),
|
||||
`test/render/board3d-scene.test.js` (an official scene on 战场#03 keeps the slabs in the board material and nothing else of
|
||||
the procedural board; dropping the scene restores the board; act2 m02 adds no slab mesh). Screenshots of the five
|
||||
affected stages before / after / metal board were compared by hand. Golden: unchanged (client only).
|
||||
|
||||
### 31.12 Account binding before entry (fork integration, 2026-10-14)
|
||||
|
||||
Owner request: integrate the working account module at 博士代号, automatically use the bound role's nickname, retain
|
||||
Skland QR for official/Bilibili channels, and keep later upstream merges small. The callsign label and nickname are
|
||||
hidden until binding, then shown as plain text without an input; 绑定账号
|
||||
replaces 开始 until a named role is obtained, and returns after unbinding or an empty role list. Boot and local-match
|
||||
recovery recheck the binding before accepting a cached entry. The dialog, styles, entry policy and session resolver
|
||||
live in the module's own directories, with small title/boot/recovery hooks. Game-entry requests use the
|
||||
existing reconnect token and a reserved namespace; the independent diagnostic routes require an administrator key
|
||||
and cannot read or mutate these rows. No new WebSocket messages or simulation rules. Multiple roles select the default, retain a
|
||||
still-valid choice on refresh/reopen, and immediately update the callsign when switched. No manual callsign entry.
|
||||
The official account-page icons are referenced remotely; authorization redirects remain provider-operated.
|
||||
The nickname retains its person icon. Opening the scan tab automatically fetches a QR: replacement is every 10 s,
|
||||
switching to 60 s from the first status 101 (waiting for phone confirmation), with manual refresh retained. Refresh
|
||||
keeps the old QR visible until the new image is encoded, then replaces its source in place without a blank frame. Requests
|
||||
are serial and check confirmation before replacement; switching tabs or hiding the page pauses timers, and closing
|
||||
the dialog or binding stops them. The provider's 101 is exposed as `awaitingConfirmation` without changing `waiting`.
|
||||
|
||||
Persistent login uses a 30-day HS256 JWT in an HttpOnly, SameSite=Strict cookie (Secure over HTTPS), with signing
|
||||
key and revocable login/role records in the same SQLite database. New tabs and restarted servers restore the
|
||||
selected role without another provider login. The JWT contains no provider credentials. Account APIs derive
|
||||
ownership on the server, reject cross-site requests and foreign role selection, and default-close diagnostic access.
|
||||
The optional WebSocket account policy resolves hello and every room/game request to the owned role; a copied game
|
||||
reconnect token alone cannot claim it. UID plus server region determines stable game identity, allowing duplicate
|
||||
nicknames; optional `gameId` and name changes do not change that identity. Unbind clears credentials and revokes
|
||||
old JWTs/open-socket authority; late provider replies cannot restore a cancelled binding. Static/media/pack paths
|
||||
deny the configured database and its WAL/SHM files, including aliases and Windows casing. These changes follow the
|
||||
owner's explicit persistence/access-control request; rooms/matches remain memory-only.
|
||||
|
||||
Reference: [skland.md](../skland.md). Tests cover token rejection/player isolation, nickname selection, authenticated
|
||||
requests and the real title/modal in a browser with a stub account provider (QR, SMS and password/CAPTCHA continuation,
|
||||
landscape-phone layout, hidden/plain-text callsign with person icon, empty roles, unbinding, fresh-tab JWT restoration
|
||||
and cached-entry checks). Server tests cover forged JWT/claims, foreign roles, logout/replay, restart/expiry,
|
||||
protected database files, cancelled provider replies and same-name/different-UID/region identities.
|
||||
Deterministic timer tests cover automatic QR generation, exact 10/60 s boundaries, pause/resume, late responses,
|
||||
manual refresh versus confirmation and retry after failure. Golden: unchanged
|
||||
(no simulation changes).
|
||||
|
||||
### 31.13 Account-scoped cross-device recovery (fork integration, 2026-10-14)
|
||||
|
||||
Owner request: continue implementing recovery with the account across devices. The title now offers 恢复账号对局
|
||||
from authenticated server state, replacing the visible local-token recovery shortcuts. A device with no local save
|
||||
can sign in independently, select the same UID + provider region, inspect its retained seat and continue it. Login,
|
||||
cookie restoration and lookup alone do not disconnect another device; Start or recovery confirmation explicitly
|
||||
obtains the role's reconnect token. Only a successful welcome enters the UI, rotates the token and replaces the old
|
||||
socket. Old-device reloads and duplicated tabs cannot silently take the role back; the connection banner can still
|
||||
explicitly continue through checked account entry, and anonymous welcomes return to the title. The UI handles empty, loading,
|
||||
stale/error and refresh states and warns about taking over a live connection.
|
||||
|
||||
The server checks both credentials and the selected role, never a supplied player/name/UID/region. Public account
|
||||
ids encode 128 digest bits as base64url after a_ (24 characters): the previous full hexadecimal digest exceeded
|
||||
player-id and normal-field limits, rejecting watch/kick/battle-result messages. No wire limit needs to change. Recovery selectors
|
||||
belong to a particular room/match; stale or foreign selectors fail without minting a fresh game. Looking up a seat
|
||||
does not extend its lifetime. The normal lobby cleanup runs before an expired stable role id is reused, preventing
|
||||
an orphaned old match. The same Match/PlayerState is resumed, including funds, shop and lineup; no simulation rule,
|
||||
protocol message or generated data changes. Solo 24-hour/co-op 10-minute disconnect windows and waiting-room grace
|
||||
are unchanged. Matches remain memory-only: this is not save recovery after a server restart, nor synchronization of
|
||||
local preferences/statistics. These are owner-requested account policies, not deviations from official gameplay.
|
||||
|
||||
Normative updates: design/network.md §8 and its recovery note, design/client.md required-account entry, META.md §1.3;
|
||||
references: skland.md §5 and ARCHITECTURE.md / DEPLOY.md. Tests: test/skland/recovery.test.js exercises actual Match
|
||||
objects through independent HTTP/WS clients; test/skland/recovery.browser.test.js runs two isolated browser contexts
|
||||
with a stub provider, plus old-device and duplicated-tab checks and desktop/phone layout. Existing account, local
|
||||
identity, i18n and docs checks are rerun. Golden moves: none (no match/sim rules changed).
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
# skland.md — the opt-in Skland account binding
|
||||
|
||||
`server/skland/` reads a player's Arknights account bindings and associates the roles with their game session.
|
||||
The title hides the 博士代号 label and nickname until binding, then displays them as plain text without an input.
|
||||
Logging in fetches the callsign from the default role's `nickName`; choosing another role updates it immediately.
|
||||
The primary button shows 绑定账号 until a named role is available, then 开始. Unbinding
|
||||
or an empty role list clears the callsign and restores 绑定账号. Boot and account-match recovery recheck the current
|
||||
server binding, so a cached manual callsign/entered flag cannot skip it. This fork requires binding for browser entry;
|
||||
an operator must enable the module to let players enter. When enabled, the server also requires the authenticated
|
||||
role for room/game requests. Login persists for 30 days through an HttpOnly JWT cookie, including across server restarts.
|
||||
Password login uses the official Hypergryph account binding service directly; the existing QR/SMS paths use the
|
||||
signed Skland API. The historical module name, URL paths and enable switch are retained for compatibility.
|
||||
It is **off by default** and stays off unless an operator turns it on
|
||||
(§2); an unconfigured server keeps no account credentials at all. It uses the public website's account endpoints and
|
||||
the community-documented Skland endpoints (§10); enabling it and storing credentials is the operator's decision, not a
|
||||
feature every server gets. The module reaches the network only when the server does; the tests inject a stub `fetchImpl`.
|
||||
|
||||
## 1. What it is, and what it is not
|
||||
|
||||
It is a per-player credential store plus a login client and a small JSON HTTP surface. It does: run the three account
|
||||
login paths (扫码 / 验证码 / 密码), fetch the account's bound roles, refresh an expired credential once, and answer the
|
||||
binding state to the game. It does **not** solve 人机验证 (§4), spend any credential, or post anything on the player's
|
||||
behalf. The game entry uses a reconnect token for transport and a server-verified JWT for account ownership (§5).
|
||||
The independent diagnostic routes are closed by default; an administrator key enables their caller-supplied ids,
|
||||
excluding the game's reserved pending/account namespaces.
|
||||
|
||||
## 2. Turning it on
|
||||
|
||||
| Switch | Effect |
|
||||
|---|---|
|
||||
| `SP_SKLAND=1` | enables the module (exactly `1`; any other value leaves it off) |
|
||||
| `SP_SKLAND_DB=<path>` | the SQLite file (default `<repo>/data/skland.db`) |
|
||||
| `SP_AUTH_SECRET=<secret>` | optional HS256 signing secret, at least 32 bytes; otherwise generated once and persisted in the database |
|
||||
| `SP_PUBLIC_ORIGIN=https://game.example.com` | optional canonical browser origin, useful behind a reverse proxy |
|
||||
| `SP_SKLAND_DIAGNOSTIC_KEY=<secret>` | enables administrator diagnostic routes; callers must supply `X-SP-Diagnostic-Key` |
|
||||
| `startServer({ skland: true })` | same as `SP_SKLAND=1` |
|
||||
| `startServer({ skland: {…} })` | enables it and takes `storeFile`, `fetchImpl` (tests), `client`, `scanTimeoutMs`, `authSecret`, `publicOrigin`, `diagnosticKey`; `authNow` injects the test clock |
|
||||
|
||||
`startServer` returns the live service as `srv.skland`, or `null` when the module is off; it logs
|
||||
`[skland] account binding on — <file>` once. On an unconfigured server no store is created and no credential is read or
|
||||
written. The off state still owns its paths: `routes.js` mounts `createSklandOffHandler`, so **every** `/skland/*`
|
||||
request answers one uniform JSON `404 { ok: false, error }` — without it a GET would fall through to the static mount
|
||||
(a 404) and a POST to the GET/HEAD gate (a 405 HTML page), two shapes for one API.
|
||||
|
||||
## 3. The three login paths
|
||||
|
||||
QR/SMS converge on `grant → cred` (`user/oauth2/v2/grant` then `zonai .../generate_cred_by_code`), then
|
||||
`finishLogin` persists the row (`server/skland/service.js`). Password login instead queries the official account
|
||||
binding service and stores its result without obtaining a Skland credential.
|
||||
|
||||
The Skland QR path is retained for both official-server and Bilibili-server bindings; role extraction preserves
|
||||
every Arknights channel returned by Skland. The phone/password form authenticates a Hypergryph account. Bilibili
|
||||
players use Skland QR login and complete any authorization redirect in the provider's interface, as reported by the
|
||||
user; the panel continues polling the same scan session. This integration does not assume that Bilibili offers a
|
||||
phone/password endpoint, or that authorization is its only possible login method.
|
||||
|
||||
| Path | The player | The server calls | Comes back |
|
||||
|---|---|---|---|
|
||||
| 扫码 | scans the QR (`scanUrl`, a `hypergryph://scan_login?…` deep link), confirms on the phone | `gen_scan/login` → `scan_status` (polled) → `token_by_scan_code` | `token`, `hgId`, **`deviceToken`** |
|
||||
| 验证码 | receives the SMS code (`send_phone_code`, type 2) and types it | `token_by_phone_code` | `token`, `hgId`, **`deviceToken`** |
|
||||
| 密码 | types phone + password | `token_by_phone_password` → binding-service grant (type 1) → `binding_list` | HG `token` and Arknights bindings; **no Skland cred required** |
|
||||
|
||||
Password lookup follows the [official account SDK](https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js),
|
||||
checked on 2026-10-14: `Account.API.grantApp('binding')` posts `{ token, appCode: 'be36d44aa36bfb5b', type: 1 }`
|
||||
to `https://as.hypergryph.com/user/oauth2/v2/grant`, receives a service **token** (not a Skland grant code), then
|
||||
GETs `https://binding-api-account-prod.hypergryph.com/account/binding/v1/binding_list` with that token and
|
||||
`appCode=arknights`. A previous local QR/SMS login or stored `deviceToken` is not a prerequisite.
|
||||
Only a real upstream status **109** (`NEED_DEVICE_VERIFY`) becomes `{ ok: false, error, needDevice: true }`;
|
||||
an incorrect password remains its own error. The 密码 endpoint takes `phone` + `password` only; passwords are never stored.
|
||||
|
||||
The [Arknights account page](https://ak.hypergryph.com/user/home) also uses this SDK. Its `/user/api/role/login`
|
||||
request consumes an opaque role token issued by `/account/binding/v1/u8_token_by_uid`; the website does not locally
|
||||
encrypt the password into that token. Reading the binding list already supplies the requested account information,
|
||||
so this module does not need that later website-session login request or its analytics fields.
|
||||
|
||||
`send_phone_code` uses **2** (`LOGIN_OR_REGISTER`) for both the initial send and a captcha retry, following the
|
||||
[official account center login implementation](https://web.hycdn.cn/user-center/index.fa9aa713.js), checked on
|
||||
2026-10-14. The previous type 24 came from an Endfield-specific example rather than this web login flow.
|
||||
The endpoint may answer status 102 (发送频率太高) — a readable error, not a captcha.
|
||||
|
||||
For `general/v1/scan_status`, both status **100** (未扫码) and **101** (已扫码待确认) return
|
||||
`state: 'waiting'`, with `awaitingConfirmation: false/true` respectively. The title's scan tab automatically fetches
|
||||
a QR and replaces it every 10 s. The first 101 starts a 60 s refresh window; repeated 101 responses do not extend it.
|
||||
The manual 重新获取二维码 action stays available. Automatic/manual replacement keeps the existing image visible
|
||||
until the new QR is encoded, then updates its source without removing the image. The same QR session keeps polling every 2 s, including while the phone confirmation propagates;
|
||||
only the response carrying `data.scanCode` advances to the token exchange. Treating 101 as an error stopped the panel
|
||||
before it could observe the user's confirmation. This mapping follows the [official account center](https://user.hypergryph.com/)
|
||||
and its [login implementation](https://web.hycdn.cn/user-center/index.fa9aa713.js), checked on 2026-10-14.
|
||||
Other failure statuses (including 102, expired on this endpoint) still surface as errors rather than waiting forever.
|
||||
QR generation and polling are serial: confirmation wins over a queued refresh, and the latest status is checked
|
||||
before automatic replacement. Leaving the scan tab or hiding the page pauses the timers; returning resumes the
|
||||
same QR and checks its status before replacing it. Closing the dialog or binding successfully stops the timers.
|
||||
Failures remain visible and retry QR generation after 10 s. The independent diagnostic page retains manual QR
|
||||
generation; its compatible `waiting` state is unchanged.
|
||||
|
||||
## 4. 人机验证 (geetest)
|
||||
|
||||
SMS sending, password authentication and the official account grant can request verification as `status: 1` with
|
||||
`data.captcha`. The client raises `SklandCaptchaError`; the service returns `{ ok: false, needCaptcha: true, captcha }`,
|
||||
plus `captchaStep: 'password' | 'accountGrant'` for password login.
|
||||
|
||||
The panel displays a verification button. After the user clicks it, `public/js/skland/captcha.js` lazily loads the
|
||||
Geetest provider's widget. The user completes the challenge in that widget; cancellation and load errors leave a
|
||||
retry button. No phone, password or account token is passed to Geetest by this module. Geetest v4 challenges use
|
||||
`geetestId` and `riskType`; the provider's validation fields plus the original `challenge` are returned exactly as
|
||||
the official HG SDK does. Legacy v3 challenges use `gt`, `challenge`, `new_captcha` and return the provider's three
|
||||
`geetest_*` fields. The proof is sent in `captcha` on the original request, never generated by the server.
|
||||
|
||||
Password retries include `captchaStep`. A challenge on `accountGrant` temporarily retains the authenticated HG token
|
||||
in server memory, usable for 120 seconds, so its retry uses the same token without repeating the password exchange.
|
||||
Pending tokens are never sent to the browser or persisted before login completes; unbind discards them.
|
||||
|
||||
## 5. The HTTP surface
|
||||
|
||||
### Game entry
|
||||
|
||||
The title uses `/skland/session/<endpoint>` with `Authorization: Bearer <game reconnect token>` and same-origin
|
||||
cookies on every request, including reads and SMS sending. It establishes the existing `hello`/`welcome` transport
|
||||
without entering the lobby. Invalid or expired transport tokens receive JSON 401. Tokens never appear in URLs,
|
||||
QR codes or provider requests. Supplied `player`, UUID, UID or server fields cannot choose another account's row.
|
||||
Before login, the server uses a reserved `@session/<playerId>` pending row. After provider confirmation it copies
|
||||
credentials to a random `@account/<subject>` row, clears the pending credentials and issues `sp_account`, a signed
|
||||
HS256 JWT cookie: HttpOnly, SameSite=Strict, Path=/, Max-Age=2592000; HTTPS uses Secure. JWT contents are signed,
|
||||
not encrypted, and contain only issuer/audience, random subject/login id, expiry and the selected role key.
|
||||
Provider tokens, passwords and signing keys never enter it. Verification requires the fixed algorithm, signature,
|
||||
issuer, audience, time bounds, matching server login record and an active binding.
|
||||
|
||||
The following endpoints have the same request/response shapes as the diagnostic table below, with no `player` needed:
|
||||
`status`, `roles`, `scan/start`, `scan/poll`, `phone/send`, `phone/login`, `password/login`, `unbind`.
|
||||
The additional POST `role/select` accepts `{ roleKey }` and selects only an owned, named role with a nonempty UID
|
||||
and region. `status`/selection also return `loginId`, `selectedRoleKey` and `gamePlayerId`. Selection is persisted
|
||||
in the server login record and JWT. On login the default valid role is selected, otherwise the first valid role;
|
||||
refresh preserves a still-valid selection or chooses a replacement. An empty list cannot start the game.
|
||||
Changing roles or unbinding while in a room is rejected; leave first. The selected nickname uses the existing
|
||||
callsign sanitizer, but never determines ownership. Binding itself does not press Start.
|
||||
|
||||
`shared/accountIdentity.js` defines identity as the provider-reported server region (`channelName`) plus normalized
|
||||
string UID. Optional `gameId` and mutable nickname are excluded, so QR and password answers may omit `gameId`
|
||||
without changing identity. The game exposes a stable `a_` + base64url of the first 16 SHA-256 digest bytes of that tuple
|
||||
as `playerId` (24 characters; `n:<playerId>` fits the existing 32-character field id limit), uses the existing
|
||||
registry/lobby maps, and permits duplicate nicknames. Different regions with the same UID remain distinct.
|
||||
Authenticated WebSocket hello ignores client-claimed name/identity and verifies that the supplied reconnect token
|
||||
belongs to this role; every room/game frame rechecks the live login and selected role. A bare reconnect token cannot
|
||||
resume an account identity, and a remembered cookie alone establishes only login transport. An anonymous socket can
|
||||
establish login transport and ping, but cannot issue room/game requests. Explicit entry is described below.
|
||||
|
||||
New tabs and restarted servers restore through the persistent cookie/database without another provider login.
|
||||
Unbind revokes the server login, clears its credentials/cookie and invalidates old JWTs, including open sockets;
|
||||
expired cookies require binding again. Login/refresh replies arriving after unbind cannot restore cleared secrets.
|
||||
Deleting the database or rotating the signing secret invalidates remembered logins. Room and match state remain
|
||||
in memory and do not survive a server restart.
|
||||
|
||||
### Cross-device match recovery
|
||||
|
||||
The title's **恢复账号对局** reads server state for the selected role instead of the browser's recent-token ring.
|
||||
A new device signs in independently and selects the same provider region + UID; it need not copy a reconnect token,
|
||||
JWT or localStorage. Restoring the login cookie, opening the title and querying recovery do not take over the seat.
|
||||
|
||||
These additional account routes use the same live transport header, JWT, same-origin checks and rate limit:
|
||||
|
||||
| Method | Session endpoint | Request | Response |
|
||||
|---|---|---|---|
|
||||
| GET | `recovery` | none | `{ ok: true, recovery: null \| { id, name, code, mode, difficulty, inMatch, round, connected, spectating } }` |
|
||||
| POST | `enter` | `{}` | `{ ok: true, gamePlayerId, token, name }`; starts a game transport or resumes the retained seat |
|
||||
| POST | `resume` | `{ id }` from the recovery response | same as enter, but only while the selected role still owns that room/match |
|
||||
|
||||
The recovery id is a selector, not a credential. It is unique to the in-memory room and match count; a new room or
|
||||
later match invalidates it. Supplied player/UID/region/nickname fields never select another role. A missing, foreign
|
||||
or stale recovery returns JSON 409 with `code: 'resume_expired'`, without creating a game session. A read does not
|
||||
extend a disconnect deadline and contains no token or private match view. Entry/recovery credentials stay in the
|
||||
POST response body, never in a URL or persistent browser storage before the server acknowledges them.
|
||||
|
||||
After an explicit Start or recovery confirmation, the client stages that token, reconnects with the cookie and waits
|
||||
for welcome to confirm the expected gamePlayerId before entering. The server keeps the same Match/PlayerState and
|
||||
reuses the existing room/state resync. A successfully attached account socket rotates the reconnect token and closes
|
||||
the old socket with 4001; obtaining the token without attaching does not kick the active device. Old tokens cannot
|
||||
silently retake the seat. Automatic boot entry requires the tab's current token to have resumed the selected role;
|
||||
a stale or duplicated tab stays at the title. Its user may still explicitly start/recover with a valid account login.
|
||||
The connection banner's 在此页面继续 uses the same checked account entry, not an obsolete token. Any anonymous
|
||||
welcome clears the entered flag, including a connection reset on an already open page.
|
||||
Ordinary reconnects retain the existing live-tab claim checks. Module-off sessions do not rotate their tokens.
|
||||
|
||||
The existing lifetimes still apply: solo matches keep a disconnected seat for 24 hours; co-op matches for 10 minutes;
|
||||
a waiting room also has its 60-second lobby grace. Actual expiration cleans the old seat through lobby.onExpire
|
||||
before the stable role id can be reused. Changing devices does not save the match to SQLite, synchronize local UI
|
||||
preferences/statistics, or make it survive server restart. The UI supplies loading, empty, stale/error and refresh
|
||||
states and warns when continuing may disconnect another device.
|
||||
|
||||
### Independent diagnostic page
|
||||
|
||||
These routes require `SP_SKLAND_DIAGNOSTIC_KEY` and the matching `X-SP-Diagnostic-Key` header; otherwise they
|
||||
return 403. Do not embed this administrator key in browser code. `skland.html` is a development diagnostic page;
|
||||
the main title uses the account-scoped endpoints above. Reserved `@session/` and `@account/` ids are forbidden.
|
||||
|
||||
Every answer is JSON, including errors (`{ ok: false, error }`), so a client is never handed the module-level HTML
|
||||
error page. Bodies are capped at 64 KiB. The handler is handed requests before the GET/HEAD gate, so it owns POST.
|
||||
|
||||
| Method | Path | Request | Response |
|
||||
|---|---|---|---|
|
||||
| GET | `/skland/status` | `?player=` | `{ playerId, status, bound, nickname, hgId, roles, error? }` |
|
||||
| GET | `/skland/roles` | `?player=&refresh=1` | `{ ok, roles }` (200 even on failure) |
|
||||
| POST | `/skland/scan/start` | `{ player }` | `{ ok, scanId, scanUrl, expiresInMs }` |
|
||||
| POST | `/skland/scan/poll` | `{ player }` | `{ ok, state: none \| waiting \| confirmed \| expired \| error, scanUrl?, awaitingConfirmation?, status?, nickname?, error? }` |
|
||||
| POST | `/skland/phone/send` | `{ player?, phone, captcha? }` | `{ ok }` or `{ ok: false, needCaptcha, captcha }` or `{ ok: false, error }` |
|
||||
| POST | `/skland/phone/login` | `{ player, phone, code }` | `{ ok, nickname, roles }` or `{ ok: false, error }` |
|
||||
| POST | `/skland/password/login` | `{ player, phone, password, captcha?, captchaStep? }` | `{ ok, nickname, roles }`, `{ ok: false, needCaptcha, captcha, captchaStep }` or `{ ok: false, error, needDevice? }` |
|
||||
| POST | `/skland/unbind` | `{ player }` | `{ ok: true }` |
|
||||
|
||||
`status` is `unbound | scanning | bound | error`; `bound` is `status === 'bound'`. A missing `player` (or phone /
|
||||
code / password, per endpoint) is a 400, an unknown `/skland/*` path a 404, a non-POST method on a POST path a 405, and a
|
||||
malformed body a 400. One 扫码 poll both reports `confirmed` and completes the login; a poll with no live session is
|
||||
`state: 'none'`.
|
||||
|
||||
```bash
|
||||
curl -s 'http://127.0.0.1:3000/skland/status?player=p1' -H 'X-SP-Diagnostic-Key: <admin-secret>'
|
||||
curl -s -X POST http://127.0.0.1:3000/skland/phone/login \
|
||||
-H 'X-SP-Diagnostic-Key: <admin-secret>' -H 'content-type: application/json' -d '{"player":"p1","phone":"13800000000","code":"123456"}'
|
||||
```
|
||||
|
||||
## 6. The database
|
||||
|
||||
One row per player in `skland_players` (`node:sqlite`, `journal_mode = WAL`, `synchronous = NORMAL`), file from
|
||||
`SP_SKLAND_DB` / `storeFile` (default `data/skland.db`; the parent directory is created). The store is synchronous;
|
||||
callers never await. The enabled module requires Node with `node:sqlite` (22.5 or newer); it does not silently
|
||||
replace persistent authentication with an in-memory fallback.
|
||||
|
||||
| Column | Holds |
|
||||
|---|---|
|
||||
| `player_id` | reserved pending/account subject key, or administrator diagnostic id — primary key |
|
||||
| `access_token` | the HG login token (refreshes official bindings or rebuilds a Skland `cred`) |
|
||||
| `device_token` | the device token of a 扫码/验证码 login (optional on Skland grants; unused by official account lookup) |
|
||||
| `hg_id` | the Hypergryph account id (`hgId`) |
|
||||
| `cred` | the long-term API credential |
|
||||
| `cred_token` | the HMAC signing key — never leaves the server |
|
||||
| `user_id` | the Skland internal user id (the column exists; no code path writes it) |
|
||||
| `d_id` | the device fingerprint the credentials are pinned to (`B` + 32 hex, per player) |
|
||||
| `nickname` | the profile name from the binding answer |
|
||||
| `binding` | the raw binding list from the selected provider (cache; parsed back on read) |
|
||||
| `binding_source` | `skland` for QR/SMS, `hypergryph` for official password lookup; selects the refresh path |
|
||||
| `status` | `unbound \| scanning \| bound \| error` |
|
||||
| `error` | the last failure message, if any |
|
||||
| `created_at`, `updated_at` | epoch ms |
|
||||
|
||||
`update()` merges only the named fields; `remove()` drops the row. **解绑 (`unbind`) clears every credential field —
|
||||
including `device_token` and `d_id` — and marks the row `unbound`.** The row itself stays. Password login can be
|
||||
attempted again directly. Existing databases receive the `binding_source` column automatically, defaulting their
|
||||
rows to `skland` so the already working QR/SMS bindings continue to refresh through their original provider.
|
||||
Successful password lookup clears any previous Skland credentials and stores only the HG account token and bindings.
|
||||
|
||||
The same SQLite file holds `account_meta` (the generated JWT signing secret) and `account_logins`
|
||||
(`id`, `subject`, `role_key`, `expires`, `revoked`). These tables enforce expiry, selection and logout revocation.
|
||||
Back up the file consistently with its WAL state and treat backups as credentials too.
|
||||
|
||||
## 7. Credential refresh
|
||||
|
||||
Official password bindings refresh by obtaining a new binding-service token from the stored HG account token and
|
||||
querying the official binding list again. An expired HG token requires login again. QR/SMS `getBinding` retries
|
||||
once on the two expiry codes, otherwise the player rebinds:
|
||||
|
||||
- **10000** — `cred_token` expired → `POST zonai /api/v1/auth/refresh` (header `cred`), retry with the new `token`.
|
||||
- **10002** — `cred` expired → re-run `getGrantCode(access_token, device_token)` then `getCred`, retry. Needs a stored
|
||||
`access_token` or `device_token`; without either it falls through to the error below.
|
||||
- Any other code, or a failed retry: the upstream message is raised; one matching 未登录 reads as
|
||||
`登录已过期,请重新绑定`. `service.roles(player, true)` re-queries only while `status === 'bound'` and persists the
|
||||
refreshed credentials.
|
||||
|
||||
## 8. Signing
|
||||
|
||||
`server/skland/crypto.js` is pure and fully unit-tested against pinned vectors (`test/skland/crypto.test.js`) — the
|
||||
composition is the part a refactor breaks silently. With `header_ca = {"platform":"3","timestamp":<ts>,"dId":<dId>,
|
||||
"vName":"1.0.0"}` in that key order and `ts = floor(now/1000) - 2` (2 s behind):
|
||||
|
||||
```
|
||||
GET: raw = path + query + ts + compact(header_ca)
|
||||
POST: raw = path + compact(body ?? {}) + ts + compact(header_ca)
|
||||
sign = md5( hmacSha256_hex(cred_token, raw) ) // SHA-256 HMAC, lowercase hex, then MD5 of that hex
|
||||
```
|
||||
|
||||
The headers are `cred`, `dId`, `platform: 3`, `timestamp`, `vName: 1.0.0`, `sign` plus the Skland mobile client
|
||||
identity (`User-Agent: Skland/…`, `X-Requested-With: com.hypergryph.skland`, `Accept-Encoding: gzip`). Only
|
||||
`getBinding` / the exposed `signedGet` / `signedPost` are signed; `generate_cred_by_code` is not. The login endpoints
|
||||
send `deviceHeaders()` instead — a `X-DeviceId` (10 hex chars), `X-DeviceModel` and `X-DeviceType: 2`, cached for the
|
||||
process so the upstream sees one device.
|
||||
|
||||
## 9. 安全模型 (security model)
|
||||
|
||||
- **Ownership comes from server-verified credentials.** Account routes need a live transport token plus the JWT for
|
||||
account operations. UUID/UID/region edits do not confer ownership; role selection is checked against stored
|
||||
provider bindings. WebSocket identity and authorization are server-enforced too. Administrator diagnostic routes
|
||||
are closed by default and cannot access reserved rows. No arbitrary upstream URL/proxy endpoint is exposed.
|
||||
- **Same-origin requests and bounded traffic.** Cross-site Origin/Fetch Metadata requests are rejected. Account HTTP
|
||||
requests share a per-address token bucket (3/s, burst 120); request bodies are capped at 64 KiB. Use HTTPS for
|
||||
remote deployments. A fully stolen valid bearer/cookie remains a credential until expiry/revocation; JWT signing
|
||||
prevents modification, not copying. HttpOnly keeps the JWT out of JavaScript storage.
|
||||
- **A `cred` (with `cred_token`) is equivalent to account access.** It is a long-term API credential, not a session
|
||||
cookie. Treat it as a secret.
|
||||
- **The store keeps credentials in plaintext SQLite.** A deployment must treat the database file as a secret: restrict
|
||||
its permissions to the server account, keep it out of backups and repositories, and **never serve or expose it over
|
||||
HTTP**. The built-in static/media/pack handlers deny the configured DB, WAL and SHM paths, including realpath
|
||||
aliases and Windows filename casing. A reverse proxy or other file server needs its own protection. This is why
|
||||
the module is opt-in.
|
||||
- A player can **解绑** after leaving their room; that clears the stored credentials. Changing the Hypergryph password invalidates
|
||||
the stored credentials upstream, so the player rebinds.
|
||||
|
||||
## 10. Legal and project rules
|
||||
|
||||
The account flow follows the public official SDK; the legacy Skland flow also follows community descriptions.
|
||||
These website endpoints have no compatibility guarantee and may change without notice. The `appCode` in `crypto.js` is a public SDK constant visible in a
|
||||
decompiled client — it is **not** an authorisation or a permission. This project is non-commercial
|
||||
([AGENTS.md](../AGENTS.md) forbids ads, payments and other monetisation) and stays that way. Enabling the module, and
|
||||
the handling of the credentials it stores, is the operator's decision and their responsibility.
|
||||
|
||||
## 11. Tests
|
||||
|
||||
Account test files (`node --test 'test/skland/*.test.js'`), against a stub upstream or widget — no account network.
|
||||
|
||||
| File | Pins |
|
||||
|---|---|
|
||||
| `test/skland/crypto.test.js` (10) | the signature composition (GET/POST, with and without query/body), the primitives, stability under a single changed input, the client identity headers, `makeDeviceId`, the cached `deviceHeaders` |
|
||||
| `test/skland/store.test.js` (10) | one row per player, full credential round-trip, merge-only update, unbind clearing credentials, WAL persistence, parent-dir creation, and migration preserving old QR credentials while persisting the official binding source |
|
||||
| `test/skland/client.test.js` (21) | QR/SMS chains, 100→101→confirmed polling, captcha retry, password → official binding-service grant/list without Skland, real device challenge 109, malformed grant/list and upstream errors, signed Skland requests and credential refresh |
|
||||
| `test/skland/http.test.js` (24) | server wiring, QR polling with official/Bilibili channel preservation, SMS/captcha, fresh password login without Skland credentials, password/grant captcha continuation on the same token, pending-login invalidation on unbind, actual device challenge and incorrect password, provider-aware role refresh and switching, player isolation and error responses |
|
||||
| `test/skland/captcha.test.js` (4) | fake Geetest v3/v4 widgets: exact proof/challenge hand-off, cancellation, missing proof, unsupported challenge and provider load errors; no real challenge or network |
|
||||
| `test/skland/docker.test.js` (8) | the one-command packaging: the compose service, the port, `SP_SKLAND`/`SP_SKLAND_DB`, the database path inside the mounted named volume (and outside every static mount), the Dockerfile's prepared+owned directory before `USER node` |
|
||||
| `test/skland/panel.test.js` (10) | `public/skland.html`: every endpoint it calls, the QR encoding `scanUrl` (not the scanId), the 2 s poll with its cleanup, explicit poll errors, the `needCaptcha` path and the "not enabled" answer |
|
||||
| `test/skland/api.test.js` (5) | the title adapter's token-only authorization header, error mapping, hello reuse and default/selected role choice |
|
||||
| `test/skland/scan.test.js` (7) | deterministic 10/60 s refresh boundaries, serial polling, manual refresh, pause/resume, late responses, completion retry and failure recovery |
|
||||
| `test/skland/session.test.js` (5) | JWT signature/claims, role ownership, cross-origin rejection, logout/replay, restart/expiry, DB download denial, late provider cancellation, same-name UID/region isolation and authenticated game transport |
|
||||
| `test/skland/entry.browser.test.js` (1, opt-in) | the real title/modal with a stub provider: QR, role/channel selection, password CAPTCHA, SMS, hidden/plain-text callsign, empty roles, unbinding, cached-entry checks, reload persistence and short landscape layout; skipped unless `SP_E2E=1` |
|
||||
| `test/skland/recovery.test.js` | independent logins restoring the same live Match/private state, UID/region and role-selection isolation, read-only lookup, explicit takeover/token rotation, stale selectors, unchanged solo/co-op windows, expired-seat cleanup and account-id compatibility with watch/kick/battle reports |
|
||||
| `test/skland/recovery.browser.test.js` (1, opt-in) | two isolated browser contexts with no shared saves: real title/login/recovery, exact state restoration, manual continue, empty/expired recovery, old-device reload and duplicated-tab safety, desktop/landscape-phone layout; `SP_E2E=1` |
|
||||
|
||||
## 12. Files
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `server/skland/crypto.js` | the signature, the appCodes, the device id and the login device headers |
|
||||
| `server/skland/client.js` | the three login chains, captcha, `getGrantCode`/`getCred`, the signed client, `getBinding` refresh |
|
||||
| `server/skland/store.js` | the SQLite credential schema, migrations and merge-only store |
|
||||
| `server/skland/service.js` | per-player orchestration, scan sessions, status/roles, `finishLogin`, `unbind` |
|
||||
| `server/skland/http.js` | administrator diagnostic JSON routes and bounded body reader |
|
||||
| `server/skland/auth.js`, `accountHttp.js` | persistent JWT/account ownership, revocation, stable game identity and scoped HTTP entry/recovery |
|
||||
| `server/skland/recovery.js` | read-only selected-role seat metadata and room/match recovery selectors |
|
||||
| `shared/accountIdentity.js` | shared UID + server-region key, independent of nickname and optional provider metadata |
|
||||
| `public/js/skland/captcha.js` | user-operated Geetest widget, loaded only when verification is needed |
|
||||
| `public/js/skland/entry.js`, `api.js` | title binding button/dialog, token-authenticated client, role selection and QR loading |
|
||||
| `public/js/skland/gate.js`, `resume.js` | checked account entry, safe automatic reconnect and the cross-device recovery picker |
|
||||
| `public/js/skland/scan.js` | automatic QR generation, 10/60 s refresh and serial 2 s confirmation polling |
|
||||
| `public/css/skland/binding.css` | feature-scoped styles, loaded by the entry without editing global stylesheets |
|
||||
| `server/skland/session.js` | reconnect-token resolver and the reserved game-binding namespace |
|
||||
|
||||
## 13. Keeping upstream merges small
|
||||
|
||||
Login logic, the callsign display, entry validation and CSS live under the module's own directories. The title
|
||||
renders these components, owns the dialog-open state and chooses 绑定账号/开始 from the verified nickname. `main.js`
|
||||
checks cached entry through `restoreBoundEntry`. The title mounts the account recovery picker; the obsolete local
|
||||
shortcut is removed from the lobby (the generic `ui/resumeMatch.js` helper remains available upstream). The identity
|
||||
helper stages explicit account tokens without persisting a claim before welcome. The server factory installs an
|
||||
optional account policy: small hooks in `server/net.js` cover connection, hello, successful attachment/token rotation
|
||||
and per-frame authorization; static handlers accept a private-file denylist. With no policy the upstream transport
|
||||
behaves as before. No server lobby, match, simulation or WebSocket message changes are needed.
|
||||
Language packs and this reference hold translations/documentation.
|
||||
Keep future account changes in these module files and preserve the small title/boot/recovery hooks when merging
|
||||
upstream. This reduces the conflict surface; edits to the same hooks/translations may still need a merge.
|
||||
|
||||
The Hypergryph and Bilibili brand images are referenced directly from the
|
||||
[official account page bundle](https://web.hycdn.cn/arknights/user/index.842215.js):
|
||||
[`hypergryph.d69547.png`](https://web.hycdn.cn/arknights/user/assets/imgs/hypergryph.d69547.png) and
|
||||
[`bilibili.11810a.png`](https://web.hycdn.cn/arknights/user/assets/imgs/bilibili.11810a.png), checked on 2026-10-14.
|
||||
Images are decorative beside text labels, so a failed image request leaves the controls usable. No game art is added
|
||||
to git. The human-operated CAPTCHA temporarily releases the game's modal focus trap for the provider widget.
|
||||
Generated
+949
-942
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.6-dev",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"description": "明日方舟「卫戍协议:盟约」非官方同人联机网页复刻(1–4 人合作,Node.js 服务器 + 浏览器客户端)",
|
||||
"license": "GPL-3.0-or-later",
|
||||
@@ -50,6 +50,7 @@
|
||||
"pixi-spine": "^4.0.6",
|
||||
"pixi.js": "^7.4.2",
|
||||
"preact": "^10.29.8",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"three": "^0.186.1",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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; the Bench is full, so they come back next round",
|
||||
"装备库存不足,未获得该装备": "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
@@ -1224,5 +1224,58 @@
|
||||
"{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
@@ -1220,5 +1220,58 @@
|
||||
"{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
@@ -1222,5 +1222,58 @@
|
||||
"{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
@@ -8,13 +8,13 @@
|
||||
// otherwise → Lobby
|
||||
// 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).
|
||||
// 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
|
||||
// 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)
|
||||
// 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:
|
||||
// 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).
|
||||
// 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 { GAME_FILES } from './ui/gameComponents.js';
|
||||
import { TitleScreen, sanitizeName } from './screens/title.js';
|
||||
import { acceptAccountWelcome, restoreBoundEntry } from './skland/gate.js';
|
||||
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
|
||||
import { RoomScreen } from './screens/room.js';
|
||||
import { GameScreen } from './screens/game.js';
|
||||
@@ -145,6 +146,7 @@ function backToLobby() {
|
||||
}
|
||||
|
||||
function onWelcome(msg) {
|
||||
const accountAccepted = acceptAccountWelcome(msg);
|
||||
identity.saveToken(msg.token);
|
||||
identity.rememberMatch(null);
|
||||
const prev = store.get();
|
||||
@@ -156,8 +158,8 @@ function onWelcome(msg) {
|
||||
|
||||
if (prevId != null && prevId !== msg.playerId) {
|
||||
// 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.
|
||||
const notice = sessionResetNotice(prev, msg.playerId);
|
||||
// expired on it): clear the old snapshot. A login-only transport stays at the title until checked account entry.
|
||||
const notice = accountAccepted ? sessionResetNotice(prev, msg.playerId) : null;
|
||||
backToLobby();
|
||||
if (notice) toast(t(notice), 'warn', { ttl: 7000 });
|
||||
} else if (prev.room || prev.match.public) {
|
||||
@@ -303,7 +305,7 @@ function App() {
|
||||
return html`<div class="app-root">
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`}
|
||||
<${ConnectionBanner} />
|
||||
<${ConnectionBanner} continueSession=${() => restoreBoundEntry(sanitizeName)} />
|
||||
<${ToastHost} />
|
||||
<${UiHosts} />
|
||||
<${GuideHost} />
|
||||
@@ -360,7 +362,7 @@ async function boot() {
|
||||
const entered = identity.wasEntered() && !!savedName;
|
||||
store.set((s) => ({
|
||||
me: { ...s.me, name: savedName },
|
||||
session: { entered },
|
||||
session: { entered: false },
|
||||
ui: { ...s.ui, pendingJoin },
|
||||
}));
|
||||
|
||||
@@ -378,9 +380,10 @@ async function boot() {
|
||||
// Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13).
|
||||
data.load('local').catch(() => {});
|
||||
|
||||
const connectWhenReady = identityReady.then(() => {
|
||||
if (entered) net.setName(savedName);
|
||||
else net.connect();
|
||||
const connectWhenReady = identityReady.then((token) => {
|
||||
if (entered && token) return restoreBoundEntry(sanitizeName, { automatic: true });
|
||||
identity.setEntered(false);
|
||||
net.connect();
|
||||
});
|
||||
// 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));
|
||||
|
||||
@@ -746,6 +746,7 @@ export function createIdentity(deps = {}) {
|
||||
let initPromise = null;
|
||||
let initialized = 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 resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
|
||||
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
|
||||
// session in storage and give back the tentative selection immediately in that interval.
|
||||
if (current && tokenHash(current) === hash) {
|
||||
explicitClaim = false;
|
||||
if (current === owned) {
|
||||
owned = null; welcomeAt = null; welcomed = false;
|
||||
sdel(session, K_WELCOME);
|
||||
@@ -911,6 +913,13 @@ export function createIdentity(deps = {}) {
|
||||
} catch { return 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) */
|
||||
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
|
||||
/** @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. */
|
||||
getTokenClaim() {
|
||||
if (explicitClaim) return { noReplace: false };
|
||||
return current === owned && owned
|
||||
? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) }
|
||||
: { noReplace: true };
|
||||
@@ -935,6 +945,7 @@ export function createIdentity(deps = {}) {
|
||||
/** @param {string} token from `welcome` */
|
||||
saveToken(token) {
|
||||
if (!isToken(token)) return;
|
||||
explicitClaim = false;
|
||||
if (current !== token) rememberMatch(null);
|
||||
if (owned !== token || welcomeAt == null) welcomeAt = now();
|
||||
owned = token;
|
||||
@@ -946,6 +957,7 @@ export function createIdentity(deps = {}) {
|
||||
},
|
||||
/** Forget this tab's token (e.g. the server said the session is invalid). */
|
||||
clearToken() {
|
||||
explicitClaim = false;
|
||||
rememberMatch(null);
|
||||
const t = current || sget(session, K_TOKEN);
|
||||
current = null;
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
// a special terrain tile's own tip; the screen resolves it with gameLogic.terrainInfo)
|
||||
// tileHover { row, col, area, idx } | null (while dragging: the drop target — the tile under the pointer)
|
||||
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px: the drawn body) | null
|
||||
// (null also until the piece is first drawn)
|
||||
// Picking (user playtest #4 item 1: the ground is drawn as tiles — a press on a tile is a press on the unit standing
|
||||
// there): every "which unit is under the pointer" — prep press / click / detail / drag start / hover (pieceAt), battle
|
||||
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
|
||||
@@ -1939,7 +1940,8 @@ export async function createFieldView(host, options = {}) {
|
||||
pieceScreenRect(uid) {
|
||||
let v = views.get('p:' + uid);
|
||||
if (!v) for (const x of views.values()) if (x.uid === uid || x.info?.uid === uid || x.id === uid) { v = x; break; }
|
||||
if (!v || v.destroyed) return null;
|
||||
// a view built this frame is placed by its first update: before that its screen record is still (0, 0)
|
||||
if (!v || v.destroyed || !v.placed) return null;
|
||||
const b = v.bounds();
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const left = r.left + b.x, top = r.top + b.y;
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
// the buckets into merged BufferGeometries).
|
||||
//
|
||||
// const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh, area?: AREAS.normal })
|
||||
// → { grid, buckets: { board, decal, pipe }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...],
|
||||
// → { grid, buckets: { board, decal, pipe, glass, raised, raisedDecal }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...],
|
||||
// terrain: { water, mire, smog, infection: [[r,c]…] }, edges: [...cyan field edge strips], bounds, stats }
|
||||
// `raised` / `raisedDecal` repeat only the device slabs — 射击台, 特制水上平台, 封印的地面 — with their side faces
|
||||
// reaching below the ground: those are runtime devices that an official scene (scene.js) does not contain, so they are
|
||||
// drawn on it (GitHub #569). The 'h' high ground is part of the scenes (act2 m01 / m02 model it) and is not repeated.
|
||||
// `devices` = the active stage devices standing on a built tile — separator walls included (act2 m01's blowers: the
|
||||
// row-13 ones at the top edge of the normal / 联防 field, the row-6 ones at its bottom edge and at the top edge of the
|
||||
// Final Assault field — user playtest #5 item 6 and its follow-up).
|
||||
@@ -40,6 +43,8 @@ export const CLIFF = 0.75;
|
||||
export const BEVEL = Object.freeze({ low: [0.035, 0.018], block: [0.04, 0.035] });
|
||||
/** Deep-sea basins: floor depth below the ground. */
|
||||
export const BASIN = 0.14;
|
||||
/** How far below the ground the device slabs' side faces reach on an official scene (its ground sits about z 0). */
|
||||
export const RAISED_FOOT = 0.08;
|
||||
|
||||
const CONTENT_OF = (g) => g !== '#' && g !== 'X';
|
||||
|
||||
@@ -321,6 +326,7 @@ export function buildBoard(stage, opts = {}) {
|
||||
const at = (r, c) => grid[r]?.[c] || null;
|
||||
const topZ = (t) => (t.glyph === 'd' ? -BASIN : t.h);
|
||||
const board = new Geom(), decal = new Geom(), pipe = new Geom(), glass = new Geom();
|
||||
const raised = new Geom(), raisedDecal = new Geom();
|
||||
const gates = [], terrain = { water: [], mire: [], smog: [], infection: [] };
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
|
||||
@@ -441,12 +447,15 @@ export function buildBoard(stage, opts = {}) {
|
||||
|
||||
const devices = opts.devices === false ? [] : stageDevices(stage, grid);
|
||||
for (const d of devices) {
|
||||
if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') slabDevice(board, decal, UVT, d, grid);
|
||||
if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') {
|
||||
slabDevice(board, decal, UVT, d, grid);
|
||||
slabDevice(raised, raisedDecal, UVT, d, grid, RAISED_FOOT);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
grid, gates, devices, terrain, edges,
|
||||
buckets: { board: board.finish(), decal: decal.finish(), pipe: pipe.finish(), glass: glass.finish() },
|
||||
buckets: { board: board.finish(), decal: decal.finish(), pipe: pipe.finish(), glass: glass.finish(), raised: raised.finish(), raisedDecal: raisedDecal.finish() },
|
||||
bounds: { x0: minX, x1: maxX, y0: minY, y1: maxY },
|
||||
stats: { tiles: grid.flat().filter((t) => t.drawn).length, vertices: board.vertexCount + decal.vertexCount + pipe.vertexCount },
|
||||
};
|
||||
@@ -513,17 +522,18 @@ export function stageDevices(stage, grid) {
|
||||
/** Device heights on their tile (platform tops are standing surfaces: tiles.js heightAt adds them). */
|
||||
export const DEVICE_H = Object.freeze({ platform: TILE_H.platform, sealedFloor: 0.05, waterPlatform: 0.2 });
|
||||
|
||||
function slabDevice(board, decal, UVT, d, grid) {
|
||||
/** `foot` lowers the side faces' bottom (the scene-board copy reaches under an official scene's ground). */
|
||||
function slabDevice(board, decal, UVT, d, grid, foot = 0) {
|
||||
const size = d.kind === 'sealedFloor' ? 0.92 : 0.94;
|
||||
const h = DEVICE_H[d.kind] || 0.2;
|
||||
const z0 = grid[d.r][d.c].h, zt = z0 + h;
|
||||
const zt = grid[d.r][d.c].h + h, z0 = grid[d.r][d.c].h - foot;
|
||||
const a = size / 2, x0 = d.c - a, x1 = d.c + a, y0 = d.r - a, y1 = d.r + a;
|
||||
const topSurf = d.kind === 'sealedFloor' ? null : UVT.plateS;
|
||||
if (topSurf) board.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(topSurf), [0, 0, 1], [1.02, 1.02, 1.02]);
|
||||
else decal.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(UVT.hazardX), [0, 0, 1], [1, 1, 1]);
|
||||
const side = UVT.graySide;
|
||||
for (const dir of ['S', 'E', 'N', 'W']) {
|
||||
const uv = surfaceUV(side, sideRect(side, size, h));
|
||||
const uv = surfaceUV(side, sideRect(side, size, h + foot));
|
||||
board.quad(sideCorners(dir, x0, x1, y0, y1, z0, zt), uv, NRM[dir], [[0.5, 0.53, 0.56], [0.5, 0.53, 0.56], [0.85, 0.88, 0.9], [0.85, 0.88, 0.9]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
// static crates (merged, same material),
|
||||
// decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per
|
||||
// material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total.
|
||||
// An official scene replaces the tiles, glass, decals, pipes and background with one mesh per material and lightmap
|
||||
// An official scene replaces the tiles, glass, decals, pipes and background (all but the device slabs, layout.js
|
||||
// buckets.raised — GitHub #569) with one mesh per material and lightmap
|
||||
// page of the parts the built areas show (load.js mergeSceneChunks). Devices, gates, edges and terrain stay.
|
||||
// The key light's shadow map is rendered only when the geometry changes (autoUpdate off).
|
||||
|
||||
@@ -303,6 +304,11 @@ export class BoardScene {
|
||||
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
|
||||
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
|
||||
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
|
||||
} else {
|
||||
// the scene has no device slabs: the 射击台 / 特制水上平台 / 封印的地面 slabs stay drawn on it (units stand
|
||||
// on them at tiles.js heightAt; GitHub #569)
|
||||
M.raised = this._mesh(board.buckets.raised, this.mat.board);
|
||||
M.raisedDecal = this._mesh(board.buckets.raisedDecal, this.mat.decal, { cast: false });
|
||||
}
|
||||
this._buildDevices(board);
|
||||
this._buildGates(board);
|
||||
|
||||
@@ -632,6 +632,7 @@ export class UnitView {
|
||||
this.lastAtk = -1; this.atkInterval = defaultInterval(ctx, info);
|
||||
this.shake = 0;
|
||||
this.screen = { x: 0, y: 0, s: 1, top: 0 };
|
||||
this.placed = false; // set by the first update's projection: until then `screen` is not on screen (bounds)
|
||||
this.destroyed = false;
|
||||
this.form = typeof info.form === 'string' ? info.form : null; // the unit's model form: an enemy's mode, a 傀儡师's 替身 (setForm, FORMS)
|
||||
this._dieForm = null; // the form it died in (die): a model built while it lies down shows that form's death
|
||||
@@ -1337,6 +1338,7 @@ export class UnitView {
|
||||
const hoverTo = this.flying && (this.alive || (this.dying > DIE_FADE_TAIL && !this.down)) ? FLY_HOVER : 0;
|
||||
if (this.hover !== hoverTo) this.hover = Math.abs(hoverTo - this.hover) < 1e-3 ? hoverTo : this.hover + (hoverTo - this.hover) * Math.min(1, dt * 6);
|
||||
const p = cam.project(this.x, this.y, this.z + this.hover + this.lift, this.screen);
|
||||
this.placed = true;
|
||||
const s = p.s;
|
||||
// fades
|
||||
if (this.fadeIn < 1) this.fadeIn = Math.min(1, this.fadeIn + dt * 4);
|
||||
@@ -2143,6 +2145,7 @@ export class ItemView {
|
||||
this.alive = true; this.remove = false; this.lift = 0; this.dimmed = false;
|
||||
this.bob = Math.random() * 6;
|
||||
this.screen = { x: 0, y: 0, s: 1, top: 0 };
|
||||
this.placed = false;
|
||||
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
|
||||
this.shadow.anchor.set(0.5);
|
||||
ctx.layers.shadow.addChild(this.shadow);
|
||||
@@ -2169,6 +2172,7 @@ export class ItemView {
|
||||
update(dt, cam, t) {
|
||||
const lifted = this.lift > 0;
|
||||
const p = cam.project(this.x, this.y, lifted ? this.z : this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
|
||||
this.placed = true;
|
||||
this.root.position.set(p.x, p.y);
|
||||
this.root.zIndex = unitDepthKey(cam, this.x, this.y, this.lift);
|
||||
const size = p.s * 0.62;
|
||||
@@ -2214,6 +2218,7 @@ export class DeviceView {
|
||||
this.facing = info.facing === -1 ? -1 : 1;
|
||||
this.aim = this.facing > 0 ? 0 : Math.PI; this.aimTo = this.aim; this.fire = 0;
|
||||
this.screen = { x: 0, y: 0, s: 1, top: 0 };
|
||||
this.placed = false;
|
||||
this._headTiles = this.spec.height + 0.2;
|
||||
this.box = ctx.createBox ? ctx.createBox() : null;
|
||||
this.gfx = new P.Graphics(); // fallback body (no tile field) and the turret head
|
||||
@@ -2270,6 +2275,7 @@ export class DeviceView {
|
||||
g.zIndex = zKey + 0.001;
|
||||
g.alpha = k;
|
||||
const p = cam.project(this.x, this.y, this.z + this.spec.height + 0.35, this.screen);
|
||||
this.placed = true;
|
||||
const show = this.alive && this.hp < this.maxHp - 0.5;
|
||||
this.hud.visible = show;
|
||||
if (show) {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
// 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.
|
||||
|
||||
import { ResumeMatchButton } from '../ui/resumeMatch.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 { 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>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
<${ResumeMatchButton} />
|
||||
<${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('统计数据')}>
|
||||
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
|
||||
|
||||
+25
-18
@@ -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
|
||||
// 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
|
||||
// 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
|
||||
// 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 { 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 { openStats } from './stats.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual } from '../store.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 { t, N_ } from '../../../shared/i18n.js';
|
||||
import { scriptOf } from '../../../shared/i18nPacks.js';
|
||||
@@ -192,8 +193,10 @@ export function TitleScreen() {
|
||||
const conn = useStore((s) => s.connection, shallowEqual);
|
||||
const pendingJoin = useStore((s) => s.ui.pendingJoin);
|
||||
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 [entering, setEntering] = useState(false);
|
||||
const assetsSettled = useData('assets');
|
||||
const assets = data.get('assets');
|
||||
const backdrop = findUiAsset(assets, BACKDROP_KEYS);
|
||||
@@ -209,16 +212,17 @@ export function TitleScreen() {
|
||||
const cssRidges = assetsSettled && (!ridges || ridgesFailed);
|
||||
|
||||
const valid = isValidName(name);
|
||||
const start = () => {
|
||||
if (!valid) { toast(t('请输入博士代号'), 'warn'); return; }
|
||||
enterSession(name);
|
||||
const start = async () => {
|
||||
if (entering) return;
|
||||
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 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
|
||||
// hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag)
|
||||
const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic';
|
||||
@@ -267,12 +271,10 @@ export function TitleScreen() {
|
||||
<div class="title-login">
|
||||
${pendingJoin ? html`<div class="title-invite">
|
||||
<${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}
|
||||
<${TextField} label=${t('博士代号')} micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
|
||||
placeholder=${t('输入你的代号(最多 {NAME_MAX_LEN} 字)', { NAME_MAX_LEN })} autoFocus=${!touchUi}
|
||||
onInput=${setName} onEnter=${start} />
|
||||
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>${t('开始')}<//>
|
||||
<${BoundCallsign} name=${name} />
|
||||
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" data-testid="binding-start" disabled=${entering} onClick=${start}>${valid ? t('开始') : t('绑定账号')}<//>
|
||||
<div class="title-conn">
|
||||
<span class=${`status-dot ${dotClass}`}></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" />
|
||||
</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>
|
||||
|
||||
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
|
||||
|
||||
<footer class="title-foot">
|
||||
<${ResumeMatchButton} />
|
||||
<${AccountResumeMatchButton} bound=${valid} onResume=${(id) => enterBoundAccount(sanitizeName, { recoveryId: id })} />
|
||||
<${PwaInstallButton} />
|
||||
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
|
||||
<span class="title-foot__meta">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
`}
|
||||
<//>`;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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}
|
||||
<//>`;
|
||||
}
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function bannerVisible(conn, entered, restoring, buildStale = false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ConnectionBanner() {
|
||||
export function ConnectionBanner({ continueSession } = {}) {
|
||||
const conn = useStore((s) => s.connection, shallowEqual);
|
||||
const entered = useStore((s) => s.session.entered);
|
||||
const restoring = useStore((s) => s.ui.restoring);
|
||||
@@ -53,7 +53,7 @@ export function ConnectionBanner() {
|
||||
: conn.status === 'closed' ? t('连接已关闭')
|
||||
: rejected ? t(conn.lastError.text) : t('正在连接服务器');
|
||||
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() }
|
||||
: rejected ? { label: t('重试'), run: () => net.reconnectNow() } : null;
|
||||
return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { store } from '../store.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { toast } from './toasts.js';
|
||||
|
||||
export function ResumeMatchButton() {
|
||||
export function ResumeMatchButton({ beforeEnter } = {}) {
|
||||
const [items, setItems] = useState(() => identity.recoverable());
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -22,13 +22,15 @@ export function ResumeMatchButton() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
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
|
||||
// would either lose the selection or falsely turn it into an established session claim.
|
||||
net.close();
|
||||
net.setName(item.name);
|
||||
net.connect();
|
||||
if (!beforeEnter || await beforeEnter()) {
|
||||
identity.setEntered(true);
|
||||
store.patch('session', { entered: true });
|
||||
}
|
||||
setOpen(false);
|
||||
setBusy(false);
|
||||
} else {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
* 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.
|
||||
* @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 segments = String(rest || '').split('/').filter((s) => s.length > 0);
|
||||
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);
|
||||
} catch { 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.
|
||||
// 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.
|
||||
|
||||
+11
-2
@@ -2,6 +2,8 @@
|
||||
// (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;
|
||||
// * /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`;
|
||||
// * 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
|
||||
@@ -48,16 +50,23 @@ const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.tes
|
||||
* The request listener for `http.createServer`.
|
||||
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
* 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)
|
||||
* 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}
|
||||
*/
|
||||
export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto' }) {
|
||||
export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto', skland = null }) {
|
||||
async function handleRequest(req, res) {
|
||||
const url = req.url || '/';
|
||||
if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; }
|
||||
const parts = splitUrl(url);
|
||||
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') {
|
||||
res.setHeader('Allow', 'GET, HEAD');
|
||||
sendError(req, res, 405, '不支持的请求方法 · Method not allowed');
|
||||
|
||||
+14
-4
@@ -29,6 +29,7 @@
|
||||
// Files go out through files.js serveFile.
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { MEDIA_PREFIX } from '../../shared/media.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.
|
||||
* @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,
|
||||
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} dirs
|
||||
* 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>}
|
||||
*/
|
||||
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 mounts = [
|
||||
{ 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; }
|
||||
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;
|
||||
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);
|
||||
return;
|
||||
}
|
||||
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
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; }
|
||||
|
||||
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 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);
|
||||
};
|
||||
}
|
||||
+55
-3
@@ -17,6 +17,9 @@
|
||||
// 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
|
||||
// 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/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 /
|
||||
@@ -29,6 +32,7 @@
|
||||
// The server only auto-listens when this file is the process entry point.
|
||||
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { getData, loadData } from './data.js';
|
||||
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.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 { answerClientError } from './http/common.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/.
|
||||
export {
|
||||
@@ -60,8 +71,12 @@ export {
|
||||
* maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number,
|
||||
* heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: 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]
|
||||
* 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,
|
||||
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
|
||||
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
|
||||
@@ -83,14 +98,20 @@ export async function startServer(opts = {}) {
|
||||
const indexOptions = assetIndexOptionsFrom(opts, publicDir);
|
||||
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : 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();
|
||||
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
|
||||
resetBuildTag();
|
||||
buildTag();
|
||||
|
||||
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);
|
||||
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); }
|
||||
network.close();
|
||||
assetIndex?.close();
|
||||
try { skland?.close(); } catch (e) { log.error('[shutdown] skland', e); }
|
||||
await new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
server.closeIdleConnections?.();
|
||||
@@ -152,7 +174,37 @@ export async function startServer(opts = {}) {
|
||||
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).
|
||||
|
||||
@@ -184,6 +184,16 @@ export class SessionRegistry {
|
||||
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 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 */
|
||||
this.connsPerKey = new Map();
|
||||
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.unref?.();
|
||||
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; }
|
||||
const conn = new Connection(ws, clientAddress(req, this.opts.trustProxy), this.now(), this.opts);
|
||||
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);
|
||||
ws.on('message', (data, isBinary) => {
|
||||
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 (!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; }
|
||||
|
||||
let res;
|
||||
@@ -640,6 +656,11 @@ export class Network {
|
||||
this.reply(conn, errorMsg(ERR.BAD_MSG, rid, `version mismatch: server ${PROTOCOL_VERSION}`));
|
||||
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);
|
||||
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.addr = conn.ip;
|
||||
session.limitKey = conn.key;
|
||||
this.accountPolicy?.attached?.(conn, { repeat });
|
||||
|
||||
let extra = null;
|
||||
try { extra = this.handler.welcomeInfo?.() ?? null; } catch (e) { this.log.error('[net] welcomeInfo crashed', e); }
|
||||
|
||||
@@ -133,12 +133,14 @@ function shocks({ count, atkScale, shockScale, radius, prob, stun, mainScale, bo
|
||||
};
|
||||
}
|
||||
|
||||
/** The free deployable tile of her range a shadow takes (PRTS: nearest to her > lower row > left column), or null. */
|
||||
/** The free deployable tile of her range a shadow takes (PRTS: nearest to her > lower row > left column), or null.
|
||||
* Deployable = on a board of this field (Battle.onFieldBoard): a teammate's half counts, a boss field's hand /
|
||||
* 临时整备区 rows and an empty half do not (#557, PR #558). */
|
||||
function shadowTile(battle, host) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const k of host.rangeKeys ?? []) {
|
||||
const r = Math.floor(k / COLS), c = k % COLS;
|
||||
if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue;
|
||||
if (!battle.grid.inRect(r, c) || !battle.onFieldBoard(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue;
|
||||
const d = hypot(r - host.tileR, c - host.tileC);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && (r < best[0] || (r === best[0] && c < best[1])))) { bd = d; best = [r, c]; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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; },
|
||||
};
|
||||
}
|
||||
@@ -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() !== '';
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { N_ } from './i18n.js';
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
/** Release version shown to players (title screen, server banner, /healthz). Kept equal to package.json "version"
|
||||
* (test/version.test.js); PROTOCOL_VERSION above is the separate wire-format number. */
|
||||
export const APP_VERSION = '0.2.6-dev';
|
||||
export const APP_VERSION = '0.2.6';
|
||||
/** A development build (the public `dev` branch): the title screen, the boot banner and the README say so, and
|
||||
* tools/package.mjs refuses to build a release zip from it without --allow-dev. */
|
||||
export const DEV_BUILD = /-dev$/.test(APP_VERSION);
|
||||
|
||||
@@ -141,3 +141,24 @@ test('live token ownership survives a duplicated tab, reload and reconnect', { t
|
||||
assert.equal(welcome.playerId, a.welcome.playerId);
|
||||
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');
|
||||
});
|
||||
@@ -184,6 +184,42 @@ test('T2 死魂灵的余息: a 魂灵之影 (the form\'s stats, 禁疗, no attac
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('user #557 (PR #558 by IceCodeNew): Given a bottom-row boss lineup, When the talent and S3 summon, Then shadows skip the bench and use only free combat tiles', () => {
|
||||
for (const kind of ['boss', 'hidden']) for (const side of ['L', 'R']) for (const free of [false, true]) {
|
||||
// Board (9, 7) becomes field (2, 7)/(2, 13). Row 1 is the temporary bench, inside BOSS_RECT.
|
||||
const units = [{ uid: 1, chessId: SLOT[5], diy: { charId: WD, skillIndex: 2 }, row: 9, col: 7, dir: 'DOWN' }];
|
||||
for (let col = 5; col <= 9; col++) {
|
||||
if (col === 7 || free && col === 8) continue;
|
||||
units.push({ uid: col, chessId: 'chess_char_1_01_a', row: 9, col });
|
||||
}
|
||||
const h = makeBattle({ kind, stageId: 'act2autochess_m02', players: [{ playerId: 'p1', side, units }], autoFinish: false });
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
const expected = free ? [[2, side === 'L' ? 8 : 12]] : [];
|
||||
assert.deepEqual(shadowsOf(h, u).map(t => [t.tileR, t.tileC]), expected, `${kind}/${side}/${free}: deployment talent`);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 40), 'S3 actually activates without an enemy');
|
||||
assert.deepEqual(shadowsOf(h, u).map(t => [t.tileR, t.tileC]), expected, `${kind}/${side}/${free}: S3 cannot spill into the bench`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('#557: a shadow may stand on the teammate\'s half of a pair field (Battle.onFieldBoard), never on the empty half of a solo boss field — no summon when nothing else fits', () => {
|
||||
// board (10, 8) facing RIGHT = field (3, 8) on the left half / (3, 12) mirrored; her own free tiles of range filled,
|
||||
// the bench tile (1, 8 / 12) and the other half's (2, 11 / 9) and (3, 12 / 8) stay in range
|
||||
for (const kind of ['boss', 'hidden']) for (const side of ['L', 'R']) for (const pair of [false, true]) {
|
||||
const units = [{ uid: 1, chessId: SLOT[6], diy: { charId: WD, skillIndex: 0 }, row: 10, col: 8, dir: 'RIGHT' },
|
||||
...[[9, 8], [11, 8], [12, 8], [9, 9]].map(([row, col], i) => ({ uid: 2 + i, chessId: 'chess_char_1_01_a', row, col }))];
|
||||
const players = [{ playerId: 'p1', side, units }];
|
||||
if (pair) players.push({ playerId: 'p2', side: side === 'L' ? 'R' : 'L', units: [{ uid: 11, chessId: 'chess_char_1_01_a', row: 12, col: 2 }] });
|
||||
const h = makeBattle({ kind, stageId: 'act2autochess_m02', players, autoFinish: false });
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
const expected = pair ? [[2, side === 'L' ? 11 : 9]] : [];
|
||||
assert.deepEqual(shadowsOf(h, u).map(t => [t.tileR, t.tileC]), expected, `${kind}/${side}/${pair ? 'pair' : 'solo'}`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('魂灵之影\'s skill: every 5 SP (time; +0–2 after each cast) a ground enemy of 维什戴尔\'s range — unmarked first — takes its ATK as arts damage, 停顿 1 s and her 残影; never an air unit', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const v = shadowOf(tier, elite);
|
||||
|
||||
+45
-11
@@ -286,12 +286,23 @@ export class Client {
|
||||
viewKind() { return this.page.evaluate(() => globalThis.__SP_VIEW__?.kind ?? null); }
|
||||
viewStats() { return this.page.evaluate(() => { try { return globalThis.__SP_VIEW__?.raw?.stats?.() ?? null; } catch { return null; } }); }
|
||||
|
||||
/** Client point to grab a prep piece (`at` = fraction of the piece rect's height, from the top). */
|
||||
piecePoint(uid, at = 0.72) {
|
||||
return this.page.evaluate((uid, at) => {
|
||||
/** Client point to grab a prep piece (`at` = fraction of the piece rect's height, from the top). Waits (up to 2.5 s)
|
||||
* until the piece is drawn and holds still: a piece bought this frame has no rect yet, and one still sliding to its
|
||||
* tile (0.22 s) would be grabbed over a neighbouring tile — picking is by the tile under the pointer. */
|
||||
async piecePoint(uid, at = 0.72) {
|
||||
const read = () => this.page.evaluate((uid, at) => {
|
||||
const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid);
|
||||
return r && r.width > 0 ? { x: r.left + r.width / 2, y: r.top + r.height * at } : null;
|
||||
if (!r || !(r.width >= 4)) return null;
|
||||
const x = r.left + r.width / 2, y = r.top + r.height * at;
|
||||
return x >= 0 && y >= 0 && x <= innerWidth && y <= innerHeight ? { x, y } : null;
|
||||
}, uid, at);
|
||||
let last = null;
|
||||
for (const end = Date.now() + 2500; Date.now() < end; await sleep(80)) {
|
||||
const p = await read();
|
||||
if (p && last && Math.abs(p.x - last.x) < 1.5 && Math.abs(p.y - last.y) < 1.5) return p;
|
||||
last = p;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/** Record the view's pieceDrop events (for diagnostics) and return + clear them. */
|
||||
@@ -342,25 +353,48 @@ export class Client {
|
||||
await m.up();
|
||||
}
|
||||
|
||||
/** An empty legal tile for a piece (mirrors the deploy rules: melee on melee tiles, ranged anywhere). */
|
||||
/**
|
||||
* An empty legal tile for a piece: the client's own legality (gameLogic/placement.js boardTargets over the screen's
|
||||
* placement context — the stage's deploy tiles under the player's terrain 机变 cards and devices, the boss field's
|
||||
* half in a boss round), so a tile a round's 机变 card took away is never chosen; the data's plain deploy tiles
|
||||
* (melee on melee tiles, ranged anywhere) when that module cannot be read.
|
||||
*/
|
||||
freeTileFor(uid, { prefer = 'gate' } = {}) {
|
||||
return this.page.evaluate((uid, prefer) => {
|
||||
return this.page.evaluate(async (uid, prefer) => {
|
||||
const { store, data } = globalThis.__SP__;
|
||||
const s = store.get();
|
||||
const priv = s.match.private;
|
||||
const st = data.lookup('stages', s.match.public.stageId);
|
||||
const pub = s.match.public;
|
||||
const st = data.lookup('stages', pub.stageId);
|
||||
const piece = [...priv.hand, ...priv.temp, ...priv.board].find((p) => p && p.uid === uid);
|
||||
if (!piece || !st?.deployTiles?.normal) return null;
|
||||
const order = (list) => {
|
||||
const a = list.slice();
|
||||
if (prefer === 'gate') a.sort((x, y) => (y[1] - x[1]) || (x[0] - y[0]));
|
||||
else a.sort((x, y) => (x[1] - y[1]) || (y[0] - x[0]));
|
||||
return a;
|
||||
};
|
||||
const used = new Set(priv.board.map((p) => `${p.row},${p.col}`));
|
||||
try {
|
||||
const P = await import('/js/ui/gameLogic/placement.js');
|
||||
const C = await import('/js/ui/gameLogic/camera.js');
|
||||
const ctx = P.placementContext({
|
||||
priv, stage: st, editable: true, field: C.deployFieldOf(pub, s.me?.playerId ?? s.me),
|
||||
getChess: (id) => data.lookup('chess', id), getToken: (id) => data.lookup('tokens', id),
|
||||
getItem: (id) => data.lookup('items', id), getEffect: (id) => data.lookup('effects', id), backups: data.get('backups'),
|
||||
});
|
||||
const legal = P.boardTargets(ctx, uid).legal.filter(([r, c]) => !used.has(`${r},${c}`));
|
||||
if (legal.length) { const [r, c] = order(legal)[0]; return { row: r, col: c }; }
|
||||
return null;
|
||||
} catch { /* the plain deploy tiles below */ }
|
||||
const rec = piece.kind === 'token' ? data.lookup('tokens', piece.id) : data.lookup('chess', piece.id);
|
||||
const melee = rec?.position === 'MELEE';
|
||||
const dt = st.deployTiles.normal;
|
||||
const tiles = melee ? dt.melee : [...dt.rangedOnly, ...dt.melee];
|
||||
const used = new Set(priv.board.map((p) => `${p.row},${p.col}`));
|
||||
const free = tiles.filter(([r, c]) => !used.has(`${r},${c}`));
|
||||
if (!free.length) return null;
|
||||
if (prefer === 'gate') free.sort((a, b) => (b[1] - a[1]) || (a[0] - b[0]));
|
||||
else free.sort((a, b) => (a[1] - b[1]) || (b[0] - a[0]));
|
||||
return { row: free[0][0], col: free[0][1] };
|
||||
const [r, c] = order(free)[0];
|
||||
return { row: r, col: c };
|
||||
}, uid, prefer);
|
||||
}
|
||||
|
||||
|
||||
@@ -798,7 +798,9 @@ async function facingTour(c, did) {
|
||||
if (!did.item && !(await c.handPieces('item')).length) did.itemBought = await buyOne(c, '.shopbar__item .scard:not(.scard--sold):not(.is-disabled)').catch(() => false);
|
||||
const units = () => c.handPieces().then((h) => h.filter((p) => p.kind === 'chess'));
|
||||
let hand = await units();
|
||||
assert.ok(hand.length >= 1, 'bought operators');
|
||||
// a bought operator can merge into an elite at once (two copies already fielded) and leave the bench empty: the
|
||||
// placement steps then wait for a later round (`did` carries them over)
|
||||
if (!hand.length) { c.note('facing: no operator on the bench this round (the bought ones merged)'); return; }
|
||||
// place + choose a direction (UP: striped rotated range + 拖回中心区域取消 while held) — the unit with the widest range
|
||||
// grid shows it best (a defender's grid is its own tile only)
|
||||
if (!did.place) {
|
||||
|
||||
+14
-1
@@ -1,6 +1,7 @@
|
||||
// .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
|
||||
// .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 assert from 'node:assert/strict';
|
||||
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');
|
||||
});
|
||||
|
||||
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`);
|
||||
}
|
||||
});
|
||||
@@ -385,6 +385,23 @@ describe('BoardScene with an official scene (stub renderer)', () => {
|
||||
assert.equal(board.sceneRes.size, 0, 'scene resources disposed');
|
||||
});
|
||||
|
||||
test('#569: the device slabs stay drawn on an official scene (the procedural board is gone); a stage without slabs adds nothing', () => {
|
||||
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
|
||||
const m03 = stages.act1autochess_m03;
|
||||
board.setScenePack(m03.id, fakeScenePack());
|
||||
board.setStage(m03);
|
||||
assert.ok(board.official && !board.meshes.board, 'the scene replaces the board');
|
||||
assert.ok(board.meshes.raised, 'the 射击台 slabs beside the blue gate stay');
|
||||
assert.equal(board.meshes.raised.geometry.getAttribute('position').count, board.board.buckets.raised.vertexCount);
|
||||
assert.equal(board.meshes.raised.material, board.mat.board, 'drawn like the procedural board');
|
||||
assert.equal(board.setScenePack(m03.id, null), true);
|
||||
assert.ok(board.meshes.board && !board.meshes.raised, 'without the scene the board draws them itself');
|
||||
board.setScenePack(stage.id, fakeScenePack());
|
||||
board.setStage(stage);
|
||||
assert.ok(board.official && !board.meshes.raised && !board.meshes.raisedDecal, `${stage.id}: no slab devices`);
|
||||
board.destroy();
|
||||
});
|
||||
|
||||
test('another stage, or dropping the scene, shows the procedural board again', () => {
|
||||
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
|
||||
board.setScenePack(stage.id, fakeScenePack());
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SURFACES, SOURCES, surfaceUV, resolveUvTable, cleanSurface, sideRect, t
|
||||
import { parseObj, mapMesh } from '../../public/js/render/board3d/obj.js';
|
||||
import {
|
||||
buildBoard, classifyStage, heightOf, AREAS, areaFor, unionAreas, objToBoard, boxProjectUV, tube, Geom, uvAt, ROWS, COLS,
|
||||
DEVICE_H, RAISED_FOOT,
|
||||
} from '../../public/js/render/board3d/layout.js';
|
||||
import { BoardScene, gatePulse, DIR_TURNS, boxData, LIGHTING } from '../../public/js/render/board3d/scene.js';
|
||||
import { loadBoardPack, loadThree, resetBoardPack, PACK_IMAGES } from '../../public/js/render/board3d/load.js';
|
||||
@@ -173,6 +174,33 @@ describe('board geometry (layout.js)', () => {
|
||||
assert.ok(!buildBoard(stages.act1autochess_m02).devices.some((d) => d.kind === 'crate'));
|
||||
});
|
||||
|
||||
test('#569: the device slabs (射击台 / 特制水上平台 / 封印的地面) are repeated for an official scene, their sides reaching under its ground; nothing else is', () => {
|
||||
const SLAB = new Set(['platform', 'waterPlatform', 'sealedFloor']);
|
||||
for (const [id, st] of Object.entries(stages)) {
|
||||
if (!Array.isArray(st?.rows)) continue;
|
||||
for (const area of [AREAS.normal, AREAS.boss]) {
|
||||
const b = buildBoard(st, { area });
|
||||
const slabs = b.devices.filter((d) => SLAB.has(d.kind));
|
||||
const decals = slabs.filter((d) => d.kind === 'sealedFloor').length;
|
||||
// per slab four sides, plus its top: a plate (raised) or the hazard decal of a sealed floor (raisedDecal)
|
||||
assert.equal(b.buckets.raised.vertexCount, (slabs.length * 5 - decals) * 4, `${id}: raised quads`);
|
||||
assert.equal(b.buckets.raisedDecal.vertexCount, decals * 4, `${id}: raised decal quads`);
|
||||
const P = b.buckets.raised.position;
|
||||
for (let i = 0; i < P.length; i += 3) {
|
||||
const d = slabs.find((x) => Math.abs(P[i] - x.c) <= 0.5 && Math.abs(P[i + 1] - x.r) <= 0.5);
|
||||
assert.ok(d, `${id}: every raised vertex is on a slab tile`);
|
||||
const h = b.grid[d.r][d.c].h;
|
||||
assert.ok(near(P[i + 2], h + DEVICE_H[d.kind]) || near(P[i + 2], h - RAISED_FOOT), `${id}: a slab top or its foot, z ${P[i + 2]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// the report's 射击台 beside the blue gate (战场#03) and the water platforms (战场#05 上半) are there; the 'h' high
|
||||
// ground of act2 m01 / m02 is the scenes' own geometry and is not repeated
|
||||
assert.ok(buildBoard(stages.act1autochess_m03, { area: AREAS.normal }).buckets.raised.vertexCount > 0);
|
||||
assert.equal(buildBoard(stages.act1autochess_m05, { area: AREAS.normal }).devices.filter((d) => d.kind === 'waterPlatform').length, 16);
|
||||
for (const id of ['act2autochess_m01', 'act2autochess_m02']) assert.equal(buildBoard(stages[id], { area: AREAS.normal }).buckets.raised.vertexCount, 0, id);
|
||||
});
|
||||
|
||||
test('malformed stages never throw and still build an island', () => {
|
||||
for (const st of [null, {}, { rows: ['xyz'] }, { rows: Array(19).fill('r'.repeat(21)), devices: [{ role: 'crate', pos: [3, 3] }, { role: 'crate', pos: 'x' }] }]) {
|
||||
const b = buildBoard(st, { area: AREAS.all });
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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 /');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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 })));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -54,7 +54,7 @@ describe('client leftovers — mock harness', { skip: !ENABLED && 'set SP_E2E=1
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
page.on('requestfailed', (r) => { if (!/fonts\.(googleapis|gstatic)/.test(r.url())) problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
await page.goto(`${base}/dev/game-mock.html?shot=1&${query}`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
page.on('requestfailed', (r) => { if (!/fonts\.(googleapis|gstatic)/.test(r.url())) problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
await page.goto(`${base}/dev/game-mock.html?shot=1&render=${render}&${query}`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
|
||||
|
||||
@@ -49,7 +49,7 @@ class Client {
|
||||
await page.setViewport({ width: this.w, height: this.h });
|
||||
page.on('console', (m) => { if (m.type() === 'error') this.problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => this.problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => this.problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
page.on('requestfailed', (r) => { if (!/fonts\.(googleapis|gstatic)/.test(r.url())) this.problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||
page.on('response', (r) => {
|
||||
if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`);
|
||||
// the served manifest's content-hashed URLs (docs/ASSETS.md): Spine files, audio through /media/~<hash>/, the rest
|
||||
@@ -145,12 +145,22 @@ class Client {
|
||||
viewKind() { return this.page.evaluate(() => globalThis.__SP_VIEW__?.kind ?? null); }
|
||||
viewStats() { return this.page.evaluate(() => { try { return globalThis.__SP_VIEW__?.raw?.stats?.() ?? null; } catch { return null; } }); }
|
||||
|
||||
/** Client point to grab a prep piece. */
|
||||
piecePoint(uid) {
|
||||
return this.page.evaluate((uid) => {
|
||||
/** Client point to grab a prep piece. Waits (up to 2.5 s) until the piece is drawn and holds still: a piece bought
|
||||
* this frame has no rect yet, and one still sliding to its tile would be grabbed over a neighbouring tile. */
|
||||
async piecePoint(uid) {
|
||||
const read = () => this.page.evaluate((uid) => {
|
||||
const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid);
|
||||
return r && r.width > 0 ? { x: r.left + r.width / 2, y: r.top + r.height * 0.72 } : null;
|
||||
if (!r || !(r.width >= 4)) return null;
|
||||
const x = r.left + r.width / 2, y = r.top + r.height * 0.72;
|
||||
return x >= 0 && y >= 0 && x <= innerWidth && y <= innerHeight ? { x, y } : null;
|
||||
}, uid);
|
||||
let last = null;
|
||||
for (const end = Date.now() + 2500; Date.now() < end; await sleep(80)) {
|
||||
const p = await read();
|
||||
if (p && last && Math.abs(p.x - last.x) < 1.5 && Math.abs(p.y - last.y) < 1.5) return p;
|
||||
last = p;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/** Client point of a board tile centre (top face). */
|
||||
@@ -500,10 +510,16 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
|
||||
if (s.phase !== 'BAND_DRAFT' || picked.has(c.label) || s.draft?.turn !== s.me) continue;
|
||||
if (!shotDraft) { await sleep(600); await c.shot('draft'); shotDraft = true; }
|
||||
if (c === guest && !guestSkipped) {
|
||||
await c.click('.draft-detail__btns .btn', '跳过');
|
||||
guestSkipped = true;
|
||||
c.note('skipped once');
|
||||
continue;
|
||||
// one skip — only while someone is left to pass to: the last player still to pick sees 跳过 disabled (PR #546)
|
||||
const canSkip = await c.page.evaluate(() => [...document.querySelectorAll('.draft-detail__btns .btn')]
|
||||
.some((b) => b.textContent.includes('跳过') && !b.disabled));
|
||||
if (canSkip) {
|
||||
await c.click('.draft-detail__btns .btn', '跳过');
|
||||
c.note('skipped once');
|
||||
continue;
|
||||
}
|
||||
c.note('跳过 disabled (the last player still to pick)');
|
||||
}
|
||||
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 3 : 7 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
|
||||
@@ -40,6 +40,9 @@ export const VENDOR_FILES = Object.freeze([
|
||||
['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.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 } = {}) {
|
||||
|
||||
Reference in new issue
Block a user