diff --git a/docs/deploy.md b/docs/deploy.md index f22a0f63..d1e059af 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -155,7 +155,9 @@ server { ## 以后若要开放用户系统 不要把 `server.js` 绑到 `0.0.0.0`。至少做到:监听 `127.0.0.1`、Nginx 只反代 -`/api/`、加 `limit_req`、PIN 改哈希、token 设过期。在此之前保持静态-only。 +`/api/`、加 `limit_req`、数据目录定期备份。PIN 哈希(scrypt)与 token 过期/吊销 +已在 P0-B 无感档案系统落地(`web/server/README.md`),公网开放前剩余前置: +限流、单实例约束、备份与隐私政策。在此之前保持静态-only(大厅自动降级为本地档案切换,仍可完整游玩)。 ## 冒烟 diff --git a/docs/design/00-global-ux.md b/docs/design/00-global-ux.md index cc1200cc..f56d0b1d 100644 --- a/docs/design/00-global-ux.md +++ b/docs/design/00-global-ux.md @@ -1,10 +1,10 @@ # 00 · 全局交互规范(Global UX) > **给开发者的实现要点摘要** -> 1. 设置项(voice / speed / bgm / guide)只在大厅页用 `` 可视化 UI 修改,存 `localStorage["soundgame.settings"]`,由 `engine/settings.js` 管理;游戏启动时只读生效,游戏内不提供修改入口(菜单里只播报当前值)。**R1 已落地,本文 §1/§2/§4 已按实现修订(R2 修订版);R7 增补 guide 设置与向导层(§6,见 14 号文档);P0-A 增补 input 设置(§1.1,见 §5.5)。** > 2. 所有提示音反馈改为震动:`engine/haptics.js`(**已实现**,震型名小写:`confirm/error/success/warning/swipe/heartbeat`);iOS Safari 不支持时**静默降级,绝不回退到提示音**。 > 3. 旁白全部整句预生成:禁止数字/碎片拼接(音色漂移根因),每款游戏旁白固定同一 `speaker` + 同一 `instruct` 常量。 -> 4. 手势全局约定:上滑=帮助/状态、下滑=菜单、单击=确认/继续、双击=重听、左右滑=选择/移动;以滑动为核心玩法的游戏可偏离(见 §5 豁免表),但必须在开场交互教学中讲清。 +> 4. 手势全局约定:上滑=帮助/状态、下滑=菜单、单击=确认/继续、双击=重听、左右滑=选择/移动;以滑动为核心玩法的游戏可偏离(见 §5 豁免表),但必须在开场交互教学中讲清。**PC 键盘平行通道(§5.5,P0-A 已实现)不改变这些语义——同一手势宪法、同一状态机,仅多一条输入通路。** > 5. 用户系统:大厅页登录,进度经 `web/server/server.js` REST API 存服务器(**已实现**),本地 localStorage 做离线缓存。 --- @@ -19,6 +19,7 @@ | `speed` | `1.0`(默认)/ `1.35` / `1.7` / `2.2` | **只作用于 voice 层**的 `playbackRate`(创建 voice source 时实时读取,**从下一句开始生效**,正在播的句子不变速)。BGM、sfx 速率不变。TTS 一律按 1.0 语速生成;`instruct` 里禁止出现语速描述词。**2.2 档为视障档**(R7 增补:资深读屏用户听速 250+ WPM,见 research/01-literature/03)。 | | `bgm` | `1.0`(默认)/ `0.3` / `0` | BGM 层独立增益总线系数。`0` = 不加载、不播放任何 `bgm/` 资产(`_playBgm` 短路),ducking 逻辑保持原样。改设置即时生效(已实现 subscribe 机制)。 | | `guide` | `"full"`(默认)/ `"standard"` / `"off"` | **向导帮助等级**(R7 增补,engine/guide.js 实时读取,见 §6 与 14 号文档):`full`=签名 earcon+向导语音+震动(新手全托管);`standard`=只留 earcon+震动(无向导语音,尊重专家);`off`=全静且**不挂任何看门狗定时器**(老手/回归测试)。 | +| `input` | `"auto"`(默认)/ `"touch"` / `"key"` | **提示文案输入模式**(P0-A 增补,`gesture.inputMode()` 读取):`auto`=跟随最近使用的通道(键盘 keydown→key,任意 touchstart→touch,会话内无输入时按能力检测);`touch`/`key`=手动覆盖。**只影响提示文案**(如启动遮罩「轻触开始」/「按空格开始」),**不关闭任何输入通道**——三通道永远并行(混合设备)。 | 存储:localStorage key = `soundgame.settings`,值形如: @@ -174,6 +175,47 @@ PUT /api/progress { game, data } (Bearer) -> 200 { ok, game } - 单击有 ~300ms 引擎级延迟:即时判定(格挡、节奏打击)一律绑滑动或双击,单击只用于非即时操作(确认、检查、菜单)。新设计继续遵守。**唯一例外**:rhythm 开启引擎 `immediateTap`(按下即发 tap、双击补发改判),因它的单击就是核心演奏输入;其他游戏不得开(每次按下都先发 tap,会污染滑动/长按语义)。 - 结算画面统一:单击=再来一局,下滑=菜单(回大厅)。教学可跳过:任何游戏的引导阶段,下滑=跳过引导(首次强制完整引导的标志位存进度里)。 +### 5.5 键盘平行通道(P0-A 已实现:engine/gesture.js 内置通道) + +> 调研与业界依据:[research/platform/02-input-parity.md](../../research/platform/02-input-parity.md)(audiogames.net 社区惯例、A Blind Legend / Blind Drive 双端先例)。 + +PC 键盘是触屏手势的**平行输入通道**,不是另一套手势宪法:键盘事件在 +`attachGestures` 内部喂入与 touch/mouse **同一状态机**,游戏层收到的 +7 种手势事件(up/down/left/right/tap/doubletap/longpress/longpressend ++ immediateTap 的 tapcancel)与内置震动**完全同构**——游戏代码零改动, +§5.1-§5.4 的全部手势语义(含各游戏豁免表)原封不动。 + +**键位表(裁定方案,`e.code`):** + +| 手势(§5.1 语义不变) | 键盘主键 | 别名 | 行为差异 | +|---|---|---|---| +| 左滑 / 右滑 | ← / → | A / D | keydown 即发(无位移/速度判定,零延迟) | +| 上滑 / 下滑 | ↑ / ↓ | W / S | 同上 | +| 单击 | 空格 | — | 走完整 tap 状态机:**保留 ~300ms 双击窗**(与触屏单击同感) | +| 双击 | **回车** | 空格×2 | 回车**即时**触发(不等双击窗);空格两连也判双击(状态机路径) | +| 长按 / 长按结束 | 按住空格 ≥450ms / 松开 | — | 与触摸长按完全一致(含旁白快进 window 事件) | + +设计裁定(P0-A,均已实现并回归): + +1. **空格也走 300ms 双击窗**(与触摸共享窗口,混合设备"触屏 tap + 空格 + tap"判 doubletap = 同一虚拟指针语义);**回车是唯一的即时双击键**—— + 确认类操作零延迟,同时保留空格×2 兼容触屏肌肉记忆。 +2. **不做 Esc 映射**:下滑已覆盖菜单语义(§5.1/§5.2),Esc 留给浏览器 + 自身/未来全屏退出,不进入手势域。 +3. **守卫**:`e.repeat` 抑制(按住只发一次);映射键 preventDefault + (防滚页/焦点激活);表单控件焦点(input/textarea/select/ + contenteditable)放行;未映射键(Tab 等)不拦截;tracking 单飞行 + 指针互斥(与多指忽略一致)。 +4. **提示随输入模式**(settings.input,见 §1.1):三通道永远并行在线, + 模式只切提示文案(遮罩「轻触开始」/「按空格开始」),供后续游戏内 + 提示与语音变体(input-parity 路线 a)接入。 +5. 遮罩键盘解锁:空格/回车 keydown 即可解锁音频(audio.js + showStartOverlay),PC 玩家全程无需鼠标。 +6. 测试:`web/harness/engine-input.test.mjs`(61 断言);live 管线 + `POST /gesture {"keyboard":true,"key":"ArrowLeft"|"Space"|"Enter"}` + 或 `{"type":"tap","keyboard":true}`(CDP Input.dispatchKeyEvent, + 会话剧本加 `keyboard:true` 即可键盘复跑)。 + ## 6. 向导层(R7 增补,已实现:engine/guide.js) > 设计裁定与台词表全录:[14-guide-layer.md](14-guide-layer.md)。本节只列与全局约定交叉的部分。 diff --git a/docs/web-engine-api.md b/docs/web-engine-api.md index 6eb74a8d..5a1fd3b6 100644 --- a/docs/web-engine-api.md +++ b/docs/web-engine-api.md @@ -12,7 +12,7 @@ web/ ├── index.html # 游戏大厅(深色风格,5 个游戏入口 + 用户/设置区) ├── serve.sh # 本地托管脚本(node web/server/server.js) ├── engine/ # 共享引擎 —— 所有游戏复用,勿修改 -│ ├── gesture.js # 6 手势识别(触摸+鼠标,内置基础震动反馈) +│ ├── gesture.js # 6 手势识别(触摸+鼠标+键盘三通道,内置基础震动反馈) │ ├── audio.js # Web Audio 三层音频引擎 + 启动遮罩(自动应用用户设置) │ ├── settings.js # 全局用户设置(voice/speed/bgm/guide,localStorage 持久化) │ ├── haptics.js # navigator.vibrate 震动封装(预定义震型 + iOS 视觉降级) @@ -122,8 +122,11 @@ new WebAudioEngine(basePath = 'audio/', opts?) - `unlock(): Promise` — 创建/恢复 AudioContext。已在用户手势中时可用; 一般用 `showStartOverlay()` 代替。 -- `showStartOverlay({ cover, text = '轻触开始' }): Promise` — - 启动遮罩,resolve 时音频已解锁。 +- `showStartOverlay({ cover, text }): Promise` — 启动遮罩,resolve 时 + 音频已解锁。解锁手势:触摸 / 鼠标 click / **键盘空格或回车 keydown** + (keydown 同属 user activation,回调内同步发起 `resume()` 即可解锁—— + PC 键盘玩家无需伸手摸鼠标)。`text` 缺省按 `gesture.inputMode()` 自适应 + (key →「按空格开始」,touch →「轻触开始」);显式传 `text` 完全覆盖。 - `play(id, opts?)` — 统一入口,按 id 前缀(`bgm/` `voice/` `sfx/`)分发: - 公共:`opts.volume`(0~1,乘层音量) - BGM:`opts.fadeMs`、`opts.loop = true`;同一首重复 `play` 不重启 @@ -238,11 +241,16 @@ guide.setEventHook(fn); // 转写 harness 用:fn({type,name,vo ```js import { settings } from '../engine/settings.js'; -settings.get(); // -> { voice: 'male'|'female', speed: 1|1.35|1.7|2.2, bgm: 1|0.3|0, guide: 'full'|'standard'|'off' } +settings.get(); // -> { voice, speed, bgm, guide, input } settings.set({ speed: 1.35 }); // 部分更新;非法键/值被忽略(console.warn) const off = settings.subscribe((snapshot, changed) => { ... }); // off() 退订 ``` +- 五项设置:`voice`(`'male'|'female'`)、`speed`(`1|1.35|1.7|2.2`)、 + `bgm`(`1|0.3|0`)、`guide`(`'full'|'standard'|'off'`)、 + `input`(`'auto'|'touch'|'key'`,P0-A 新增——提示文案输入模式, + `gesture.inputMode()` 读取;只影响提示,不关闭任何输入通道)。 + - 非法值永远不会进入存储——读入时逐项校验,坏数据自动回退默认值。 - 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置。 需要自定义响应(如设置页 UI)时才 `subscribe`。 @@ -338,6 +346,56 @@ const detach = attachGestures(element, onGesture, config?) `onGesture` 签名因此扩为:`'up'|'down'|'left'|'right'|'tap'|'doubletap'| 'longpress'|'longpressend'|'tapcancel'`。 +### 键盘通道(PC 平行输入,P0-A) + +`attachGestures` 内置第三条输入通道(与 touch/mouse 并列,**喂同一状态机**, +游戏层收到的手势事件与震动反馈完全同构——游戏代码零改动)。设计依据与 +业界惯例见 `research/platform/02-input-parity.md`。 + +| 手势(触屏语义) | 键盘主键 | 别名 | 行为 | +|---|---|---|---| +| 左/右滑 | ← / → | A / D | keydown 即发,零延迟(无位移/速度判定) | +| 上/下滑 | ↑ / ↓ | W / S | 同上 | +| 单击 | 空格 | — | 走完整 tap 状态机:抬起判 tap,**保留 ~300ms 双击窗**(与触屏同感) | +| 双击 | **回车** | 空格×2 | 回车**即时**触发(不等双击窗,确认类零延迟);空格连按两下走状态机同样判出 | +| 长按 | 按住空格 ≥450ms | — | `longpress`,松开 `longpressend`(旁白快进 window 事件照发) | + +行为要点(`web/harness/engine-input.test.mjs` 全量断言,61 条): + +- **空格 = 虚拟指针**:keydown 落元素中心进 `handleDown`,keyup 收尾—— + tap/双击窗/长按定时器全部复用触摸路径;双击窗与触摸**共享**(混合设备 + "触屏点一下 + 空格点一下"也判 doubletap,语义 = 同一虚拟指针)。 +- **回车即时双击**:直接走 doubletap 分支(含 confirm 震动),同时作废 + 等待中的单击(与触屏双击的收尾语义一致)。 +- **immediateTap 游戏(rhythm)零改动**:空格按下即发 tap(kick 演奏)、 + 长按补 `tapcancel`、双击改判——键盘路径逐条对齐触摸路径。 +- **守卫**:`e.repeat` 抑制(按住 ↑ 只发一次 up;映射键 repeat 仍 + preventDefault 防连滚);映射键 keydown 一律 `preventDefault`(防方向键/ + 空格滚页、回车激活焦点元素);焦点在 `INPUT/TEXTAREA/SELECT/ + contenteditable` 时放行不吞键(大厅打字正常);未映射键不拦截(Tab 等 + 无障碍键放行);`tracking` 单飞行指针互斥(空格按住期间方向键/触摸互斥, + 与多指忽略语义一致)。 +- **无 Esc 映射**(裁定:下滑已覆盖菜单语义,Esc 留给浏览器/未来全屏)。 +- 监听挂 `window`(游戏页焦点在 body,冒泡必达),`detach()` 时移除; + 键盘监听在无 `window` 的 mock 环境(playtest/转写 harness)自动跳过, + 对既有触摸路径零影响。 + +**提示随输入模式(`settings.input` + `inputMode()`)**: + +```js +import { inputMode } from '../engine/gesture.js'; +inputMode(); // 'touch' | 'key' —— 当前提示应使用的输入模式 +``` + +- `settings.input`(`'auto'` 默认 / `'touch'` / `'key'`,settings.js VALID + 枚举):手动覆盖优先;`auto` = 跟随最近使用的通道(映射键 keydown → + `key`,任意 touchstart → `touch`),会话内尚无输入时按能力检测 + (`maxTouchPoints > 0 && (pointer: coarse)` → 触屏)。 +- **只影响提示文案,不关闭任何输入通道**——touch/mouse/key 三通道永远 + 并行在线(混合设备友好)。当前接入点:启动遮罩默认文字 + (`showStartOverlay` 不传 `text` 时按 `inputMode()` 选「轻触开始」/ + 「按空格开始」);游戏内提示文案由各游戏 HTML 后续接入。 + ## engine/game.js — `Game` / `StateMachineGame` ```js diff --git a/web/dungeon/index.html b/web/dungeon/index.html index 2a047e3d..d82174d1 100644 --- a/web/dungeon/index.html +++ b/web/dungeon/index.html @@ -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)); } diff --git a/web/engine/audio.js b/web/engine/audio.js index 942a5840..483a2147 100644 --- a/web/engine/audio.js +++ b/web/engine/audio.js @@ -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} 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); }); } diff --git a/web/engine/gesture.js b/web/engine/gesture.js index 65ccd38d..2a506dd8 100644 --- a/web/engine/gesture.js +++ b/web/engine/gesture.js @@ -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); + } }; } diff --git a/web/engine/settings.js b/web/engine/settings.js index b3692543..1788f33e 100644 --- a/web/engine/settings.js +++ b/web/engine/settings.js @@ -1,13 +1,16 @@ /** * settings.js — 全局用户设置(纯音频游戏合集共享)。 * - * 四项设置: + * 五项设置: * | 键 | 取值 | 默认 | 作用 | * |--------|-------------------------------------|----------|----------------------------------------| * | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice// 下资产 | * | 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} */ diff --git a/web/escape/index.html b/web/escape/index.html index 20334e8f..2eafe386 100644 --- a/web/escape/index.html +++ b/web/escape/index.html @@ -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)); diff --git a/web/harness/engine-input.test.mjs b/web/harness/engine-input.test.mjs new file mode 100644 index 00000000..22c42790 --- /dev/null +++ b/web/harness/engine-input.test.mjs @@ -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('全部通过。'); diff --git a/web/harness/live/control.mjs b/web/harness/live/control.mjs index 734e8537..890a9dca 100644 --- a/web/harness/live/control.mjs +++ b/web/harness/live/control.mjs @@ -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)' : ''}`]); diff --git a/web/index.html b/web/index.html index 970c1664..186c3b50 100644 --- a/web/index.html +++ b/web/index.html @@ -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 @@

纯音频游戏 · 一副耳机,六种手势,无需眼看

-
-

用户

-
-

+ +
+

玩家

+
+ +
+ + +

-
+

设置

@@ -151,6 +220,14 @@
+
+ + +
@@ -206,105 +283,450 @@
- 建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击
+ 建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击(PC 键盘:方向键 / WASD、空格、回车)
单击判定有约 300ms 延迟(双击识别所需),属于正常设计
diff --git a/web/profiles.js b/web/profiles.js new file mode 100644 index 00000000..6183e016 --- /dev/null +++ b/web/profiles.js @@ -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. 各档案进度快照(原始 key → 原始字符串值;切走时回灌) + * soundgame.sync. 各档案已知的服务器桶时间戳 {: {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'; +} diff --git a/web/radio/index.html b/web/radio/index.html index 448fbd58..76125d3f 100644 --- a/web/radio/index.html +++ b/web/radio/index.html @@ -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)); diff --git a/web/rhythm/index.html b/web/rhythm/index.html index 47ea12be..836d4414 100644 --- a/web/rhythm/index.html +++ b/web/rhythm/index.html @@ -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 diff --git a/web/rpg/index.html b/web/rpg/index.html index 3165bd41..c00d3bd6 100644 --- a/web/rpg/index.html +++ b/web/rpg/index.html @@ -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)); diff --git a/web/server/README.md b/web/server/README.md index 048febc1..b77fb89f 100644 --- a/web/server/README.md +++ b/web/server/README.md @@ -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 `。 -### 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 ` -- 成功:`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 ` -- 请求:`{ "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 { "": "" } -├── sessions.json { "": "" } -└── .json { "": , ... } 一用户一文件 +├── profiles.json { "seq": 3, "profiles": { "": { "name", "pinHash", "createdAt" } } } +├── tokens.json { "": { "profileId", "createdAt", "lastSeen", "expiresAt" } } +├── profiles/ +│ └── .json { "id": "", "games": { "": { "updatedAt", "rev", "data" } } } +├── users.json (v1 遗留,迁移源,保留不动) +├── sessions.json (v1 遗留,迁移源,保留不动) +└── uuu.json (v1 遗留进度,已迁至 profiles/uuu.json,保留不动) ``` +- `pinHash`:`null`(匿名档案)或 `"scrypt$16384$8$1$$"` +- 一档案一进度文件,按游戏分桶;`rev` 单调递增,作 updatedAt 同值的破平局保险 +- v1 → v2 启动迁移(单向,幂等跳过):`users.json` → `profiles.json`(pid=旧用户名, + PIN 立即哈希)、`sessions.json` → `tokens.json`(补 180 天过期)、 + `.json` → `profiles/.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)。 diff --git a/web/server/server.js b/web/server/server.js index df0b9c86..cfc14dc5 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -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/.json { : } 进度,一用户一文件 - * 并发写入:按文件串行 Promise 队列。 + * 3. 数据落盘(data/ 目录自动创建;env SG_DATA_DIR 可覆盖,供测试): + * data/profiles.json { seq, profiles: { : {name, pinHash, createdAt} } } + * data/tokens.json { : {profileId, createdAt, lastSeen, expiresAt} } + * data/profiles/.json { id, games: { : {updatedAt, rev, data} } }(桶级包装) + * v1 兼容迁移(启动时单向,原文件保留不动): + * users.json {username: pin明文} → profiles.json(PIN 立即 scrypt 哈希,pid=旧用户名) + * sessions.json {token: username} → tokens.json(补 180 天滑动过期) + * .json {game: data} → profiles/.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} username -> pin */ -let users = {}; -/** @type {Record} token -> username */ -let sessions = {}; +/** @type {{ seq: number, profiles: Record }} */ +let profileIndex = { seq: 0, profiles: {} }; +/** @type {Record} */ +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_ 与迁移档案(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; + // 旧进度文件 .json(扁平 {game: data})→ profiles/.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}`); }); } diff --git a/web/server/test/api-smoke.mjs b/web/server/test/api-smoke.mjs new file mode 100644 index 00000000..9ba9050f --- /dev/null +++ b/web/server/test/api-smoke.mjs @@ -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/.json → profiles.json/tokens.json/ + * profiles/.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/.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(); + } +}); diff --git a/web/simon/index.html b/web/simon/index.html index 76a53fdb..7991b061 100644 --- a/web/simon/index.html +++ b/web/simon/index.html @@ -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)); diff --git a/web/space/index.html b/web/space/index.html index 60149bc7..795c340b 100644 --- a/web/space/index.html +++ b/web/space/index.html @@ -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)); diff --git a/web/storyline/index.html b/web/storyline/index.html index 58e3fcb2..e8f8d429 100644 --- a/web/storyline/index.html +++ b/web/storyline/index.html @@ -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)); diff --git a/web/template/index.html b/web/template/index.html index 2dc116ff..1c13af22 100644 --- a/web/template/index.html +++ b/web/template/index.html @@ -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));