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
+24 -8
View File
@@ -32,6 +32,12 @@
* - settings.voice:voice 层按性别解析路径——play('voice/intro') 实际依次尝试
* `audio/voice/<gender>/intro.{ogg,m4a,mp3,wav}`,找不到回退平铺的
* `audio/voice/intro.*`(过渡期资产未分性别也不会炸)。游戏代码不用改。
* - 输入模式(settings.input + gesture.inputMode() 运行时跟随):**生效模式为键盘**
* (settings.input==='key',或 auto 且最近使用键盘/纯桌面初始判定)时,voice
* 候选链最前面多一跳 `audio/voice/<gender>/key/<id>.*`——键盘措辞变体资产
* (P1 双套资产路线 a,仅覆盖含操作词的句子),缺失时自然回退
* `voice/<gender>/` 主资产 → 平铺,触屏措辞资产零改动。缓存键带输入模式,
* 模式切换后从下一句起用另一套(与性别切换同语义)。
* - settings.speed:voice 层语速,**变速不变调**——voice 走 SoundTouch 时间
* 拉伸管线(vendor/soundtouch.js,ScriptProcessorNode),从下一句开始生效。
* - settings.bgm:BGM 层增益;改动立即作用于进行中的 BGM(150ms 渐变)。
@@ -342,18 +348,25 @@ export class WebAudioEngine {
* id -> 候选路径列表(带扩展名直接用,否则按回退表依次尝试)。
* voice 层按 settings.voice 优先尝试 `voice/<gender>/` 子目录,
* 找不到再回退平铺路径(过渡期兼容)。
* **键盘变体(P1 路线 a)**:生效输入模式为键盘(gesture.inputMode()==='key',
* 即 settings.input==='key' 或 auto 且键盘在跟)时,候选链前置
* `voice/<gender>/key/<id>` 一跳——键盘措辞资产只有含操作词的句子,其余
* id 此跳 404 后自然落回 `voice/<gender>/` 主资产 → 平铺,游戏无感。
*/
_candidates(id) {
const name = id.slice(id.lastIndexOf('/') + 1);
const hasExt = name.includes('.');
if (id.startsWith('voice/')) {
const genderBase = this.basePath + 'voice/' + settings.get().voice + '/' + id.slice(6);
const rest = id.slice(6);
const gender = settings.get().voice;
const genderBase = this.basePath + 'voice/' + gender + '/' + rest;
const flatBase = this.basePath + id;
if (hasExt) return [genderBase, flatBase];
return [
...EXT_FALLBACKS.map((ext) => genderBase + ext),
...EXT_FALLBACKS.map((ext) => flatBase + ext),
];
// key 候选只按「生效模式」动态决定(无独立缓存层,见 _load 的缓存键)
const bases = inputMode() === 'key'
? [this.basePath + 'voice/' + gender + '/key/' + rest, genderBase, flatBase]
: [genderBase, flatBase];
if (hasExt) return bases;
return bases.flatMap((b) => EXT_FALLBACKS.map((ext) => b + ext));
}
const base = this.basePath + id;
if (hasExt) return [base];
@@ -365,8 +378,11 @@ export class WebAudioEngine {
* 资产缺失/无法解码时 console.error 并返回 null,不抛异常。
*/
_load(id) {
// voice 层缓存键带性别:切换 settings.voice 后不会复用旧性别的缓存结果
const key = id.startsWith('voice/') ? `${settings.get().voice}|${id}` : id;
// voice 层缓存键带性别 + 输入模式(key 变体/主资产/平铺是三套不同音频):
// 切换 settings.voice 或输入模式后不会复用旧缓存结果,自然从下一句生效
const key = id.startsWith('voice/')
? `${inputMode() === 'key' ? 'key' : 'main'}|${settings.get().voice}|${id}`
: id;
if (this._cache.has(key)) return this._cache.get(key);
const p = (async () => {
if (!this.ctx) return null;