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

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

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

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

P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
This commit is contained in:
xsl
2026-09-09 23:39:23 +08:00
parent 3704cd0792
commit ede522dda1
22 changed files with 2370 additions and 275 deletions
+60 -2
View File
@@ -5,6 +5,12 @@
* 外部 agent 通过 HTTP 逐步玩任意一款 SoundGame:
* POST /session {game, settings?, viewport?} 开新页(预注入探针 + 设置种子)
* POST /gesture {type, x?, y?} 真实触摸手势(tap/doubletap/swipe四向/longpress)
* POST /gesture {keyboard:true, key, holdMs?} 键盘手势(CDP Input.dispatchKeyEvent;
* key=ArrowLeft|…|KeyW…|Space|Enter,
* holdMs 默认 60,长按空格传 ≥450)
* POST /gesture {type, keyboard:true} 同款手势类型走键盘(tap→空格快按、
* doubletap→回车、longpress→空格按 600ms、
* swipeup→↑…)——会话剧本加 keyboard:true 即可复跑
* GET /events?since=N 增量事件(voice/fetch/震动/console/手势…)
* GET /listen 增量转写判定(OK/TRUNCATED/MISMATCH/SILENCE/SFX_BGM_ONLY)
* GET /transcript 合并时间轴全量(复盘用)
@@ -45,6 +51,7 @@ function mapSettings(raw = {}) {
const out = {};
if (raw.voice !== undefined) out.voice = raw.voice; // male|female
if (raw.guide !== undefined) out.guide = raw.guide; // full|standard|off
if (raw.input !== undefined) out.input = raw.input; // auto|touch|key(提示文案输入模式)
if (raw.speed !== undefined) {
out.speed = typeof raw.speed === 'number' ? raw.speed : SPEED_MAP[raw.speed] ?? 1.0;
}
@@ -295,7 +302,58 @@ async function buildListenRows() {
}
// ---------------------------------------------------------------- 手势编排
async function gesture({ type, x, y }) {
// 键盘通道(engine/gesture.js 键位):key 用 KeyboardEvent.code 值。
const KEY_DESC = {
ArrowUp: { code: 'ArrowUp', key: 'ArrowUp', vk: 38 },
ArrowDown: { code: 'ArrowDown', key: 'ArrowDown', vk: 40 },
ArrowLeft: { code: 'ArrowLeft', key: 'ArrowLeft', vk: 37 },
ArrowRight: { code: 'ArrowRight', key: 'ArrowRight', vk: 39 },
KeyW: { code: 'KeyW', key: 'w', vk: 87 },
KeyA: { code: 'KeyA', key: 'a', vk: 65 },
KeyS: { code: 'KeyS', key: 's', vk: 83 },
KeyD: { code: 'KeyD', key: 'd', vk: 68 },
Space: { code: 'Space', key: ' ', vk: 32, text: ' ' },
Enter: { code: 'Enter', key: 'Enter', vk: 13, text: '\r' },
};
// 触摸手势类型 → 键盘等价(gesture.js 键位表):type 走键盘时按此换算
const TYPE_TO_KEY = {
tap: { key: 'Space', holdMs: 60 },
doubletap: { key: 'Enter', holdMs: 60 },
longpress: { key: 'Space', holdMs: 600 },
swipeup: { key: 'ArrowUp', holdMs: 60 },
swipedown: { key: 'ArrowDown', holdMs: 60 },
swipeleft: { key: 'ArrowLeft', holdMs: 60 },
swiperight: { key: 'ArrowRight', holdMs: 60 },
};
/** 键盘手势:CDP Input.dispatchKeyEvent(rawKeyDown 会让页面收不到 code 派生
* 的完整 KeyboardEvent 特性,用 keyDown/keyUp 对),比 Runtime.evaluate 派发
* 合成事件更拟真(走真实输入管线,含默认行为与焦点语义)。 */
async function keyGesture({ key, type, holdMs }) {
let desc = key ? KEY_DESC[key] : null;
let hold = holdMs;
if (!desc && type) {
const m = TYPE_TO_KEY[type];
if (!m) throw new Error(`未知键盘手势类型: ${type}`);
desc = KEY_DESC[m.key];
hold = hold ?? m.holdMs;
}
if (!desc) throw new Error(`未知按键: ${key}(可用 ${Object.keys(KEY_DESC).join('|')})`);
hold = hold ?? 60;
const label = key || type;
// 先把动作本身记进页面时间轴(t=按键开始),附 keyboard/key 供审计区分通道
await S.page.eval(`window.__live && window.__live.events.push({ev:'gesture', type:${JSON.stringify(label)}, keyboard:true, key:${JSON.stringify(desc.code)}, t:performance.now()})`).catch(() => {});
const base = { key: desc.key, code: desc.code, windowsVirtualKeyCode: desc.vk, nativeVirtualKeyCode: desc.vk };
await S.page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base, ...(desc.text ? { text: desc.text, unmodifiedText: desc.text } : {}) });
await sleep(hold);
await S.page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
await sleep(400); // 与触摸手势一致:给游戏响应时间(空格单击有 ~300ms 双击窗延迟)
await drain();
}
async function gesture(body = {}) {
if (body.keyboard) return keyGesture(body); // 键盘通道:{"keyboard":true,"key":...} 或 {type,keyboard:true}
const { type, x, y } = body;
const cx = x ?? Math.round(S.viewport.width / 2);
const cy = y ?? Math.round(S.viewport.height / 2);
const pt = (px, py) => [{ x: px, y: py, id: 1 }];
@@ -395,7 +453,7 @@ async function writeArtifacts() {
for (const e of S.events) {
const t = e.ta;
if (t == null) continue;
if (e.ev === 'gesture') items.push([t, `**GESTURE** ${e.type} @(${e.x},${e.y})`]);
if (e.ev === 'gesture') items.push([t, e.keyboard ? `**KEY** ${e.key || e.type}` : `**GESTURE** ${e.type} @(${e.x},${e.y})`]);
else if (e.ev === 'vstart') items.push([t, `VOICE ▶ ${voiceIdOf(e.url)} (dur ${e.dur}s)`]);
else if (e.ev === 'vend') items.push([t, `VOICE ■ ${voiceIdOf(e.url)} (${e.reason})`]);
else if (e.ev === 'start' && e.layer !== 'voice') items.push([t, `${(e.layer || 'sfx').toUpperCase()} ♪ ${e.url ? voiceIdOf(e.url) : '?'}${e.loop ? ' (loop)' : ''}`]);