feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)

配对码与认领(B):
- POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏
- 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化
- api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22

键盘语音双套资产(A,路线 a):
- 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原)
- 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条)
- check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成)
- voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引
- mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动

回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12)
同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
This commit is contained in:
xsl
2026-09-10 00:43:23 +08:00
parent ede522dda1
commit 566241e05d
694 changed files with 4242 additions and 168 deletions
+49 -1
View File
@@ -14,7 +14,10 @@
* 最近通道(keydown→key / touchstart→touch),初始按能力检测;
* K7 immediateTap(rhythm):空格按下即 tap、长按补 tapcancel、双击改判——
* 键盘路径与触摸路径行为逐条一致;
* K8 detach 后键盘监听移除;孤儿 keyup 不产生事件。
* K8 detach 后键盘监听移除;孤儿 keyup 不产生事件;
* K9 voice 键盘变体路由(P1 路线 a):生效模式=key 时 audio.js 候选链前置
* voice/<gender>/key/<id>(回退 gender → 平铺),touch 模式链与 P1 前一致;
* 缓存键按输入模式分桶(key|<gender>|<id>)。
*
* 运行:tools/node/bin/node web/harness/engine-input.test.mjs
*/
@@ -245,6 +248,51 @@ assert(events.length === 0, 'detach 同时清掉挂起的定时器(无迟到 t
keyUp('Space');
assert(events.length === 0, '孤儿空格 keyup(无对应认领)无事件无异常');
// ---- K9 voice 键盘变体资产路由(P1 路线 a,engine/audio.js _candidates/_load)----
console.log('\n=== K9 voice 键盘变体路由(key 候选前置 + 回退链) ===');
{
const detach9 = attachGestures(root, () => {}); // K8 已 detach:重新挂上才能用 keyDown 驱动 inputMode 跟随
const { WebAudioEngine } = await import(path.join(WEB, 'engine', 'audio.js'));
const engine = new WebAudioEngine('audio/');
settings.set({ input: 'auto' });
keyDown('ArrowUp'); // 初始即 key(纯桌面),按键再确认一次
assert(inputMode() === 'key', 'auto + 纯桌面 → 生效模式 key');
let c = engine._candidates('voice/help');
assert(c[0] === 'audio/voice/female/key/help.ogg', 'key 模式:候选链首跳 voice/<gender>/key/<id>.ogg');
assert(c[4] === 'audio/voice/female/help.ogg', 'key 模式:第二跳回 voice/<gender>/<id>(主资产)');
assert(c[8] === 'audio/voice/help.ogg', 'key 模式:第三跳平铺回退(触屏/角色句兜底)');
assert(c.length === 12, 'key 模式:3 跳 × 4 扩展名 = 12 个候选');
c = engine._candidates('voice/help.ogg');
assert(c.length === 3 && c[0].endsWith('/key/help.ogg') && c[1].endsWith('/female/help.ogg'),
'key 模式:带扩展名 id 同样三跳,key 在最前');
settings.set({ input: 'touch' });
c = engine._candidates('voice/help');
assert(!c.some((u) => u.includes('/key/')) && c[0] === 'audio/voice/female/help.ogg' && c.length === 8,
'touch 模式:无 key 跳,候选链与 P1 前完全一致(gender → flat × 4 扩展)');
settings.set({ input: 'auto' });
fireDoc('touchstart');
c = engine._candidates('voice/help');
assert(!c.some((u) => u.includes('/key/')), 'auto 运行时跟随:触摸后变体跳消失');
keyDown('ArrowUp');
c = engine._candidates('voice/help');
assert(c[0] === 'audio/voice/female/key/help.ogg', 'auto 运行时跟随:键盘一按变体跳回来(语音随最近通道切措辞)');
// 缓存键按生效模式分桶:key/main 两套结果互不串缓存(与性别切换同语义)
engine._cache.clear();
await engine._load('voice/help'); // ctx 为 null → 直接 null,但仍写入缓存
const keys = [...engine._cache.keys()];
assert(keys.length === 1 && keys[0] === 'key|female|voice/help',
'缓存键带输入模式:key 模式下为 key|<gender>|<id>');
fireDoc('touchstart');
await engine._load('voice/help');
assert([...engine._cache.keys()].some((k) => k === 'main|female|voice/help'),
'切回 touch 后同一 id 落独立 main|<gender>|<id> 缓存桶');
detach9();
}
console.log(`\n========================================`);
console.log(`断言通过 ${passed} 条,失败 ${failures.length} 条`);
if (failures.length) { failures.forEach((m) => console.log('FAILED: ' + m)); process.exit(1); }
+2 -1
View File
@@ -109,7 +109,8 @@ globalThis.localStorage = {
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true,
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测)
value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true,
});
class MockParam {
+3 -1
View File
@@ -230,7 +230,9 @@ async function buildListenRows() {
s.sent = true; // pending 的会在响应里翻回 false
voices.push({
seg: s.idx, voiceId: s.voiceId, t0: s.t0, t1: s.t1,
expected: (VOICE_TEXT[S.game] || {})[s.voiceId] || null,
// key 变体会话(settings.input='key')对照 __key__ 表,变体句按键盘措辞验收
expected: (S.settings?.input === 'key' && (VOICE_TEXT.__key__?.[S.game] || {})[s.voiceId])
|| (VOICE_TEXT[S.game] || {})[s.voiceId] || null,
});
}
// 间隙探针:相邻 voice 段之间 / 录音起点到第一段,>1.5s 且未探过
+4 -1
View File
@@ -93,7 +93,10 @@ export function installMock(gameDir) {
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } },
// maxTouchPoints: 1 —— 转写 harness 是手机 persona:inputMode() 判 touch,
// voice 走触屏措辞主资产(与 voice_text.json 主表文本一致;键盘变体桌面
// 路由由 harness/engine-input.test.mjs K9 专测)
value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } },
configurable: true,
});
+182
View File
@@ -720,5 +720,187 @@
"unlock_echo": "解锁回声强化,侧向听得更远。",
"unlock_blade": "解锁武器,开局带着短刃。",
"unlock_none": "金币还不够,继续攒。"
},
"__key__": {
"storyline": {
"help": "操作说明。按空格:继续剧情。按回车:重听刚才的台词。按上方向键:听这段说明和当前进度。按下方向键:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,按左方向键或者按右方向键,做出你的决定。选择无法撤销。",
"menu": "菜单。按左方向键:重新开始整个故事。按右方向键,或者按空格:回到故事。按上方向键:听操作说明。",
"p1": "请选择。按左方向键:先检查背包里的收音机。按右方向键:先摸黑寻找出口。",
"p2": "请选择。按左方向键:钻进旁边的通风管道。按右方向键:躺进路边的尸体堆里装死。",
"p3": "请选择。按左方向键:循着哭声过去看看。按右方向键:不去管它,直接去北区。",
"p4": "请选择。按左方向键:带上小满一起走。按右方向键:留下食物,独自上路。",
"p5": "请选择。按左方向键:走桥下的污水涵洞。按右方向键:爬桥上悬着的钢索。",
"p6": "请选择。按左方向键:交出坐标,保命要紧。按右方向键:拒绝他,想办法周旋。",
"p7": "请选择。按左方向键:追上去,警告避难所。按右方向键:自己逃命,越远越好。",
"p8": "请选择。按左方向键:用公开频道广播,让全城都听见。按右方向键:用加密频段,只发给避难所。",
"n00_intro": "大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:按空格,继续剧情;按回车,重听上一句;按上方向键,听你的状态和帮助;按下方向键,呼出菜单。当我说“请选择”的时候,按左方向键,或者按右方向键,做出你的决定。选择一旦做出,就无法回头。现在,按空格,睁开眼睛。",
"e1_dawn": "加密频道那头沉默了三秒。然后,一个你等了三十天的声音响了起来:“姐?姐!是你吗?”天亮之前,避难所的救援队打开了大门。猎狗的人,在北区的高墙下溃散了。你抱着晨晨,听见广播塔里,重新响起了音乐。后来人们说,这座城市,是从一个女人的声音开始,重新活过来的。——好结局:黎明。感谢你,陪林晚走完了这条路。按空格,可以回到故事开始的地方。",
"e2_price": "你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。按空格,重新开始。",
"e3_alone": "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。按空格,重新开始。",
"e4_static": "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。按空格,重新开始。",
"skip_hint": "按空格可跳过旁白。",
"gd_continue": "按空格,继续。",
"gd_brief": "按空格继续剧情。请选择的时候,按左右方向键。",
"gd_again": "还在吗?按空格继续。"
},
"escape": {
"g1_move": "先活动一下。按任意方向键,走一小步。",
"g2_wall": "这个房间不大,四面都是墙。再按两下方向键,试试碰碰墙。",
"g3_left": "跟着我指的路走。按左方向键。",
"g4_down": "对。再按下方向键。",
"g5_inspect": "听到水声了吗?按一下空格,仔细检查这个房间。",
"g6_doubletap": "钥匙能开锁。按回车,是把物品用出去的用法。现在,按回车试试。",
"g7_goal": "找到能用这把钥匙打开的东西。需要帮助时,在原地按回车。",
"guide_wrong_up": "不是这个方向。按上方向键。",
"guide_wrong_down": "不是这个方向。按下方向键。",
"guide_wrong_left": "不是这个方向。按左方向键。",
"guide_wrong_right": "不是这个方向。按右方向键。",
"guide_blunt_up": "按一下上方向键。",
"guide_blunt_down": "按一下下方向键。",
"guide_blunt_left": "按一下左方向键。",
"guide_blunt_right": "按一下右方向键。",
"reopen_prompt": "按上方向键,重新听一遍引导。按空格,直接开始。",
"stuck_key_2": "去西南角的储物间,按空格检查,找到钥匙。",
"stuck_cabinet_2": "去东边的铁柜房,按回车,用钥匙打开铁柜。",
"stuck_box_2": "去西北角的电箱房,按回车,把保险丝装进电箱。",
"stuck_gate_2": "去东南角的大门,按回车,打开它,离开这里。",
"nav_up": "按上方向键。",
"nav_down": "按下方向键。",
"nav_left": "按左方向键。",
"nav_right": "按右方向键。",
"start_room": "你站在设施中央的房间里。方向键移动,按空格检查,按回车使用物品。",
"inspect_gate_ready": "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。按回车,打开它,离开这里。",
"use_fail": "这里没有用得上东西的地方。换个房间试试,或者先按空格检查一下周围。",
"again": "按空格,再来一次。按下方向键,打开菜单。",
"help_ops": "方向键,在房间之间移动。按空格,检查房间,或者重听房间的声音。按回车,使用物品,或者查看背包。",
"skip_hint": "按空格可跳过旁白。"
},
"simon": {
"t1_up": "按上方向键,是这个声音。",
"t2_down": "按下方向键,是这个声音。",
"t3_left": "按左方向键,是这个声音。",
"t4_right": "按右方向键,是这个声音。",
"t5_tap": "按空格,是这个声音。",
"t6_double": "按回车,是这个声音。",
"t_wrong_up": "不是这个,按上方向键试试。",
"t_wrong_down": "不是这个,按下方向键试试。",
"t_wrong_left": "不是这个,按左方向键试试。",
"t_wrong_right": "不是这个,按右方向键试试。",
"t_wrong_tap": "不是这个,按空格试试。",
"t_wrong_double": "不是这个,按回车试试。",
"t_blunt_up": "按一下上方向键。",
"t_blunt_down": "按一下下方向键。",
"t_blunt_left": "按一下左方向键。",
"t_blunt_right": "按一下右方向键。",
"t_blunt_tap": "轻轻按一下空格。",
"t_blunt_double": "按一下回车。",
"t7_retry": "答错可以消耗机会重听。按上方向键,可以随时重听。",
"replay": "按空格,再来一局。",
"rules_prompt": "按上方向键,重听规则。按下方向键,打开菜单。",
"rules": "上行音,按上方向键。下行音,按下方向键。左边的音,按左方向键。右边的音,按右方向键。一声轻响,按空格。两声轻响,按回车。",
"skip_hint": "按空格可跳过旁白。",
"gd_relisten": "需要的话,按上方向键可以重新听一遍序列。",
"gd_economy": "记不住就按上方向键,重新听一遍。免费机会有两次,用完要扣一次机会。",
"gd_occ_up": "这一轮,按上方向键是作答。",
"gd_occ_dt": "这一轮,按回车是作答。",
"gd_occ_both": "这一轮,按上方向键和回车都是作答。"
},
"rhythm": {
"tut_1": "听,底鼓。在下一小节的同一个位置,按空格。",
"tut_2": "这次是左和右。跟着打,按左方向键,按右方向键。",
"tut_3": "最后一句,三个音,中间是按回车。",
"select_hint": "左右方向键选曲目,按空格开始,按下方向键回大厅。",
"restart": "按空格再来一遍,按右方向键下一首,按下方向键回大厅。",
"menu_main": "菜单。按空格切换选项,按回车确认。",
"skip_hint": "按空格可跳过旁白。",
"gd_miss_early": "抢拍了。等声音落下再按。",
"gd_miss_wrong": "方向不对。跟着音色按方向键。"
},
"space": {
"drill_left": "听,左边有陨石。按左方向键,把它拨开!",
"drill_right": "这次在右边。按右方向键!",
"drill_up": "上面有东西,声音又尖又高。按上方向键!",
"drill_down": "下面是低沉的轰隆。按下方向键!",
"drill_retry_left": "那是左边来的,按左方向键。",
"drill_retry_right": "那是右边来的,按右方向键。",
"drill_retry_up": "声音又高又尖,是上面,按上方向键。",
"drill_retry_down": "轰隆声在下面,按下方向键。",
"protect_left": "训练护盾接住了。那颗在左边,听到左边的呼啸,按左方向键。",
"protect_right": "训练护盾接住了。那颗在右边,听到右边的呼啸,按右方向键。",
"protect_up": "训练护盾接住了。那颗在上面,听到高高的呼啸,按上方向键。",
"protect_down": "训练护盾接住了。那颗在下面,听到低沉的轰隆,按下方向键。",
"feat_boost": "试试按空格,引擎会短促加速,直接冲过空隙。",
"feat_shield": "按回车展开护盾,能挡下一次撞击。",
"hit_left": "左边撞上的。听到左边的呼啸,按左方向键。",
"hit_right": "右边撞上的。听到右边的呼啸,按右方向键。",
"hit_up": "上面撞上的。听到高高的呼啸,按上方向键。",
"hit_down": "下面撞上的。听到低沉的轰隆,按下方向键。",
"restart_hint": "按空格再飞一次,按下方向键回大厅。",
"second_run_ask": "按上方向键重新听演练,按空格直接出发。",
"menu_main": "菜单。按空格切换选项,按回车确认。",
"help_recap": "左边来按左方向键,右边来按右方向键,上面来按上方向键,下面来按下方向键。按空格加速,按回车护盾。",
"skip_hint": "按空格可跳过旁白。",
"gd_hit_hint": "被撞了。听方位,按对应方向键就能拨开。"
},
"rpg": {
"sys_continue_ask": "检测到你的旅程记录。按空格继续,按右方向键开始新的旅程。",
"sys_menu": "菜单。左右方向键选择,按空格确认。",
"help_explore": "左右方向键切换选项,按空格确认。按回车重新听场景,按上方向键查看状态,按下方向键打开菜单。",
"help_battle": "战斗中,左右方向键选招式,按空格出手。听到敌人的前摇,按回车格挡。",
"btl_lose_hint": "记住,听清前摇,按回车格挡。",
"tut_b2": "就是现在,按回车格挡!",
"tut_b3": "挡住了!轮到你了。左右方向键选攻击,按空格出剑。",
"shop_open": "商店。左右方向键看看货,按空格购买。",
"skip_hint": "按空格可跳过旁白。",
"tut_battle_2": "左右方向键切换招式,攻击、破空斩、防御、金疮药。按空格,出手。",
"tut_battle_3": "敌人出手前有一声前摇。听到前摇,马上按回车,就能格挡。",
"tut_battle_5": "记住:听清前摇,按回车格挡。开始吧!",
"tut_b0": "战斗里,左右方向键选招式,按空格出手。听到这个声音,就是你的回合。先按一按方向键,试试。",
"tut_b6": "来不及格挡,就选防御。左右方向键,选防御,按空格。",
"gd_parry_miss": "前摇声起的时候按回车,才能格挡。",
"gd_stall": "想好了吗?左右方向键选择,按空格出手。"
},
"radio": {
"gd_brief": "按空格继续节目。请选择的时候,按左或右方向键。",
"gd_continue": "按空格,继续。",
"gd_again": "还在吗?按空格继续。",
"gd_ring1": "热线在闪。按空格,接听。",
"gd_ring2": "灯还亮着,听众在等。按空格,接听。",
"sys_help": "按空格继续,按回车重新听。按上方向键听状态,按下方向键开菜单。请选择时,左右方向键。",
"sys_menu": "菜单。按空格切换选项,按回车确认。",
"sys_skip": "想跳过这句,按空格就行。",
"sys_continue_ask": "检测到你的夜班记录。按空格继续上次,按右方向键开始新的夜晚。",
"p1": "老人在发抖。按左方向键,陪他哼那首老歌。按右方向键,冷静问他路口的路牌。",
"p2": "雨声更密了。按左方向键,留在原地陪他等巡逻队。按右方向键,转接派出所。",
"p3": "按左方向键,告诉她这趟车值得上。按右方向键,把选择还给她。",
"p4": "按左方向键,唱一段摇篮曲哄她睡。按右方向键,轻轻问她怎么了。",
"p5": "孩子的声音在抖。按左方向键,告诉她这不是她的错。按右方向键,答应她明天都会好。",
"p6": "摔门声你听见了。按左方向键,替孩子给她带句话。按右方向键,劝她回家把话说开。",
"p7": "她的呼吸平了。按左方向键,留在直播里,让全城等这个门铃。按右方向键,换垫乐,私下陪她说完。",
"p8": "方向盘比话筒危险。按左方向键,陪他聊聊闺女。按右方向键,让他立刻进服务区。",
"p9": "留言机红灯在闪,是老主播三年前没播完的那句晚安。按左方向键,替他播出去。按右方向键,照常收播。"
},
"dungeon": {
"sys_continue_ask": "检测到探窟记录。按空格继续,按右方向键重新开始。",
"tut_d0": "先学会用耳朵看。按空格,发出声波脉冲。",
"tut_walk": "按住空格不放,你就往前走。",
"tut_turn": "按左右方向键,可以转身。",
"tut_replay": "按回车,再发一次脉冲,不要钱。",
"tut_status": "按上方向键,随时听自己的状态。",
"tut_menu": "按下方向键,打开菜单。",
"tut_parry": "听到它的动静,按回车格挡。",
"tut_attack": "现在按空格,挥拳打它。",
"tut_flee_hold": "想逃跑,按住空格别松手。",
"tut_stairs_on": "楼梯就在脚下。按空格,下潜。",
"tut_skip": "已跳过教学。按空格发脉冲,先听听四周。",
"sys_menu": "菜单。按空格切换,按回车确认。",
"help_explore": "按空格发脉冲,按住空格前进,左右方向键转身。按回车再听,上方向键查状态,下方向键开菜单。",
"help_battle": "敌人前摇声响时按回车格挡,按空格挥击。打不过就按住空格逃跑。",
"die_adv_1": "下次,前摇声响时就按回车。",
"die_adv_2": "打不过,就按住空格逃跑。",
"gd_pulse": "按空格发脉冲,听听四周。",
"gd_turn": "轮到你了。按空格挥击。",
"settle_again": "按空格,再探一次。"
}
}
}