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:
+126
-79
@@ -7,8 +7,9 @@
|
||||
* (navigator.maxTouchPoints=1 手机 persona;guide:'off' 回归纯玩法逻辑)。
|
||||
*
|
||||
* 资产契约:引擎对缺失资产只 console.error 不崩——本测试在资产未落盘时也必须
|
||||
* 全绿(每句 8s 兜底续链推进),故 plays() 查询不过滤 missing 项;资产齐套后
|
||||
* 同样全绿(顺序/次数断言与时长无关)。资产审计只做信息性报告不判失败。
|
||||
* 全绿(每句 15s 兜底续链推进,R2 F1 起 8s→15s:兜底须高于全部真实资产),
|
||||
* 故 plays() 查询不过滤 missing 项;资产齐套后同样全绿(顺序/次数断言与时长无关)。
|
||||
* 资产审计只做信息性报告不判失败。
|
||||
*
|
||||
* 运行:tools/node/bin/node web/detective/test/playtest.mjs
|
||||
* 退出码 0 = 全部断言通过。seed/pack 由 opts 注入复现(§6.2)。
|
||||
@@ -223,15 +224,16 @@ async function navToItem(game, itemId) {
|
||||
await tick(650);
|
||||
}
|
||||
|
||||
/** 完整听取一个条目(nav + tap + 等链播完)。 */
|
||||
/** 完整听取一个条目(nav + tap + 等链播完;资产缺失时 15s/句兜底 → 6 句 ≈95s 余量)。 */
|
||||
async function listenItem(game, itemId) {
|
||||
await navToItem(game, itemId);
|
||||
g(game, 'tap');
|
||||
return waitState(() => game._play === null && game.state === 'BOARD', 80000);
|
||||
return waitState(() => game._play === null && game.state === 'BOARD', 120000);
|
||||
}
|
||||
|
||||
/** CONFRONT 内选一句:滑到条目 → 单击进选句 → 滑到句 → 单击确认 → 单击定下。 */
|
||||
async function cfSelectLine(game, caseData, lineId) {
|
||||
/** CONFRONT 内选一句:滑到条目 → 单击进选句 → 滑到句 → 单击确认 → 单击定下。
|
||||
* entryIdx 可选:R2 F7 选句位置记忆——进选句相位时断言起始 lineIdx。 */
|
||||
async function cfSelectLine(game, caseData, lineId, entryIdx) {
|
||||
const itemId = caseData.lineItem[lineId];
|
||||
const target = LISTEN_ITEMS.indexOf(itemId);
|
||||
let guard = 0;
|
||||
@@ -243,6 +245,9 @@ async function cfSelectLine(game, caseData, lineId) {
|
||||
await tick(650);
|
||||
g(game, 'tap'); // 确认条目 → 进选句
|
||||
await tick(300);
|
||||
if (entryIdx != null) {
|
||||
assert(game._cf?.lineIdx === entryIdx, `选句入口=记忆位 ${entryIdx}(实际 ${game._cf?.lineIdx},R2 F7)`);
|
||||
}
|
||||
const ids = caseData.layout.lines[itemId];
|
||||
const li = ids.indexOf(lineId);
|
||||
let lg = 0;
|
||||
@@ -252,30 +257,32 @@ async function cfSelectLine(game, caseData, lineId) {
|
||||
await tick(120);
|
||||
}
|
||||
g(game, 'tap'); // 进确认(重播该句 + cf_confirm)
|
||||
await tick(17000); // 确认链(句 + cf_confirm)走完——玩家听清「就这句?」再定下
|
||||
await tick(32000); // 确认链(句 + cf_confirm)走完——玩家听清「就这句?」再定下(15s 兜底余量)
|
||||
g(game, 'tap'); // 定下这句
|
||||
}
|
||||
|
||||
/** 从 BOARD 空闲发起一次完整指证(长按进入 + 两侧选句),等判定链收尾回板。
|
||||
* 长按带重试:E2 受理条件=无语音在播——播报尾音未排空时补一拍重试(与时长解耦)。 */
|
||||
async function confront(game, caseData, lineA, lineB, { maxMs = 90000 } = {}) {
|
||||
async function confront(game, caseData, lineA, lineB, { maxMs = 120000, entryIdxA } = {}) {
|
||||
for (let attempt = 0; attempt < 3 && game.state !== 'CONFRONT'; attempt++) {
|
||||
g(game, 'longpress');
|
||||
if (game.state !== 'CONFRONT') await tick(1500);
|
||||
}
|
||||
await waitState(() => game.state === 'CONFRONT' && game._cf && game._cf.phase === 'item1', 12000);
|
||||
await tick(9000); // cf_start(缺失时 8s 兜底)→ 条目报名
|
||||
await cfSelectLine(game, caseData, lineA);
|
||||
await tick(17500); // cf_second + 第一份自动重播(P1 记忆契约)走完
|
||||
await tick(16000); // cf_start(缺失时 15s 兜底)→ 条目报名
|
||||
await cfSelectLine(game, caseData, lineA, entryIdxA);
|
||||
await tick(32000); // cf_second + 第一份自动重播(P1 记忆契约)走完
|
||||
await cfSelectLine(game, caseData, lineB);
|
||||
return waitState(() => game.state === 'BOARD' && !game._play, maxMs);
|
||||
}
|
||||
|
||||
/** 从 BOARD 走到 FINISH(滑到结案陈词条目 + 单击)。 */
|
||||
/** 从 BOARD 走到 FINISH(滑到结案陈词条目 → 单击进确认闸(fin_pre)→ 闸内再单击进三问,R2 F5)。 */
|
||||
async function enterFinish(game) {
|
||||
await navToItem(game, 'case');
|
||||
g(game, 'tap');
|
||||
await tick(200);
|
||||
g(game, 'tap');
|
||||
await tick(200);
|
||||
}
|
||||
|
||||
/** 快速跳过教学直达 BOARD(新案已开)。 */
|
||||
@@ -318,10 +325,10 @@ async function scenario1() {
|
||||
let mark = audio.log.length;
|
||||
g(game, 'tap');
|
||||
await waitState(() => game.state === 'DRILL' && game._drill && game._drill.step === 0, 20000);
|
||||
await tick(36000); // sys_new_case + intro_1..3 + tut_d0 四句整链(缺失时 8s/句兜底)
|
||||
await tick(80000); // sys_new_case + intro_1..3 + tut_d0 五句整链(缺失时 15s/句兜底)
|
||||
assert(game.run && game.run.seed === 42, 'opts.seed=42 注入新案');
|
||||
assert(plays(audio, mark).orderOk('voice/sys_new_case', 'voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/tut_d0'),
|
||||
'D0 序:sys_new_case → intro_1/2/3 → tut_d0(资产缺失时整链仍经 8s 兜底推进——§4.3 契约)');
|
||||
'D0 序:sys_new_case → intro_1/2/3 → tut_d0(资产缺失时整链仍经 15s 兜底推进——§4.3 契约)');
|
||||
assert(plays(audio).has('bgm/board'), 'DRILL bgm/board');
|
||||
|
||||
// D0:左滑或右滑任一(首输入达成点)
|
||||
@@ -338,12 +345,17 @@ async function scenario1() {
|
||||
}
|
||||
|
||||
// D1:单击 → 老葛真实证词链播(教学寄生于真实案卷)
|
||||
await tick(9000); // tut_listen(兜底)+ 回正报名
|
||||
await tick(32000); // R2 F2:item_w2 报名落声 → tut_listen 串行接播 → 回正报名(互不掐)
|
||||
{
|
||||
const seg = plays(audio, mark);
|
||||
assert(seg.orderOk('voice/item_w2', 'voice/tut_listen'),
|
||||
'R2 F2:D0 报名(item_w2)先于 D1 指令(tut_listen)串行落声(原并行互掐,live R1 实测指令只播 0.4s)');
|
||||
}
|
||||
mark = audio.log.length;
|
||||
g(game, 'tap');
|
||||
assert(game._play !== null, 'D1 单击开始证词链播');
|
||||
const w1Ids = game._case.layout.lines.w1;
|
||||
const done1 = await waitState(() => game._drill && game._drill.step === 2, 80000);
|
||||
const done1 = await waitState(() => game._drill && game._drill.step === 2, 120000);
|
||||
assert(done1, 'D1 证词链播完 → D2');
|
||||
{
|
||||
const seg = plays(audio, mark);
|
||||
@@ -358,24 +370,24 @@ async function scenario1() {
|
||||
assert(game.run.heard.includes('w1'), 'D1 听完 w1 入账 heard(st_asked 真实来源)');
|
||||
|
||||
// D2:双击重新听最后一句(免费)
|
||||
await tick(9000); // tut_replay
|
||||
await tick(16000); // tut_replay
|
||||
mark = audio.log.length;
|
||||
const lastW1 = game.run.lastLine.w1;
|
||||
g(game, 'doubletap');
|
||||
await tick(9000);
|
||||
await tick(16000);
|
||||
assert(plays(audio, mark).has('voice/' + lastW1), `D2 双击重播最后一句(${lastW1})`);
|
||||
assert(game._drill?.step === 3, 'D2 重播完 → D3');
|
||||
|
||||
// D3:上滑状态链(st_asked_1 → st_doubt_0 → recap_go)
|
||||
await tick(9000); // tut_status
|
||||
await tick(16000); // tut_status
|
||||
mark = audio.log.length;
|
||||
g(game, 'up');
|
||||
await waitState(() => game._drill?.step === 4, 45000);
|
||||
await waitState(() => game._drill?.step === 4, 70000);
|
||||
assert(plays(audio, mark).orderOk('voice/st_asked_1', 'voice/st_doubt_0'), 'D3 状态链:st_asked_1 → st_doubt_0');
|
||||
assert(plays(audio, mark).has('voice/recap_go'), 'D3 状态链尾 recap_go');
|
||||
assert(plays(audio, mark).has('voice/recap_go'), 'D3 状态链尾 recap_go(未听齐时仍是「接着查」)');
|
||||
|
||||
// D4:长按进入指证(E2 豁免教学)
|
||||
await tick(9000); // tut_confront
|
||||
await tick(16000); // tut_confront
|
||||
mark = audio.log.length;
|
||||
g(game, 'longpress');
|
||||
await tick(200);
|
||||
@@ -387,12 +399,12 @@ async function scenario1() {
|
||||
|
||||
// D5:任意选择组合(选 w2 首句 vs scene 首句——真判定必 miss 的组合)只播 tut_cf_ok
|
||||
const before = { cfMiss: game.run.cfMiss, doubts: [...game.run.doubts], zHits: game.run.zHits };
|
||||
await tick(17000); // cf_start + tut_cf_try 链走完 → 条目报名
|
||||
await cfSelectLine(game, game._case, game._case.layout.lines.w2[0]);
|
||||
await tick(17500); // cf_second + 自动重播
|
||||
await tick(32000); // cf_start + tut_cf_try 链走完 → 条目报名
|
||||
await cfSelectLine(game, game._case, game._case.layout.lines.w2[0], 0); // R2 F7:首访条目记忆位=0
|
||||
await tick(32000); // cf_second + 自动重播
|
||||
mark = audio.log.length;
|
||||
const vib5 = vibrateLog.length;
|
||||
await cfSelectLine(game, game._case, game._case.layout.lines.scene[0]);
|
||||
await cfSelectLine(game, game._case, game._case.layout.lines.scene[0], 0);
|
||||
const done5 = await waitState(() => game.state === 'DRILL' && game._drill?.step === 6, 30000);
|
||||
assert(done5, 'D5 判定被教学截获 → 回 DRILL D6');
|
||||
assert(plays(audio, mark).has('voice/tut_cf_ok'), 'D5 任意组合只播 tut_cf_ok(练习不算数)');
|
||||
@@ -412,7 +424,7 @@ async function scenario1() {
|
||||
// 上滑随时可用(教过后)
|
||||
mark = audio.log.length;
|
||||
g(game, 'up');
|
||||
await tick(40000);
|
||||
await tick(50000);
|
||||
assert(plays(audio, mark).orderOk('voice/st_asked_1', 'voice/st_doubt_0'), 'BOARD 上滑状态链随时可用');
|
||||
game.stop();
|
||||
|
||||
@@ -425,7 +437,7 @@ async function scenario1() {
|
||||
await waitState(() => b.game.state === 'DRILL', 20000);
|
||||
mark = b.audio.log.length;
|
||||
g(b.game, 'down');
|
||||
await waitState(() => b.game.state === 'BOARD', 20000);
|
||||
assert(await waitState(() => b.game.state === 'BOARD', 25000), '下滑跳过教学直达 BOARD');
|
||||
assert(plays(b.audio, mark).has('voice/tut_skip'), '下滑跳过教学播 tut_skip');
|
||||
assert(b.game.meta.drillDone === true, '跳过也置 drillDone');
|
||||
b.game._save();
|
||||
@@ -528,7 +540,7 @@ async function scenario4() {
|
||||
|
||||
mark = audio.log.length;
|
||||
g(game, 'up');
|
||||
await tick(40000);
|
||||
await tick(50000);
|
||||
assert(plays(audio, mark).has('voice/st_asked_1'), '听完 1 份 → 上滑 st_asked_1');
|
||||
|
||||
// w4:播放中双击=当前句重播、左右滑静默忽略、单击停止、断句续听
|
||||
@@ -574,13 +586,15 @@ async function scenario4() {
|
||||
await tick(9000);
|
||||
assert(plays(audio, mark).has('voice/' + scCur), `打断后双击重听当前句(${scCur})`);
|
||||
|
||||
// 全部听齐 → st_asked_6
|
||||
// 全部听齐 → st_asked_6;R2 F6:尾句换指路 recap_cf(不再是干巴巴的「接着查」)
|
||||
for (const id of ['w1', 'w3', 'w4', 'fore', 'scene']) assert(await listenItem(game, id), `${id} 听取完成`);
|
||||
assert(game.run.heard.length === 6, '六份卷宗全部入账');
|
||||
mark = audio.log.length;
|
||||
g(game, 'up');
|
||||
await tick(40000);
|
||||
await tick(50000);
|
||||
assert(plays(audio, mark).has('voice/st_asked_6'), '六份听齐 → st_asked_6');
|
||||
assert(plays(audio, mark).has('voice/recap_cf') && !plays(audio, mark).has('voice/recap_go'),
|
||||
'R2 F6:听齐未破时状态链尾=recap_cf(「挑一句你不信的,按住指证」),不再播 recap_go');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
@@ -603,7 +617,7 @@ async function scenario5() {
|
||||
{
|
||||
const p = plays(audio, mark);
|
||||
assert(p.has('sfx/contra_hit') && p.has('voice/cf_hit') && p.has('voice/z_react_1'),
|
||||
'M1 hit 链:contra_hit → cf_hit → z_react_1(zHits=1 递进取句)');
|
||||
'M1 hit 链:contra_hit → cf_hit → z_react_1(R2 F3:按轴取句 lamp→react_1)');
|
||||
assert(p.orderOk('sfx/contra_hit', 'voice/cf_hit', 'voice/z_react_1'), 'M1 hit 链序:音先响、语音后接');
|
||||
assert(p.orderOk('voice/cf_confirm', 'voice/cf_second', 'voice/z_2'), '第二份选择前自动重播第一份选定句(P1 记忆契约)');
|
||||
}
|
||||
@@ -611,27 +625,30 @@ async function scenario5() {
|
||||
assert(game.run.doubts[0] === true && game.run.zHits === 1, 'M1 doubts[0] 置位 + zHits=1');
|
||||
assert(game.run.cfMiss === 0, 'hit 不计 cfMiss(账目语义=冗余/错过)');
|
||||
|
||||
// M2(key:z_3 vs w2_3)
|
||||
// M2(key:z_3 vs w2_3)——R2 F7:w3 选句入口应记忆在 z_2(idx 1),不再从 z_1 扫起
|
||||
mark = audio.log.length;
|
||||
assert(await confront(game, cs, 'z_3', 'w2_3'), 'M2 指证完成');
|
||||
assert(plays(audio, mark).has('voice/z_react_2') && game.run.doubts[1] === true, 'M2 → z_react_2 + doubts[1]');
|
||||
assert(await confront(game, cs, 'z_3', 'w2_3', { entryIdxA: 1 }), 'M2 指证完成(选句入口=记忆位 1)');
|
||||
assert(plays(audio, mark).has('voice/z_react_2') && game.run.doubts[1] === true, 'M2 → z_react_2(key 轴)+ doubts[1]');
|
||||
|
||||
// M3/M4 共用 z_4(一句两断言,选 z_4 两次分别对 fo_3 与 sc_4)
|
||||
mark = audio.log.length;
|
||||
assert(await confront(game, cs, 'z_4', 'fo_3'), 'M3 指证完成(z_4 vs fo_3)');
|
||||
assert(plays(audio, mark).has('voice/z_react_3') && game.run.doubts[2] === true, 'M3 → z_react_3 + doubts[2]');
|
||||
assert(await confront(game, cs, 'z_4', 'fo_3', { entryIdxA: 2 }), 'M3 指证完成(z_4 vs fo_3,入口=记忆位 2)');
|
||||
assert(plays(audio, mark).has('voice/z_react_w') && game.run.doubts[2] === true,
|
||||
'M3 → z_react_w(R2 F3:weapon 轴专属慌「铜器…我怎么会知道」,不再错配秤/灯)');
|
||||
mark = audio.log.length;
|
||||
assert(await confront(game, cs, 'z_4', 'sc_4'), 'M4 指证完成(z_4 vs sc_4,同一句两次)');
|
||||
assert(plays(audio, mark).has('voice/z_react_4') && game.run.doubts[3] === true, 'M4 → z_react_4 + doubts[3]');
|
||||
assert(await confront(game, cs, 'z_4', 'sc_4', { entryIdxA: 3 }), 'M4 指证完成(z_4 vs sc_4,同一句两次,入口=记忆位 3)');
|
||||
assert(plays(audio, mark).has('voice/z_react_3') && game.run.doubts[3] === true, 'M4 → z_react_3(scale 轴)+ doubts[3]');
|
||||
|
||||
// 疑点 4/4:hit 链尾自动播一次 hint_fin(引导去结案)
|
||||
// 疑点 4/4:hit 链尾追加 reactFinal 收官句 + 自动播一次 hint_fin(R2 F3)
|
||||
assert(game._doubtCount() === 4 && game.run.zHits === 4, '四疑点齐 + zHits=4');
|
||||
assert(plays(audio, mark).has('voice/hint_fin'), '4/4 齐时 hit 链尾播 hint_fin(一次)');
|
||||
assert(plays(audio, mark).orderOk('voice/z_react_3', 'voice/z_react_4', 'voice/hint_fin'),
|
||||
'4/4 齐时链尾:轴句 → reactFinal(z_react_4 逐客)→ hint_fin(一次)');
|
||||
assert(!game.run.hintsGiven.includes('hint_fin'), '庆祝性 hint_fin 不计入保底提示账(S 档可达)');
|
||||
mark = audio.log.length;
|
||||
g(game, 'up');
|
||||
await tick(40000);
|
||||
await tick(50000);
|
||||
assert(plays(audio, mark).has('voice/st_doubt_4'), '状态链 st_doubt_4(疑点实数)');
|
||||
assert(plays(audio, mark).has('voice/hint_fin'), 'R2 F6:疑点齐时状态链尾=hint_fin(去结案)');
|
||||
|
||||
// 长按豁免边界(§11 边界):播放中长按=引擎快进不进指证;空闲长按=CONFRONT
|
||||
await navToItem(game, 'w1');
|
||||
@@ -645,14 +662,28 @@ async function scenario5() {
|
||||
g(game, 'longpress');
|
||||
await tick(200);
|
||||
assert(game.state === 'CONFRONT', 'E2:空闲长按 → CONFRONT');
|
||||
// CONFRONT 长按 = error 震(选择语境不作输入)
|
||||
// CONFRONT 条目相位长按 = error 震(选择语境不作输入)
|
||||
vib = vibrateLog.length;
|
||||
g(game, 'longpress');
|
||||
await tick(200);
|
||||
assert(vibratedError(vib) && game.state === 'CONFRONT', 'CONFRONT 长按 error 震不受理');
|
||||
assert(vibratedError(vib) && game.state === 'CONFRONT', 'CONFRONT 条目相位长按 error 震不受理');
|
||||
// R2 F7:选句相位长按放行引擎快进——语音在播不震;空闲无可快进=error 震
|
||||
await tick(16000); // cf_start + 报名落声
|
||||
g(game, 'tap'); // 确认当前条目 → 进选句(播 cf_pick_line + 入口句)
|
||||
await tick(400); // 语音在播中
|
||||
vib = vibrateLog.length;
|
||||
g(game, 'longpress');
|
||||
await tick(200);
|
||||
assert(!vibratedError(vib) && game._cf && (game._cf.phase === 'line1' || game._cf.phase === 'line2'),
|
||||
'R2 F7:选句相位语音在播长按=引擎快进接管(无 error 震、相位不变)');
|
||||
await tick(2000); // 等落声(空闲)
|
||||
vib = vibrateLog.length;
|
||||
g(game, 'longpress');
|
||||
await tick(200);
|
||||
assert(vibratedError(vib), 'R2 F7:选句相位空闲长按=error 震(无可快进)');
|
||||
// 菜单「返回证词板」退出指证
|
||||
g(game, 'down');
|
||||
await tick(10000); // sys_menu + 首项
|
||||
await tick(32000); // sys_menu + 首项
|
||||
mark = audio.log.length;
|
||||
g(game, 'doubletap'); // 双击确认 sys_menu_resume(=返回证词板)
|
||||
await waitState(() => game.state === 'BOARD', 15000);
|
||||
@@ -724,22 +755,32 @@ async function scenario7() {
|
||||
}
|
||||
assert(game._doubtCount() === 4, '四疑点齐');
|
||||
|
||||
// 错指小顺(optIdx 1)→ acc_shun + fin_clear → 回 BOARD(无限再机会)
|
||||
// 错指小顺(optIdx 1)→ acc_shun + fin_clear + st_wrong → 回 BOARD(无限再机会)
|
||||
let mark = audio.log.length;
|
||||
// R2 F5:结案确认闸——首击只播 fin_pre 不进 FINISH;滑走撤闸;闸内再击才进
|
||||
await navToItem(game, 'case');
|
||||
g(game, 'tap');
|
||||
await tick(200);
|
||||
assert(game.state === 'BOARD' && game._caseGate === true && plays(audio, mark).has('voice/fin_pre'),
|
||||
'R2 F5:结案陈词首击=确认闸(fin_pre),仍 BOARD');
|
||||
g(game, 'left'); // 滑走撤闸
|
||||
await tick(200);
|
||||
assert(game._caseGate === false && game.state === 'BOARD', 'R2 F5:滑走撤闸留板(继续查)');
|
||||
await enterFinish(game);
|
||||
assert(plays(audio, mark).has('voice/fin_start'), 'FINISH 入口播 fin_start');
|
||||
assert(plays(audio, mark).orderOk('voice/fin_pre', 'voice/fin_start'), '闸内再击 → FINISH(fin_pre → fin_start)');
|
||||
await waitState(() => game._fin && game._fin.phase === 'who', 15000);
|
||||
mark = audio.log.length;
|
||||
g(game, 'right');
|
||||
await tick(650);
|
||||
assert(plays(audio, mark).has('voice/item_w2'), '真凶问右滑播选项名(item_w2)');
|
||||
assert(plays(audio, mark).has('voice/item_who_w2'), 'R2 F4:真凶问右滑播人名(item_who_w2「学徒小顺。」),不再念卷宗名');
|
||||
g(game, 'tap');
|
||||
await tick(17500); // 两段确认链(重播选项名 + fin_who_confirm)走完
|
||||
await tick(32000); // 两段确认链(重播人名 + fin_who_confirm)走完
|
||||
assert(game._fin?.phase === 'whoConfirm' && plays(audio, mark).has('voice/fin_who_confirm'),
|
||||
'真凶问两段确认(重播选项名 + fin_who_confirm)');
|
||||
'真凶问两段确认(重播人名 + fin_who_confirm「就指认这位?」——R2 F4 中性化)');
|
||||
g(game, 'tap');
|
||||
assert(await waitState(() => game.state === 'BOARD', 30000), '错指 → 回 BOARD');
|
||||
assert(plays(audio, mark).orderOk('voice/acc_shun', 'voice/fin_clear'), '错指链:acc_shun(不在场证明自述)→ fin_clear');
|
||||
assert(await waitState(() => game.state === 'BOARD', 60000), '错指 → 回 BOARD');
|
||||
assert(plays(audio, mark).orderOk('voice/acc_shun', 'voice/fin_clear', 'voice/st_wrong'),
|
||||
'错指链:acc_shun(不在场证明自述)→ fin_clear → st_wrong(R2 F9 当场记账)');
|
||||
assert(game.run.wrongWho === 1, 'wrongWho 计数');
|
||||
|
||||
// 错指老葛(optIdx 0,无需滑动)→ acc_ge(无限再机会)
|
||||
@@ -749,7 +790,7 @@ async function scenario7() {
|
||||
g(game, 'tap');
|
||||
await tick(200);
|
||||
g(game, 'tap');
|
||||
assert(await waitState(() => game.state === 'BOARD', 30000), '再错指仍回 BOARD(无限再机会)');
|
||||
assert(await waitState(() => game.state === 'BOARD', 60000), '再错指仍回 BOARD(无限再机会)');
|
||||
assert(plays(audio, mark).has('voice/acc_ge'), '错指老葛 → acc_ge');
|
||||
assert(game.run.wrongWho === 2, 'wrongWho=2');
|
||||
|
||||
@@ -765,14 +806,14 @@ async function scenario7() {
|
||||
g(game, 'right');
|
||||
g(game, 'right');
|
||||
await tick(650);
|
||||
assert(plays(audio, mark).has('voice/item_w3'), '滑到周先生(item_w3)');
|
||||
assert(plays(audio, mark).has('voice/item_who_w3'), '滑到周先生(item_who_w3,R2 F4 人名)');
|
||||
mark = audio.log.length;
|
||||
g(game, 'tap');
|
||||
await tick(200);
|
||||
g(game, 'tap'); // 正式指认
|
||||
assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 25000), '指认对 → 凶器问');
|
||||
assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 45000), '指认对 → 凶器问');
|
||||
assert(plays(audio, mark).has('voice/fin_ask_weapon'), '凶器问入口 fin_ask_weapon');
|
||||
await tick(10000); // 问句链 + busy 解除 + 首选项报名落声
|
||||
await tick(17000); // 问句链 + busy 解除 + 首选项报名落声
|
||||
// 凶器 4 选项全可预览(左右滑一次听一项,选项序=layout.wpOpts)
|
||||
for (let i = 0; i < 4; i++) { g(game, 'right'); await tick(300); }
|
||||
{
|
||||
@@ -785,17 +826,17 @@ async function scenario7() {
|
||||
for (let i = 0; i < wpIdx; i++) g(game, 'right');
|
||||
await tick(300);
|
||||
g(game, 'tap');
|
||||
assert(await waitState(() => game._fin && game._fin.phase === 'motive', 25000), '凶器答完 → 动机问');
|
||||
assert(await waitState(() => game._fin && game._fin.phase === 'motive', 45000), '凶器答完 → 动机问');
|
||||
assert(plays(audio).has('voice/fin_wp_right') && !plays(audio).has('voice/fin_wp_wrong'), '答对凶器播 fin_wp_right');
|
||||
assert(game.run.weaponAns === 'wp_2', 'weaponAns 入账');
|
||||
await tick(10000); // 问句链 + busy 解除
|
||||
await tick(17000); // 问句链 + busy 解除
|
||||
// 动机答错 mv_2 → fin_mv_wrong(真话归因,不重答)→ VERDICT
|
||||
const mvIdx = cs.layout.mvOpts.indexOf('mv_2');
|
||||
for (let i = 0; i < mvIdx; i++) g(game, 'right');
|
||||
await tick(300);
|
||||
mark = audio.log.length;
|
||||
g(game, 'tap');
|
||||
assert(await waitState(() => game.state === 'VERDICT', 25000), '动机答完 → VERDICT');
|
||||
assert(await waitState(() => game.state === 'VERDICT', 45000), '动机答完 → VERDICT');
|
||||
assert(plays(audio, mark).has('voice/fin_mv_wrong'), '动机答错播 fin_mv_wrong(不重答,记账)');
|
||||
|
||||
// ---- 场景 8:VERDICT 死窗 + 链锁(§7.4:dungeon H1 同款) ----
|
||||
@@ -810,7 +851,7 @@ async function scenario7() {
|
||||
g(game, 'right');
|
||||
g(game, 'longpress');
|
||||
assert(game.state === 'VERDICT', '链内上滑/下滑/双击/右滑/长按全部吞掉(链锁)');
|
||||
const settleDone = await waitState(() => game.state === 'SETTLE', 140000);
|
||||
const settleDone = await waitState(() => game.state === 'SETTLE', 220000);
|
||||
assert(settleDone, 'VERDICT 全链播完 → SETTLE');
|
||||
const p = plays(audio, mark);
|
||||
assert(p.orderOk('voice/fin_right', 'voice/z_break_1', 'voice/z_break_2', 'voice/z_break_3', 'sfx/gavel'),
|
||||
@@ -899,6 +940,12 @@ async function scenario10() {
|
||||
await tick(10000);
|
||||
g(a.game, 'doubletap');
|
||||
await waitState(() => a.game.state === 'BOARD', 15000);
|
||||
// R2 F6:六份听齐且疑点未齐时,BOARD 一级提示换 gd_board_cf
|
||||
a.game.run.heard = [...LISTEN_ITEMS]; // 测试钩子:六份听齐(真实路径由场景 4 覆盖)
|
||||
g(a.game, 'right'); // 手势触发重挂(一级提示词按局面重取)
|
||||
await tick(11000);
|
||||
assert(plays(a.audio, mark).has('voice/gd_board_cf'), 'R2 F6:听齐未破时 BOARD 一级=gd_board_cf(不再教「换卷宗」)');
|
||||
a.game.run.heard = []; // 还原,免污染后续断言
|
||||
// FINISH 20s
|
||||
await enterFinish(a.game);
|
||||
await waitState(() => a.game._fin && a.game._fin.phase === 'who', 15000);
|
||||
@@ -1017,14 +1064,14 @@ async function scenario11() {
|
||||
assert(a2.game.run.seed === 55 && a2.game.run.pack === 0, '读档还原 (seed,pack)');
|
||||
assert(JSON.stringify(a2.game._case.layout) === JSON.stringify(a1.game._case.layout), '读档 layout 复用存档值(不重洗)');
|
||||
const p = plays(a2.audio, mark);
|
||||
await tick(58000); // 复述链 6 句(缺失时 8s/句兜底)整链走完
|
||||
await tick(100000); // 复述链 6 句(缺失时 15s/句兜底)整链走完
|
||||
const pAfter = plays(a2.audio, mark);
|
||||
assert(pAfter.orderOk('voice/st_asked_3', 'voice/item_w2', 'voice/item_w3', 'voice/item_w4', 'voice/st_doubt_0', 'voice/recap_go'),
|
||||
'复述链序:st_asked_3 → 未听 3 名直报(item_w2/w3/w4)→ st_doubt_0 → recap_go');
|
||||
'复述链序:st_asked_3 → 未听 3 名直报(item_w2/w3/w4)→ st_doubt_0 → recap_go(未听齐时尾句不变)');
|
||||
assert(a2.game.run.heard.length === 3, 'heard 还原 3 份');
|
||||
mark = a2.audio.log.length;
|
||||
g(a2.game, 'up');
|
||||
await tick(58000);
|
||||
await tick(100000);
|
||||
assert(plays(a2.audio, mark).orderOk('voice/st_asked_3', 'voice/item_w2', 'voice/st_doubt_0'),
|
||||
'上滑同链可重放(中断复述链=状态链同构)');
|
||||
a2.game.stop();
|
||||
@@ -1079,9 +1126,9 @@ async function scenario12() {
|
||||
g(game, 'tap');
|
||||
await tick(200);
|
||||
g(game, 'tap');
|
||||
assert(await waitState(() => game.state === 'BOARD', 30000), '错指周回 BOARD');
|
||||
assert(plays(audio, mark).has('voice/acc_zhou') && plays(audio, mark).has('voice/fin_clear'),
|
||||
'错指周先生 → acc_zhou(不在场证明)+ fin_clear');
|
||||
assert(await waitState(() => game.state === 'BOARD', 60000), '错指周回 BOARD');
|
||||
assert(plays(audio, mark).orderOk('voice/acc_zhou', 'voice/fin_clear', 'voice/st_wrong'),
|
||||
'错指周先生 → acc_zhou(不在场证明)+ fin_clear + st_wrong 记账(R2 F9)');
|
||||
|
||||
// 保底提示 = b_hint_c1/c2(m1b 已发现 → 轮询到 m4b)
|
||||
game.run.doubts = [true, false, false, false]; // 钩子:仅 m1b 已发现(真实路径同上)
|
||||
@@ -1098,21 +1145,21 @@ async function scenario12() {
|
||||
g(game, 'tap');
|
||||
await tick(200);
|
||||
g(game, 'tap');
|
||||
assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 25000), '指认小顺 → 凶器问');
|
||||
await tick(10000); // 问句链 + busy 解除
|
||||
assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 45000), '指认小顺 → 凶器问');
|
||||
await tick(17000); // 问句链 + busy 解除
|
||||
const wpIdx = cs.layout.wpOpts.indexOf('wp_2');
|
||||
for (let i = 0; i < wpIdx; i++) g(game, 'right');
|
||||
await tick(300);
|
||||
g(game, 'tap');
|
||||
assert(await waitState(() => game._fin && game._fin.phase === 'motive', 25000), '凶器对 → 动机问');
|
||||
await tick(10000); // 问句链 + busy 解除
|
||||
assert(await waitState(() => game._fin && game._fin.phase === 'motive', 45000), '凶器对 → 动机问');
|
||||
await tick(17000); // 问句链 + busy 解除
|
||||
const mvIdx = cs.layout.mvOpts.indexOf('mv_1');
|
||||
for (let i = 0; i < mvIdx; i++) g(game, 'right');
|
||||
await tick(300);
|
||||
mark = audio.log.length;
|
||||
g(game, 'tap');
|
||||
assert(await waitState(() => game.state === 'VERDICT', 25000), '三问齐 → VERDICT');
|
||||
assert(await waitState(() => game.state === 'SETTLE', 140000), '结算链完整(链锁)');
|
||||
assert(await waitState(() => game.state === 'VERDICT', 45000), '三问齐 → VERDICT');
|
||||
assert(await waitState(() => game.state === 'SETTLE', 220000), '结算链完整(链锁)');
|
||||
const p = plays(audio, mark);
|
||||
assert(p.orderOk('voice/fin_right_b', 'voice/b_shun_break_1', 'voice/b_shun_break_2', 'voice/b_shun_break_3', 'sfx/gavel'),
|
||||
'pack B 认罪链:fin_right_b → b_shun_break_1..3 → gavel');
|
||||
@@ -1164,14 +1211,14 @@ async function scenario14() {
|
||||
|
||||
let mark = audio.log.length;
|
||||
g(game, 'down');
|
||||
await tick(10000); // sys_menu + 首项(兜底)
|
||||
await tick(32000); // sys_menu + 首项(15s/句兜底余量)
|
||||
assert(game.menuOpen, '下滑呼出模态菜单');
|
||||
const p0 = plays(audio, mark);
|
||||
assert(p0.has('voice/sys_menu') && p0.has('voice/sys_menu_resume'), '菜单开场:sys_menu + 首项 resume');
|
||||
// 帮助(上滑)
|
||||
mark = audio.log.length;
|
||||
g(game, 'up');
|
||||
await tick(20000);
|
||||
await tick(32000);
|
||||
assert(plays(audio, mark).orderOk('voice/sys_help', 'voice/settings_hint'), '菜单上滑=帮助链(sys_help + settings_hint)');
|
||||
// 循环切项:单击 resume→cf→save→help→restart→resume(5 项循环回绕)
|
||||
mark = audio.log.length;
|
||||
@@ -1185,11 +1232,11 @@ async function scenario14() {
|
||||
await tick(300);
|
||||
mark = audio.log.length;
|
||||
g(game, 'doubletap');
|
||||
assert(await waitState(() => game.state === 'CONFRONT', 15000), '菜单「去指证」进 CONFRONT');
|
||||
assert(await waitState(() => game.state === 'CONFRONT', 25000), '菜单「去指证」进 CONFRONT');
|
||||
assert(plays(audio, mark).has('voice/cf_start'), '去指证 = 长按同效(cf_start 同点)');
|
||||
// 「返回证词板」退出
|
||||
g(game, 'down');
|
||||
await tick(10000);
|
||||
await tick(32000);
|
||||
mark = audio.log.length;
|
||||
g(game, 'doubletap'); // resume(=返回证词板)
|
||||
assert(await waitState(() => game.state === 'BOARD', 15000), '菜单「返回证词板」关闭回板');
|
||||
@@ -1197,14 +1244,14 @@ async function scenario14() {
|
||||
// 保存并回大厅:PUT data.summary 命中生成规则
|
||||
const fetch0 = fetchLog.length;
|
||||
g(game, 'down');
|
||||
await tick(10000); // 开菜单(idx=0 resume)
|
||||
await tick(32000); // 开菜单(idx=0 resume)
|
||||
g(game, 'tap'); // → cf
|
||||
await tick(300);
|
||||
g(game, 'tap'); // → save
|
||||
await tick(300);
|
||||
mark = audio.log.length;
|
||||
g(game, 'doubletap'); // 确认保存
|
||||
assert(await waitState(() => plays(audio, mark).has('voice/sys_saved'), 15000), '确认保存播 sys_saved');
|
||||
assert(await waitState(() => plays(audio, mark).has('voice/sys_saved'), 25000), '确认保存播 sys_saved');
|
||||
const put = fetchLog.slice(fetch0).find((f) => f.url === '/api/progress' && f.opts && f.opts.method === 'PUT');
|
||||
assert(put, '保存触发 PUT /api/progress');
|
||||
if (put) {
|
||||
@@ -1240,7 +1287,7 @@ function assetReport() {
|
||||
} else {
|
||||
ok('voice 双性别目录未落盘(资产 agent 并行生产中)');
|
||||
}
|
||||
ok(`运行期引用 id 共 ${allIds.size} 个,其中缺失 ${missing.size} 个${missing.size ? '(每句 8s 兜底全链推进,流程未卡死——§4.3 契约)' : ''}`);
|
||||
ok(`运行期引用 id 共 ${allIds.size} 个,其中缺失 ${missing.size} 个${missing.size ? '(每句 15s 兜底全链推进,流程未卡死——§4.3 契约)' : ''}`);
|
||||
}
|
||||
|
||||
// ================================ 主流程 ================================
|
||||
|
||||
Reference in New Issue
Block a user