- abyss:game.js 242 断言全绿(剧本A-F六路径/氧气门三形态/sonarGate仲裁/结算死窗/双轨看门狗/radio 8项修复继承);资产 148句+11key变体(电台滤波带内能量47-62%→69-80%) - detective:game.js 265 断言真资产下全绿(矩阵不变式独立重算/指证全路径/评级12组注入/VERDICT死窗/二周目变体);真资产复跑回炉3处实现修复(无进展轨墙钟化/菜单暂停记账/停止播报真停声——mock时长掩盖的正确性bug) - 资产:TTS 539条×2格式、Mureka 7首 $0.21(累计$1.08/$2.00)、SYN12+SA3 19件(QC抓出1例AAC过冲并修复)、封面×2、check_all_assets 10游戏全绿(abyss 298/298、detective 279/279) - 平台接线:大厅双卡片、BUCKETS 增 abyss/detective 桶(H4 断言更新 10 游戏)、voice_text.json 10游戏991句 - 回归:14 套件全绿(10 playtest+guide+engine-voice+engine-input+api-smoke 12/12) - 设计偏差登记:abyss 7条/detective 9条(全为设计内部冲突按承诺清单/归因真话律取优)
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 天滑动过期,到期后大厅引导「重新登录或新建」。
启动
# 推荐:用项目自带 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/startper-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 位数字。三种语义:
- 只设 PIN(
{pin}):当前档案原地升级为可跨设备登录(claimed: true)。 - 命名(±PIN)(
{name, pin?},且该名字尚无已认领档案):当前匿名档案原地 命名(+设 PIN),进度零迁移——Firebase「匿名账号升级不换 UID」模式。 - 登录/合并(
{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择优后带新基线重试(大厅已实现)
数据格式
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 冒烟 + 大厅分桶纯函数单测(零依赖)
覆盖: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)。