research(new8/batch2): abyss + detective 迭代闭环收口——双双 R1-R2 连续达标裁定可交付

abyss《深海回音》:
- R1 补 synthesis(5/5 愿意玩 P0=0)+ 补录 R1 缺失裁决材料
- R2 十修全部现场坐实:F1 ping迟到/尾句保护首播制/链末看门狗不拆/回波静默一拍/dv4_dual扣账口播/crit水面变体/看门狗13s/震循环1.1s/震去重/状态链人数句
- 补录语音18段(flock串行,QC无离群);playtest 242→278
- 终裁:4/4 人设满分档,连续 2 轮达标 → 可交付 ✅

detective《雨夜专案》:
- R1 取证轮:补跑 live 两会话(认真新手33分钟全弧线/乱按200s三级保底抢救),5/5 愿意玩 P0=0
- R2 十一修全部坐实:intro完整落声/tut不被掐/按轴取句/人名指认/结案确认闸/指路句/选句记忆位/hint轻震/st_wrong当场记账/tut_skip补动词/L2疑点齐分支
- 补录+改词33条(29新增+4重录);playtest 265→279
- 终裁:6/6 愿意玩,连续 2 轮达标 → 可交付 ✅

公共:00-program 批次2进度账收账、13号文档 R2 断言关键词、gen_voice_dual 两游戏段、voice_text.json
验证:abyss 278/278 + detective 279/279 + guide 122/122 + check_all_assets 全项目全绿
This commit is contained in:
xsl
2026-09-14 00:05:42 +08:00
parent 005851d01a
commit c8843233b0
670 changed files with 6726 additions and 218 deletions
+189 -18
View File
@@ -497,6 +497,9 @@ realLog('== 主路径 · 剧本 A(e1,guide=off)==');
await tapTo(game, () => game.phase === 'DIVE' && game.diveN === 4);
assert(game.diveForm === 'double', '氧气门:oxy=6 ≥4 → DIVE(4) 双载形态');
assert(game.oxy === 2, 'DIVE(4) 双载起播 oxy 6-3-1=2(双载额外 +1,§4.3)');
await advance(() => voicePlays(playLog, 'voice/dv4_dual') === 1);
assert(voicePlays(playLog, 'voice/dv4_dual') === 1,
'R2-F4:双载形态下潜链补播 dv4_dual「这趟往返,扣四格气」(3+1 扣账口播化)');
// 8. DIVE(4):滑坡危机拍(landslide 前置 2s + danger 震循环)→ p7 双载提示
await reachSearch(game);
@@ -505,8 +508,11 @@ realLog('== 主路径 · 剧本 A(e1,guide=off)==');
assert(voicePlays(playLog, 'sfx/echo_groan') === 2 && voicePlays(playLog, 'sfx/echo_metal_r') === 1,
'D4 搜索:echo_groan 渐强 + echo_metal_r(§6.3:接近指挥舱必播金属回波)');
assert(voicePlays(playLog, 'sfx/landslide') === 1, 'D4 危机拍:landslide 前置起播');
const dVibs = vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length;
assert(dVibs >= 3, `D4 danger 震循环(每遍 ≥400ms,实际 ${dVibs} 遍)`);
const dVibsAll = vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN);
assert(dVibsAll.length >= 3, `D4 danger 震循环(实际 ${dVibsAll.length} 遍)`);
const dGaps = dVibsAll.slice(1).map((v, k) => v.t - dVibsAll[k].t);
assert(dGaps.every((gap) => gap >= 1000),
`R2-F8:danger 震循环间隔 ≥1000ms(实际间隔 ${dGaps.join('/')},R1 的 500ms 约 10 连震过密)`);
const iSlide = lastIndexOf(playLog, 'sfx/landslide');
const iSlideV = lastIndexOf(playLog, 'voice/dv4_slide');
assert(iSlide < iSlideV, 'landslide 前置 2s 后才接 dv4_slide 语音(R4 危机拍优先)');
@@ -519,9 +525,10 @@ realLog('== 主路径 · 剧本 A(e1,guide=off)==');
'hasLog=false → 心结引入播 gao_c(条件差分)');
await g(game, 'right'); // p8 右 = 不问
await tapTo(game, () => game.phase === 'SURFACE' && game.diveN === 4);
await advance(() => voicePlays(playLog, 'voice/oxy_crit') === 1);
assert(game.oxy === 2 && voicePlays(playLog, 'voice/oxy_crit') === 1,
'SURFACE(4):oxy=2 → crit 档跨档播报(§4.5:双载收队 2-lefts)');
await advance(() => voicePlays(playLog, 'voice/oxy_crit_deck') === 1);
assert(game.oxy === 2 && voicePlays(playLog, 'voice/oxy_crit_deck') === 1
&& voicePlays(playLog, 'voice/oxy_crit') === 0,
'SURFACE(4):oxy=2 → crit 档跨档播报水面变体 oxy_crit_deck(R2-F5;§4.5:双载收队 2-lefts)');
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
'G2:oxy_crit 播报位附 warning 震');
@@ -637,8 +644,8 @@ realLog('== 四结局矩阵(剧本 B / C / D / E / F)==');
});
assert(game.phase === 'SURFACE' && game.nodeId === 'c_none' && game.diveForm === 'cancel',
'剧本 C:SURFACE(3) oxy=2 ≤2 → 氧气门走终局取消链(dv4_none 起播)');
assert(game.oxy === 2 && voicePlays(playLog, 'voice/oxy_crit') === 1,
'剧本 C:D3 后 oxy=2(crit 档,取消门与 b_rule「气不够一潜必须上浮」一致)');
assert(game.oxy === 2 && voicePlays(playLog, 'voice/oxy_crit_deck') === 1,
'剧本 C:D3 后 oxy=2(crit 档水面变体,取消门与 b_rule「气不够一潜必须上浮」一致)');
await playRun(game, SCRIPT_C, { from: ci });
assert(voicePlays(playLog, 'voice/dv4_none') === 1 && voicePlays(playLog, 'voice/gao_final') === 1,
'取消链完整播出 dv4_none → gao_final(措辞克制、零描写)');
@@ -682,6 +689,8 @@ realLog('== 四结局矩阵(剧本 B / C / D / E / F)==');
});
assert(game.diveForm === 'single' && game.oxy === 0,
'剧本 D:氧气门 oxy=3 → 单载形态;DIVE(4) oxy 3-3=0');
assert(voicePlays(playLog, 'voice/dv4_dual') === 0,
'R2-F4:单载形态不播 dv4_dual(扣账句只随双载 3+1 出现)');
await playRun(game, SCRIPT_D, { from: 6 });
assert(voicePlays(playLog, 'voice/p7_chen') === 1 && voicePlays(playLog, 'voice/p7') === 0
&& voicePlays(playLog, 'voice/p7_zheng') === 0, '单载·留守=陈医官 → 播 p7_chen(按 stayer 选提示)');
@@ -895,6 +904,19 @@ realLog('== 菜单交互(guide=off,p1 期间呼出)==');
assert(game.phase === 'COMMS' && game.nodeId === 'p1', '下滑关闭菜单 → 回原状态');
await advance(() => game.choiceArmed);
// V1 复核(R1 遗留:报名与执行一致性)——快速连切 3 下(menuIdx 回绕到 0=回到
// 水下)立即双击:执行的恰是 menuIdx 当前项(报名队列滞后不劫持执行)
await g(game, 'down');
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await g(game, 'tap');
await g(game, 'tap');
assert(game.menuIdx === 0, 'V1:连切 3 下回绕到首项(回到水下)');
await g(game, 'doubletap');
assert(game.phase === 'COMMS' && game.nodeId === 'p1',
'V1:双击执行 = menuIdx 当前项(回到水下 → 关菜单回原节点,无劫持)');
await advance(() => game.choiceArmed);
// 保存并回大厅(第三项)
await g(game, 'down');
await advance(() => !game.voicePlaying);
@@ -961,13 +983,16 @@ realLog('== 中断复述(guide=off,预置存档 node=bai_b)==');
await advance(() => game.choiceArmed);
assert(game.nodeId === 'p3' && game.promptPlaying === false, '恢复局内推进正常:bai_b 链末 → p3');
// 上滑状态链(G6):prog_d2 → oxy_low → sys_help(oxy=5 → low 档整句)
// 上滑状态链(G6,R2-F10):prog_d2 → prog_saved1 → oxy_low → sys_help
// (存档 saved.zhou=1;oxy=5 → low 档整句)
await g(game, 'up');
await advance(() => !game.voicePlaying);
const iP = lastIndexOf(playLog, 'voice/prog_d2');
const iS = lastIndexOf(playLog, 'voice/prog_saved1');
const iR = lastIndexOf(playLog, 'voice/oxy_low');
const iH = lastIndexOf(playLog, 'voice/sys_help');
assert(iP >= 0 && iP < iR && iR < iH, '恢复局上滑状态链顺序(G6):prog_d2 → oxy_low → sys_help');
assert(iP >= 0 && iP < iS && iS < iR && iR < iH,
'恢复局上滑状态链顺序(G6+R2-F10):prog_d2 → prog_saved1 → oxy_low → sys_help');
await advance(() => game.choiceArmed); // 状态播完局面复述回 p3
teardown(game);
}
@@ -1049,7 +1074,7 @@ realLog('== 看门狗双轨(guide=full)==');
teardown(game);
}
// ---- C:COMMS 链末 25s/50s gd_continue/gd_again + 双轨去重
// ---- C:COMMS 链末 13s/30s gd_continue/gd_again(R2-F7:full 档对齐 standard)+ 双轨去重
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
@@ -1061,9 +1086,9 @@ realLog('== 看门狗双轨(guide=full)==');
await advance(() => !game.voicePlaying); // z1_a 链末等待
const tW = now;
await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1);
assert(now - tW === 25000, `COMMS 链末 25s 静默 → gd_continue(实际 ${now - tW}ms)`);
assert(now - tW === 13000, `COMMS 链末 13s 静默 → gd_continue(R2-F7,实际 ${now - tW}ms)`);
await advance(() => voicePlays(playLog, 'voice/gd_again') === 1);
assert(now - tW === 50000, `50s → gd_again(第二级新句,实际 ${now - tW}ms)`);
assert(now - tW === 30000, `30s → gd_again(第二级新句,实际 ${now - tW}ms)`);
await advance(() => now >= tW + 80000);
assert(voicePlays(playLog, 'voice/gd_again') === 1 && voicePlays(playLog, 'voice/gd_prog1') === 0,
'两级用尽彻底静默;prog1 同级被 gd_continue 交叉登记跳过');
@@ -1075,11 +1100,11 @@ realLog('== 看门狗双轨(guide=full)==');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'F1 前置:链末无效手势照常零静默(gd_invalid)');
const gc0 = voicePlays(playLog, 'voice/gd_continue');
await advance(() => voicePlays(playLog, 'voice/gd_continue') === gc0 + 1);
assert(now - tW2 === 25000, `F1:链末等待中发无效手势,看门狗仍在阈值内触发(25s 整,实际 ${now - tW2}ms)`);
assert(now - tW2 === 13000, `F1:链末等待中发无效手势,看门狗仍在阈值内触发(13s 整,实际 ${now - tW2}ms)`);
teardown(game);
}
// ---- D:p* 播完 25s/50s 重播提示句两级后静默;ENDING 25s/50s;签名总账
// ---- D:p* 播完 25s/50s 重播提示句两级后静默;ENDING 13s/30s(R2-F7);签名总账
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
@@ -1118,9 +1143,9 @@ realLog('== 看门狗双轨(guide=full)==');
const gc0 = voicePlays(playLog, 'voice/gd_continue');
const ga0 = voicePlays(playLog, 'voice/gd_again');
await advance(() => voicePlays(playLog, 'voice/gd_continue') === gc0 + 1);
assert(now - tE === 25000, `ENDING 结算 25s 静默 → gd_continue(实际 ${now - tE}ms)`);
assert(now - tE === 13000, `ENDING 结算 13s 静默 → gd_continue(R2-F7,实际 ${now - tE}ms)`);
await advance(() => voicePlays(playLog, 'voice/gd_again') === ga0 + 1);
assert(now - tE === 50000, `50s → gd_again(第二级新句,实际 ${now - tE}ms)`);
assert(now - tE === 30000, `30s → gd_again(第二级新句,实际 ${now - tE}ms)`);
await advance(() => now >= tE + 70000);
assert(voicePlays(playLog, 'voice/gd_again') === ga0 + 1, 'ENDING 两级用尽彻底静默');
await g(game, 'tap');
@@ -1261,6 +1286,151 @@ realLog('== 结算死窗(幽灵手势不截结算链)==');
}
}
// ================================================================ R2 修复专项
// (round1/synthesis.md F1/F2/F3/F5/F6/F9/F10 逐条现场对应断言)
realLog('== R2 修复专项(ping 即时 / 尾句保护 / 跳句复播 / crit 场景 / 状态链)==');
{
// ---- F1:向导散句(gd_ping1)在播时单击发脉冲 → 先掐散句、ping 立即出音
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
await advance(() => voicePlays(playLog, 'voice/gd_ping1') === 1); // 10s 看门狗第一句
await flush(10); // 等引擎 _load 落地、_voice 置位(fetch/decode 微任务链)
assert(audio.isVoicePlaying(), '前置:gd_ping1 向导散句在播');
const ping0 = voicePlays(playLog, 'sfx/sonar_ping');
await g(game, 'tap'); // 照做单击:掐散句 + 立即出 ping(R1 根因:入队迟到 12~17s)
assert(voicePlays(playLog, 'sfx/sonar_ping') === ping0 + 1,
'R2-F1:gd 行在播时单击 → sonar_ping 立即起播(不经 sonarGate 排队)');
assert(!audio.isVoicePlaying(), 'R2-F1:gd 散句被掐(玩家已照做,提示说完即过时)');
assert(game._sonarQueue.length === 0, 'R2-F1:脉冲不入 sonarGate 队(零迟到)');
await advance(() => game.phase === 'COMMS');
assert(voicePlays(playLog, 'sfx/echo_metal_l') === 1 && voicePlays(playLog, 'voice/dv1_metal') === 1,
'F1 后回波链正常走完(echo → 解读,顺序复原)');
teardown(game);
}
// ---- F3:回波→静默一拍→解读(wait ≥ 资产实长 2.76s + 一拍)
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
await g(game, 'tap');
await advance(() => game.currentVoiceId === 'voice/dv1_metal');
const tEcho = playLog.find((e) => e.id === 'sfx/echo_metal_l').t;
const recMetal = game._voiceIntervals.find((r) => r.id === 'voice/dv1_metal');
assert(recMetal && recMetal.start - tEcho >= 3300,
`R2-F3:echo_metal_l 起播 → dv1_metal 起播间隔 ${recMetal.start - tEcho}ms ≥ 3300(资产 2.76s + 静默一拍)`);
teardown(game);
}
// ---- F2a:dyn 注入的档位句纳入 F5 尾句保护(播放中单击不跳 + beat)
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
const ciA = await playRun(game, SCRIPT_A, {
stop: () => game.currentVoiceId === 'voice/oxy_full',
});
assert(game.nodeId === 's_tier' && game.currentVoiceId === 'voice/oxy_full',
'前置:档位句 oxy_full 在播(dyn 注入 s_tier 链尾)');
await g(game, 'tap');
assert(game.voicePlaying && game.currentVoiceId === 'voice/oxy_full',
'R2-F2:档位句播放中单击不跳句(R1 根因:尾句保护按 _nodeSteps 重算漏掉 dyn 句)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
'G4:档位句上空点单击播 beat 轻震');
await playRun(game, SCRIPT_A, { from: ciA }); // 链路未受污染,照常到 e1
assert(game.endingId === 'e1', 'F2 验证后剧本 A 照常走完(e1)');
teardown(game);
}
// ---- F2b:签收链入口 800ms 死窗(SURFACE(4)→g9_in 过场的在途 tap 被吞)
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playRun(game, SCRIPT_B, {
stop: () => game.nodeId === 'g9_in' && game.voicePlaying,
});
assert(game.currentVoiceId === 'voice/g9_in', '前置:签收链首句 g9_in 在播');
await g(game, 'tap'); // 800ms 死窗内的在途 tap(R1 live:g9_in 被剁到 0.28s)
assert(game.voicePlaying && game.currentVoiceId === 'voice/g9_in',
'R2-F2:签收链入口 800ms 死窗内的在途 tap 被吞(g9_in 不被掐)');
await playRun(game, SCRIPT_B, { from: 8 });
assert(game.endingId === 'e2' && voicePlays(playLog, 'voice/g9_in') === 1
&& voicePlays(playLog, 'voice/g9_knot') === 1, '死窗后签收链完整有序(g9_in → g9_knot → p9)');
teardown(game);
}
// ---- F6:叙事尾句首播保护 / 复播可即掐(G5 后半句兑现)
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
await g(game, 'tap');
await advance(() => game.currentVoiceId === 'voice/z1_a');
await g(game, 'tap'); // 首播中的叙事尾句:不跳
assert(game.voicePlaying && game.currentVoiceId === 'voice/z1_a',
'R2-F6:首播中叙事尾句单击不跳(首播必完整,radio 吃尾根因不回潮)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
'G4:首播保护句上的空点单击播 beat 轻震');
await advance(() => !game.voicePlaying); // z1_a 自然播完(链末等待,登记 _tailHeard)
await g(game, 'down'); // 开菜单
await advance(() => game.phase === 'MENU' && !game.voicePlaying);
await g(game, 'down'); // 关菜单 → 重播 z1_a(局面复述)
await advance(() => game.currentVoiceId === 'voice/z1_a' && game.voicePlaying);
await g(game, 'tap'); // 复播:单击即掐
assert(!game.voicePlaying && game.nodeId === 'z1_a',
'R2-F6:链自然播完后复述重播,尾句单击即跳(复播可即掐,sys_skip 承诺兑现)');
assert(voicePlays(playLog, 'voice/z1_a') === 2, 'z1_a 恰重播一次(菜单复述)');
await g(game, 'tap'); // 链末继续,剧情正常推进
await advance(() => game.currentVoiceId === 'voice/z1_b');
assert(game.nodeId === 'z1_b', '跳句后链末判定无错位(z1_a → z1_b 正常续链)');
teardown(game);
}
// ---- F5/F9/F10:水下 crit 状态链(原句保留)+ warning 震按档去重 + 已救人数
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playRun(game, SCRIPT_D, {
stop: () => game.searchArmed && game.diveN === 4,
});
assert(game.oxy === 0 && game.diveForm === 'single', '前置:剧本 D 单载 D4 搜索等待,oxy=0(crit)');
const warn0 = vibrateLog.filter((v) => JSON.stringify(v.pattern) === WARN_PATTERN).length;
await g(game, 'up');
await advance(() => !game.voicePlaying && game.searchArmed);
const iP = lastIndexOf(playLog, 'voice/prog_d4');
const iS = lastIndexOf(playLog, 'voice/prog_saved3'); // 剧本 D 此时 saved=3(舟/白/郑)
const iR = lastIndexOf(playLog, 'voice/oxy_crit');
assert(iP >= 0 && iP < iS && iS < iR,
'R2-F10+F5:水下状态链 prog_d4 → prog_saved3 → oxy_crit(人数=N=3;水下保留原句)');
assert(voicePlays(playLog, 'voice/oxy_crit_deck') === 0,
'R2-F5:水下 crit 不用水面变体(oxy_crit_deck 零播出)');
const warn1 = vibrateLog.filter((v) => JSON.stringify(v.pattern) === WARN_PATTERN).length;
assert(warn1 === warn0 + 1, 'G2:进入 crit 档首次状态链附 warning 震');
await g(game, 'up'); // 同档再次查状态
await advance(() => !game.voicePlaying && game.searchArmed);
const warn2 = vibrateLog.filter((v) => JSON.stringify(v.pattern) === WARN_PATTERN).length;
assert(warn2 === warn1, 'R2-F9:crit 档不变时复听状态链不重复 warning 震(按档进入去重)');
assert(voicePlays(playLog, 'voice/prog_saved3') === 2, 'F10:人数句照播(去重只针对震与口令)');
teardown(game);
}
}
// ================================================================ 键盘变体路由
// (settings.input='key' → 引擎候选链前置 voice/<gender>/key/ 探测;'touch' 不探测)
@@ -1326,10 +1496,11 @@ realLog('== 静态审计 ==');
{
const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8');
for (const id of ['voice/prog_d1', 'voice/prog_d2', 'voice/prog_d3', 'voice/prog_d4',
'voice/oxy_full', 'voice/oxy_ok', 'voice/oxy_low', 'voice/oxy_crit', 'voice/oxy_gain',
'voice/prog_saved1', 'voice/prog_saved2', 'voice/prog_saved3', 'voice/prog_saved4', 'voice/prog_saved5',
'voice/oxy_full', 'voice/oxy_ok', 'voice/oxy_low', 'voice/oxy_crit', 'voice/oxy_crit_deck', 'voice/oxy_gain',
'voice/dry_a', 'voice/dry_b', 'voice/gd_ping1', 'voice/gd_ping2',
'voice/gd_prog1', 'voice/gd_prog2', 'voice/gd_continue', 'voice/gd_again',
'voice/p7', 'voice/p7_chen', 'voice/p7_zheng', 'voice/g9_dry']) {
'voice/p7', 'voice/p7_chen', 'voice/p7_zheng', 'voice/g9_dry', 'voice/dv4_dual']) {
assert(src.includes(`'${id}'`), `分档/差分整句字面量存在:${id}`);
}
assert(!/voice\/\$\{/.test(src), '无模板字符串拼接 voice id');