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:
+191
-5
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user