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

676 lines
34 KiB
JavaScript
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.
/**
* api-smoke.mjs — 无感用户系统(P0-B)零依赖冒烟测试。
*
* 覆盖(完成标准第 1 条):
* A. POST /api/device 匿名建档(自动名玩家一/二…)与 token 重附着
* B. 进度 PUT-GET-LWW:legacy 放行、baseUpdatedAt 守卫 409、全桶 GET
* C. claim 命名/设 PIN、改名、PIN-only 认领
* D. claim-merge(换设备登录):旧档案与新设备档案按桶 LWW 合并、token 改绑
* E. 旧客户端 login/register 兼容 + logout 吊销
* F. v1 迁移:users.json/sessions.json/<username>.json → profiles.json/tokens.json/
* profiles/<pid>.json(PIN 明文即刻 scrypt 哈希、旧 token 继续有效)
* G. per-IP 匿名建档频控(429)与 /server/ 静态目录封禁(数据不外泄)
* H. 大厅 localStorage 分桶逻辑单测(web/profiles.js 纯函数,Map 版 storage)
* I. 配对码跨设备(P1):start(6 位/10min/同档案 1 码/频控)→ join 按桶 LWW 合并
* + token 改绑 → 单次使用作废 + join 5 连错封锁 + 已设 PIN joiner 拒绝 + 日志不落码
* J. token 180 天滑动过期:到期 401(大厅「档案已过期」引导的服务器侧路径)
*
* 运行:tools/node/bin/node web/server/test/api-smoke.mjs(或任意 node ≥18)
* 全部通过退出码 0;任一失败抛异常退出非 0。测试数据全部写进临时目录。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
BUCKETS, SETTINGS_KEY,
applyAuth, cnNum, decideBucket, digitsToCn, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, removeProfile, restoreSnapshot, setCurrentProfile,
snapshotKeyOf, stashSnapshot, syncKeyOf, writeRegistry,
} from '../../profiles.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVER = path.join(HERE, '..', 'server.js');
const NODE = process.execPath;
// ---------------- 服务器实例管理 ----------------
function tmpDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'soundgame-smoke-'));
}
async function startServer({ dataDir = tmpDataDir(), env = {} } = {}) {
const port = 20000 + Math.floor(Math.random() * 20000);
const child = spawn(NODE, [SERVER], {
env: { ...process.env, PORT: String(port), SG_DATA_DIR: dataDir, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
const logs = [];
child.stdout.on('data', (c) => logs.push(String(c)));
child.stderr.on('data', (c) => logs.push(String(c)));
const base = `http://127.0.0.1:${port}`;
const deadline = Date.now() + 15000;
for (;;) {
if (child.exitCode !== null) throw new Error(`服务器提前退出:\n${logs.join('')}`);
if (Date.now() > deadline) throw new Error('服务器启动超时');
try {
const r = await fetch(base + '/');
if (r.ok) break;
} catch (_) { /* 尚未监听 */ }
await new Promise((r) => setTimeout(r, 100));
}
return {
base,
dataDir,
logs,
stop() {
return new Promise((resolve) => {
child.on('exit', resolve);
child.kill('SIGTERM');
setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) {} resolve(); }, 2000);
});
},
};
}
async function call(base, method, apiPath, { token, body } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const resp = await fetch(base + apiPath, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await resp.json().catch(() => ({}));
return { status: resp.status, json };
}
// ---------------- H. 大厅分桶逻辑单测(纯函数,无需服务器) ----------------
test('H1. 快照回灌/倒灌:玩家一/玩家二进度互不污染', () => {
const storage = mockStorage();
// 玩家一在线:rpg 进度 + 设置
storage.setItem('soundgame.progress.rpg', JSON.stringify({ schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 }));
storage.setItem(SETTINGS_KEY, JSON.stringify({ voice: 'male', speed: 1.7 }));
storage.setItem('soundgame.rpg.tutIntroSeen', '1'); // 教学标记:不入桶(跨档案共享,01 §4.6)
writeRegistry(storage, [
{ id: 'p_aaa', name: '玩家一', token: 't1' },
{ id: 'p_bbb', name: '玩家二', token: 't2' },
]);
setCurrentProfile(storage, 'p_aaa');
// 切走:回灌玩家一
stashSnapshot(storage, 'p_aaa');
assert.equal(storage.getItem('soundgame.progress.rpg'), JSON.stringify({ schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 }));
assert.ok(!JSON.parse(storage.getItem(snapshotKeyOf('p_aaa'))).hasOwnProperty('soundgame.rpg.tutIntroSeen'), '教学标记不应入快照');
// 切入玩家二:倒灌前先清空活动 key → 无残留串档
restoreSnapshot(storage, 'p_bbb');
assert.equal(storage.getItem('soundgame.progress.rpg'), null, '切到空档案必须清掉玩家一的 rpg 存档');
assert.equal(storage.getItem(SETTINGS_KEY), null, '设置桶同样隔离');
assert.equal(storage.getItem('soundgame.rpg.tutIntroSeen'), '1', '教学标记保留(跨档案共享是既定行为)');
// 玩家二游玩后切回玩家一:进度原样回归
storage.setItem('soundgame.progress.rpg', JSON.stringify({ schema: 1, updatedAt: '2026-09-09T11:00:00Z', summary: '新手', lv: 1 }));
stashSnapshot(storage, 'p_bbb');
restoreSnapshot(storage, 'p_aaa');
assert.deepEqual(JSON.parse(storage.getItem('soundgame.progress.rpg')), { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 });
});
test('H2. decideBucket 桶级 LWW 裁决', () => {
const newer = { updatedAt: '2026-09-09T12:00:00Z', rev: 3, data: { updatedAt: '2026-09-09T12:00:00Z', summary: 'new' } };
const older = { updatedAt: '2026-09-09T11:00:00Z', rev: 2, data: { updatedAt: '2026-09-09T11:00:00Z', summary: 'old' } };
// 服务器缺 → 本地上云
assert.equal(decideBucket('{"updatedAt":"2026-09-09T10:00:00Z"}', null), 'push-local');
assert.equal(decideBucket(null, null), 'keep');
// 本地缺 → 服务器倒灌(修「7/8 游戏只 PUT 不 GET」由大厅统一做)
assert.equal(decideBucket(null, older), 'take-server');
// 两边都有:data.updatedAt 新者胜
assert.equal(decideBucket('{"updatedAt":"2026-09-09T12:00:00Z"}', older), 'push-local');
assert.equal(decideBucket('{"updatedAt":"2026-09-09T10:00:00Z"}', older), 'take-server');
assert.equal(decideBucket('{"updatedAt":"2026-09-09T11:00:00Z"}', older), 'keep');
// 无时间戳(settings / simon 最高分):内容等价 keep,否则服务器胜(对齐旧「登录拉设置」行为)
assert.equal(decideBucket('{"voice":"male"}', { updatedAt: 'x', rev: 1, data: { voice: 'male' } }), 'keep');
assert.equal(decideBucket('{"voice":"male"}', { updatedAt: 'x', rev: 1, data: { voice: 'female' } }), 'take-server');
assert.equal(decideBucket('5', { updatedAt: 'x', rev: 1, data: 5 }), 'keep'); // simon.best 标量
// 本地损坏 → 服务器兜底
assert.equal(decideBucket('{broken', older), 'take-server');
});
test('H3. 注册表 / 旧登录态收养 / 本地自动名 / 游戏侧 auth 视图', () => {
const storage = mockStorage();
assert.deepEqual(readRegistry(storage), []);
assert.equal(getCurrentProfile(storage), null);
assert.equal(nextLocalProfileName([]), '玩家一');
assert.equal(nextLocalProfileName([{ id: 'a', name: 'x' }, { id: 'b', name: 'y' }]), '玩家三');
// v1 旧登录态识别
storage.setItem('soundgame.auth', JSON.stringify({ username: 'uuu', token: 'ab'.repeat(24) }));
const legacy = migrateLegacyAuth(storage);
assert.equal(legacy.username, 'uuu');
assert.equal(migrateLegacyAuth(mockStorage()), null);
// 有 token → 游戏兼容视图 {username, token};无 token(纯本地档案)→ 移除
applyAuth(storage, { id: 'p1', name: '玩家一', token: 'tk' });
assert.deepEqual(JSON.parse(storage.getItem('soundgame.auth')), { username: '玩家一', token: 'tk' });
applyAuth(storage, { id: 'L1', name: '玩家二', token: null });
assert.equal(storage.getItem('soundgame.auth'), null);
});
test('H4. 受管桶清单覆盖 11 个可同步游戏 + _settings', () => {
const games = BUCKETS.map((b) => b.game).sort();
// hunt 为第 13 个游戏(《围猎》联机切片,23-hunt.md §12 进度桶)
assert.deepEqual(games, ['_settings', 'abyss', 'detective', 'dungeon', 'escape', 'hunt', 'radio', 'rhythm', 'rpg', 'simon', 'space']);
for (const b of BUCKETS) assert.ok(b.key.startsWith('soundgame.'), '桶 key 应保持游戏原始 key 名(游戏零改动)');
});
test('H5. 配对码逐位中文读法(0 读「零」非字母 O;speechSynthesis 前置纯函数)', () => {
assert.equal(digitsToCn('048920'), '零 四 八 九 二 零');
assert.equal(digitsToCn('000000'), '零 零 零 零 零 零');
assert.equal(digitsToCn('123456'), '一 二 三 四 五 六');
assert.equal(digitsToCn(''), '');
assert.equal(digitsToCn('9'), '九');
// 自动名词表与 server.js cnNum 同源(玩家一~九可听)
assert.equal(cnNum(1), '一');
assert.equal(cnNum(10), '十');
assert.equal(cnNum(21), '二十一');
});
test('H6. 从本机移除:清快照/同步表/注册表项;当前档案连带 CURRENT/AUTH,非当前不动 AUTH', () => {
const storage = mockStorage();
writeRegistry(storage, [
{ id: 'p_aaa', name: '玩家一', token: 't1' },
{ id: 'p_bbb', name: '玩家二', token: 't2' },
]);
setCurrentProfile(storage, 'p_bbb');
storage.setItem(snapshotKeyOf('p_aaa'), JSON.stringify({ 'soundgame.progress.rpg': '{}' }));
storage.setItem(syncKeyOf('p_aaa'), JSON.stringify({ rpg: { updatedAt: 'x', rev: 1 } }));
applyAuth(storage, { id: 'p_bbb', name: '玩家二', token: 't2' });
// 移除非当前档案:不清 CURRENT/AUTH
let list = removeProfile(storage, 'p_aaa');
assert.equal(list.length, 1);
assert.equal(storage.getItem(snapshotKeyOf('p_aaa')), null);
assert.equal(storage.getItem(syncKeyOf('p_aaa')), null);
assert.equal(storage.getItem('soundgame.profile.current'), 'p_bbb');
assert.ok(storage.getItem('soundgame.auth'), '非当前档案移除不应动 AUTH');
// 移除当前档案:连带清 CURRENT/AUTH(调用方负责新建/切换)
storage.setItem(snapshotKeyOf('p_bbb'), '{}');
list = removeProfile(storage, 'p_bbb');
assert.deepEqual(list, []);
assert.equal(storage.getItem('soundgame.profile.current'), null);
assert.equal(storage.getItem('soundgame.auth'), null);
assert.equal(storage.getItem(snapshotKeyOf('p_bbb')), null);
assert.equal(getCurrentProfile(storage), null);
});
function mockStorage() {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
_map: m,
};
}
// ---------------- A-G. API 冒烟(共享一个服务器实例的顺序场景) ----------------
test('A-G. API 全链路(device → progress LWW → claim → 合并 → 兼容 → 防护)', async () => {
const srv = await startServer({ env: { SG_DEVICE_LIMIT: '100' } });
try {
// ---- A. 匿名建档 ----
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.status, 201);
const A = { profileId: r.json.profileId, token: r.json.token };
assert.match(A.profileId, /^p_[0-9a-f]{12}$/);
assert.match(A.token, /^[0-9a-f]{48}$/);
assert.equal(r.json.name, '玩家一');
assert.equal(r.json.claimed, false);
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const B = { profileId: r.json.profileId, token: r.json.token };
assert.equal(r.json.name, '玩家二');
// token 重附着(幂等,不新建档案、不消耗 seq)
r = await call(srv.base, 'POST', '/api/device', { body: { token: A.token } });
assert.equal(r.status, 200);
assert.equal(r.json.attached, true);
assert.equal(r.json.profileId, A.profileId);
r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.json.name, '玩家三');
// ---- B. 进度 PUT-GET-LWW ----
const rpgSave = { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章·Lv7', lv: 7 };
// legacy PUT(无 baseUpdatedAt,存量游戏路径)→ 放行
r = await call(srv.base, 'PUT', '/api/progress', { token: A.token, body: { game: 'rpg', data: rpgSave } });
assert.equal(r.status, 200);
assert.ok(r.json.updatedAt && r.json.rev === 1);
// 带 baseUpdatedAt 的守卫写
r = await call(srv.base, 'PUT', '/api/progress', {
token: A.token,
body: { game: 'rpg', data: { ...rpgSave, lv: 8 }, baseUpdatedAt: r.json.updatedAt },
});
assert.equal(r.status, 200);
assert.equal(r.json.rev, 2);
const aRpgAt = r.json.updatedAt;
// 过期基线 → 409 + 服务器桶(客户端择优重试用)
r = await call(srv.base, 'PUT', '/api/progress', {
token: A.token,
body: { game: 'rpg', data: { ...rpgSave, lv: 1 }, baseUpdatedAt: '2000-01-01T00:00:00.000Z' },
});
assert.equal(r.status, 409);
assert.equal(r.json.server.data.lv, 8, '409 应携带服务器当前桶');
assert.equal(r.json.server.updatedAt, aRpgAt);
// 单桶 GET:形状兼容旧客户端(radio 直接读 j.data)
r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: A.token });
assert.equal(r.status, 200);
assert.equal(r.json.game, 'rpg');
assert.equal(r.json.data.lv, 8);
assert.equal(r.json.updatedAt, aRpgAt);
// 全桶 GET(大厅一次拉回)
r = await call(srv.base, 'GET', '/api/progress', { token: A.token });
assert.equal(r.status, 200);
assert.ok(r.json.games.rpg && r.json.games.rpg.data.lv === 8);
assert.ok(r.json.games._settings === undefined || r.json.games._settings.data !== undefined);
// 未写过 radio → GET 单桶 data 为 null(radio 新设备开局路径)
r = await call(srv.base, 'GET', '/api/progress?game=radio', { token: A.token });
assert.equal(r.json.data, null);
// 未授权
r = await call(srv.base, 'GET', '/api/progress?game=rpg');
assert.equal(r.status, 401);
// ---- C. claim:命名 + 设 PIN ----
r = await call(srv.base, 'POST', '/api/claim', { token: A.token, body: { name: '爸爸', pin: '1234' } });
assert.equal(r.status, 200);
assert.equal(r.json.profileId, A.profileId);
assert.equal(r.json.name, '爸爸');
assert.equal(r.json.claimed, true);
r = await call(srv.base, 'GET', '/api/profile', { token: A.token });
assert.equal(r.json.name, '爸爸');
assert.equal(r.json.claimed, true);
// 改名(PUT profile)
r = await call(srv.base, 'PUT', '/api/profile', { token: A.token, body: { name: '妈妈' } });
assert.equal(r.status, 200);
// 已认领名字被抢注 → 409
r = await call(srv.base, 'PUT', '/api/profile', { token: B.token, body: { name: '妈妈' } });
assert.equal(r.status, 409);
// PIN-only 认领(给当前档案补设 PIN,不改名)
r = await call(srv.base, 'POST', '/api/claim', { token: B.token, body: { pin: '5678' } });
assert.equal(r.status, 200);
r = await call(srv.base, 'GET', '/api/profile', { token: B.token });
assert.equal(r.json.claimed, true);
// ---- D. claim-merge:换设备登录,按桶 LWW 合并 ----
// 设备 C(新设备匿名档案):dungeon 桶 + 比妈妈更新的 rpg 桶
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const C = { profileId: r.json.profileId, token: r.json.token };
r = await call(srv.base, 'PUT', '/api/progress', {
token: C.token,
body: { game: 'dungeon', data: { schema: 1, summary: '第 3 层', floor: 3 } },
});
assert.equal(r.status, 200);
const cDungeonAt = r.json.updatedAt;
await new Promise((res) => setTimeout(res, 20)); // 服务器时钟至少推进
const cRpg = { schema: 1, updatedAt: '2026-09-09T20:00:00Z', summary: '第三章·Lv12', lv: 12 };
r = await call(srv.base, 'PUT', '/api/progress', { token: C.token, body: { game: 'rpg', data: cRpg } });
assert.equal(r.status, 200);
// 用「妈妈 + 1234」在设备 C 登录 → 匿名档案 C 合并进妈妈的档案,token 改绑
r = await call(srv.base, 'POST', '/api/claim', { token: C.token, body: { name: '妈妈', pin: '1234' } });
assert.equal(r.status, 200);
assert.equal(r.json.merged, true);
assert.equal(r.json.profileId, A.profileId, 'C 的 token 应改绑到妈妈的档案');
const CTOKEN = C.token; // token 不变,指向已变
r = await call(srv.base, 'GET', '/api/progress', { token: CTOKEN });
assert.equal(r.json.games.rpg.data.lv, 12, 'LWW:C 的 rpg 更新,应胜出');
assert.equal(r.json.games.dungeon.data.floor, 3, '合并应带入 C 独有的 dungeon 桶');
// 旧 token(妈妈原设备 A)不受影响,且两端看到同一档案
r = await call(srv.base, 'GET', '/api/progress?game=dungeon', { token: A.token });
assert.equal(r.json.data.floor, 3);
// 错误 PIN → 401
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const D = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/claim', { token: D.token, body: { name: '妈妈', pin: '0000' } });
assert.equal(r.status, 401);
// 无 PIN 直接抢已认领名 → 401
r = await call(srv.base, 'POST', '/api/claim', { token: D.token, body: { name: '妈妈' } });
assert.equal(r.status, 401);
// ---- E. 旧客户端兼容:register / login / logout ----
r = await call(srv.base, 'POST', '/api/register', { body: { username: '老王', pin: '4321' } });
assert.equal(r.status, 201);
r = await call(srv.base, 'POST', '/api/register', { body: { username: '老王', pin: '4321' } });
assert.equal(r.status, 409);
r = await call(srv.base, 'POST', '/api/login', { body: { username: '老王', pin: '9999' } });
assert.equal(r.status, 401);
r = await call(srv.base, 'POST', '/api/login', { body: { username: '老王', pin: '4321' } });
assert.equal(r.status, 200);
const oldToken = r.json.token;
assert.equal(r.json.username, '老王');
r = await call(srv.base, 'GET', '/api/profile', { token: oldToken });
assert.equal(r.json.name, '老王');
// logout 吊销
r = await call(srv.base, 'POST', '/api/logout', { token: oldToken });
assert.equal(r.status, 200);
r = await call(srv.base, 'GET', '/api/profile', { token: oldToken });
assert.equal(r.status, 401);
// ---- G1. /server/ 数据目录静态封禁 ----
let resp = await fetch(srv.base + '/server/data/profiles.json');
assert.equal(resp.status, 404, 'profiles.json 不应可被静态读取');
resp = await fetch(srv.base + '/x/../server/data/tokens.json');
assert.ok(resp.status === 404 || resp.status === 403, '穿越变体也应被拦');
resp = await fetch(srv.base + '/');
assert.equal(resp.status, 200, '大厅不受影响');
// 落盘检查:PIN 哈希而非明文
const idx = JSON.parse(fs.readFileSync(path.join(srv.dataDir, 'profiles.json'), 'utf8'));
for (const p of Object.values(idx.profiles)) {
assert.ok(p.pinHash === null || p.pinHash.startsWith('scrypt$'), 'PIN 必须以 scrypt$ 哈希存储');
}
assert.ok(!fs.readFileSync(path.join(srv.dataDir, 'profiles.json'), 'utf8').includes('"1234"'));
} finally {
await srv.stop();
}
});
test('F. v1 迁移:users.json/sessions.json/<username>.json → 档案分桶(旧用户无感)', async () => {
const dataDir = tmpDataDir();
const oldToken = '7c20d0f3534a31893157529e1f71c7e2c28e26fd07c8d8fc'; // 现网实测形状
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ uuu: '0858' }, null, 2));
fs.writeFileSync(path.join(dataDir, 'sessions.json'), JSON.stringify({ [oldToken]: 'uuu' }, null, 2));
fs.writeFileSync(path.join(dataDir, 'uuu.json'), JSON.stringify({
_settings: { voice: 'male', speed: 1, bgm: 1, guide: 'full' },
rpg: { schema: 1, summary: '第一章·Lv3', lv: 3 },
}, null, 2));
const srv = await startServer({ dataDir });
try {
// 旧 token 继续有效(迁移进 tokens.json,补 180 天过期)
let r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: oldToken });
assert.equal(r.status, 200);
assert.equal(r.json.data.lv, 3, '旧进度数据应完整保留(桶包装、data 透传)');
r = await call(srv.base, 'GET', '/api/progress?game=_settings', { token: oldToken });
assert.equal(r.json.data.voice, 'male');
// 旧客户端 用户名+PIN 登录兼容(映射到档案)
r = await call(srv.base, 'POST', '/api/login', { body: { username: 'uuu', pin: '0000' } });
assert.equal(r.status, 401);
r = await call(srv.base, 'POST', '/api/login', { body: { username: 'uuu', pin: '0858' } });
assert.equal(r.status, 200);
assert.equal(r.json.profileId, 'uuu', 'pid 沿用旧用户名(进度文件零改名)');
const t2 = r.json.token;
r = await call(srv.base, 'GET', '/api/progress', { token: t2 });
assert.equal(r.json.games.rpg.data.lv, 3);
// 新设备用 claim 登录旧档案 → 合并路径可用(匿名档案进度并入 uuu)
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const dev = r.json;
r = await call(srv.base, 'PUT', '/api/progress', {
token: dev.token,
body: { game: 'dungeon', data: { schema: 1, summary: '第 2 层', floor: 2 } },
});
assert.equal(r.status, 200);
r = await call(srv.base, 'POST', '/api/claim', { token: dev.token, body: { name: 'uuu', pin: '0858' } });
assert.equal(r.status, 200);
assert.equal(r.json.merged, true);
r = await call(srv.base, 'GET', '/api/progress', { token: dev.token });
assert.equal(r.json.games.rpg.data.lv, 3, 'uuu 旧进度在合并后保留');
assert.equal(r.json.games.dungeon.data.floor, 2, '新设备进度并入');
// 迁移落盘断言:PIN 已哈希、原 v1 文件保留不动
const idxRaw = fs.readFileSync(path.join(dataDir, 'profiles.json'), 'utf8');
assert.ok(idxRaw.includes('scrypt$'), '迁移后 PIN 应为 scrypt 哈希');
assert.ok(!idxRaw.includes('0858'), '明文 PIN 不应出现在 profiles.json');
assert.equal(JSON.parse(fs.readFileSync(path.join(dataDir, 'users.json'), 'utf8')).uuu, '0858', 'v1 原文件保留');
assert.ok(fs.existsSync(path.join(dataDir, 'profiles', 'uuu.json')), '进度迁至 profiles/uuu.json');
const tokensDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tokens.json'), 'utf8'));
assert.ok(tokensDisk[oldToken] && tokensDisk[oldToken].profileId === 'uuu');
assert.ok(Date.parse(tokensDisk[oldToken].expiresAt) > Date.now(), '旧 token 补 180 天过期');
} finally {
await srv.stop();
}
});
test('G2. per-IP 匿名建档频控', async () => {
const srv = await startServer({ env: { SG_DEVICE_LIMIT: '2' } });
try {
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.status, 201);
const firstToken = r.json.token;
r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.status, 201);
r = await call(srv.base, 'POST', '/api/device', { body: {} });
assert.equal(r.status, 429);
// 重附着(带有效 token)不受频控影响
r = await call(srv.base, 'POST', '/api/device', { body: { token: firstToken } });
assert.equal(r.status, 200);
assert.equal(r.json.attached, true);
} finally {
await srv.stop();
}
});
// ---------------- I. 配对码跨设备(P1) ----------------
test('I1. 配对码全流程:start(6 位/10min/同档案重生成顶替)→ join 按桶 LWW 合并 → 单次作废', async () => {
const srv = await startServer({});
try {
// 老设备:建档 + rpg 进度
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
const oldDev = { profileId: r.json.profileId, token: r.json.token, name: r.json.name };
r = await call(srv.base, 'PUT', '/api/progress', {
token: oldDev.token,
body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章·Lv7', lv: 7 } },
});
assert.equal(r.status, 200);
// start:6 位数字、10 分钟有效期、需鉴权
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
assert.equal(r.status, 200);
assert.ok(r.json.ok);
assert.match(r.json.code, /^\d{6}$/);
const firstCode = r.json.code;
const ttl = Date.parse(r.json.expiresAt) - Date.now();
assert.ok(ttl > 9 * 60 * 1000 && ttl <= 10 * 60 * 1000, `expiresAt 应为 ~10 分钟(实际 ${ttl}ms)`);
r = await call(srv.base, 'POST', '/api/pair/start');
assert.equal(r.status, 401, 'pair/start 必须鉴权');
// 同档案重新生成:旧码立即作废(同档案同时仅 1 个有效码)
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
const code2 = r.json.code;
let code = code2;
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const stranger = { token: r.json.token };
// 被顶替的旧码 → 404(计 1 次错误尝试,未到封锁阈值不影响后续)
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: firstCode } });
assert.equal(r.status, 404, '被重新生成顶替的旧码必须立即作废');
// 非 6 位数字 → 400(格式校验在先,不计入连错计数)
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: '000001-not-real' } });
assert.equal(r.status, 400, '非 6 位数字 → 400');
// 新设备:匿名档案 + dungeon 进度 + 更新的 rpg 进度
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const newDev = { profileId: r.json.profileId, token: r.json.token };
r = await call(srv.base, 'PUT', '/api/progress', {
token: newDev.token,
body: { game: 'dungeon', data: { schema: 1, summary: '第 3 层', floor: 3 } },
});
assert.equal(r.status, 200);
r = await call(srv.base, 'PUT', '/api/progress', {
token: newDev.token,
body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T20:00:00Z', summary: '第三章·Lv12', lv: 12 } },
});
assert.equal(r.status, 200);
// join:新设备档案按桶 LWW 并入老档案,token 改绑(与 claim-merge 同语义)
r = await call(srv.base, 'POST', '/api/pair/join', { token: newDev.token, body: { code } });
assert.equal(r.status, 200);
assert.equal(r.json.ok, true);
assert.equal(r.json.merged, true);
assert.equal(r.json.profileId, oldDev.profileId, '新设备 token 应改绑到码对应的老档案');
assert.equal(r.json.name, oldDev.name);
assert.equal(r.json.token, newDev.token, 'join 复用新设备 token(改绑,不发新)');
assert.ok(r.json.mergedGames >= 2, `至少 rpg+dungeon 两桶更新(实际 ${r.json.mergedGames})`);
// 合并结果:LWW 裁决(rpg 取新设备的第 12 级;dungeon 是新设备独有桶)
r = await call(srv.base, 'GET', '/api/progress', { token: newDev.token });
assert.equal(r.json.games.rpg.data.lv, 12, 'LWW:新设备 rpg 更新,应胜出');
assert.equal(r.json.games.dungeon.data.floor, 3);
// 老设备 token 不受影响,且看到同一份合并后的档案
r = await call(srv.base, 'GET', '/api/progress?game=dungeon', { token: oldDev.token });
assert.equal(r.json.data.floor, 3);
// 单次使用:同一码再用 → 失效(404)
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const third = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/pair/join', { token: third.token, body: { code } });
assert.equal(r.status, 404, 'join 成功后旧码必须立即失效');
// 无 token 新设备(全新浏览器)join:直接为老档案签发新 token
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { body: { code } });
assert.equal(r.status, 200);
assert.equal(r.json.merged, false);
assert.equal(r.json.profileId, oldDev.profileId);
assert.match(r.json.token, /^[0-9a-f]{48}$/, '无档案设备应拿到老档案的新 token');
r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: r.json.token });
assert.equal(r.json.data.lv, 12, '无档案 join 也能拉到合并后的进度');
// joiner 已设 PIN(已认领档案)→ 409,防误合并
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const claimed = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/claim', { token: claimed.token, body: { pin: '1111' } });
assert.equal(r.status, 200);
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { token: claimed.token, body: { code } });
assert.equal(r.status, 409, '已设 PIN 的档案不允许作为 join 源被合并');
// joiner 已是该档案(同设备重复转入)→ 幂等成功,消费码
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { token: oldDev.token, body: { code } });
assert.equal(r.status, 200);
assert.equal(r.json.merged, false);
assert.equal(r.json.token, oldDev.token);
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code } });
assert.equal(r.status, 404, '幂等转入同样消费码(单次)');
// 日志不落配对码明文(与 PIN 同待遇):本会话所有生成过的码逐一比对
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
const logged = srv.logs.join('');
for (const c of [code, code2, r.json.code]) {
assert.ok(!logged.includes(c), `服务器日志不得包含配对码明文 ${c}`);
}
} finally {
await srv.stop();
}
});
test('I2. 配对码防护:join 5 连错封锁 10 分钟、start per-IP 频控、过期码失败计数', async () => {
const srv = await startServer({ env: { SG_PAIR_LIMIT: '3' } });
try {
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
const dev = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const guesser = { token: r.json.token };
// 5 次错误码 → 全部 404,第 6 次起 429(封锁)
for (let i = 1; i <= 5; i++) {
r = await call(srv.base, 'POST', '/api/pair/join', { token: guesser.token, body: { code: '00000' + i } });
assert.equal(r.status, 404, `第 ${i} 次错误码应 404`);
}
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const legit = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
const code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { token: legit.token, body: { code } });
assert.equal(r.status, 429, '连错 5 次后同 IP 一切 join 被封锁(含正确码)');
assert.ok(r.json.error.includes('10 分钟'), '封锁提示应引导等待与重新生成');
// start per-IP 频控:SG_PAIR_LIMIT=3 → 第 4 次 429
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
assert.equal(r.status, 200); // 2
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
assert.equal(r.status, 200); // 3
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
assert.equal(r.status, 429, 'pair/start 应受 per-IP 频控');
} finally {
await srv.stop();
}
});
// ---------------- J. token 180 天滑动过期(大厅「档案已过期」引导的服务器侧路径) ----------------
test('J1. 过期 token → 401;pair/join 对过期 token 视作无档案设备(引导可自愈)', async () => {
const dataDir = tmpDataDir();
// 手工落一份「token 已过期」的数据:1 个档案 + 1 条 179 天前过期的 token
const expiredToken = 'ab'.repeat(24);
fs.mkdirSync(path.join(dataDir, 'profiles'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'profiles.json'), JSON.stringify({
seq: 1,
profiles: { p_dead: { name: '玩家一', pinHash: null, createdAt: '2025-01-01T00:00:00Z' } },
}, null, 2));
fs.writeFileSync(path.join(dataDir, 'tokens.json'), JSON.stringify({
[expiredToken]: {
profileId: 'p_dead', createdAt: '2025-01-01T00:00:00Z',
lastSeen: '2025-01-01T00:00:00Z', expiresAt: '2025-06-01T00:00:00Z', // 早已过期
},
}, null, 2));
const srv = await startServer({ dataDir });
try {
// 全部鉴权端点对过期 token 一律 401(大厅据此弹「档案已过期,重新登录或新建」)
let r = await call(srv.base, 'GET', '/api/profile', { token: expiredToken });
assert.equal(r.status, 401);
assert.ok(r.json.error.includes('未授权'), '错误文案供大厅 isAuthExpired 判定');
r = await call(srv.base, 'GET', '/api/progress', { token: expiredToken });
assert.equal(r.status, 401);
r = await call(srv.base, 'PUT', '/api/progress', { token: expiredToken, body: { game: 'rpg', data: {} } });
assert.equal(r.status, 401);
r = await call(srv.base, 'POST', '/api/pair/start', { token: expiredToken });
assert.equal(r.status, 401);
// 过期 token 已被惰性清扫出 tokens.json
const tokensDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tokens.json'), 'utf8'));
assert.ok(!tokensDisk[expiredToken], '过期 token 应被惰性清扫');
// 恢复路径 A(大厅「新玩家」):匿名建档照常可用
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const helper = { token: r.json.token, profileId: r.json.profileId };
assert.notEqual(helper.profileId, 'p_dead');
r = await call(srv.base, 'GET', '/api/profile', { token: helper.token });
assert.equal(r.status, 200);
// 恢复路径 B(配对转入):pair/join 带过期 token 不 401,视作无档案设备直发新 token
// (服务器端对 join 的宽容 = 大厅引导文案「从其他设备转入」可以无感自愈)
r = await call(srv.base, 'POST', '/api/pair/start', { token: helper.token });
const code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { token: expiredToken, body: { code } });
assert.equal(r.status, 200, 'join 对过期 token 应按无档案处理而非 401');
assert.match(r.json.token, /^[0-9a-f]{48}$/);
assert.equal(r.json.profileId, helper.profileId);
} finally {
await srv.stop();
}
});