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:
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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('全部通过。');
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user