feat(new8/R3): 双游戏收口修复轮 + 验证档案(radio G1-G6 / dungeon E0-1..E1-2)

- radio:帮助链去重(同等待点二次=beat震+earcon,全量口令38→20)/rep_low警告震/空点轻震/状态链重排(状态在前)/「首播必完整、复播可即掐」显式规则登记/G3核查为设计使然转断言;221→246断言
- dungeon:看门狗双轨制(无操作轨+无进展轨,30/60/90s三级升级到导航接管)/跳教学90s自动滚出(2403s→95s内)/踩楼梯到达即报贴身铁器/band1新词「带回来一点,不多」全管线重录/逃跑长按900ms+提示句;1610→1642断言
- R3 验证(进行中已入库部分):全量回归 9/9 全绿;R3 转写×4;P2/P3/P4 终审×6份(radio 满分档首评/三审,dungeon 4/4.5/3-4 全票愿意玩,无P0);两处独立定址同一残余=漫游型迷路刷不响无进展轨(R4 第一条)
- 8套件+guide全绿;Mureka 累计 $0.87/$2.00
This commit is contained in:
xsl
2026-09-02 23:08:35 +08:00
parent 111dfed68a
commit f7509ad5d8
40 changed files with 9298 additions and 67 deletions
+191 -5
View File
@@ -443,6 +443,9 @@ realLog('== 主路径 · 剧本 A(E1,guide=off)==');
'voice/ep_deer_ok', 'voice/ep_kid_ok', 'voice/ep_wife_ok', 'voice/ep_han_ok']) {
assert(voicePlays(playLog, id) === 1, `结局链播出 ${id}`);
}
assert(voicePlays(playLog, 'sfx/onair_sting') === 1 && voicePlays(playLog, 'sfx/line_drop') >= 1
&& voicePlays(playLog, 'sfx/tape_static') === 1,
'SA3 音效接线:onair_sting(开场台标)/ line_drop(幕间挂断)/ tape_static(p9 直面)各就位');
const sd2 = saveData();
assert(sd2 && sd2.phase === 'ENDING' && sd2.ending === 'e1' && sd2.endings.e1 === true,
'ENDING 进入即存档,endings 收集 e1');
@@ -635,7 +638,8 @@ realLog('== 状态播报与无效手势(guide=full)==');
const iH = lastIndexOf(playLog, 'voice/sys_help');
const iP2 = lastIndexOf(playLog, 'voice/prog_c2');
const iR2 = lastIndexOf(playLog, 'voice/rep_mid');
assert(iH < iP2 && iP2 < iR2, 'BRIDGE 上滑状态链顺序:sys_help → prog_c2(下一通)→ rep_mid');
assert(iP2 >= 0 && iP2 < iR2 && iR2 < iH,
'BRIDGE 上滑状态链顺序(G6):prog_c2(下一通)→ rep_mid → sys_help(状态在前、帮助垫后)');
await advance(() => game.phase === 'RING');
assert(game.callN === 2, 'BRIDGE 上滑后自动链继续进 RING(c2)');
teardown(game);
@@ -748,7 +752,7 @@ realLog('== 中断复述(guide=off,预置存档 node=k1_asko)==');
const iH = lastIndexOf(playLog, 'voice/sys_help');
const iP = lastIndexOf(playLog, 'voice/prog_c3');
const iR = lastIndexOf(playLog, 'voice/rep_mid');
assert(iH >= 0 && iH < iP && iP < iR, '上滑状态链顺序:sys_help → prog_c3 → rep_mid');
assert(iP >= 0 && iP < iR && iR < iH, '上滑状态链顺序(G6):prog_c3 → rep_mid → sys_help');
assert(voicePlays(playLog, 'voice/prog_c3') === 1, 'prog_c3 整句(第三通)');
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/k1_asko') === 2, '状态播完重播当前节点(局面复述)');
@@ -1128,6 +1132,189 @@ realLog('== R2 修复回归(F1 看门狗重挂 / F3 铃声垫 / F4 续玩跳
}
}
// ================================================================ R3 修复回归
// (research/new8/radio/round2/synthesis.md G1-G6 打磨轮;断言增量登记
// docs/design/15-radio.md 文末「R3 修复回归」小节)
realLog('== R3 修复回归(G1 帮助链去重 / G2 rep_low warning 震 / G3 male 回退确认 / G4 空点轻震 / G5 首播复播显式规则 / G6 状态链重排)==');
{
const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); // warning 震型(G2)
const BEAT_PATTERN = JSON.stringify([8]); // beat 震型(G1 标记 / G4 轻震)
// ---- G6+G2+G1(ONAIR):上滑状态链「prog → rep → sys_help」+ rep_low warning 震
// + 同一等待点第 2 次起口令换纯音频标记(震 + nudge earcon),新等待点仍全量
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const earcons = [];
const origEarcon = game.guide._playEarcon.bind(game.guide);
game.guide._playEarcon = (name) => { earcons.push(name); return origEarcon(name); };
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'tap'); // 接听 → z1_ring_in
await advance(() => !game.voicePlaying); // 链末等待(等待点 = S:z1_ring_in)
await g(game, 'up'); // 该等待点第 1 次:全量口令
await advance(() => !game.voicePlaying);
const iP1 = lastIndexOf(playLog, 'voice/prog_c1');
const iR1 = lastIndexOf(playLog, 'voice/rep_low');
const iH1 = lastIndexOf(playLog, 'voice/sys_help');
assert(iP1 >= 0 && iP1 < iR1 && iR1 < iH1,
'G6:ONAIR 上滑状态链顺序 prog_c1 → rep_low → sys_help(状态在前、帮助垫后)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
'G2:ONAIR 上滑复听 rep_low(rep=0)附 warning 震');
await advance(() => !game.voicePlaying); // 局面复述播完,回到同一链末等待
await g(game, 'up'); // 同一等待点第 2 次:纯音频标记
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/sys_help') === 1,
'G1:同一等待点第 2 次上滑不重播 sys_help 口令(提示永不逐字重复)');
assert(voicePlays(playLog, 'voice/prog_c1') === 2 && voicePlays(playLog, 'voice/rep_low') === 2,
'G1:状态句照播(复述状态=合法帮助,去重只针对口令)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
'G1:第 2 次帮助播 beat 轻震标记(不加新台词的纯音频方案)');
assert(earcons.includes('nudge'),
'G1:第 2 次帮助播 nudge earcon(复用统一 8 音词表,零新增)');
await g(game, 'tap'); // 链末单击继续 → z1_a(新等待点)
await advance(() => !game.voicePlaying);
await g(game, 'up'); // 新等待点第 1 次
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/sys_help') === 2,
'G1:去重按等待点——新等待点(z1_a)首滑仍全量口令,非全局静默');
teardown(game);
}
// ---- G2(RING):上滑复听 rep_low 附 warning 震(live 局 B t=198.5/497.5 同款证据位,
// R1 P3-3 遗留——原实现只在 BRIDGE 跨档播报时震且该分支实际不可达)
clearSave(); resetSettings('off'); // off 档:warning 震来自游戏层,与向导档位无关
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'up'); // 状态播报:prog_c1 → rep_low(rep=0)
await advance(() => !game.voicePlaying);
const iP1 = lastIndexOf(playLog, 'voice/prog_c1');
const iR1 = lastIndexOf(playLog, 'voice/rep_low');
assert(iP1 >= 0 && iP1 < iR1, 'G2 前置:RING 上滑播 prog_c1 → rep_low(rep=0 → low 档)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
'G2:RING 上滑复听 rep_low 附 warning 震(R1 P3-3 遗留补齐)');
teardown(game);
}
// ---- G6+G2+G1(ENDING):上滑状态链「结局主句 → 口碑 → sys_help」+ 去重
clearSave(); resetSettings('full');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(), summary: '已收播·静音 · 结局1/4',
phase: 'ENDING', node: 'e4', call: 5, ending: 'e4',
rep: 0, knot: false, heardArgument: false,
bond: { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 }, endings: { e4: true }, nights: 2,
lastCloser: null,
}));
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
await g(game, 'tap'); // 续玩 → 结局链重播(F4 锁只锁首句)
await advance(() => game.awaitingRestart); // 播完回结算态
await g(game, 'up'); // 第 1 次:e4_a → rep_low → sys_help
await advance(() => !game.voicePlaying);
const iE = lastIndexOf(playLog, 'voice/e4_a');
const iR = lastIndexOf(playLog, 'voice/rep_low');
const iH = lastIndexOf(playLog, 'voice/sys_help');
assert(iE >= 0 && iE < iR && iR < iH,
'G6:ENDING 上滑状态链顺序 e4_a(结局主句)→ rep_low → sys_help');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
'G2:ENDING 上滑复听 rep_low 附 warning 震');
const helpN = voicePlays(playLog, 'voice/sys_help');
await g(game, 'up'); // 同一等待点(E:e4)第 2 次:纯音频标记
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/sys_help') === helpN,
'G1:ENDING 同一等待点第 2 次上滑不重播 sys_help 口令');
teardown(game);
}
// ---- G4+G5:不可跳句空点 beat 轻震(p* / F5 尾句 / F4 锁三处)+
// 「首播必完整、复播可即掐」——F4 锁只锁首播一句,复播(局面复述)不重新武装
clearSave(); resetSettings('off');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(), summary: '第4通·争吵的夫妻 · 口碑+9 · 结局0/4',
phase: 'ONAIR', node: 'w1_say', call: 4, ending: null,
rep: 9, knot: false, heardArgument: true,
bond: { zhou: 1, deer: 1, kid: 1, wife: 0, han: 0 }, endings: {}, nights: 1, lastCloser: null,
}));
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
await g(game, 'tap'); // 恢复 w1_say 组(首句受 F4 锁)
assert(game.currentVoiceId === 'voice/w1_say', 'G4 前置:断点续玩恢复,首句 w1_say 在播');
await g(game, 'tap'); // 锁内单击:不跳(G5 ASK 恢复首句例外)
assert(game.currentVoiceId === 'voice/w1_say',
'G5:ASK 恢复链首句例外——锁内单击不跳(首播必完整,F4)');
assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === BEAT_PATTERN).length === 1,
'G4:F4 锁内空点单击播 beat 轻震(可区分于真确认的 [20])');
await advance(() => game.currentVoiceId === 'voice/w1_sayo'); // 首句播完,锁释放
await g(game, 'down'); // 呼出菜单
await advance(() => !game.voicePlaying);
await g(game, 'down'); // 关菜单 → 局面复述(重播 w1_say 组 = 复播)
assert(game.currentVoiceId === 'voice/w1_say', 'G5 前置:关菜单局面复述回放首句 w1_say');
await g(game, 'tap'); // 复播的首句单击 → 可跳(锁不随复播重新武装)
await flush();
assert(game.currentVoiceId === 'voice/w1_sayo',
'G5:复播可即掐——F4 锁只锁首播一句,复播(局面复述)首句单击照常跳过');
await advance(() => game.currentVoiceId === 'voice/w1_saye');
await g(game, 'tap'); // F5 链末句(交接 p7 前)单击不可跳
assert(game.currentVoiceId === 'voice/w1_saye' && game.voicePlaying,
'G5:首播必完整——链末关键句(交接抉择提示前)单击不可跳(F5)');
assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === BEAT_PATTERN).length === 2,
'G4:F5 尾句空点单击播 beat 轻震(R2 P3-4「p9 后空点」证据位)');
await advance(() => !game.voicePlaying); // 尾句完整播完 → 链末等待
await g(game, 'tap'); // 链末单击继续 → p7 提示句起播
const vibP = vibrateLog.length;
await g(game, 'tap'); // p* 播放中单击不可跳(PROMPT_RE 门)
assert(game.currentVoiceId === 'voice/p7' && game.promptPlaying,
'G5:首播必完整——p* 首播单击不跳(PROMPT_RE 门)');
assert(vibrateLog.slice(vibP).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
'G4:p* 不可跳句空点单击播 beat 轻震(R1 P3-1 证据位)');
teardown(game);
}
// ---- G3:male 设置下 k1_a/k1_b(糖糖 NPC 平铺组)回退行为——性别目录 404 后
// 平铺版照常播出(非静默),回退链工作正常 → 确认 + 断言 + 文档注记
clearSave(); resetSettings('off');
settings.set({ voice: 'male' });
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(), summary: '第3通·失眠的孩子 · 口碑+2 · 结局0/4',
phase: 'ONAIR', node: 'k1_in', call: 3, ending: null,
rep: 2, knot: false, heardArgument: false,
bond: { zhou: 1, deer: 1, kid: 0, wife: 0, han: 0 }, endings: {}, nights: 3, lastCloser: null,
}));
{
const { audio, playLog, game } = makeRig();
const errBefore = consoleErrs.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
await g(game, 'tap'); // 恢复 → k1_in(链末等待)→ 单击续进 k1_a → k1_b
await tapUntil(game, () => game.currentVoiceId === 'voice/k1_b', 6);
await advance(() => !game.voicePlaying);
assert(urlHits('audio/voice/male/k1_a') >= 1 && urlHits('audio/voice/male/k1_b') >= 1,
'G3 前置:male 设置先尝试性别目录 male/k1_a、male/k1_b(无资产属设计——NPC 平铺组不做双性别)');
assert(voicePlays(playLog, 'voice/k1_a') === 1 && voicePlays(playLog, 'voice/k1_b') === 1,
'G3:male/k1_a、k1_b 404 后回退平铺版播出(非静默,引擎「性别目录→平铺」回退链正常)');
assert(!consoleErrs.slice(errBefore).some((e) => e.includes('k1_a') || e.includes('k1_b')),
'G3:回退命中平铺版 → 零「资产缺失」console.error(若真静默则修,实测确认无需修)');
settings.set({ voice: 'female' });
teardown(game);
}
}
// ================================================================ 静态审计
// (数字规范:分档整句字面量、无运行时 id 拼接;存档 summary 格式;资产盘点)
@@ -1147,9 +1334,8 @@ realLog('== 静态审计 ==');
const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))];
assert(ids.length > 0, `game.js 提取到 ${ids.length} 个资产 id`);
const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext)));
// Stable Audio 3 批次缺口(gen/gen_sfx.py 头注:HF token 未配置本轮跳过,
// R2 F3 已移除 phone_ring(程序合成铃声循环垫落盘);剩 11 件待补;
// 缺音效走引擎 console.error 静默降级,不阻塞玩法(§6.1 降级方案)。
// Stable Audio 3 批次已全部补产落地(2026-09-02,research/new8/batch1-assets-report.md
// §8:含 R2 F3 phone_ring,共 12 件);保留清单作为守卫——若未来出现新缺失必须仍属该组。
const STABLE_AUDIO_PENDING = new Set(['sfx/onair_sting', 'sfx/line_connect',
'sfx/line_drop', 'sfx/amb_rain_city', 'sfx/amb_station', 'sfx/amb_kid_room', 'sfx/arg_muffled',
'sfx/amb_street_rain', 'sfx/amb_cab', 'sfx/brake_screech', 'sfx/tape_static']);