Files
xsl 005851d01a feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片
- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决
- 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏
- 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言
- 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步
- 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿
- 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描)
- 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目

验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
2026-09-13 11:08:01 +08:00

282 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SoundGame Web 服务器(web/server/server.js)
Node 零依赖(仅用内置模块)的静态文件 + 用户系统 API 服务器。
P0-B 起采用**无感档案系统**(设计见 `research/platform/01-identity.md` 方案 B 与
`03-data-sync.md`):首次访问 0 输入 0 点击自动建匿名档案(自动名「玩家一/二/…」),
认领(命名 + 设 PIN)完全可选,同设备多玩家靠大厅档案切换器隔离,进度按游戏分桶、
服务器时钟裁决的桶级 LWW 同步。8 个游戏目录零改动——分桶/倒灌发生在大厅层。
P1 增补**配对码跨设备**(`01 §4.2` 旅程 3):老设备在「档案管理 → 转移到新设备」
生成 6 位配对码(逐位朗读),新设备输入即可把本机进度按桶 LWW 合并进老档案;
token 180 天滑动过期,到期后大厅引导「重新登录或新建」。
23 增补**《围猎》联机 WebSocket**(设计 `docs/design/23-hunt.md` §6):同一 HTTP
服务器、同一端口上新增 `GET /ws/hunt` 升级端点(`ws.js` 手写握手与帧编解码,零依赖),
房间管理与服务端权威战斗模拟在 `hunt-room.js`(全内存,不落盘)。协议见下文
[《围猎》WS 协议](#围猎wshunt-协议23-huntmd-6)。
> 模块格式标记:本目录 `package.json` 仅有 `{"type":"commonjs"}`(私有、零依赖、
> 无可安装内容)——祖先目录 `AutoVideo/package.json` 带 `"type":"module"`,会把本目录
> `.js` 误判为 ESM 导致 server.js 无法启动;该标记把本目录 `.js` 固定回 CommonJS。
> (`test/*.mjs` 恒为 ESM,不受影响。)
## 启动
```bash
# 推荐:用项目自带 node
tools/node/bin/node web/server/server.js
# 或用封装脚本(打印局域网地址)
bash web/serve.sh
# 自定义端口(默认 8000)/ 数据目录(默认 web/server/data/,测试用)
PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js
```
- 静态根目录:`web/`(`/` 即游戏大厅 `index.html`);`web/server/` 目录一律 404(内含 token 与 PIN 哈希)
- 数据目录:自动创建;检测到 v1 旧文件(`users.json`/`sessions.json`)时启动即单向迁移(原文件保留不动)
## ⚠ 安全说明
家庭局域网级认证(比之前加固了一档,但仍**请勿暴露公网**):
- PIN 以 **scrypt 哈希**(N=16384, r=8, node:crypto 内置)存储,不再明文;
- token **180 天滑动过期**、可 logout 吊销、惰性清扫,不再无限增长;
- 匿名建档端点有 per-IP 频控(默认 10 个/天,`SG_DEVICE_LIMIT` 覆盖);
- 配对码:**仅存内存不落盘、不进日志**;10 分钟单次有效、同档案同时仅 1 个有效码、
`pair/start` per-IP 频控(默认 20 个/天,`SG_PAIR_LIMIT` 覆盖)、`pair/join`
**连错 5 次封锁同 IP 10 分钟**(10^6 码空间 × 5 次 → 单窗口猜中概率 ≤ 5×10⁻⁶);
- 仍是 HTTP 明文传输、无 TLS、无登录失败退避——威胁模型是「家庭 WiFi 可信 +
防家人恶作剧」。公网开放 API 的前置清单见 `docs/deploy.md`「以后若要开放用户系统」。
## API
所有 API 请求/响应均为 JSON(`Content-Type: application/json`)。
错误响应统一为 `{ "error": "描述" }` + 相应 HTTP 状态码。
需要鉴权的端点用请求头 `Authorization: Bearer <token>`。
### POST /api/device —— 匿名自动建档(核心)
无参(或带已固化设备的旧 token 重附着)。
- 请求:`{}` 或 `{ "token": "<本机已有的 token>" }`
- 成功(新建):`201 { "ok": true, "profileId": "p_a1b2c3d4e5f6", "token": "<48 位随机 hex>", "name": "玩家一", "claimed": false }`
- `name` 由服务器按档案序号自动命名:玩家一、玩家二、玩家三……(可听词表)
- `token` 即设备身份,客户端存 localStorage,之后一切鉴权用它
- 成功(重附着):`200 { ..., "attached": true }`——幂等,不新建档案、不占频控额度
- 失败:`429`(同 IP 当日新建数超限)
大厅(`web/index.html`)首访静默调用本端点建档;纯静态公网形态(无 API)自动降级为
本地档案(`web/profiles.js` 同一套逻辑,token 为 null)。
### GET /api/profile —— 当前档案信息
- 请求头:Bearer
- 成功:`200 { "profileId": "p_…", "name": "玩家一", "claimed": false, "createdAt": "…" }`
### PUT /api/profile —— 改名
- 请求头:Bearer;请求:`{ "name": "爸爸" }`(1~24 位字母/数字/中文/`_`/`-`)
- 成功:`200 { "ok": true, "name": "爸爸" }`
- 失败:`400`(格式非法)/ `401` / `409`(该名字已被其他**已认领**档案占用;匿名档案重名不限制)
### POST /api/claim —— 认领(命名 + 设 PIN)/ 换设备登录(合并)
携带当前设备 token(Bearer 或 body.token)。请求:`{ "name"?, "pin"? }`,
`pin` 为 4~6 位数字。三种语义:
1. **只设 PIN**(`{pin}`):当前档案原地升级为可跨设备登录(`claimed: true`)。
2. **命名(±PIN)**(`{name, pin?}`,且该名字尚无已认领档案):当前匿名档案原地
命名(+设 PIN),进度零迁移——Firebase「匿名账号升级不换 UID」模式。
3. **登录/合并**(`{name, pin}`,且该名字已有已认领档案):验 PIN 后把**当前匿名
档案**的进度按桶 LWW(updatedAt 新者胜,同值比 rev)合并进目标档案,当前 token
改绑目标档案,匿名档案删除。返回 `{ "ok": true, "merged": true, "mergedGames": 3, "profileId": "…", "name": "爸爸", "claimed": true }`。
- 失败:`400`(格式)/ `401`(PIN 缺失或不正确)/ `409`(当前档案已设 PIN,需先建新匿名档案)
### POST /api/login —— 旧客户端兼容
- 请求:`{ "username": "uuu", "pin": "0858" }`
- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu", "claimed": true }`
- v1 用户迁移后照常可登录;若存量 PIN 尚为明文,验证成功时**透明升级**为 scrypt 哈希。
大厅在 token 180 天过期后的「名字 + PIN 重新登录」路径也走本端点(无需有效 token)。
### POST /api/register —— 旧客户端兼容(建议改用 device + claim)
- 请求:`{ "username": "ming", "pin": "1234" }` → `201 { "ok": true, "username": "ming", "profileId": "…", "token": "…" }`
- 直接创建已认领档案(不合并当前匿名档案的进度)。
### POST /api/logout —— 吊销当前 token
- 请求头:Bearer;成功:`200 { "ok": true }`
### POST /api/pair/start —— 生成配对码(老设备出示,P1)
- 请求头:Bearer(匿名 / 已认领档案均可)
- 请求:`{}`
- 成功:`200 { "ok": true, "code": "483920", "expiresAt": "2026-09-09T12:10:00Z" }`
- `code`:6 位数字,**10 分钟有效、单次使用**;同档案同时仅 1 个有效码,
重新生成会立即使旧码作废(单码作废 + 重生成即调用本端点)
- 码仅存服务器内存:进程重启即全部失效(老设备再点一次「生成配对码」即可)
- 失败:`401` / `429`(同 IP 当日生成数超 `SG_PAIR_LIMIT`,默认 20)
### POST /api/pair/join —— 配对码转入(新设备,P1)
- 请求:`{ "code": "483920" }`;请求头 Bearer 可选(新设备当前匿名档案 token)。
token 无效/过期时按「无档案设备」处理,不报 401。
- 成功(带 token,与 claim-merge 同语义——**新设备当前档案按桶 LWW 并入码对应的
老档案**,方向裁定:本地新进度并入老档案):
`200 { "ok": true, "merged": true, "mergedGames": 2, "profileId": "p_…", "name": "玩家一", "token": "<原 token,已改绑>", "claimed": false }`
- 成功(无 token,全新浏览器):直接为老档案签发新 token(`merged: false`)
- 成功(joiner 已是该档案):幂等返回当前 token(`merged: false`),码照常作废
- 成功即**作废该码**(单次使用),并发重复 join 靠同步删除保证原子
- 失败:
- `400`(码格式非法,不计入连错)
- `404`(码不存在 / 已过期 / 已用过——统一文案不泄露状态;**计入连错**)
- `409`(joiner 当前档案已设 PIN:请先「+ 新玩家」建匿名档案再转入;不消费码)
- `429`(同 IP 连错 5 次 → 封锁 10 分钟)
- 日志脱敏:配对码明文不进日志(与 PIN 同待遇)
### GET /api/progress —— 读进度
- 单桶(旧形状,游戏兼容,如 radio 直接读 `data`):
`GET /api/progress?game=rpg` → `200 { "game": "rpg", "data": { … }, "updatedAt": "…", "rev": 2 }`
(无存档时 `data` 为 `null`)
- 全桶(大厅用,一次拉回替代旧的 8 次串行 GET):
`GET /api/progress` → `200 { "profileId": "…", "name": "…", "games": { "rpg": { "updatedAt": "…", "rev": 2, "data": { … } }, … } }`
- 失败:`401` / `400`(game 名非法)
### PUT /api/progress —— 写进度(baseUpdatedAt LWW 守卫)
- 请求:`{ "game": "rpg", "data": { … }, "baseUpdatedAt": "<上次已知服务器桶 updatedAt>" }`
- `game`:1~32 位字母/数字/`_`/`-`;大厅约定 `_settings` 桶存用户设置(玩家级)
- `data`:任意 JSON 值原样透传(宽容收);大厅卡片展示 `data.summary`(客户端约定)
- `baseUpdatedAt` **可省略**:视为 legacy 客户端无条件放行(服务器 console 警告)——
各游戏存量 PUT 不带此字段,兼容不破
- 成功:`200 { "ok": true, "game": "rpg", "updatedAt": "<服务器时间>", "rev": 3 }`
- `updatedAt` 由**服务器时钟**打点(不信任客户端墙钟)
- `409`(LWW 冲突,携带的 baseUpdatedAt 落后于服务器桶):
`{ "error": "…", "game": "rpg", "server": { "updatedAt": "…", "rev": 4, "data": { … } } }`
——客户端应比较 `data.updatedAt` 择优后带新基线重试(大厅已实现)
## 《围猎》WS(/ws/hunt)协议(23-hunt.md §6)
联机守卫游戏《围猎》的实时通道:零依赖 WebSocket(`ws.js` 手写握手 + 帧编解码,
仅 node:crypto/node:events)+ 房间与服务端权威战斗模拟(`hunt-room.js`,全内存
不落盘,单实例约束不恶化)。字段级契约的唯一来源是 `docs/design/23-hunt.md` §6,
本节是实现侧的运行/调试说明。
### 传输(§6.1,ws.js)
- 端点 `GET /ws/hunt`;其它路径的 upgrade 请求一律 404 断开。
- 握手校验 `Upgrade: websocket` / `Connection: …upgrade…` / `Sec-WebSocket-Key` /
`Sec-WebSocket-Version: 13`,回 `101` + `Sec-WebSocket-Accept`。
- 帧纪律:客户端帧必须带 mask(否则 close 1002);支持 opcode 0/1/8/9/10;
二进制帧 close 1003;单条消息 >4KB close 1009;非法 UTF-8 close 1007;
RSV/未知 opcode/控制帧分片 close 1002。服务器帧不 mask。
- 活性:服务器每 15s ping,**45s 无 pong 判死**(等同断线,走 §6.6 宽限)。
- 速率帽:每连接 20 条应用消息/s,超限 err `rate`,10s 内再犯 close 1008。
- 背压:每连接待发队列;`snap` 可丢(新顶旧),`joined/ev/err` 不丢;队列 >200 close 1013。
- 连接后 5s 内必须发 `join`,否则 close 1008。
### 消息骨架
- C→S 三条:`join`(`{t,ver:1,room:"quick"|4位房码,name,teach?,resume?}`)、
`act`(`{t,kind:"attack"|"callout"|"skill"|"start",dir?,seq,at}`)、`leave`。
- S→C 四类:`joined` / `ev`(11 种 kind:spawn/kill/miss/skill/breach/wave/
waveclear/callout/botline/roster/end,带房间单调时钟 `ts`)/ `snap`(2Hz 全量兜底:
mobs + slots 冷却)/ `err`(code ∈ cooldown|skill_cd|bad_dir|not_ingame|room_full|
room_gone|bad_msg|rate)。字段逐条以 23-hunt.md §6.2/§6.3 表为准。
- `roster`(joined 内与 ev 内同构)每条目附带 `alive`(断线广播 false)——
是 §6.3 字段表的显式超集,断线/回槽通知所必需。
### 房间生命周期(§6.4-§6.6 实现要点)
- 快速匹配复用 `phase='lobby'` 未满房;显式房码 join 不存在即以该码建房;
对局中/教学房 join → err `room_full`。
- lobby 上限 12s(自建房起算);任一人类 `act start` 或满 4 人立即开局;
开局 bot 补位至 max(3, 人类数),第 4 座仅 4 人局启用(座 3 `char=null`,
该槽喊话/呼喊由客户端哨音处理,服务器不发其 botline)。
- 教学房:`join teach:true` → 强制单人新房、跳过 lobby 直入 TEACH(joined.state
.phase='teach'),教学波 3 狼(方位 N,逼近 12s,突入零惩罚);狼 1 未杀则补放
至多 2 次(对应"做错重教 ≤2 次后放过"),教学段 bot AI 挂起但呼喊必答
help_ack;教学波出清后休整 8s 同场接续第 1 波。
- 断线(含 45s 无 pong)→ 槽位 alive=false(roster 广播)+ 30s 宽限静默;
宽限内带 `resume` 重连 → 重发 joined(同 slot/seed/char/resume)+ 紧随 snap;
超时:剩余人类 ≥3 → 槽位移除,否则转 bot(不可逆,char 沿用、播 cover、
冷却延续不清零);lobby/end 阶段超时直接移除。最后一个人类宽限耗尽 → 房灭
(bot 不续命)。`leave` 立即按同一规则处理(无宽限)。房灭/转 bot 后迟到的
resume → err `room_gone`。终局后房间保留 30s 再对全员 close 1000。
- 防区:槽位号入房后不变;开局时按**在座顺序**分配 §3.3 防区表(roster ev 下发最终
sector)——中途离开造成槽位空洞时,槽 3 玩家可能拿到 3 人局第 3 防区(char 仍 null),
属有意的边界处理。
- seed 建房即掷(joined.seed);刷怪剧本 `rng(wave)=mulberry32((seed^(wave*0x9E3779B9))>>>0)`
+ 座位数轮转发牌,同 seed 同剧本;bot 反应带 rng 走独立的 seed 派生流。
- botline/callout 喊话节流(§7.4):10s 窗氛围句(greet/cover/kill/breach/skill/wave)
≤2 条超出丢弃;`ev callout` 与 `botline help_ack` 为优先级句永不被氛围顶掉
(其速率由 5s/座呼喊节流与"一次呼喊一次应答"源头约束)。
### 时钟与测试钩子
房间一切时间逻辑由 `manager.tick(dtMs)` 推进(本进程 100ms setInterval 驱动);
测试直接 import `hunt-room.js`、用 FakeConn 替身连接 + 快进 tick 即可确定性复现
(`createManager({ rateLimit: Infinity })` 关闭墙钟速率帽,`joinTimeoutMs` 可调)。
导出 `buildWaveScript/gradeFor/mulberry32/DIRS/LAYOUTS/WAVE_TABLE` 等供断言。
## 数据格式
```
web/server/data/
├── profiles.json { "seq": 3, "profiles": { "<pid>": { "name", "pinHash", "createdAt" } } }
├── tokens.json { "<token>": { "profileId", "createdAt", "lastSeen", "expiresAt" } }
├── profiles/
│ └── <pid>.json { "id": "<pid>", "games": { "<game>": { "updatedAt", "rev", "data" } } }
├── users.json (v1 遗留,迁移源,保留不动)
├── sessions.json (v1 遗留,迁移源,保留不动)
└── uuu.json (v1 遗留进度,已迁至 profiles/uuu.json,保留不动)
```
- `pinHash`:`null`(匿名档案)或 `"scrypt$16384$8$1$<salt-hex>$<hash-hex>"`
- 一档案一进度文件,按游戏分桶;`rev` 单调递增,作 updatedAt 同值的破平局保险
- v1 → v2 启动迁移(单向,幂等跳过):`users.json` → `profiles.json`(pid=旧用户名,
PIN 立即哈希)、`sessions.json` → `tokens.json`(补 180 天过期)、
`<username>.json` → `profiles/<username>.json`(各桶补 `updatedAt`/`rev`)
所有写入按文件串行排队(Promise 链),tmp + rename 原子落盘,并发请求不会写坏文件。
**单实例约束**:两个 server.js 进程共享同一 data/ 会绕过写队列。
## 静态服务
- MIME:`.ogg audio/ogg`、`.m4a audio/mp4`、`.js text/javascript`、
`.html text/html`、`.png image/png`、`.json application/json` 等;
- 目录自动解析 `index.html`;
- 支持简化版 HTTP Range(单段 `bytes=start-end` / `start-` / `-suffix`),
返回 206 + `Content-Range`,供音频元素拖动进度;
- 路径穿越防护:请求路径限制在 `web/` 内;`web/server/` 目录(token/PIN 哈希/进度数据)一律 404。
## 测试
```bash
tools/node/bin/node web/server/test/api-smoke.mjs # API 冒烟 + 大厅分桶纯函数单测(零依赖)
tools/node/bin/node web/server/test/hunt-sim.test.mjs # 《围猎》房间/战斗模拟 + WS 传输冒烟(零依赖)
tools/node/bin/node web/server/test/hunt-balance-scan.mjs 24 # bot 难度多种子平衡扫描(开发工具)
```
api-smoke 覆盖:device 建档/重附着、进度 PUT-GET-LWW 冲突(含 legacy 放行)、claim 命名/设 PIN/
合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H6)、
配对码全流程(I1:start 规约/join 桶级 LWW 合并/单次作废/无 token 转入/幂等/日志脱敏)、
配对码防护(I2:join 5 连错封锁、start per-IP 频控)、token 过期 401 路径(J1)。
hunt-sim 覆盖(13-playtest-criteria.md §24 服务器侧条目;固定 seed + 快进 tick 确定性复现):
1 真人+2 bot 打完 5 波全流程(逐波核对 §3.4 剧本、击杀账与 ev end 评级/耐久一致、
snap 逐 tick 与房间真值一致、greet 间隔 ≥1.5s)、seed 复现、断线 30s 内 resume 回槽
(同 slot/seed/char + 紧随 snap)、宽限超时转 bot(char 沿用、转后产出 act、cover 喊话)、
4 人局剩余 ≥3 改槽位移除、leave 立即转 bot、出击冷却 2.5s/旋风斩 8s/呼喊 5s 与
bad_dir/not_ingame/bad_msg、出击弧 ±45° 与 eta≤4 近战窗、突入狼 -1/熊 -2、失守立即终局
(grade:null 即"失守不写榜"的服务器侧口径)、终局 30s 后 close 1000 房灭回收、
教学波突入零惩罚与同场接续第 1 波、lobby 12s 自动开局/满 4 人开局/房码 join、
呼喊→bot 补位偏置 ×2(下次出击方向落入呼喊者防区,help_ack 恰一次)、
喊话 10s ≤2 条氛围节流与呼救优先、速率帽与 join 超时;WS 真机冒烟(手写握手/帧客户端):
101 与 Accept 哈希、无 mask→1002、二进制→1003、ping→pong、分片拼装、静态/REST 回归。