feat(platform/P0): 无感用户系统 + PC 键盘双端一致(两 agent 并行 + 集成收口)

用户系统(B,游戏零改动):
- POST /api/device 首访 0 交互静默建档(自动玩家一/二…,per-IP 频控);断网降级纯本地照玩
- 大厅档案切换器(切换=flush 上云→快照回灌→倒灌→服务器回拉,profileId 分桶修同设备串档;aria menu 编号可听)
- claim 可选认领(命名+scrypt PIN);旧 users/sessions 单向迁移(PIN 明文即刻哈希,已真实数据验证);login 兼容+claim-merge 桶级 LWW;logout;/server/ 静态封禁
- PUT baseUpdatedAt 守卫(legacy 放行+警告);GET 无参全桶回拉(修 7/8 游戏跨设备只 PUT 不 GET)
- web/profiles.js 分桶纯函数 + api-smoke.mjs 7/7(建档/LWW/claim-merge/迁移/频控/封禁/分桶 H1-H4)

键盘双端(A,游戏代码零改动):
- gesture.js 键盘通道 +137 行:←↑↓→/WASD=滑动、空格=单击(走 300ms 双击窗与触摸共享虚拟指针)、回车=即时 doubletap、按住空格 ≥450ms=长按;repeat 抑制/表单守卫/方向键 preventDefault
- settings.input(auto/touch/key)+ gesture.inputMode();遮罩键盘解锁与文案自适应(按空格开始/轻触开始)
- 8 游戏遮罩改用自适应默认文案;大厅设置面板「操作方式」+页脚键位提示
- live control.mjs 键盘注入通道(CDP dispatchKeyEvent);engine-input.test 61 断言

回归:12 套件全绿(8 playtest + guide + engine-voice + engine-input + api-smoke 7/7);8001 服务器已重启加载新 API,/api/device 端到端验证通过

