Files
SoundGame/web/server
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
..

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 协议。

模块格式标记:本目录 package.json 仅有 {"type":"commonjs"}(私有、零依赖、 无可安装内容)——祖先目录 AutoVideo/package.json 带 "type":"module",会把本目录 .js 误判为 ESM 导致 server.js 无法启动;该标记把本目录 .js 固定回 CommonJS。 (test/*.mjs 恒为 ESM,不受影响。)

启动

# 推荐:用项目自带 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。

测试

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 回归。