P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
This commit is contained in:
xsl
2026-09-09 23:39:23 +08:00
parent 3704cd0792
commit ede522dda1
22 changed files with 2370 additions and 275 deletions
+1 -1
View File
@@ -24,7 +24,7 @@
const audio = new WebAudioEngine('audio/');
const game = new DungeonGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
await audio.showStartOverlay({ cover: 'cover.png' });
game.start();
attachGestures(root, (g) => game.onGesture(g));
}
+23 -6
View File
@@ -20,8 +20,8 @@
* **自动播放限制**:iOS / Chrome 要求 AudioContext 必须在用户手势中 resume。
* 标准流程(必须遵守,否则无声):
* const audio = new WebAudioEngine('audio/');
* await audio.showStartOverlay({ cover: 'cover.png' }); // 遮罩:封面 + "轻触开始"
* // 此处 AudioContext 已解锁
* await audio.showStartOverlay({ cover: 'cover.png' }); // 遮罩:封面 + 提示文字
* // 此处 AudioContext 已解锁(触摸/鼠标点击或空格/回车 keydown 均可解锁)
* game.start();
*
* 与 Android 的一个差异:Web 端 sfx 是按需解码并缓存 AudioBuffer,解码完成后
@@ -51,7 +51,7 @@
*/
import { settings } from './settings.js';
import { setLongpressBoostEventsEnabled } from './gesture.js';
import { setLongpressBoostEventsEnabled, inputMode } from './gesture.js';
import { SoundTouch, SimpleFilter, WebAudioBufferSource, getWebAudioNode } from './vendor/soundtouch.js';
const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav'];
@@ -152,14 +152,18 @@ export class WebAudioEngine {
}
/**
* 启动遮罩层:全屏封面 + "轻触开始"。第一次点按时解锁音频并移除遮罩。
* 启动遮罩层:全屏封面 + 提示文字。第一次点按/按键时解锁音频并移除遮罩。
* @param {object} [opts]
* @param {string} [opts.cover] 封面图地址(如 'cover.png'),缺省纯黑背景
* @param {string} [opts.text='轻触开始']
* @param {string} [opts.text] 提示文字;缺省按输入模式自适应——
* gesture.inputMode()==='key' →「按空格开始」,否则「轻触开始」
* (settings.input / 最近使用通道决定,见 gesture.js)。
* 显式传 text 的调用完全覆盖(各游戏页现状)。
* @returns {Promise<void>} resolve 时音频已解锁,可安全 start()
*/
showStartOverlay(opts = {}) {
const { cover, text = '轻触开始' } = opts;
const { cover } = opts;
const text = opts.text ?? (inputMode() === 'key' ? '按空格开始' : '轻触开始');
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'start-overlay';
@@ -180,6 +184,7 @@ export class WebAudioEngine {
const onTap = () => {
if (done) return;
done = true;
if (hasWin) window.removeEventListener('keydown', onKey);
this.unlock().then(() => {
overlay.remove();
resolve();
@@ -195,6 +200,18 @@ export class WebAudioEngine {
overlay.addEventListener('touchend', (e) => { e.preventDefault(); onTap(); }, { passive: false });
// 桌面鼠标 / 辅助技术触发的 click 不受影响,照常解锁
overlay.addEventListener('click', onTap);
// 键盘解锁(PC 平行通道):空格/回车 keydown 同属 user activation,回调
// 内同步发起 unlock() 即可满足自动播放限制。挂 window 而非遮罩(游戏页
// 焦点在 body,keydown 不落在遮罩元素上),解锁后移除。此时 gesture.js
// 尚未 attach(遮罩先于 attachGestures),键盘解锁必须在这里做。
const hasWin = typeof window !== 'undefined' && typeof window.addEventListener === 'function';
const onKey = (e) => {
if (e.repeat) return;
if (e.code !== 'Enter' && e.code !== 'Space') return;
e.preventDefault();
onTap();
};
if (hasWin) window.addEventListener('keydown', onKey);
});
}
+136 -1
View File
@@ -1,5 +1,5 @@
/**
* gesture.js — 6 手势识别(触摸 + 鼠标双支持),对齐 Android GestureEngine 行为。
* gesture.js — 6 手势识别(触摸 + 鼠标 + 键盘三通道),对齐 Android GestureEngine 行为。
*
* 手势集合:'up' | 'down' | 'left' | 'right' | 'tap' | 'doubletap'
* | 'longpress' | 'longpressend'
@@ -42,9 +42,25 @@
* 注意:绑定点按的元素的 touchstart/touchmove/touchend 会被 preventDefault
* (阻止页面滚动、双指缩放与系统合成鼠标事件),元素需应用
* style.css 里的全屏样式(touch-action: none)。
*
* 键盘通道(PC 平行输入,research/platform/02-input-parity.md §2/§3):
* - ←↑↓→ 与 WASD = 四向滑动(keydown 即发,无位移/速度判定,零延迟);
* - 空格 = 单击:作为虚拟指针喂入**同一状态机**——抬起判 tap(保留 ~300ms
* 双击窗延迟,与触屏单击同感;双击窗与触摸共享,混合设备"触屏点一下 +
* 空格点一下"也判 doubletap,语义 = 同一虚拟指针);
* - 回车 = 双击专用键:即时触发 doubletap,不等双击窗(同时作废等待中的
* 单击),确认类操作零延迟;
* - 按住空格 ≥450ms = longpress,松开 = longpressend(旁白快进 window
* 事件照发,与触摸长按完全一致)。
* 事件与内置震动与 touch/mouse 完全同构——游戏代码零改动。守卫:
* e.repeat 抑制(按住 ↑ 只发一次 up)、映射键 preventDefault(防滚动/
* 焦点激活)、表单控件焦点放行(input/textarea/select/contenteditable)、
* tracking 单飞行指针互斥(与多指忽略语义一致)、未映射键不拦截(Tab
* 等无障碍键放行)。提示文案随输入模式见 inputMode()(settings.input)。
*/
import { vibrate } from './haptics.js';
import { settings } from './settings.js';
export const DEFAULT_GESTURE_CONFIG = {
swipeMinDistance: 64, // px,累积位移阈值(≈ Android 64dp)
@@ -65,10 +81,17 @@ export const DEFAULT_GESTURE_CONFIG = {
* recentlyTouched() 取两者较大值,把解锁 tap 也纳入 700ms 守卫。
*/
let lastGlobalTouchEnd = 0;
// 输入模式跟随(inputMode()):最近一次键盘/触摸使用时刻,任一通道有动作
// 即切提示。模块级共享(与 lastGlobalTouchEnd 同理:遮罩等处的输入不经过
// 任何手势实例)。
let lastKeyInputAt = 0;
let lastTouchInputAt = 0;
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
const markGlobalTouchEnd = () => { lastGlobalTouchEnd = performance.now(); };
document.addEventListener('touchend', markGlobalTouchEnd, true);
document.addEventListener('touchcancel', markGlobalTouchEnd, true);
// 输入模式跟随:任何触摸开始即认为触屏在用(提示文案切触摸版)
document.addEventListener('touchstart', () => { lastTouchInputAt = performance.now(); }, true);
}
/** 长按引擎事件:audio.js 监听它实现"长按=旁白临时倍速"。 */
@@ -94,6 +117,44 @@ function _dispatchLongpress(type) {
}
}
// ---- 键盘通道常量与输入模式 ----
/** e.code → 手势方向(WASD 为方向键别名,单手党惯例,默认开启)。 */
const KEY_DIR = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right',
};
const TAP_KEY_CODE = 'Space'; // 空格 = 单击(虚拟指针,走完整状态机)
const DOUBLE_KEY_CODE = 'Enter'; // 回车 = 双击专用键(即时,不等 300ms 窗)
/** 初始能力检测:触屏手机 true;纯桌面 false;触屏笔记本(主指针为鼠标)
* false(初始按键鼠提示,运行时跟随会纠正,见 inputMode)。 */
function isTouchLikely() {
try {
if (typeof navigator === 'undefined' || !(navigator.maxTouchPoints > 0)) return false;
if (typeof matchMedia !== 'function') return true; // 无 matchMedia 环境:有触点即按触屏处理
return matchMedia('(pointer: coarse)').matches;
} catch (_) {
return false;
}
}
/**
* 当前提示输入模式:'touch' | 'key'。
* settings.input 手动覆盖优先('touch'/'key');默认 'auto' = 跟随最近使用
* 的通道(映射键 keydown → key,任意 touchstart → touch),会话内尚无输入时
* 按 isTouchLikely() 初始判定。
* **只影响提示文案**(audio.js 启动遮罩「轻触开始」/「按空格开始」等,游戏内
* 提示由各游戏 HTML 后续接入),**不关闭任何输入通道**——touch/mouse/key
* 三通道永远并行在线,混合设备无需玩家配置。
*/
export function inputMode() {
const pref = settings.get().input;
if (pref === 'touch' || pref === 'key') return pref;
if (lastKeyInputAt === 0 && lastTouchInputAt === 0) return isTouchLikely() ? 'touch' : 'key';
return lastKeyInputAt >= lastTouchInputAt ? 'key' : 'touch';
}
export function attachGestures(element, onGesture, config = {}) {
const cfg = { ...DEFAULT_GESTURE_CONFIG, ...config };
@@ -297,6 +358,72 @@ export function attachGestures(element, onGesture, config = {}) {
e.preventDefault(); // 防移动端长按弹菜单
}
// ---- 键盘(PC 平行通道,与 touch/mouse 喂同一状态机)----
// 监听挂 window(键盘事件目标是焦点元素而非手势根,游戏页焦点在 body,
// 冒泡到 window 必达);keydown 认领映射键,keyup 只处理空格(收尾虚拟
// 指针)。方向键/回车在 keydown 即时出手势,无 keyup 语义。
let keyPointer = null; // 空格按下时认领的虚拟指针坐标(元素中心,抬起复用)
/** 表单控件守卫:焦点在大厅输入框等处时放行,不吞键(打字/候选导航正常)。 */
function isFormTarget(t) {
if (!t || typeof t !== 'object') return false;
const tag = String(t.tagName || '').toUpperCase();
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable === true;
}
/** 键盘虚拟指针落点 = 元素中心。坐标只用于位移判定(tapSlop/滑动),键盘
* 路径永不 move,必然只走出 tap/doubletap/longpress 分支,不会误判滑动。 */
function keyCenter() {
if (typeof element.getBoundingClientRect === 'function') {
const r = element.getBoundingClientRect();
if (r && (r.width || r.height)) return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
return keyPointer || { x: 0, y: 0 }; // 无 DOM 环境(harness mock):坐标无关紧要
}
function onKeyDown(e) {
if (isFormTarget(e.target)) return; // 表单焦点:放行,不 preventDefault
const dir = KEY_DIR[e.code];
const isTapKey = e.code === TAP_KEY_CODE;
const isDoubleKey = e.code === DOUBLE_KEY_CODE;
if (!dir && !isTapKey && !isDoubleKey) return; // 未映射键不拦截(Tab 等无障碍键放行;无 Esc 映射)
e.preventDefault(); // 抑制方向键/空格滚屏、回车激活焦点元素(repeat 也要防,否则按住连滚)
if (e.repeat) return; // 自动重复抑制:按住 ↑ 只发一次 up、按住空格只认领一次
lastKeyInputAt = performance.now(); // 通道标记:inputMode() 提示跟随
if (dir) {
if (tracking) return; // 单飞行指针:触摸/空格进行中忽略(与多指忽略语义一致)
cancelPendingTap(); // 与触屏滑动一致:滑动作废等待中的单击
lastTapTime = 0;
vibrate('swipe');
onGesture(dir); // 即时,无位移/速度判定(零延迟)
return;
}
if (tracking) return;
if (isDoubleKey) {
// 回车 = 双击专用键:即时 doubletap(不等 300ms 双击窗),同时作废
// 等待中的单击——收尾语义与触屏双击分支完全一致(含 confirm 震动)。
cancelPendingTap();
lastTapTime = 0;
vibrate('confirm');
onGesture('doubletap');
return;
}
// 空格:虚拟指针按下,进同一状态机(起 450ms 长按定时器;immediateTap
// 游戏按下即发 tap——rhythm「空格=kick 演奏」零改动成立)。
keyPointer = keyCenter();
handleDown(keyPointer.x, keyPointer.y);
}
function onKeyUp(e) {
if (e.code !== TAP_KEY_CODE) return;
if (!keyPointer) return; // 这下空格未被键盘通道认领(表单内按下 / 触摸占用时按下)
const p = keyPointer;
keyPointer = null;
handleUp(p.x, p.y); // 复用收尾:长按→longpressend;快按→tap(双击窗内两下=doubletap)
}
const hasWindow = typeof window !== 'undefined' && typeof window.addEventListener === 'function';
const opts = { passive: false };
element.addEventListener('touchstart', onTouchStart, opts);
element.addEventListener('touchmove', onTouchMove, opts);
@@ -306,6 +433,10 @@ export function attachGestures(element, onGesture, config = {}) {
element.addEventListener('mousemove', onMouseMove);
element.addEventListener('mouseup', onMouseUp);
element.addEventListener('contextmenu', onContextMenu);
if (hasWindow) {
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
}
return function detach() {
cancelPendingTap();
@@ -319,5 +450,9 @@ export function attachGestures(element, onGesture, config = {}) {
element.removeEventListener('mousemove', onMouseMove);
element.removeEventListener('mouseup', onMouseUp);
element.removeEventListener('contextmenu', onContextMenu);
if (hasWindow) {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
}
};
}
+7 -3
View File
@@ -1,13 +1,16 @@
/**
* settings.js — 全局用户设置(纯音频游戏合集共享)。
*
* 四项设置:
* 五项设置:
* | 键 | 取值 | 默认 | 作用 |
* |--------|-------------------------------------|----------|----------------------------------------|
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
* | speed | 1.0 \| 1.35 \| 1.7 \| 2.2 | 1.0 | voice 层 source.playbackRate(语速;2.2 为视障档) |
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
* | guide | 'full' \| 'standard' \| 'off' | 'full' | 向导帮助等级(engine/guide.js 实时读取)|
* | input | 'auto' \| 'touch' \| 'key' | 'auto' | 提示文案输入模式(gesture.inputMode():
* | | | | auto=跟随最近使用的通道;只影响提示文案,
* | | | | 不关闭任何输入通道) |
*
* localStorage 持久化(key: 'soundgame.settings'),同域下大厅与所有游戏
* 页面天然共享;跨标签页修改通过 'storage' 事件同步给订阅者。
@@ -24,16 +27,17 @@
const STORAGE_KEY = 'soundgame.settings';
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number, guide: 'full'|'standard'|'off' }} SoundGameSettings */
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number, guide: 'full'|'standard'|'off', input: 'auto'|'touch'|'key' }} SoundGameSettings */
/** @type {SoundGameSettings} */
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0, guide: 'full' };
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0, guide: 'full', input: 'auto' };
const VALID = {
voice: ['male', 'female'],
speed: [1.0, 1.35, 1.7, 2.2],
bgm: [1.0, 0.3, 0],
guide: ['full', 'standard', 'off'],
input: ['auto', 'touch', 'key'],
};
/** @type {SoundGameSettings} */
+1 -1
View File
@@ -23,7 +23,7 @@
const audio = new WebAudioEngine('audio/');
const game = new EscapeGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
attachGestures(root, (g) => game.onGesture(g));
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
/**
* engine-input.test.mjs — 引擎键盘输入通道回归(P0-A,research/platform/02-input-parity.md)。
*
* 键盘通道不变式(gesture.js 与 touch/mouse 喂同一状态机,事件完全同构):
* K1 方向键/WASD = 四向滑动:keydown 即发、零延迟、swipe 震动;
* K2 空格 = 单击:抬起判 tap,**保留 ~300ms 双击窗**(两次空格=doubletap、
* 第一次 pending tap 被取消),窗口与触摸共享(触屏 tap + 空格 tap 也判双击);
* K3 回车 = 双击专用键:即时 doubletap(不等窗),作废等待中的单击;
* K4 按住空格 ≥450ms = longpress(+window 快进事件),松开 = longpressend,无 tap;
* K5 守卫:e.repeat 抑制(但映射键 repeat 仍 preventDefault 防连滚)、表单焦点
* 放行、未映射键不拦截(Tab/Esc)、tracking 单飞行指针互斥;
* K6 inputMode():settings.input(新增 auto/touch/key)手动优先,auto 跟随
* 最近通道(keydown→key / touchstart→touch),初始按能力检测;
* K7 immediateTap(rhythm):空格按下即 tap、长按补 tapcancel、双击改判——
* 键盘路径与触摸路径行为逐条一致;
* K8 detach 后键盘监听移除;孤儿 keyup 不产生事件。
*
* 运行:tools/node/bin/node web/harness/engine-input.test.mjs
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const WEB = path.resolve(HERE, '..');
let passed = 0; const failures = [];
const assert = (cond, msg) => { if (cond) { passed++; console.log(' ✓ ' + msg); } else { failures.push(msg); console.log(' ✗ ' + msg); } };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---- 全局 mock(在 import 引擎前就位;真实定时器 + 真实 performance.now)----
const winL = new Map(); // window 监听器(gesture.js 键盘通道挂这里)
const docL = new Map(); // document 捕获监听器(合成鼠标守卫 + 输入模式跟随)
const windowEvents = []; // window.dispatchEvent 收到的 CustomEvent(长按快进通道)
globalThis.window = {
addEventListener: (t, fn) => { if (!winL.has(t)) winL.set(t, []); winL.get(t).push(fn); },
removeEventListener: (t, fn) => { const a = winL.get(t) || []; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); },
dispatchEvent: (e) => { windowEvents.push(e); return true; },
};
globalThis.document = {
addEventListener: (t, fn) => { if (!docL.has(t)) docL.set(t, []); docL.get(t).push(fn); },
removeEventListener: () => {},
};
const storageMap = new Map();
globalThis.localStorage = {
getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null),
setItem: (k, v) => storageMap.set(k, String(v)),
removeItem: (k) => storageMap.delete(k),
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { maxTouchPoints: 0, vibrate: (p) => { vibrateLog.push(p); return true; } }, // 纯桌面能力 → inputMode 初始 'key'
configurable: true,
});
// (无 matchMedia:isTouchLikely 走 maxTouchPoints=0 → false)
const { attachGestures, inputMode } = await import(path.join(WEB, 'engine', 'gesture.js'));
const { settings } = await import(path.join(WEB, 'engine', 'settings.js'));
// 等进程时钟走过一个双击窗:全新 Node 进程 performance.now() 可能 < 300ms,
// 引擎 lastTapTime 初值 0 会让第一次点按被误判进双击窗(浏览器 page uptime
// 不会如此早,纯测试环境假象)。
await sleep(350);
// ---- 事件合成 ----
const BODY_TARGET = { tagName: 'BODY' };
function fireKey(type, init = {}) {
const ev = { code: '', repeat: false, target: BODY_TARGET, prevented: false,
preventDefault() { this.prevented = true; }, ...init };
for (const fn of winL.get(type) || []) fn(ev);
return ev;
}
const keyDown = (code, extra) => fireKey('keydown', { code, ...extra });
const keyUp = (code, extra) => fireKey('keyup', { code, ...extra });
function fireDoc(type) { for (const fn of docL.get(type) || []) fn({}); }
function makeRoot() {
const listeners = new Map();
return {
listeners,
addEventListener: (t, fn) => { if (!listeners.has(t)) listeners.set(t, []); listeners.get(t).push(fn); },
removeEventListener: (t, fn) => { const a = listeners.get(t) || []; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); },
getBoundingClientRect: () => ({ left: 0, top: 0, width: 390, height: 844 }),
};
}
/** 在元素上合成一次触摸(tap:down→up 同坐标,走真实 onTouchStart/End)。 */
function touchTap(root, x = 195, y = 422) {
const mk = () => ({ preventDefault() {} });
const down = { ...mk(), touches: [{ clientX: x, clientY: y }] };
const up = { ...mk(), touches: [], changedTouches: [{ clientX: x, clientY: y }] };
fireDoc('touchstart'); // document 捕获(输入模式跟随)
for (const fn of root.listeners.get('touchstart') || []) fn(down);
for (const fn of root.listeners.get('touchend') || []) fn(up);
}
// ---------------------------------------------------------------- 会话
const root = makeRoot();
const events = [];
const vib = () => JSON.stringify(vibrateLog.slice(-1)[0] || null);
const gest = (g) => events.push(g);
const detach = attachGestures(root, gest);
console.log('\n=== K6a settings.input 默认与 inputMode 初始 ===');
assert(settings.get().input === 'auto', 'settings.input 默认 auto');
assert(settings.set({ input: 'bogus' }).input === 'auto', '非法 input 值被忽略(VALID 枚举)');
assert(inputMode() === 'key', 'auto 且无输入历史:纯桌面能力检测 → key');
console.log('\n=== K1 方向键 / WASD = 四向滑动(即时) ===');
let ev = keyDown('ArrowLeft'); keyUp('ArrowLeft');
assert(events.pop() === 'left' && events.length === 0, '← 即发 swipeleft(无延迟)');
assert(vib() === '[10]', '方向键滑动 → swipe 震动 [10]');
keyDown('ArrowRight'); assert(events.pop() === 'right', '→ = right');
keyDown('ArrowUp'); assert(events.pop() === 'up', '↑ = up');
keyDown('ArrowDown'); assert(events.pop() === 'down', '↓ = down');
keyDown('KeyW'); keyUp('KeyW'); assert(events.pop() === 'up', 'W 别名 = up');
keyDown('KeyA'); assert(events.pop() === 'left', 'A 别名 = left');
keyDown('KeyS'); assert(events.pop() === 'down', 'S 别名 = down');
keyDown('KeyD'); assert(events.pop() === 'right', 'D 别名 = right');
assert(ev.prevented === true, '映射键 keydown 被 preventDefault(防页面滚动)');
console.log('\n=== K5a repeat 抑制 / 未映射键放行 ===');
ev = keyDown('ArrowUp', { repeat: true });
assert(events.length === 0, 'e.repeat 方向键不重发手势');
assert(ev.prevented === true, 'repeat 仍 preventDefault(按住方向键不连滚页面)');
ev = keyDown('Tab');
assert(events.length === 0 && ev.prevented === false, 'Tab 不拦截(无障碍键放行)');
keyDown('Escape');
assert(events.length === 0, 'Esc 无映射(裁定:不做 Esc,下滑已覆盖菜单)');
ev = keyDown('Space', { repeat: true });
assert(events.length === 0 && ev.prevented === true, '空格 repeat 抑制且防滚动');
keyUp('Space');
assert(events.length === 0, '被抑制的空格 repeat 不会被 keyup 认领(无孤儿 tap)');
console.log('\n=== K2 空格 = 单击(~300ms 双击窗,与触屏同感) ===');
keyDown('Space'); keyUp('Space');
assert(events.length === 0, '空格快按后不立即出 tap(等双击窗,与触摸一致)');
await sleep(380);
assert(events.pop() === 'tap' && events.length === 0, '~300ms 双击窗关闭后回调 tap');
assert(vib() === '[20]', 'tap → confirm 震动 [20]');
keyDown('Space'); keyUp('Space');
await sleep(400); keyDown('Space'); keyUp('Space');
await sleep(380);
assert(events.length === 2 && events[0] === 'tap' && events[1] === 'tap', '慢速两连空格 = 两个独立 tap');
events.length = 0;
keyDown('Space'); keyUp('Space'); keyDown('Space'); keyUp('Space');
assert(events.pop() === 'doubletap' && events.length === 0, '快速两连空格 = doubletap(即时改判)');
await sleep(400);
assert(events.length === 0, '第一次空格的 pending tap 被取消(无双击窗泄漏的 tap)');
console.log('\n=== K3 回车 = 双击专用键(即时,不等窗) ===');
keyDown('Enter'); keyUp('Enter');
assert(events.pop() === 'doubletap' && events.length === 0, '回车同 tick 即时 doubletap(零延迟)');
assert(vib() === '[20]', '回车 doubletap → confirm 震动');
keyDown('Space'); keyUp('Space'); // 空格 tap 进入 300ms 等待
await sleep(100);
keyDown('Enter'); keyUp('Enter'); // 窗内回车
assert(events.pop() === 'doubletap', '窗内回车 → doubletap');
await sleep(450);
assert(events.length === 0, '等待中的空格单击被回车作废(cancelPendingTap)');
ev = keyDown('Enter', { repeat: true });
assert(events.length === 0 && ev.prevented === true, '回车 repeat 抑制');
console.log('\n=== K4 按住空格 ≥450ms = 长按 ===');
keyDown('Space');
await sleep(520);
assert(events.pop() === 'longpress' && events.length === 0, '按住空格 ~450ms 触发 longpress');
assert(windowEvents.some((e) => e.type === 'soundgame:longpress'), "派发 window 'soundgame:longpress'(旁白快进通道)");
keyUp('Space');
assert(events.pop() === 'longpressend' && events.length === 0, '松开 → longpressend');
assert(windowEvents.some((e) => e.type === 'soundgame:longpressend'), "派发 window 'soundgame:longpressend'");
await sleep(400);
assert(events.length === 0, '长按后不触发 tap/doubletap');
keyDown('Space');
await sleep(380); // < 450ms
keyUp('Space');
assert(events.length === 0, '不足 450ms 的按住不是长按(先不发包)');
await sleep(380);
assert(events.pop() === 'tap', '短于阈值的按住仍走单击(380ms < 450ms)');
console.log('\n=== K5b 表单焦点守卫 ===');
for (const target of [{ tagName: 'INPUT' }, { tagName: 'TEXTAREA' }, { tagName: 'SELECT' }, { isContentEditable: true }]) {
ev = keyDown('Space', { target });
assert(events.length === 0 && ev.prevented === false, `表单焦点(${target.tagName || 'contenteditable'})放行:不吞键不拦截`);
}
keyUp('Space');
await sleep(400);
assert(events.length === 0, '表单内按下的空格 keyup 不产生孤儿 tap');
ev = keyDown('ArrowLeft', { target: { tagName: 'INPUT' } });
assert(events.length === 0 && ev.prevented === false, '表单内方向键放行(输入框光标移动正常)');
console.log('\n=== K5c tracking 单飞行指针互斥 ===');
keyDown('Space'); // 空格虚拟指针占用 tracking
keyDown('ArrowLeft'); keyUp('ArrowLeft');
keyDown('Enter'); keyUp('Enter');
assert(events.length === 0, '空格按住期间方向键/回车被忽略(单指针语义,与多指忽略一致)');
keyUp('Space');
await sleep(380);
assert(events.pop() === 'tap', '占用结束后空格自身仍正常结算 tap');
keyDown('ArrowRight');
assert(events.pop() === 'right', '抬起后方向键恢复工作');
console.log('\n=== K2b 触摸 + 键盘共享双击窗(同一虚拟指针) ===');
touchTap(root); // 触摸 tap → pending
keyDown('Space'); keyUp('Space'); // 300ms 内空格 tap
assert(events.pop() === 'doubletap' && events.length === 0, '触屏 tap + 空格 tap 判 doubletap(混合设备语义)');
await sleep(400);
assert(events.length === 0, '触摸那一下的 pending tap 同样被取消');
console.log('\n=== K7 immediateTap(rhythm:空格=演奏输入,零改动成立) ===');
const root2 = makeRoot();
const events2 = [];
const detach2 = attachGestures(root2, (g) => events2.push(g), { immediateTap: true });
keyDown('Space');
assert(events2.pop() === 'tap' && events2.length === 0, 'immediateTap:空格按下即发 tap(kick)');
await sleep(520);
assert(events2.pop() === 'longpress' && events2.pop() === 'tapcancel' && events2.length === 0, '按住 → 序曲 tap 撤回(tapcancel)+ longpress');
keyUp('Space');
assert(events2.pop() === 'longpressend', '松开 → longpressend');
keyDown('Space'); keyUp('Space'); keyDown('Space'); keyUp('Space');
assert(events2.length === 3 && events2[0] === 'tap' && events2[1] === 'tap' && events2[2] === 'doubletap',
'两连空格 = tap+tap+doubletap 改判(双击序曲不发 tapcancel,与触摸一致)');
events2.length = 0;
keyDown('Enter');
assert(events2.pop() === 'doubletap', 'immediateTap 模式回车同样即时 doubletap');
detach2();
console.log('\n=== K6b inputMode 运行时跟随 + 手动覆盖 ===');
fireDoc('touchstart');
assert(inputMode() === 'touch', 'auto:touchstart → 提示切触摸版');
keyDown('ArrowDown');
assert(inputMode() === 'key', 'auto:键盘 keydown → 提示切键盘版(最近使用优先)');
settings.set({ input: 'touch' });
assert(inputMode() === 'touch', 'settings.input=touch 手动覆盖优先');
settings.set({ input: 'key' });
assert(inputMode() === 'key', 'settings.input=key 手动覆盖优先');
settings.set({ input: 'auto' });
assert(inputMode() === 'key', '回 auto 后恢复跟随最近通道(=key)');
console.log('\n=== K8 detach / 孤儿事件 ===');
events.length = 0; // K6b 的方向键判定已断言过,清场
detach();
keyDown('ArrowLeft'); keyDown('Space'); keyUp('Space'); keyDown('Enter');
assert(events.length === 0, 'detach 后键盘监听已移除(无手势泄漏)');
await sleep(400);
assert(events.length === 0, 'detach 同时清掉挂起的定时器(无迟到 tap)');
keyUp('Space');
assert(events.length === 0, '孤儿空格 keyup(无对应认领)无事件无异常');
console.log(`\n========================================`);
console.log(`断言通过 ${passed} 条,失败 ${failures.length} 条`);
if (failures.length) { failures.forEach((m) => console.log('FAILED: ' + m)); process.exit(1); }
console.log('全部通过。');
+60 -2
View File
@@ -5,6 +5,12 @@
* 外部 agent 通过 HTTP 逐步玩任意一款 SoundGame:
* POST /session {game, settings?, viewport?} 开新页(预注入探针 + 设置种子)
* POST /gesture {type, x?, y?} 真实触摸手势(tap/doubletap/swipe四向/longpress)
* POST /gesture {keyboard:true, key, holdMs?} 键盘手势(CDP Input.dispatchKeyEvent;
* key=ArrowLeft|…|KeyW…|Space|Enter,
* holdMs 默认 60,长按空格传 ≥450)
* POST /gesture {type, keyboard:true} 同款手势类型走键盘(tap→空格快按、
* doubletap→回车、longpress→空格按 600ms、
* swipeup→↑…)——会话剧本加 keyboard:true 即可复跑
* GET /events?since=N 增量事件(voice/fetch/震动/console/手势…)
* GET /listen 增量转写判定(OK/TRUNCATED/MISMATCH/SILENCE/SFX_BGM_ONLY)
* GET /transcript 合并时间轴全量(复盘用)
@@ -45,6 +51,7 @@ function mapSettings(raw = {}) {
const out = {};
if (raw.voice !== undefined) out.voice = raw.voice; // male|female
if (raw.guide !== undefined) out.guide = raw.guide; // full|standard|off
if (raw.input !== undefined) out.input = raw.input; // auto|touch|key(提示文案输入模式)
if (raw.speed !== undefined) {
out.speed = typeof raw.speed === 'number' ? raw.speed : SPEED_MAP[raw.speed] ?? 1.0;
}
@@ -295,7 +302,58 @@ async function buildListenRows() {
}
// ---------------------------------------------------------------- 手势编排
async function gesture({ type, x, y }) {
// 键盘通道(engine/gesture.js 键位):key 用 KeyboardEvent.code 值。
const KEY_DESC = {
ArrowUp: { code: 'ArrowUp', key: 'ArrowUp', vk: 38 },
ArrowDown: { code: 'ArrowDown', key: 'ArrowDown', vk: 40 },
ArrowLeft: { code: 'ArrowLeft', key: 'ArrowLeft', vk: 37 },
ArrowRight: { code: 'ArrowRight', key: 'ArrowRight', vk: 39 },
KeyW: { code: 'KeyW', key: 'w', vk: 87 },
KeyA: { code: 'KeyA', key: 'a', vk: 65 },
KeyS: { code: 'KeyS', key: 's', vk: 83 },
KeyD: { code: 'KeyD', key: 'd', vk: 68 },
Space: { code: 'Space', key: ' ', vk: 32, text: ' ' },
Enter: { code: 'Enter', key: 'Enter', vk: 13, text: '\r' },
};
// 触摸手势类型 → 键盘等价(gesture.js 键位表):type 走键盘时按此换算
const TYPE_TO_KEY = {
tap: { key: 'Space', holdMs: 60 },
doubletap: { key: 'Enter', holdMs: 60 },
longpress: { key: 'Space', holdMs: 600 },
swipeup: { key: 'ArrowUp', holdMs: 60 },
swipedown: { key: 'ArrowDown', holdMs: 60 },
swipeleft: { key: 'ArrowLeft', holdMs: 60 },
swiperight: { key: 'ArrowRight', holdMs: 60 },
};
/** 键盘手势:CDP Input.dispatchKeyEvent(rawKeyDown 会让页面收不到 code 派生
* 的完整 KeyboardEvent 特性,用 keyDown/keyUp 对),比 Runtime.evaluate 派发
* 合成事件更拟真(走真实输入管线,含默认行为与焦点语义)。 */
async function keyGesture({ key, type, holdMs }) {
let desc = key ? KEY_DESC[key] : null;
let hold = holdMs;
if (!desc && type) {
const m = TYPE_TO_KEY[type];
if (!m) throw new Error(`未知键盘手势类型: ${type}`);
desc = KEY_DESC[m.key];
hold = hold ?? m.holdMs;
}
if (!desc) throw new Error(`未知按键: ${key}(可用 ${Object.keys(KEY_DESC).join('|')})`);
hold = hold ?? 60;
const label = key || type;
// 先把动作本身记进页面时间轴(t=按键开始),附 keyboard/key 供审计区分通道
await S.page.eval(`window.__live && window.__live.events.push({ev:'gesture', type:${JSON.stringify(label)}, keyboard:true, key:${JSON.stringify(desc.code)}, t:performance.now()})`).catch(() => {});
const base = { key: desc.key, code: desc.code, windowsVirtualKeyCode: desc.vk, nativeVirtualKeyCode: desc.vk };
await S.page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base, ...(desc.text ? { text: desc.text, unmodifiedText: desc.text } : {}) });
await sleep(hold);
await S.page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
await sleep(400); // 与触摸手势一致:给游戏响应时间(空格单击有 ~300ms 双击窗延迟)
await drain();
}
async function gesture(body = {}) {
if (body.keyboard) return keyGesture(body); // 键盘通道:{"keyboard":true,"key":...} 或 {type,keyboard:true}
const { type, x, y } = body;
const cx = x ?? Math.round(S.viewport.width / 2);
const cy = y ?? Math.round(S.viewport.height / 2);
const pt = (px, py) => [{ x: px, y: py, id: 1 }];
@@ -395,7 +453,7 @@ async function writeArtifacts() {
for (const e of S.events) {
const t = e.ta;
if (t == null) continue;
if (e.ev === 'gesture') items.push([t, `**GESTURE** ${e.type} @(${e.x},${e.y})`]);
if (e.ev === 'gesture') items.push([t, e.keyboard ? `**KEY** ${e.key || e.type}` : `**GESTURE** ${e.type} @(${e.x},${e.y})`]);
else if (e.ev === 'vstart') items.push([t, `VOICE ▶ ${voiceIdOf(e.url)} (dur ${e.dur}s)`]);
else if (e.ev === 'vend') items.push([t, `VOICE ■ ${voiceIdOf(e.url)} (${e.reason})`]);
else if (e.ev === 'start' && e.layer !== 'voice') items.push([t, `${(e.layer || 'sfx').toUpperCase()} ♪ ${e.url ? voiceIdOf(e.url) : '?'}${e.loop ? ' (loop)' : ''}`]);
+548 -140
View File
@@ -26,9 +26,48 @@
padding: 16px 20px;
}
.panel h3 { margin: 0 0 12px; font-size: 14px; color: #9aa0a6; font-weight: normal; letter-spacing: 0.1em; }
/* 用户区 */
/* 玩家区(档案切换器) */
#user-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
#user-row input {
#profile-btn {
background: #4f8cff;
border: none;
border-radius: 8px;
color: #fff;
padding: 10px 18px;
font-size: 15px;
cursor: pointer;
}
#profile-btn[aria-expanded="true"] { background: #3c74e0; }
#user-msg { font-size: 13px; color: #9aa0a6; width: 100%; margin: 8px 0 0; min-height: 1em; }
#profile-menu {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
padding: 10px;
background: #0b0d10;
border: 1px solid #262c36;
border-radius: 10px;
}
#profile-menu button {
background: #161a20;
border: 1px solid #262c36;
border-radius: 8px;
color: #e8eaed;
padding: 10px 14px;
font-size: 15px;
text-align: left;
cursor: pointer;
}
#profile-menu button:hover { border-color: #4f8cff; }
#profile-menu button[aria-checked="true"] { color: #4f8cff; }
/* 档案管理 */
#profile-manage { margin-top: 12px; padding-top: 12px; border-top: 1px solid #262c36; }
#profile-manage h4 { margin: 0 0 10px; font-size: 13px; color: #9aa0a6; font-weight: normal; }
.manage-form { margin-bottom: 12px; }
.manage-form label { display: block; font-size: 13px; color: #9aa0a6; margin-bottom: 6px; }
.manage-row { display: flex; flex-wrap: wrap; gap: 8px; }
.manage-row input {
background: #0b0d10;
border: 1px solid #262c36;
border-radius: 8px;
@@ -37,7 +76,8 @@
font-size: 14px;
width: 140px;
}
#user-row button {
.manage-row input[inputmode="numeric"] { width: 110px; }
.manage-row button {
background: #4f8cff;
border: none;
border-radius: 8px;
@@ -46,9 +86,7 @@
font-size: 14px;
cursor: pointer;
}
#user-row button.secondary { background: #262c36; }
#user-msg { font-size: 13px; color: #e57373; width: 100%; margin: 0; min-height: 1em; }
#user-name { color: #4f8cff; font-weight: bold; }
.manage-note { font-size: 12px; color: #5f6368; margin: 4px 0 0; }
/* 设置区 */
#settings-row { display: flex; flex-wrap: wrap; gap: 16px; }
.setting-item { display: flex; flex-direction: column; gap: 6px; }
@@ -110,13 +148,44 @@
<p>纯音频游戏 · 一副耳机,六种手势,无需眼看</p>
</header>
<section class="panel" id="user-panel">
<h3>用户</h3>
<div id="user-row"></div>
<p id="user-msg"></p>
<!-- 玩家区:匿名档案切换器(首访 0 输入 0 点击自动建档,research/platform/01-identity.md 方案 B) -->
<section class="panel" id="user-panel" aria-label="玩家">
<h3>玩家</h3>
<div id="user-row">
<button id="profile-btn" aria-haspopup="menu" aria-expanded="false">玩家:…</button>
</div>
<div id="profile-menu" role="menu" aria-label="切换玩家" hidden></div>
<div id="profile-manage" role="group" aria-labelledby="manage-title" hidden>
<h4 id="manage-title">档案管理</h4>
<div class="manage-form">
<label for="rename-input">新名字(1~24 位,可用中文;玩家一这类自动名可听,自定义名仅显示)</label>
<div class="manage-row">
<input id="rename-input" maxlength="24">
<button id="rename-btn">改名</button>
</div>
</div>
<div class="manage-form" id="pin-form">
<label for="pin-input">设置 PIN(4~6 位数字;设置后可用「名字 + PIN」在其他设备登录本档案)</label>
<div class="manage-row">
<input id="pin-input" inputmode="numeric" maxlength="6" aria-describedby="pin-hint">
<button id="pin-btn">设置 PIN</button>
</div>
<p class="manage-note" id="pin-hint">PIN 是可选项,只为防家人恶作剧;不设也完整可玩。</p>
</div>
<div class="manage-form" id="login-form">
<label for="login-name-input">已有档案?用名字 + PIN 登录(本机当前进度会按游戏合并上去)</label>
<div class="manage-row">
<input id="login-name-input" maxlength="24">
<input id="login-pin-input" inputmode="numeric" maxlength="6" aria-label="PIN">
<button id="login-btn">登录</button>
</div>
</div>
<p class="manage-note" id="offline-note" hidden>当前为离线档案(无本地服务器):可改名、可切换,PIN 与登录需要家庭服务器。</p>
</div>
<p id="user-msg" role="status" aria-live="polite"></p>
</section>
<section class="panel" id="settings-panel">
<section class="panel" id="settings-panel" aria-label="设置">
<h3>设置</h3>
<div id="settings-row">
<div class="setting-item">
@@ -151,6 +220,14 @@
<option value="0">关闭</option>
</select>
</div>
<div class="setting-item">
<label for="sel-input">操作方式</label>
<select id="sel-input">
<option value="auto">自动</option>
<option value="touch">触屏</option>
<option value="key">键盘</option>
</select>
</div>
</div>
</section>
@@ -206,105 +283,450 @@
</main>
<footer>
建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击<br>
建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击(PC 键盘:方向键 / WASD、空格、回车)<br>
单击判定有约 300ms 延迟(双击识别所需),属于正常设计
</footer>
<script type="module">
import { settings } from './engine/settings.js';
import {
BUCKETS, SETTINGS_KEY,
applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, readStorageJSON, restoreSnapshot,
setCurrentProfile, snapshotKeyOf, stashSnapshot, syncKeyOf,
writeRegistry, writeStorageJSON,
} from './profiles.js';
const AUTH_KEY = 'soundgame.auth';
const GAMES = ['radio', 'dungeon', 'rpg', 'storyline', 'escape', 'simon', 'rhythm', 'space'];
// 公网站点只托管静态文件、无用户 API(docs/deploy.md):档案系统自动降级为
// 纯本地档案(research/platform/01-identity.md 方案 A = 方案 B 的本地后端),
// 不再整体隐藏玩家面板。本地 ./web/serve.sh 与服务器档案同一套代码。
const PUBLIC_STATIC =
location.protocol === 'https:' && location.hostname.endsWith('xiangsilian.com');
// ---------- 认证状态 ----------
function getAuth() {
try { return JSON.parse(localStorage.getItem(AUTH_KEY)) || null; }
catch (_) { return null; }
}
function setAuth(a) {
if (a) localStorage.setItem(AUTH_KEY, JSON.stringify(a));
else localStorage.removeItem(AUTH_KEY);
}
// settings.input 白名单值(engine/settings.js VALID.input:'auto' | 'touch' | 'key',
// 引擎侧手势分发由键盘适配工作线实现;此处只写入设置)
const INPUT_MODES = ['auto', 'touch', 'key'];
const NAME_RE = /^[A-Za-z0-9_\-一-龥]{1,24}$/;
const PIN_RE = /^\d{4,6}$/;
// 服务器不可达(如纯静态托管)时静默降级为仅本地模式
async function api(path, opts = {}) {
const auth = getAuth();
// ---------- 服务器 API(不可达时静默降级为纯本地档案) ----------
async function api(path, opts = {}, tokenOverride) {
const cur = getCurrentProfile(localStorage);
const token = tokenOverride || (cur && cur.token) || null;
const headers = { 'Content-Type': 'application/json' };
if (auth) headers['Authorization'] = 'Bearer ' + auth.token;
if (token) headers['Authorization'] = 'Bearer ' + token;
const resp = await fetch('/api/' + path, { ...opts, headers });
const body = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(body.error || ('HTTP ' + resp.status));
if (!resp.ok) {
const err = new Error(body.error || ('HTTP ' + resp.status));
err.status = resp.status;
err.body = body;
throw err;
}
return body;
}
// ---------- 用户区 UI ----------
const userRow = document.getElementById('user-row');
// ---------- UI 元素 / 读屏播报 ----------
const profileBtn = document.getElementById('profile-btn');
const profileMenu = document.getElementById('profile-menu');
const profileManage = document.getElementById('profile-manage');
const userMsg = document.getElementById('user-msg');
const renameInput = document.getElementById('rename-input');
const renameBtn = document.getElementById('rename-btn');
const pinForm = document.getElementById('pin-form');
const pinInput = document.getElementById('pin-input');
const pinBtn = document.getElementById('pin-btn');
const loginForm = document.getElementById('login-form');
const loginNameInput = document.getElementById('login-name-input');
const loginPinInput = document.getElementById('login-pin-input');
const loginBtn = document.getElementById('login-btn');
const offlineNote = document.getElementById('offline-note');
function showMsg(text) { userMsg.textContent = text || ''; }
function announce(text) { userMsg.textContent = text || ''; }
function renderUserArea() {
const auth = getAuth();
userRow.innerHTML = '';
if (auth) {
const span = document.createElement('span');
span.innerHTML = '已登录:<span id="user-name"></span>';
span.querySelector('#user-name').textContent = auth.username;
const logout = document.createElement('button');
logout.className = 'secondary';
logout.textContent = '退出';
logout.onclick = () => {
setAuth(null);
renderUserArea();
clearProgress();
showMsg('');
};
userRow.append(span, logout);
} else {
const nameInput = document.createElement('input');
nameInput.placeholder = '用户名';
nameInput.maxLength = 24;
const pinInput = document.createElement('input');
pinInput.placeholder = 'PIN(4~6 位数字)';
pinInput.inputMode = 'numeric';
pinInput.maxLength = 6;
const loginBtn = document.createElement('button');
loginBtn.textContent = '登录';
const regBtn = document.createElement('button');
regBtn.className = 'secondary';
regBtn.textContent = '注册';
const submit = async (kind) => {
showMsg('');
try {
if (kind === 'register') {
await api('register', {
method: 'POST',
body: JSON.stringify({ username: nameInput.value.trim(), pin: pinInput.value.trim() }),
});
}
const r = await api('login', {
method: 'POST',
body: JSON.stringify({ username: nameInput.value.trim(), pin: pinInput.value.trim() }),
});
setAuth({ username: r.username, token: r.token });
renderUserArea();
await pullSettings();
await loadProgress();
} catch (e) {
showMsg(e.message);
}
};
loginBtn.onclick = () => submit('login');
regBtn.onclick = () => submit('register');
userRow.append(nameInput, pinInput, loginBtn, regBtn);
// ---------- 同步映射表(各档案已知的服务器桶 updatedAt/rev) ----------
function getSyncMap(id) { return readStorageJSON(localStorage, syncKeyOf(id), {}) || {}; }
function setSyncBucket(id, game, updatedAt, rev) {
const m = getSyncMap(id);
m[game] = { updatedAt, rev };
writeStorageJSON(localStorage, syncKeyOf(id), m);
}
function moveStorageKey(from, to) {
const v = localStorage.getItem(from);
if (v !== null) {
localStorage.setItem(to, v);
localStorage.removeItem(from);
}
}
// ---------- 设置区 ----------
// ---------- 桶级 LWW:倒灌 / 上传 / 409 处理(大厅统一做,游戏零改动) ----------
function refreshCard(game) {
const bucket = BUCKETS.find((b) => b.game === game);
const el = document.querySelector(`a.card[data-game="${game}"] .progress`);
if (!el || !bucket) return;
const data = readStorageJSON(localStorage, bucket.key, null);
el.textContent = data && typeof data === 'object' && typeof data.summary === 'string'
? data.summary
: '';
}
function refreshCards() {
for (const { game } of BUCKETS) refreshCard(game);
}
function pourServerBucket(bucket, sv) {
localStorage.setItem(bucket.key, JSON.stringify(sv.data));
if (bucket.key === SETTINGS_KEY) applyServerSettings(sv.data);
refreshCard(bucket.game);
}
/**
* 上传一个桶。带 baseUpdatedAt 守卫;409(别处更新过)时按 data.updatedAt
* 桶级 LWW 择优:本地确较新 → 用服务器新基线重放;否则采纳服务器副本倒灌。
*/
async function putBucket(profile, game, raw, baseUpdatedAt) {
const data = JSON.parse(raw);
const body = { game, data };
if (baseUpdatedAt) body.baseUpdatedAt = baseUpdatedAt;
try {
const r = await api('progress', { method: 'PUT', body: JSON.stringify(body) }, profile.token);
setSyncBucket(profile.id, game, r.updatedAt, r.rev);
} catch (e) {
if (e.status !== 409 || !e.body || !e.body.server) throw e;
const sv = e.body.server;
const bucket = BUCKETS.find((b) => b.game === game);
const localRaw = localStorage.getItem(bucket.key);
const decision = decideBucket(localRaw, sv);
if (decision === 'push-local') {
return putBucket(profile, game, localRaw, sv.updatedAt); // 本地确较新,用新基线重放一次
}
if (decision === 'take-server') {
pourServerBucket(bucket, sv);
setSyncBucket(profile.id, game, sv.updatedAt, sv.rev);
}
}
}
/**
* 档案同步:GET 全桶 → 逐桶 LWW(decideBucket)——本地缺/旧 → 服务器倒灌回
* 游戏 original key(修「7/8 游戏只 PUT 不 GET」存量 bug);服务器缺 → 本地
* 上云。游戏代码继续只读本地,零改动。
*/
async function syncProfile(profile) {
if (!profile || !profile.token) return; // 纯本地档案(公网静态 / 离线建档)
const r = await api('progress', { method: 'GET' }, profile.token);
const serverGames = (r && r.games) || {};
for (const bucket of BUCKETS) {
const sv = serverGames[bucket.game] || null;
const localRaw = localStorage.getItem(bucket.key);
switch (decideBucket(localRaw, sv)) {
case 'take-server':
pourServerBucket(bucket, sv);
setSyncBucket(profile.id, bucket.game, sv.updatedAt, sv.rev);
break;
case 'push-local':
try {
await putBucket(profile, bucket.game, localRaw, sv ? sv.updatedAt : undefined);
} catch (_) { /* 单桶失败不阻塞其余桶 */ }
break;
default:
if (sv) setSyncBucket(profile.id, bucket.game, sv.updatedAt, sv.rev);
}
}
}
// ---------- 档案切换(先全部 flush 完成,再回灌/倒灌,失败即中止——01 §4.6) ----------
async function switchTo(targetId) {
const list = readRegistry(localStorage);
const from = getCurrentProfile(localStorage);
const target = list.find((p) => p.id === targetId);
if (!target || (from && from.id === targetId)) return false;
if (from && from.token) {
try {
await syncProfile(from); // flush:当前档案进度全部上云
} catch (e) {
announce(`同步失败,已取消切换:${e.message}`);
return false;
}
}
if (from) stashSnapshot(localStorage, from.id); // 回灌:活动 key → 旧档案快照
restoreSnapshot(localStorage, target.id); // 倒灌:新档案快照 → 活动 key(先清空防串档)
setCurrentProfile(localStorage, target.id);
applyAuth(localStorage, target); // 游戏侧虚拟视图:soundgame.auth
let cur = getCurrentProfile(localStorage);
if (!cur.token && !PUBLIC_STATIC) {
// 纯本地档案遇到服务器可用:升级为服务器档案(首访时服务器短暂不可达的自愈)
try { await bindLocalProfile(cur); cur = getCurrentProfile(localStorage); } catch (_) {}
}
try {
await syncProfile(cur); // 回拉:服务器进度倒灌 + 本地新进度上云
} catch (e) {
announce(`已切换到${cur.name}(部分云端进度拉取失败:${e.message})`);
reflectSettings(); renderUserArea(); refreshCards();
return true;
}
reflectSettings(); renderUserArea(); refreshCards();
announce(`已切换到${cur.name}`);
return true;
}
/** 纯本地档案升级为服务器档案:快照/同步表换 id,注册表原位替换,名字保留。 */
async function bindLocalProfile(local) {
const r = await api('device', { method: 'POST', body: '{}' });
moveStorageKey(snapshotKeyOf(local.id), snapshotKeyOf(r.profileId));
moveStorageKey(syncKeyOf(local.id), syncKeyOf(r.profileId));
const list = readRegistry(localStorage).map((p) =>
p.id === local.id ? { ...p, id: r.profileId, token: r.token } : p);
writeRegistry(localStorage, list);
setCurrentProfile(localStorage, r.profileId);
if (r.name !== local.name) {
await api('profile', { method: 'PUT', body: JSON.stringify({ name: local.name }) }, r.token)
.catch(() => { /* 服务器命名失败不影响本地使用 */ });
}
applyAuth(localStorage, getCurrentProfile(localStorage));
}
function newLocalProfile() {
return {
id: 'L' + Math.random().toString(36).slice(2, 10),
name: nextLocalProfileName(readRegistry(localStorage)),
token: null,
};
}
async function createNewProfile() {
closeMenu();
let p;
if (PUBLIC_STATIC) {
p = newLocalProfile();
} else {
try {
const r = await api('device', { method: 'POST', body: '{}' });
p = { id: r.profileId, name: r.name, token: r.token };
} catch (_) {
p = newLocalProfile(); // 服务器不可达:纯本地档案,之后自动补绑
}
}
const list = readRegistry(localStorage);
list.push(p);
writeRegistry(localStorage, list);
const ok = await switchTo(p.id);
announce(ok === false ? '已取消创建' : `已创建玩家档案:${p.name}`);
}
// ---------- 首次初始化:0 输入 0 点击静默建档 ----------
async function init() {
if (readRegistry(localStorage).length === 0) {
let first = null;
let created = false;
// v1 旧登录态({username, token})收养为首个档案(01 §4.4)
const legacy = migrateLegacyAuth(localStorage);
if (legacy && legacy.token && !PUBLIC_STATIC) {
try {
const r = await api('profile', { method: 'GET' }, legacy.token);
first = { id: r.profileId, name: r.name, token: legacy.token, claimed: !!r.claimed };
} catch (_) { first = null; /* token 失效:走匿名建档,旧档可稍后名字+PIN 找回 */ }
}
if (!first) {
created = true;
if (PUBLIC_STATIC) {
first = newLocalProfile();
} else {
try {
const r = await api('device', { method: 'POST', body: '{}' });
first = { id: r.profileId, name: r.name, token: r.token };
} catch (_) {
first = newLocalProfile();
}
}
}
writeRegistry(localStorage, [first]);
setCurrentProfile(localStorage, first.id);
applyAuth(localStorage, first);
renderUserArea(); refreshCards();
if (created) announce(`已创建玩家档案:${first.name}`);
// 既有本地进度「认领」进首个档案并上云(游客数据原地升级,不丢——01 §4.4)
try { await syncProfile(first); refreshCards(); } catch (_) { /* 离线:只留本地 */ }
return;
}
let cur = getCurrentProfile(localStorage) || readRegistry(localStorage)[0];
setCurrentProfile(localStorage, cur.id);
if (!cur.token && !PUBLIC_STATIC) {
try {
await bindLocalProfile(cur);
cur = getCurrentProfile(localStorage);
} catch (_) { /* 服务器仍不可达:保持纯本地档案 */ }
}
applyAuth(localStorage, cur);
renderUserArea(); refreshCards();
try {
await syncProfile(cur);
refreshCards();
} catch (_) { /* 离线:本地主存,游戏照玩 */ }
}
// ---------- 玩家区 UI ----------
function renderUserArea() {
const cur = getCurrentProfile(localStorage);
profileBtn.textContent = cur ? `玩家:${cur.name}` : '玩家:…';
profileBtn.setAttribute('aria-label', cur ? `切换玩家,当前玩家:${cur.name}` : '切换玩家');
const online = !!(cur && cur.token);
pinForm.hidden = !online;
loginForm.hidden = !online;
offlineNote.hidden = online || !cur;
closeMenu();
}
function menuButton(text, label, onclick) {
const b = document.createElement('button');
b.textContent = text;
if (label) b.setAttribute('aria-label', label);
b.onclick = onclick;
return b;
}
function openMenu() {
const list = readRegistry(localStorage);
const cur = getCurrentProfile(localStorage);
profileMenu.innerHTML = '';
for (const p of list) {
const isCur = !!cur && p.id === cur.id;
const b = menuButton(
(isCur ? '✓ ' : '') + p.name + (p.claimed ? '(已设PIN)' : ''),
isCur ? `当前玩家:${p.name}` : `切换到玩家:${p.name}`,
() => {
closeMenu();
if (isCur) { announce(`当前就是${p.name}`); return; }
switchTo(p.id);
},
);
b.setAttribute('role', 'menuitemradio');
b.setAttribute('aria-checked', String(isCur));
profileMenu.append(b);
}
const newBtn = menuButton('+ 新玩家', '新建玩家档案', createNewProfile);
newBtn.setAttribute('role', 'menuitem');
profileMenu.append(newBtn);
const manageBtn = menuButton('档案管理(改名 / 设 PIN / 登录)', '打开档案管理', () => {
closeMenu();
profileManage.hidden = !profileManage.hidden;
if (!profileManage.hidden) renameInput.focus();
});
manageBtn.setAttribute('role', 'menuitem');
profileManage.hidden = true;
profileMenu.append(manageBtn);
profileMenu.hidden = false;
profileBtn.setAttribute('aria-expanded', 'true');
const first = profileMenu.querySelector('button');
if (first) first.focus();
}
function closeMenu() {
profileMenu.hidden = true;
profileMenu.innerHTML = '';
profileBtn.setAttribute('aria-expanded', 'false');
}
profileBtn.onclick = () => {
if (profileMenu.hidden) openMenu();
else closeMenu();
};
document.addEventListener('click', (e) => {
if (!profileMenu.hidden && !document.getElementById('user-panel').contains(e.target)) closeMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!profileMenu.hidden) { closeMenu(); profileBtn.focus(); }
else if (!profileManage.hidden) profileManage.hidden = true;
}
});
// ---------- 档案管理:改名 / 设 PIN / 名字+PIN 登录 ----------
renameBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur) return;
const name = renameInput.value.trim();
if (!NAME_RE.test(name)) { announce('名字需为 1~24 位字母/数字/中文/_/-'); return; }
try {
if (cur.token) await api('profile', { method: 'PUT', body: JSON.stringify({ name }) }, cur.token);
} catch (e) { announce(e.message); return; }
const list = readRegistry(localStorage).map((p) => (p.id === cur.id ? { ...p, name } : p));
writeRegistry(localStorage, list);
applyAuth(localStorage, getCurrentProfile(localStorage)); // soundgame.auth.username 同步
renameInput.value = '';
renderUserArea();
announce(`已改名为${name}`);
};
pinBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) { announce('离线档案不支持 PIN(需要家庭服务器)'); return; }
const pin = pinInput.value.trim();
if (!PIN_RE.test(pin)) { announce('PIN 需为 4~6 位数字'); return; }
try {
await api('claim', { method: 'POST', body: JSON.stringify({ pin }) }, cur.token);
} catch (e) { announce(e.message); return; }
const list = readRegistry(localStorage).map((p) => (p.id === cur.id ? { ...p, claimed: true } : p));
writeRegistry(localStorage, list);
pinInput.value = '';
renderUserArea();
announce(`已为${cur.name}设置 PIN,可在其他设备用「${cur.name} + PIN」登录`);
};
loginBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) { announce('离线档案不支持登录(需要家庭服务器)'); return; }
const name = loginNameInput.value.trim();
const pin = loginPinInput.value.trim();
if (!NAME_RE.test(name)) { announce('请输入要登录的名字'); return; }
if (!PIN_RE.test(pin)) { announce('PIN 需为 4~6 位数字'); return; }
// 先把本机当前进度全部 flush 上去,确保服务器端合并时两边的桶都最新
try {
await syncProfile(cur);
} catch (e) { announce(`同步失败:${e.message}`); return; }
let r;
try {
r = await api('claim', { method: 'POST', body: JSON.stringify({ name, pin }) }, cur.token);
} catch (e) { announce(e.message); return; }
if (r.profileId && r.profileId !== cur.id) {
// 服务器已把本机 token 改绑目标档案:搬运快照/同步表,注册表原位替换
moveStorageKey(snapshotKeyOf(cur.id), snapshotKeyOf(r.profileId));
moveStorageKey(syncKeyOf(cur.id), syncKeyOf(r.profileId));
const list = readRegistry(localStorage).map((p) =>
p.id === cur.id ? { id: r.profileId, name: r.name, token: cur.token, claimed: true } : p);
writeRegistry(localStorage, list);
setCurrentProfile(localStorage, r.profileId);
applyAuth(localStorage, getCurrentProfile(localStorage));
} else {
const list = readRegistry(localStorage).map((p) =>
p.id === cur.id ? { ...p, claimed: true } : p);
writeRegistry(localStorage, list);
}
const now = getCurrentProfile(localStorage);
try { await syncProfile(now); } catch (_) { /* 合并结果稍后再拉 */ }
loginNameInput.value = '';
loginPinInput.value = '';
renderUserArea(); refreshCards(); reflectSettings();
announce(r.merged ? `欢迎回来,${now.name}(两台设备进度已合并)` : `已登录:${now.name}`);
};
// ---------- 设置区(含「操作方式」,settings.input 由引擎侧消费) ----------
const selVoice = document.getElementById('sel-voice');
const selSpeed = document.getElementById('sel-speed');
const selBgm = document.getElementById('sel-bgm');
const selGuide = document.getElementById('sel-guide');
const selInput = document.getElementById('sel-input');
function readRawSettings() { return readStorageJSON(localStorage, SETTINGS_KEY, {}) || {}; }
function reflectSettings() {
const s = settings.get();
@@ -312,76 +734,62 @@
selSpeed.value = String(s.speed);
selBgm.value = String(s.bgm);
selGuide.value = s.guide;
const inp = readRawSettings().input;
selInput.value = INPUT_MODES.includes(inp) ? inp : 'auto';
}
/** 服务器 _settings 桶倒灌:走 settings.set 白名单;input 再并入原始 JSON 垫底。 */
function applyServerSettings(data) {
if (!data || typeof data !== 'object') return;
const { input, ...rest } = data;
const patch = { ...rest };
if (INPUT_MODES.includes(input)) patch.input = input; // 白名单外键不进 set(避免引擎告警噪音)
settings.set(patch);
try {
const raw = readRawSettings();
if (INPUT_MODES.includes(input)) raw.input = input;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(raw));
} catch (_) { /* 隐私模式 */ }
reflectSettings();
}
async function pushSettingsBucket() {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) return;
const data = { ...settings.get(), input: readRawSettings().input || 'auto' };
const base = getSyncMap(cur.id)._settings && getSyncMap(cur.id)._settings.updatedAt;
putBucket(cur, '_settings', JSON.stringify(data), base).catch(() => { /* 离线只留本地 */ });
}
function onSettingChange() {
const patch = {
settings.set({
voice: selVoice.value,
speed: Number(selSpeed.value),
bgm: Number(selBgm.value),
guide: selGuide.value,
};
settings.set(patch); // 始终写 localStorage
// 已登录时同步到服务器(game 键 '_settings')
if (getAuth()) {
api('progress', {
method: 'PUT',
body: JSON.stringify({ game: '_settings', data: settings.get() }),
}).catch(() => { /* 离线时只留本地 */ });
}
input: selInput.value,
});
// 兼容垫底:若引擎侧 input 白名单尚未合入(settings.set 会忽略并警告),
// 直接并入持久化 JSON,引擎支持后自动生效。
try {
const raw = readRawSettings();
raw.input = selInput.value;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(raw));
} catch (_) { /* 隐私模式 */ }
pushSettingsBucket();
}
selVoice.onchange = onSettingChange;
selSpeed.onchange = onSettingChange;
selBgm.onchange = onSettingChange;
selGuide.onchange = onSettingChange;
// 登录后从服务器拉回设置(服务器为准)
async function pullSettings() {
try {
const r = await api('progress?game=_settings');
if (r.data && typeof r.data === 'object') {
settings.set(r.data);
reflectSettings();
}
} catch (_) { /* 无服务器或尚未保存过设置 */ }
}
// ---------- 游戏进度摘要 ----------
function clearProgress() {
document.querySelectorAll('a.card .progress').forEach((el) => { el.textContent = ''; });
}
async function loadProgress() {
clearProgress();
if (!getAuth()) return;
for (const game of GAMES) {
try {
const r = await api('progress?game=' + encodeURIComponent(game));
if (r.data && typeof r.data.summary === 'string' && r.data.summary) {
const el = document.querySelector(`a.card[data-game="${game}"] .progress`);
if (el) el.textContent = r.data.summary;
}
} catch (_) { /* 单个游戏拉取失败不影响其他 */ }
}
}
selInput.onchange = onSettingChange;
// ---------- 启动 ----------
// 公网站点只托管静态文件,不跑用户 API(PIN 明文,不宜暴露)。
// 本地 ./web/serve.sh(http + 非 443)仍显示登录。
const PUBLIC_STATIC =
location.protocol === 'https:' && location.hostname.endsWith('xiangsilian.com');
reflectSettings();
if (PUBLIC_STATIC) {
const userPanel = document.getElementById('user-panel');
if (userPanel) userPanel.hidden = true;
} else {
renderUserArea();
if (getAuth()) {
pullSettings();
loadProgress();
}
}
renderUserArea();
refreshCards();
init(); // 网络部分全部静默失败降级:不登录(无服务器)也完整可玩
</script>
</body>
</html>
+185
View File
@@ -0,0 +1,185 @@
/**
* profiles.js — 无感档案系统的大厅侧纯函数(P0-B)。
*
* 被 web/index.html(UI / 网络编排)与 web/server/test/api-smoke.mjs(node 断言)
* 共用:只做 localStorage 形状计算,不碰 DOM / fetch——测试里传一个 Map 版
* storage({getItem,setItem,removeItem})即可。自动名汉字数字与 server.js 的
* cnNum 保持同一词表(玩家一~九可听,见 research/platform/01-identity.md §4.2)。
*
* 档案分桶模型(research/platform/03-data-sync.md §4.4):
* soundgame.profiles 本机档案表 [{id, name, token|null, claimed?}]
* soundgame.profile.current 当前档案 id
* soundgame.p.<id> 各档案进度快照(原始 key → 原始字符串值;切走时回灌)
* soundgame.sync.<id> 各档案已知的服务器桶时间戳 {<game>: {updatedAt, rev}}
* soundgame.auth 虚拟视图:当前档案 token(游戏侧只读它,key 名不变)
*
* 游戏继续读写原始 key(soundgame.progress.rpg 等),一个文件都不用改——
* 切换档案 = 大厅先把活动 key 回灌进旧档案快照,再把新档案快照倒灌回活动 key。
* 一次性教学标记(tutIntroSeen / guideDone / drillDone)不入桶:跨档案共享是
* 已裁定的可接受行为(01 §4.6)。
*/
export const REGISTRY_KEY = 'soundgame.profiles';
export const CURRENT_KEY = 'soundgame.profile.current';
export const AUTH_KEY = 'soundgame.auth';
export const SETTINGS_KEY = 'soundgame.settings';
/** 受管桶:localStorage 原始 key ↔ 服务器 game 桶名。 */
export const BUCKETS = [
{ key: 'soundgame.progress.rpg', game: 'rpg' },
{ key: 'soundgame.progress.dungeon', game: 'dungeon' },
{ key: 'soundgame.progress.radio', game: 'radio' },
{ key: 'soundgame.rhythm.progress', game: 'rhythm' },
{ key: 'soundgame.simon.best', game: 'simon' },
{ key: 'soundgame.space.best', game: 'space' },
{ key: 'soundgame.escape.stats', game: 'escape' },
{ key: SETTINGS_KEY, game: '_settings' },
];
export function snapshotKeyOf(id) {
return `soundgame.p.${id}`;
}
export function syncKeyOf(id) {
return `soundgame.sync.${id}`;
}
// ---------------- storage JSON 小工具 ----------------
export function readStorageJSON(storage, key, fallback = null) {
try {
const v = JSON.parse(storage.getItem(key));
return v === null || v === undefined ? fallback : v;
} catch (_) {
return fallback;
}
}
export function writeStorageJSON(storage, key, value) {
try {
storage.setItem(key, JSON.stringify(value));
} catch (_) {
/* 隐私模式:写不进就只留内存态(调用方自行兜底) */
}
}
// ---------------- 本机档案表 ----------------
export function readRegistry(storage) {
const list = readStorageJSON(storage, REGISTRY_KEY, []);
return Array.isArray(list)
? list.filter((p) => p && typeof p.id === 'string' && typeof p.name === 'string')
: [];
}
export function writeRegistry(storage, list) {
writeStorageJSON(storage, REGISTRY_KEY, list);
}
export function getCurrentProfile(storage) {
const id = storage.getItem(CURRENT_KEY);
return readRegistry(storage).find((p) => p.id === id) || null;
}
export function setCurrentProfile(storage, id) {
try {
storage.setItem(CURRENT_KEY, id);
} catch (_) { /* 隐私模式 */ }
}
/** 游戏侧兼容视图:soundgame.auth = {username, token}(8 个游戏只读 .token)。 */
export function applyAuth(storage, profile) {
try {
if (profile && profile.token) {
storage.setItem(AUTH_KEY, JSON.stringify({ username: profile.name, token: profile.token }));
} else {
storage.removeItem(AUTH_KEY);
}
} catch (_) { /* 隐私模式 */ }
}
/** v1 旧登录态识别({username, token},2026-09 之前的大厅写入)。 */
export function migrateLegacyAuth(storage) {
const a = readStorageJSON(storage, AUTH_KEY, null);
return a && typeof a === 'object' && typeof a.token === 'string' && a.token ? a : null;
}
// ---------------- 自动名 ----------------
export function cnNum(n) {
const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
if (!Number.isInteger(n) || n <= 0 || n > 99) return String(n);
if (n < 10) return d[n];
const t = Math.floor(n / 10);
const u = n % 10;
return (t > 1 ? d[t] : '') + '十' + (u ? d[u] : '');
}
/** 纯本地档案(无服务器 / 公网静态形态)的自动名。 */
export function nextLocalProfileName(registry) {
return '玩家' + cnNum(registry.length + 1);
}
// ---------------- 快照:回灌(切走)/ 倒灌(切入) ----------------
/** 回灌:把当前活动 key 原样存进档案快照。 */
export function stashSnapshot(storage, id) {
const snap = {};
for (const b of BUCKETS) {
const v = storage.getItem(b.key);
if (v !== null) snap[b.key] = v;
}
writeStorageJSON(storage, snapshotKeyOf(id), snap);
}
/** 倒灌:先清空活动 key(防上一个档案的残留串档),再从快照恢复。 */
export function restoreSnapshot(storage, id) {
const snap = readStorageJSON(storage, snapshotKeyOf(id), {}) || {};
for (const b of BUCKETS) {
try {
storage.removeItem(b.key);
} catch (_) { /* 隐私模式 */ }
}
for (const [k, v] of Object.entries(snap)) {
if (typeof v === 'string') {
try {
storage.setItem(k, v);
} catch (_) { /* 隐私模式 */ }
}
}
}
// ---------------- 桶级 LWW 裁决(纯函数) ----------------
/**
* 决定本地/服务器桶谁生效。时间戳取 data.updatedAt(游戏写入的客户端时间,
* 存在于 rpg/dungeon/radio/rhythm 等存档体内);服务器桶自身的 updatedAt 是
* 「接收时间」,不参与内容比较(不信任两端时钟对齐,03 §2.4)。
* 无时间戳可比较(如 _settings、simon 最高分)→ 内容相同 keep、否则服务器胜
* ——与旧「登录即拉设置」行为一致,同时修掉 local-always-wins 分叉 bug(03 §1.2)。
*
* @param {string|null} localRaw localStorage 原始字符串(null = 本地无档)
* @param {{updatedAt: string, rev: number, data: any}|null} serverBucket
* @returns {'keep'|'take-server'|'push-local'}
*/
export function decideBucket(localRaw, serverBucket) {
const hasServer = !!(serverBucket && serverBucket.data !== null && serverBucket.data !== undefined);
const hasLocal = localRaw !== null && localRaw !== undefined;
if (!hasServer) return hasLocal ? 'push-local' : 'keep';
if (!hasLocal) return 'take-server';
let local;
try {
local = JSON.parse(localRaw);
} catch (_) {
return 'take-server'; // 本地损坏 → 服务器副本兜底
}
const sd = serverBucket.data;
if (
local && typeof local === 'object' && typeof local.updatedAt === 'string' &&
sd && typeof sd === 'object' && typeof sd.updatedAt === 'string'
) {
if (local.updatedAt === sd.updatedAt) return 'keep';
return local.updatedAt > sd.updatedAt ? 'push-local' : 'take-server';
}
return localRaw === JSON.stringify(sd) ? 'keep' : 'take-server';
}
+1 -1
View File
@@ -23,7 +23,7 @@
const audio = new WebAudioEngine('audio/');
const game = new RadioGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
attachGestures(root, (g) => game.onGesture(g));
+1 -1
View File
@@ -24,7 +24,7 @@
const audio = new WebAudioEngine('audio/');
const game = new RhythmGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
// immediateTap:单击是演奏输入(kick),按下立即回调,不等 300ms
+1 -1
View File
@@ -23,7 +23,7 @@
const audio = new WebAudioEngine('audio/');
const game = new RpgGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
attachGestures(root, (g) => game.onGesture(g));
+103 -41
View File
@@ -2,6 +2,11 @@
Node 零依赖(仅用内置模块)的静态文件 + 用户系统 API 服务器。
P0-B 起采用**无感档案系统**(设计见 `research/platform/01-identity.md` 方案 B 与
`03-data-sync.md`):首次访问 0 输入 0 点击自动建匿名档案(自动名「玩家一/二/…」),
认领(命名 + 设 PIN)完全可选,同设备多玩家靠大厅档案切换器隔离,进度按游戏分桶、
服务器时钟裁决的桶级 LWW 同步。8 个游戏目录零改动——分桶/倒灌发生在大厅层。
## 启动
```bash
@@ -11,78 +16,126 @@ tools/node/bin/node web/server/server.js
# 或用封装脚本(打印局域网地址)
bash web/serve.sh
# 自定义端口(默认 8000)
PORT=8001 tools/node/bin/node web/server/server.js
# 自定义端口(默认 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/data/`(首次启动自动创建)
- 静态根目录:`web/`(`/` 即游戏大厅 `index.html`);`web/server/` 目录一律 404(内含 token 与 PIN 哈希)
- 数据目录:自动创建;检测到 v1 旧文件(`users.json`/`sessions.json`)时启动即单向迁移(原文件保留不动)
## ⚠ 安全说明
这是**家庭局域网玩具级认证**,请勿暴露到公网:
家庭局域网级认证(比之前加固了一档,但仍**请勿暴露公网**):
- PIN 以**明文**存储在 `data/users.json`;
- token 永不过期,明文存于 `data/sessions.json`;
- HTTP 明文传输,无 TLS、无防爆破、无速率限制。
用途仅限于同一 WiFi 下的家庭成员各自存档。公网 `game.xiangsilian.com` **不跑本服务**,
只托管静态文件(见 `docs/deploy.md`)。要公网开用户系统请换正经认证方案。
- PIN 以 **scrypt 哈希**(N=16384, r=8, node:crypto 内置)存储,不再明文;
- token **180 天滑动过期**、可 logout 吊销、惰性清扫,不再无限增长;
- 匿名建档端点有 per-IP 频控(默认 10 个/天,`SG_DEVICE_LIMIT` 覆盖);
- 仍是 HTTP 明文传输、无 TLS、无登录失败退避——威胁模型是「家庭 WiFi 可信 +
防家人恶作剧」。公网开放 API 的前置清单见 `docs/deploy.md`「以后若要开放用户系统」。
## API
所有 API 请求/响应均为 JSON(`Content-Type: application/json`)。
错误响应统一为 `{ "error": "描述" }` + 相应 HTTP 状态码。
需要鉴权的端点用请求头 `Authorization: Bearer <token>`。
### POST /api/register
### POST /api/device —— 匿名自动建档(核心)
注册新用户。
无参(或带已固化设备的旧 token 重附着)。
- 请求:`{ "username": "ming", "pin": "1234" }`
- `username`:1~24 位字母/数字/中文/`_`/`-`
- `pin`:4~6 位数字
- 成功:`201 { "ok": true, "username": "ming" }`
- 失败:`400`(格式非法)/ `409 { "error": "用户名已被注册" }`
- 请求:`{}` 或 `{ "token": "<本机已有的 token>" }`
- 成功(新建):`201 { "ok": true, "profileId": "p_a1b2c3d4e5f6", "token": "<48 位随机 hex>", "name": "玩家一", "claimed": false }`
- `name` 由服务器按档案序号自动命名:玩家一、玩家二、玩家三……(可听词表)
- `token` 即设备身份,客户端存 localStorage,之后一切鉴权用它
- 成功(重附着):`200 { ..., "attached": true }`——幂等,不新建档案、不占频控额度
- 失败:`429`(同 IP 当日新建数超限)
### POST /api/login
大厅(`web/index.html`)首访静默调用本端点建档;纯静态公网形态(无 API)自动降级为
本地档案(`web/profiles.js` 同一套逻辑,token 为 null)。
登录,返回 token。
### GET /api/profile —— 当前档案信息
- 请求:`{ "username": "ming", "pin": "1234" }`
- 成功:`200 { "ok": true, "token": "<48 位随机 hex>", "username": "ming" }`
- 失败:`401 { "error": "用户名或 PIN 错误" }`
- 请求头:Bearer
- 成功:`200 { "profileId": "p_…", "name": "玩家一", "claimed": false, "createdAt": "…" }`
token 存内存并落盘 `data/sessions.json`(服务器重启后会话仍有效)。
### PUT /api/profile —— 改名
### GET /api/progress?game=xxx
- 请求头:Bearer;请求:`{ "name": "爸爸" }`(1~24 位字母/数字/中文/`_`/`-`)
- 成功:`200 { "ok": true, "name": "爸爸" }`
- 失败:`400`(格式非法)/ `401` / `409`(该名字已被其他**已认领**档案占用;匿名档案重名不限制)
读取某游戏的进度数据。
### POST /api/claim —— 认领(命名 + 设 PIN)/ 换设备登录(合并)
- 请求头:`Authorization: Bearer <token>`
- 成功:`200 { "game": "escape", "data": { ... } }`(无存档时 `data` 为 `null`)
- 失败:`401`(token 缺失/无效)/ `400`(缺少 game 参数)
携带当前设备 token(Bearer 或 body.token)。请求:`{ "name"?, "pin"? }`,
`pin` 为 4~6 位数字。三种语义:
### PUT /api/progress
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 }`。
写入某游戏的进度数据(覆盖该 game 键)。
- 失败:`400`(格式)/ `401`(PIN 缺失或不正确)/ `409`(当前档案已设 PIN,需先建新匿名档案)
- 请求头:`Authorization: Bearer <token>`
- 请求:`{ "game": "escape", "data": { "summary": "已逃脱 2 次 / 最快 3:25", ... } }`
- `game`:1~32 位字母/数字/`_`/`-`;大厅约定用 `_settings` 存用户设置
- `data`:任意 JSON 值;大厅卡片会展示 `data.summary` 字符串(若存在)
- 成功:`200 { "ok": true, "game": "escape" }`
- 失败:`401` / `400`
### POST /api/login —— 旧客户端兼容
- 请求:`{ "username": "uuu", "pin": "0858" }`
- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu" }`
- v1 用户迁移后照常可登录;若存量 PIN 尚为明文,验证成功时**透明升级**为 scrypt 哈希。
### POST /api/register —— 旧客户端兼容(建议改用 device + claim)
- 请求:`{ "username": "ming", "pin": "1234" }` → `201 { "ok": true, "username": "ming", "profileId": "…", "token": "…" }`
- 直接创建已认领档案(不合并当前匿名档案的进度)。
### POST /api/logout —— 吊销当前 token
- 请求头:Bearer;成功:`200 { "ok": true }`
### 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/
├── users.json { "<username>": "<pin>" }
├── sessions.json { "<token>": "<username>" }
└── <username>.json { "<game>": <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/ 会绕过写队列。
## 静态服务
@@ -91,4 +144,13 @@ web/server/data/
- 目录自动解析 `index.html`;
- 支持简化版 HTTP Range(单段 `bytes=start-end` / `start-` / `-suffix`),
返回 206 + `Content-Range`,供音频元素拖动进度;
- 路径穿越防护:请求路径限制在 `web/` 内。
- 路径穿越防护:请求路径限制在 `web/` 内;`web/server/` 目录(token/PIN 哈希/进度数据)一律 404。
## 测试
```bash
tools/node/bin/node web/server/test/api-smoke.mjs # API 冒烟 + 大厅分桶纯函数单测(零依赖)
```
覆盖:device 建档/重附着、进度 PUT-GET-LWW 冲突(含 legacy 放行)、claim 命名/设 PIN/
合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H4)。
+514 -66
View File
@@ -1,25 +1,41 @@
/**
* server.js — SoundGame 用户系统 + 静态文件服务器(Node 零依赖,仅内置模块)。
*
* 功能:
* 1. 静态托管 web/ 根目录(正确 MIME,支持简化版 Range 请求供音频拖动)。
* P0-B 无感用户系统(research/platform/01-identity.md 方案 B + 03-data-sync.md §3):
*
* 1. 静态托管 web/ 根目录(正确 MIME,简化版 Range 供音频拖动)。
* web/server/ 目录(含 data/ 的 token 与 PIN 哈希)一律 404,不对外提供。
*
* 2. JSON API:
* - POST /api/register {username, pin} 注册(用户名 + 4~6 位数字 PIN)
* - POST /api/login {username, pin} 登录,返回 token(随机 hex,
* 内存 + 落盘 data/sessions.json)
* - GET /api/progress?game=xxx 读进度(需 Authorization: Bearer token)
* - PUT /api/progress {game, data} 写进度(同上)
* - POST /api/device {token?} 匿名自动建档 → {profileId, token, name}(自动名
* 「玩家一/二/…」);带有效 token 时为重附着(幂等)
* - GET /api/profile 当前档案信息(Bearer)
* - PUT /api/profile {name} 改名(Bearer)
* - POST /api/claim {name?, pin?} 认领:给当前档案命名和/或设 PIN(scrypt 哈希)。
* 若名字+PIN 已有档案 → 验 PIN 后把当前匿名档案的
* 进度按桶 LWW 合并进目标档案并改绑 token(换设备登录)
* - POST /api/login {username, pin} 旧客户端兼容(v1 用户迁移后仍可登录)
* - POST /api/register {username, pin} 旧客户端兼容(等价于直接建已认领档案,建议改用 claim)
* - POST /api/logout 吊销当前 token(Bearer)
* - GET /api/progress?game=xxx 读单桶,保持 {game, data} 形状(radio 等游戏兼容)
* - GET /api/progress 读全部桶 {games: {...}}(大厅一次拉回,修 03 §1.2)
* - PUT /api/progress {game, data, baseUpdatedAt?}
* 写桶。baseUpdatedAt 与服务器桶不符 → 409 + 服务器桶
* (LWW 冲突,客户端择优重试);缺省视为 legacy 放行
* (console 警告——各游戏存量 PUT 不带此字段)。
*
* 数据落盘(data/ 目录自动创建):
* data/users.json { username: pin }
* data/sessions.json { token: username }
* data/<username>.json { <game>: <data> } 进度,一用户一文件
* 并发写入:按文件串行 Promise 队列。
* 3. 数据落盘(data/ 目录自动创建;env SG_DATA_DIR 可覆盖,供测试):
* data/profiles.json { seq, profiles: { <pid>: {name, pinHash, createdAt} } }
* data/tokens.json { <token>: {profileId, createdAt, lastSeen, expiresAt} }
* data/profiles/<pid>.json { id, games: { <game>: {updatedAt, rev, data} } }(桶级包装)
* v1 兼容迁移(启动时单向,原文件保留不动):
* users.json {username: pin明文} → profiles.json(PIN 立即 scrypt 哈希,pid=旧用户名)
* sessions.json {token: username} → tokens.json(补 180 天滑动过期)
* <username>.json {game: data} → profiles/<username>.json(每桶补 updatedAt/rev)
*
* 启动:node web/server/server.js (端口 env PORT,默认 8000)
*
* ⚠ 安全说明:这是家庭局域网玩具级认证——PIN 明文存储、token 不过期、
* HTTP 明文传输。请勿暴露到公网。详见 web/server/README.md。
* 安全水位(家庭局域网):PIN scrypt 哈希(N=16384, r=8)、token 180 天滑动过期 +
* 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)。
* HTTP 明文传输不变——请勿暴露公网,详见 web/server/README.md。
*/
const http = require('node:http');
@@ -29,10 +45,19 @@ const path = require('node:path');
const crypto = require('node:crypto');
const PORT = Number(process.env.PORT) || 8000;
const ROOT = path.resolve(__dirname, '..'); // web/
const DATA_DIR = path.join(__dirname, 'data'); // web/server/data/
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
const ROOT = path.resolve(__dirname, '..'); // web/
const DATA_DIR = process.env.SG_DATA_DIR
? path.resolve(process.env.SG_DATA_DIR)
: path.join(__dirname, 'data'); // web/server/data/
const PROFILES_DIR = path.join(DATA_DIR, 'profiles');
const PROFILES_FILE = path.join(DATA_DIR, 'profiles.json');
const TOKENS_FILE = path.join(DATA_DIR, 'tokens.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json'); // v1 兼容
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json'); // v1 兼容
const DEVICE_LIMIT = Number(process.env.SG_DEVICE_LIMIT) || 10; // per-IP 每日匿名建档上限
const TOKEN_TTL_MS = 180 * 24 * 3600 * 1000; // 180 天滑动
const TOKEN_TOUCH_MS = 6 * 3600 * 1000; // 滑动续期节流(6h 内不重复落盘)
const MIME = {
'.html': 'text/html; charset=utf-8',
@@ -57,13 +82,14 @@ const MIME = {
const USERNAME_RE = /^[A-Za-z0-9_\-一-龥]{1,24}$/;
const PIN_RE = /^\d{4,6}$/;
const TOKEN_RE = /^[0-9a-f]{16,128}$/i;
// ---------------- 状态 ----------------
/** @type {Record<string, string>} username -> pin */
let users = {};
/** @type {Record<string, string>} token -> username */
let sessions = {};
/** @type {{ seq: number, profiles: Record<string, {name: string, pinHash: string|null, createdAt: string}> }} */
let profileIndex = { seq: 0, profiles: {} };
/** @type {Record<string, {profileId: string, createdAt: string, lastSeen: string, expiresAt: string}>} */
let tokens = {};
/** 按文件串行的写入队列:file -> Promise 链尾 */
const writeQueues = new Map();
@@ -92,9 +118,169 @@ function writeJson(file, obj) {
});
}
function progressFile(username) {
// username 已被 USERNAME_RE 限制为安全字符,可直接作文件名
return path.join(DATA_DIR, `${username}.json`);
// ---------------- PIN:scrypt 哈希(node:crypto 内置,零依赖) ----------------
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
function hashPin(pin) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(pin, salt, SCRYPT.keylen, SCRYPT);
return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('hex')}$${hash.toString('hex')}`;
}
/** 恒时比较;v1 明文 PIN(不以 scrypt$ 开头)直接比对(调用方负责透明升级为哈希)。 */
function verifyPin(pin, stored) {
if (typeof stored !== 'string' || !stored || typeof pin !== 'string') return false;
if (!stored.startsWith('scrypt$')) return stored === pin;
const parts = stored.split('$');
if (parts.length !== 6) return false;
try {
const [, N, r, p, saltHex, hashHex] = parts;
const expected = Buffer.from(hashHex, 'hex');
const actual = crypto.scryptSync(pin, Buffer.from(saltHex, 'hex'), expected.length, {
N: Number(N), r: Number(r), p: Number(p),
});
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
} catch (_) {
return false;
}
}
// ---------------- 档案 / token ----------------
/** pid 兼容两种形态:新生 p_<hex> 与迁移档案(pid=旧用户名,已被 USERNAME_RE 限制为安全文件名字符)。 */
function profileGamesFile(pid) {
if (typeof pid !== 'string' || !USERNAME_RE.test(pid)) throw new Error('bad pid');
return path.join(PROFILES_DIR, `${pid}.json`);
}
async function loadGamesDoc(pid) {
const doc = await readJson(profileGamesFile(pid), null);
if (doc && typeof doc === 'object' && doc.games && typeof doc.games === 'object') return doc;
return { id: pid, games: {} };
}
/** 读-改-写整体进队列(fn 收到当前 doc,返回 null 跳过落盘)。 */
function withGamesDoc(pid, fn) {
const file = profileGamesFile(pid);
return enqueueWrite(file, async () => {
const doc = await loadGamesDoc(pid);
const mutated = await fn(doc);
if (mutated === false) return null;
const tmp = file + '.tmp';
await fsp.writeFile(tmp, JSON.stringify(doc, null, 2));
await fsp.rename(tmp, file);
return doc;
});
}
function persistProfileIndex() {
return writeJson(PROFILES_FILE, profileIndex);
}
function cleanupTokens() {
const now = Date.now();
let removed = 0;
for (const [tok, entry] of Object.entries(tokens)) {
if (!entry || !profileIndex.profiles[entry.profileId] || Date.parse(entry.expiresAt) < now) {
delete tokens[tok];
removed += 1;
}
}
return removed;
}
function persistTokens() {
const removed = cleanupTokens();
if (removed) console.log(`[server] 清扫过期/悬空 token ${removed} 条`);
return writeJson(TOKENS_FILE, tokens);
}
function issueToken(profileId) {
const token = crypto.randomBytes(24).toString('hex');
const now = new Date().toISOString();
tokens[token] = {
profileId,
createdAt: now,
lastSeen: now,
expiresAt: new Date(Date.now() + TOKEN_TTL_MS).toISOString(),
};
persistTokens();
return token;
}
/** Bearer 鉴权 + 滑动续期(节流落盘)。返回 {token, profileId, profile} 或 null。 */
function authProfile(req) {
const h = req.headers.authorization || '';
const m = h.match(/^Bearer\s+([0-9a-f]+)$/i);
if (!m) return null;
const token = m[1].toLowerCase();
return authByToken(token);
}
function authByToken(token) {
const entry = tokens[token];
if (!entry || !profileIndex.profiles[entry.profileId]) return null;
if (Date.parse(entry.expiresAt) < Date.now()) {
delete tokens[token];
persistTokens();
return null;
}
if (Date.now() - Date.parse(entry.lastSeen) > TOKEN_TOUCH_MS) {
entry.lastSeen = new Date().toISOString();
entry.expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString();
persistTokens();
}
return { token, profileId: entry.profileId, profile: profileIndex.profiles[entry.profileId] };
}
function newProfileId() {
for (;;) {
const pid = 'p_' + crypto.randomBytes(6).toString('hex');
if (!profileIndex.profiles[pid]) return pid;
}
}
/** 桶级 LWW 裁决(03 §3.4):updatedAt 新者胜,同值比 rev。 */
function newerBucket(a, b) {
if (!a) return b;
if (!b) return a;
if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt ? a : b;
return (a.rev || 0) >= (b.rev || 0) ? a : b;
}
function findClaimedByName(name) {
for (const [pid, p] of Object.entries(profileIndex.profiles)) {
if (p.pinHash && p.name === name) return pid;
}
return null;
}
/** 自动名:玩家一、玩家二 … 玩家九十九(预生成语音词表覆盖一~九,见 01 §4.2)。 */
function cnNum(n) {
const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
if (!Number.isInteger(n) || n <= 0 || n > 99) return String(n);
if (n < 10) return d[n];
const t = Math.floor(n / 10);
const u = n % 10;
return (t > 1 ? d[t] : '') + '十' + (u ? d[u] : '');
}
// ---------------- per-IP 频控(仅匿名建档) ----------------
const deviceCreations = new Map(); // ip -> {day, count}
function allowDeviceCreate(req) {
const ip = String(req.socket.remoteAddress || '?').replace(/^::ffff:/, '');
const day = new Date().toISOString().slice(0, 10);
const rec = deviceCreations.get(ip);
if (!rec || rec.day !== day) {
deviceCreations.set(ip, { day, count: 1 });
return true;
}
if (rec.count >= DEVICE_LIMIT) return false;
rec.count += 1;
return true;
}
// ---------------- HTTP 工具 ----------------
@@ -127,11 +313,11 @@ function readBody(req) {
});
}
function authUser(req) {
const h = req.headers.authorization || '';
const m = h.match(/^Bearer\s+([0-9a-f]+)$/i);
if (!m) return null;
return sessions[m[1]] || null;
async function parseBody(req) {
const raw = await readBody(req);
if (!raw) return {};
const obj = JSON.parse(raw);
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
}
// ---------------- API ----------------
@@ -139,63 +325,247 @@ function authUser(req) {
async function handleApi(req, res, url) {
const route = `${req.method} ${url.pathname}`;
// ---- 匿名自动建档 / 重附着 ----
if (route === 'POST /api/device') {
const body = await parseBody(req);
if (body.token !== undefined) {
if (typeof body.token !== 'string' || !TOKEN_RE.test(body.token)) {
return sendJson(res, 400, { error: 'token 格式非法' });
}
const auth = authByToken(body.token.toLowerCase());
if (auth) {
return sendJson(res, 200, {
ok: true, attached: true, profileId: auth.profileId, token: auth.token,
name: auth.profile.name, claimed: !!auth.profile.pinHash,
});
}
// 失效 token:视作无参,重新建档(下方统一处理)
}
if (!allowDeviceCreate(req)) {
return sendJson(res, 429, { error: `本机今日匿名档案创建数已达上限(${DEVICE_LIMIT})` });
}
const pid = newProfileId();
profileIndex.seq += 1;
const profile = {
name: '玩家' + cnNum(profileIndex.seq),
pinHash: null,
createdAt: new Date().toISOString(),
};
profileIndex.profiles[pid] = profile;
await persistProfileIndex();
const token = issueToken(pid);
return sendJson(res, 201, { ok: true, profileId: pid, token, name: profile.name, claimed: false });
}
// ---- 档案信息 / 改名 ----
if (route === 'GET /api/profile') {
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const p = auth.profile;
return sendJson(res, 200, {
profileId: auth.profileId, name: p.name, claimed: !!p.pinHash, createdAt: p.createdAt,
});
}
if (route === 'PUT /api/profile') {
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const { name } = await parseBody(req);
if (typeof name !== 'string' || !USERNAME_RE.test(name)) {
return sendJson(res, 400, { error: '名字需为 1~24 位字母/数字/中文/_/-' });
}
const holder = findClaimedByName(name);
if (holder && holder !== auth.profileId) {
return sendJson(res, 409, { error: '该名字已被其他已认领档案使用' });
}
auth.profile.name = name;
await persistProfileIndex();
return sendJson(res, 200, { ok: true, name });
}
// ---- 认领:命名 / 设 PIN / 名字+PIN 登录(合并) ----
if (route === 'POST /api/claim') {
const body = await parseBody(req);
const auth = authProfile(req) || (typeof body.token === 'string' && TOKEN_RE.test(body.token)
? authByToken(body.token.toLowerCase())
: null);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const { name, pin } = body;
if (pin !== undefined && pin !== null && (typeof pin !== 'string' || !PIN_RE.test(pin))) {
return sendJson(res, 400, { error: 'PIN 需为 4~6 位数字' });
}
const profile = auth.profile;
// 只设 PIN(不改名):当前档案升级为可跨设备登录
if (name === undefined || name === null || name === '') {
if (!pin) return sendJson(res, 400, { error: '缺少 name 或 pin' });
profile.pinHash = hashPin(pin);
await persistProfileIndex();
return sendJson(res, 200, { ok: true, profileId: auth.profileId, name: profile.name, claimed: true });
}
if (typeof name !== 'string' || !USERNAME_RE.test(name)) {
return sendJson(res, 400, { error: '名字需为 1~24 位字母/数字/中文/_/-' });
}
const targetPid = findClaimedByName(name);
// 目标不存在(或就是自己):原地命名 + 设 PIN,进度零迁移
if (!targetPid || targetPid === auth.profileId) {
profile.name = name;
if (pin) profile.pinHash = hashPin(pin);
await persistProfileIndex();
return sendJson(res, 200, { ok: true, profileId: auth.profileId, name, claimed: !!profile.pinHash });
}
// 目标已存在:验 PIN → 当前匿名档案按桶 LWW 合并进目标 → token 改绑
if (profile.pinHash) {
return sendJson(res, 409, { error: '当前档案已设 PIN;请先用「新玩家」建匿名档案再登录' });
}
if (!pin) return sendJson(res, 401, { error: `「${name}」已设 PIN,请输入 PIN 登录` });
const target = profileIndex.profiles[targetPid];
if (!verifyPin(pin, target.pinHash)) {
return sendJson(res, 401, { error: 'PIN 不正确' });
}
if (!target.pinHash.startsWith('scrypt$')) {
target.pinHash = hashPin(pin); // v1 明文透明升级(迁移漏网兜底)
}
const srcPid = auth.profileId;
let mergedGames = 0;
await withGamesDoc(targetPid, (dst) => {
// 源/目标两文件各自串行;合并按桶 LWW(newerBucket),重复执行幂等
return loadGamesDoc(srcPid).then((src) => {
for (const [game, bucket] of Object.entries(src.games)) {
const cur = dst.games[game];
if (!cur || newerBucket(cur, bucket) !== cur) {
dst.games[game] = bucket;
mergedGames += 1;
}
}
});
});
tokens[auth.token].profileId = targetPid; // token 改绑目标档案
await persistTokens();
delete profileIndex.profiles[srcPid]; // 匿名源档案出索引(其 games 文件成孤儿,惰性清理见下)
await persistProfileIndex();
enqueueWrite(profileGamesFile(srcPid), async () => {
await fsp.unlink(profileGamesFile(srcPid)).catch(() => {});
});
console.log(`[server] claim 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新)`);
return sendJson(res, 200, {
ok: true, merged: true, mergedGames, profileId: targetPid, name: target.name, claimed: true,
});
}
// ---- 旧客户端兼容:登录(v1 用户名+PIN,映射到档案) ----
if (route === 'POST /api/login') {
const { username, pin } = await parseBody(req);
const pid = typeof username === 'string' ? findClaimedByName(username) : null;
if (!pid || typeof pin !== 'string' || !verifyPin(pin, profileIndex.profiles[pid].pinHash)) {
return sendJson(res, 401, { error: '用户名或 PIN 错误' });
}
const profile = profileIndex.profiles[pid];
if (!profile.pinHash.startsWith('scrypt$')) {
profile.pinHash = hashPin(pin); // 明文透明升级
await persistProfileIndex();
}
const token = issueToken(pid);
return sendJson(res, 200, { ok: true, token, username: profile.name, profileId: pid, name: profile.name });
}
// ---- 旧客户端兼容:注册(直接建已认领档案;新客户端请用 /api/device + /api/claim) ----
if (route === 'POST /api/register') {
const { username, pin } = JSON.parse(await readBody(req));
const { username, pin } = await parseBody(req);
if (typeof username !== 'string' || !USERNAME_RE.test(username)) {
return sendJson(res, 400, { error: '用户名需为 1~24 位字母/数字/中文/_/-' });
}
if (typeof pin !== 'string' || !PIN_RE.test(pin)) {
return sendJson(res, 400, { error: 'PIN 需为 4~6 位数字' });
}
if (users[username] !== undefined) {
if (findClaimedByName(username)) {
return sendJson(res, 409, { error: '用户名已被注册' });
}
users[username] = pin;
await writeJson(USERS_FILE, users);
return sendJson(res, 201, { ok: true, username });
const pid = newProfileId();
profileIndex.profiles[pid] = { name: username, pinHash: hashPin(pin), createdAt: new Date().toISOString() };
profileIndex.seq += 1;
await persistProfileIndex();
const token = issueToken(pid);
return sendJson(res, 201, { ok: true, username, profileId: pid, token });
}
if (route === 'POST /api/login') {
const { username, pin } = JSON.parse(await readBody(req));
if (typeof username !== 'string' || typeof pin !== 'string'
|| users[username] === undefined || users[username] !== pin) {
return sendJson(res, 401, { error: '用户名或 PIN 错误' });
}
const token = crypto.randomBytes(24).toString('hex');
sessions[token] = username;
await writeJson(SESSIONS_FILE, sessions);
return sendJson(res, 200, { ok: true, token, username });
// ---- 登出(吊销当前 token) ----
if (route === 'POST /api/logout') {
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
delete tokens[auth.token];
await persistTokens();
return sendJson(res, 200, { ok: true });
}
// ---- 进度:读单桶 / 读全桶 ----
if (route === 'GET /api/progress') {
const username = authUser(req);
if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const game = url.searchParams.get('game');
if (!game) return sendJson(res, 400, { error: '缺少 game 参数' });
const all = await readJson(progressFile(username), {});
return sendJson(res, 200, { game, data: all[game] ?? null });
const doc = await loadGamesDoc(auth.profileId);
if (!game) {
// 全桶一次拉回(大厅用;03 §3.3——替代旧的 8 次串行 GET)
const games = {};
for (const [g, bucket] of Object.entries(doc.games)) {
games[g] = { updatedAt: bucket.updatedAt, rev: bucket.rev, data: bucket.data };
}
return sendJson(res, 200, { profileId: auth.profileId, name: auth.profile.name, games });
}
if (!/^[A-Za-z0-9_\-]{1,32}$/.test(game)) {
return sendJson(res, 400, { error: 'game 需为 1~32 位字母/数字/_/-' });
}
const bucket = doc.games[game];
// 形状兼容旧客户端:{game, data}(radio 直接读 j.data)
return sendJson(res, 200, {
game,
data: bucket ? bucket.data : null,
updatedAt: bucket ? bucket.updatedAt : null,
rev: bucket ? bucket.rev : 0,
});
}
// ---- 进度:写桶(baseUpdatedAt LWW 守卫) ----
if (route === 'PUT /api/progress') {
const username = authUser(req);
if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const { game, data } = JSON.parse(await readBody(req));
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
const { game, data, baseUpdatedAt } = await parseBody(req);
if (typeof game !== 'string' || !/^[A-Za-z0-9_\-]{1,32}$/.test(game)) {
return sendJson(res, 400, { error: 'game 需为 1~32 位字母/数字/_/-' });
}
if (data === undefined) {
return sendJson(res, 400, { error: '缺少 data 字段' });
}
const file = progressFile(username);
// 读-改-写整体进队列,避免并发写互相覆盖
await enqueueWrite(file, async () => {
const all = await readJson(file, {});
all[game] = data;
const tmp = file + '.tmp';
await fsp.writeFile(tmp, JSON.stringify(all, null, 2));
await fsp.rename(tmp, file);
if (baseUpdatedAt !== undefined && typeof baseUpdatedAt !== 'string') {
return sendJson(res, 400, { error: 'baseUpdatedAt 需为字符串(上次已知服务器桶 updatedAt)' });
}
const pid = auth.profileId;
let conflict = null;
let written = null;
await withGamesDoc(pid, (doc) => {
const bucket = doc.games[game];
if (baseUpdatedAt !== undefined && bucket && bucket.updatedAt !== baseUpdatedAt) {
conflict = bucket; // 409 在队列外返回(响应不能在队列回调里写两次)
return false;
}
if (baseUpdatedAt === undefined) {
console.warn(`[server] legacy PUT(缺 baseUpdatedAt,放行): pid=${pid} game=${game}`);
}
const now = new Date().toISOString(); // 服务器时钟裁决(03 §3.1)
doc.games[game] = { updatedAt: now, rev: (bucket ? bucket.rev || 0 : 0) + 1, data };
written = doc.games[game];
});
return sendJson(res, 200, { ok: true, game });
if (conflict) {
return sendJson(res, 409, {
error: '进度已被其他设备更新(LWW 冲突),请择优后重试',
game,
server: { updatedAt: conflict.updatedAt, rev: conflict.rev, data: conflict.data },
});
}
return sendJson(res, 200, { ok: true, game, updatedAt: written.updatedAt, rev: written.rev });
}
return sendJson(res, 404, { error: '未知 API 端点' });
@@ -215,6 +585,12 @@ async function serveStatic(req, res, url) {
res.writeHead(403).end('Forbidden');
return;
}
// web/server/(token / PIN 哈希 / 进度数据)一律不对外提供(规范化后判定,防穿越变体)
const serverDir = path.join(ROOT, 'server') + path.sep;
if (filePath.startsWith(serverDir)) {
res.writeHead(404).end('Not Found');
return;
}
let stat;
try {
@@ -269,12 +645,83 @@ async function serveStatic(req, res, url) {
fs.createReadStream(filePath).pipe(res);
}
// ---------------- v1 → v2 迁移(启动时,单向,原文件保留) ----------------
async function migrate() {
await fsp.mkdir(PROFILES_DIR, { recursive: true });
const now = new Date().toISOString();
// users.json(v1 {username: pin明文})→ profiles.json(pid=旧用户名,PIN 立即哈希)
if (!fs.existsSync(PROFILES_FILE)) {
const idx = { seq: 0, profiles: {} };
const oldUsers = await readJson(USERS_FILE, null);
let migrated = 0;
if (oldUsers && typeof oldUsers === 'object') {
for (const [name, pin] of Object.entries(oldUsers)) {
if (typeof name !== 'string' || !USERNAME_RE.test(name)) continue;
idx.profiles[name] = {
name,
pinHash: typeof pin === 'string' && pin.startsWith('scrypt$') ? pin : hashPin(String(pin)),
createdAt: now,
};
idx.seq += 1;
// 旧进度文件 <username>.json(扁平 {game: data})→ profiles/<username>.json 桶包装
const oldProg = await readJson(path.join(DATA_DIR, `${name}.json`), null);
const games = {};
if (oldProg && typeof oldProg === 'object') {
for (const [g, d] of Object.entries(oldProg)) {
if (typeof g === 'string' && /^[A-Za-z0-9_\-]{1,32}$/.test(g)) {
games[g] = { updatedAt: now, rev: 1, data: d };
}
}
}
await writeJson(profileGamesFile(name), { id: name, games });
migrated += 1;
}
}
await writeJson(PROFILES_FILE, idx);
if (migrated > 0) {
console.log(`[server] v1 迁移: users.json → profiles.json(${migrated} 个档案,PIN 已 scrypt 哈希;原文件保留不动)`);
}
}
profileIndex = await readJson(PROFILES_FILE, { seq: 0, profiles: {} });
if (!profileIndex || typeof profileIndex !== 'object' || !profileIndex.profiles) {
profileIndex = { seq: 0, profiles: {} };
}
profileIndex.seq = Number(profileIndex.seq) || 0;
// sessions.json(v1 {token: username})→ tokens.json(补 180 天滑动过期)
if (!fs.existsSync(TOKENS_FILE)) {
const migratedTokens = {};
const oldSessions = await readJson(SESSIONS_FILE, null);
let n = 0;
if (oldSessions && typeof oldSessions === 'object') {
for (const [tok, user] of Object.entries(oldSessions)) {
if (typeof tok === 'string' && TOKEN_RE.test(tok) && profileIndex.profiles[user]) {
migratedTokens[tok.toLowerCase()] = {
profileId: user, createdAt: now, lastSeen: now,
expiresAt: new Date(Date.now() + TOKEN_TTL_MS).toISOString(),
};
n += 1;
}
}
}
await writeJson(TOKENS_FILE, migratedTokens);
if (n > 0) console.log(`[server] v1 迁移: sessions.json → tokens.json(${n} 条 token 补 180 天过期;原文件保留不动)`);
}
tokens = await readJson(TOKENS_FILE, {});
}
// ---------------- 启动 ----------------
async function main() {
await fsp.mkdir(DATA_DIR, { recursive: true });
users = await readJson(USERS_FILE, {});
sessions = await readJson(SESSIONS_FILE, {});
await migrate();
const removed = cleanupTokens();
if (removed) {
console.log(`[server] 清扫过期/悬空 token ${removed} 条`);
await persistTokens();
}
const server = http.createServer(async (req, res) => {
try {
@@ -301,6 +748,7 @@ async function main() {
console.log(`[server] SoundGame 服务器已启动: http://localhost:${PORT}/`);
console.log(`[server] 静态根目录: ${ROOT}`);
console.log(`[server] 数据目录: ${DATA_DIR}`);
console.log(`[server] 档案数: ${Object.keys(profileIndex.profiles).length},token 数: ${Object.keys(tokens).length}`);
});
}
+425
View File
@@ -0,0 +1,425 @@
/**
* 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)
*
* 运行: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, decideBucket, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, 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. 受管桶清单覆盖 8 个可同步游戏 + _settings', () => {
const games = BUCKETS.map((b) => b.game).sort();
assert.deepEqual(games, ['_settings', 'dungeon', 'escape', 'radio', 'rhythm', 'rpg', 'simon', 'space']);
for (const b of BUCKETS) assert.ok(b.key.startsWith('soundgame.'), '桶 key 应保持游戏原始 key 名(游戏零改动)');
});
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();
}
});
+1 -1
View File
@@ -21,7 +21,7 @@
const audio = new WebAudioEngine('audio/');
const game = new SimonGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
attachGestures(root, (g) => game.onGesture(g));
+1 -1
View File
@@ -24,7 +24,7 @@
const audio = new WebAudioEngine('audio/');
const game = new SpaceGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
attachGestures(root, (g) => game.onGesture(g));
+1 -1
View File
@@ -23,7 +23,7 @@
const audio = new WebAudioEngine('audio/');
const game = new StorylineGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
attachGestures(root, (g) => game.onGesture(g));
+1 -1
View File
@@ -30,7 +30,7 @@
const audio = new WebAudioEngine('audio/');
const game = new TemplateGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
await audio.showStartOverlay({ cover: 'cover.png' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
attachGestures(root, (g) => game.onGesture(g));