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:
+96
-15
@@ -34,6 +34,16 @@
|
||||
* - F4 断点续玩恢复后的首句播完才武装跳句(_resumeGuard → _skipLockId);
|
||||
* - F5 链末句跳过保护(等待/过场/抉择提示前的末句单击不可跳)。
|
||||
*
|
||||
* R3 打磨(research/new8/radio/round2/synthesis.md G1-G6):
|
||||
* - G1 帮助链去重:同一等待点第 2 次上滑起 sys_help 口令换「纯音频标记」
|
||||
* (beat 轻震 + nudge earcon,不加新台词方案),状态句照播;
|
||||
* - G2 rep_low 整句播报处(幕间跨档 + 各态上滑复听)一律附 warning 震;
|
||||
* - G3 k1_a/k1_b(糖糖 NPC 平铺组)male 设置下经引擎「性别目录→平铺」回退链
|
||||
* 播出,非静默——确认 + 断言 + 文档注记(设计即单音色,不补双性别资产);
|
||||
* - G4 不可跳句(p* 首播 / F5 尾句 / F4 锁)上的空点单击补 beat 轻震标记;
|
||||
* - G5「首播必完整、复播可即掐」升为显式规则(15-radio.md R3 节)+ 断言;
|
||||
* - G6 上滑状态链重排:「当前通号 → 口碑 → 帮助」(状态在前、帮助垫后)。
|
||||
*
|
||||
* 确定性:铃声为无缝循环垫(无定时器重触发),测试与转写可复现。
|
||||
*/
|
||||
import { Game } from '../engine/game.js';
|
||||
@@ -281,6 +291,7 @@ export class RadioGame extends Game {
|
||||
this.sysSkipShown = false; // sys_skip 开场每实例一次(storyline skip_hint 先例)
|
||||
this.saved = null; // ASK 态暂存的存档
|
||||
this._menuQueue = []; // 菜单项报名队列(游戏侧排队,不截断当前报名)
|
||||
this._helpSeen = new Set(); // G1:已全量播过 sys_help 口令的等待点(实例级,第 2 次起纯音频标记)
|
||||
|
||||
// F4 断点续玩跳句锁:_resume 置位,恢复链首句起播时落为该句 id,播完即清
|
||||
//(首句播完才武装跳句——ASK「单击=确认」与「单击=跳句」的语义陷阱,R2 F4)
|
||||
@@ -391,10 +402,13 @@ export class RadioGame extends Game {
|
||||
});
|
||||
break;
|
||||
case 'up': {
|
||||
// 状态播报(第几通 + 口碑档),留在 RING
|
||||
// 状态播报(第几通 + 口碑档),留在 RING——本就「状态在前」且无帮助句
|
||||
//(§2.3 RING 行),G6 重排天然满足;G2:rep_low 附 warning 震
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const ids = [PROG_VOICES[Math.max(0, this.callN - 1)], TIER_VOICES[tierOf(this.rep)]];
|
||||
const tier = TIER_VOICES[tierOf(this.rep)];
|
||||
this._tierCue(tier);
|
||||
const ids = [PROG_VOICES[Math.max(0, this.callN - 1)], tier];
|
||||
this._playSeq(ids, () => {
|
||||
this.voicePlaying = false;
|
||||
if (this.phase === 'RING') this._armRingWatchdog();
|
||||
@@ -441,6 +455,11 @@ export class RadioGame extends Game {
|
||||
if (!this.promptPlaying && !this._isTailSentence()
|
||||
&& this.currentVoiceId !== this._skipLockId) {
|
||||
this.audio.skipVoice();
|
||||
} else {
|
||||
// G4(R1 P3-1 / R2 P3-4 遗留):不可跳句上的单击「点了但没选上」,
|
||||
// gesture 层确认震 [20] 游戏侧无法撤销——补一记最轻 beat [8] 形成
|
||||
// 可区分签名(20→8 连击 ≠ 真确认的单记 20),消解「像点上了」误导
|
||||
vibrate('beat');
|
||||
}
|
||||
} else if (node.prompt != null && this.choiceArmed) {
|
||||
this.armChoiceHint(); // 抉择等待态空点 = 重新挂载兜底(25s 计时重置)
|
||||
@@ -519,12 +538,17 @@ export class RadioGame extends Game {
|
||||
this._bridgeChain(this._bridgeAnnounce ? [this._bridgeAnnounce] : []);
|
||||
break;
|
||||
case 'up': {
|
||||
// 帮助 + 状态,自动链继续(垫乐喘息不被打断——链尾照常进下一通)
|
||||
// G6:状态(下一通 + 口碑档)在前、帮助垫后,自动链继续(垫乐喘息不被
|
||||
// 打断——链尾照常进下一通);G2:rep_low 附 warning 震;
|
||||
// G1:同一等待点第 2 次起口令换纯音频标记
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const ids = ['voice/sys_help'];
|
||||
const ids = [];
|
||||
if (this.callN < CALLS.length) ids.push(PROG_VOICES[this.callN]); // 下一通
|
||||
ids.push(TIER_VOICES[tierOf(this.rep)]);
|
||||
const tier = TIER_VOICES[tierOf(this.rep)];
|
||||
this._tierCue(tier);
|
||||
ids.push(tier);
|
||||
this._withHelp(ids, 'B');
|
||||
this._bridgeChain(ids);
|
||||
break;
|
||||
}
|
||||
@@ -546,8 +570,12 @@ export class RadioGame extends Game {
|
||||
switch (g) {
|
||||
case 'tap':
|
||||
if (this.awaitingRestart) this.newNight();
|
||||
else if (this.voicePlaying && this.currentVoiceId !== this._skipLockId) {
|
||||
this.audio.skipVoice(); // F4:续玩恢复的首句播完才武装跳句
|
||||
else if (this.voicePlaying) {
|
||||
if (this.currentVoiceId !== this._skipLockId) {
|
||||
this.audio.skipVoice(); // F4:续玩恢复的首句播完才武装跳句
|
||||
} else {
|
||||
vibrate('beat'); // G4:续玩锁首句上的空点单击同款轻震标记
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'doubletap':
|
||||
@@ -558,12 +586,16 @@ export class RadioGame extends Game {
|
||||
this._playEndingChain();
|
||||
break;
|
||||
case 'up': {
|
||||
// 帮助 + 状态(结局主句=结局名播报 + 口碑档整句,§2.3「结局名+口碑」;
|
||||
// storyline P3-2 先例),播完回结算态
|
||||
// 状态(结局主句=结局名播报 + 口碑档整句,§2.3「结局名+口碑」;
|
||||
// storyline P3-2 先例)在前 + 帮助垫后(G6),G2:rep_low 附 warning 震,
|
||||
// G1:同一等待点第 2 次起口令换纯音频标记;播完回结算态
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const def = ENDINGS[this.endingId] || ENDINGS.e1;
|
||||
this._playSeq(['voice/sys_help', def.voices[0], TIER_VOICES[tierOf(this.rep)]], () => {
|
||||
const tier = TIER_VOICES[tierOf(this.rep)];
|
||||
this._tierCue(tier);
|
||||
const ids = this._withHelp([def.voices[0], tier], `E:${this.endingId}`);
|
||||
this._playSeq(ids, () => {
|
||||
this.voicePlaying = false;
|
||||
if (this.phase === 'ENDING') {
|
||||
this.awaitingRestart = true;
|
||||
@@ -605,11 +637,15 @@ export class RadioGame extends Game {
|
||||
this._execMenuItem(MENU_ITEMS[this.menuIdx].id); // 双击确认执行
|
||||
break;
|
||||
case 'up': {
|
||||
// 帮助 + 菜单内设置提示 + 复述当前项(清空报名队列:帮助链顶掉一切)
|
||||
// 帮助 + 菜单内设置提示 + 复述当前项(清空报名队列:帮助链顶掉一切)。
|
||||
// MENU 链无通号/口碑状态句,G6 重排不适用(帮助保持在前);G1 适用:
|
||||
// 同一菜单等待点第 2 次起口令换纯音频标记,设置提示与当前项照播
|
||||
this._menuQueue = [];
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
this._playSeq(['voice/sys_help', 'voice/sys_settings', MENU_ITEMS[this.menuIdx].voice], () => {
|
||||
const ids = this._withHelp([], 'M');
|
||||
ids.push('voice/sys_settings', MENU_ITEMS[this.menuIdx].voice);
|
||||
this._playSeq(ids, () => {
|
||||
this.voicePlaying = false;
|
||||
});
|
||||
break;
|
||||
@@ -1113,15 +1149,60 @@ export class RadioGame extends Game {
|
||||
|
||||
// ---------------------------------------------------------------- 帮助 / 复述
|
||||
|
||||
/** 上滑:帮助 + 状态(第几通 + 口碑档整句),随后重播当前节点(局面复述)。 */
|
||||
/**
|
||||
* G2(R1 P3-3 遗留):rep_low 整句播报附 warning 震——「还没立起来」出口的
|
||||
* 同时手腕有感。原实现只在 BRIDGE 跨档播报时震,各态上滑复听不震,
|
||||
* TIER_VOICES 注释与行为不符(live 局 A t=99.6-102.0 RING 上滑复现)。
|
||||
*/
|
||||
_tierCue(tierId) {
|
||||
if (tierId === TIER_VOICES.low) vibrate('warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* G1 帮助链去重(R3,压力局 sys_help 口令 38 次逐字重播,违反「提示永不逐字
|
||||
* 重复」公理 §11 #9)。不加新台词方案:同一等待点第 1 次全量口播 sys_help;
|
||||
* 第 2 次起改「纯音频标记」——beat 轻震 + nudge earcon(复用统一 8 音词表,
|
||||
* 零新增),状态句照播(复述状态=合法帮助,P1 先例)。等待点键:ONAIR/INTRO
|
||||
* 按节点、BRIDGE/菜单整段、ENDING 按结局;集合实例级(不跨实例持久化,
|
||||
* sys_skip 每实例一次同款口径——重进游戏首滑仍是完整口令)。
|
||||
* @returns {boolean} true = 本次应全量口播 sys_help
|
||||
*/
|
||||
_helpSpokenOnce(waitKey) {
|
||||
if (this._helpSeen.has(waitKey)) {
|
||||
vibrate('beat'); // 轻震标记(gesture 层确认震之外的 game 层「口令省略」签名)
|
||||
if (this.guide.level() !== 'off') {
|
||||
// earcon 直用 guide 合成器(复用 nudge「轻提醒」音,零新增词表;
|
||||
// guide 无公开单发 earcon API,_startRing 直用引擎总线同款先例)
|
||||
this.guide._playEarcon('nudge');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this._helpSeen.add(waitKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** G6:状态在前、帮助垫后——把帮助句接在状态链尾(「当前通号 → 口碑 → 帮助」)。 */
|
||||
_withHelp(ids, waitKey) {
|
||||
if (this._helpSpokenOnce(waitKey)) ids.push('voice/sys_help');
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上滑:状态(第几通 + 口碑档整句)在前 + 帮助垫后(G6 重排,round2 G6/
|
||||
* P1 建议「状态放最前」),随后重播当前节点(局面复述)。
|
||||
* INTRO 开播前无通号/口碑可报,仅帮助(或标记)后重播当前段(实现取舍登记)。
|
||||
*/
|
||||
_playHelpAndResume() {
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const ids = ['voice/sys_help'];
|
||||
const ids = [];
|
||||
if (this.phase === 'ONAIR' && this.callN >= 1) {
|
||||
ids.push(PROG_VOICES[this.callN - 1]);
|
||||
ids.push(TIER_VOICES[tierOf(this.rep)]);
|
||||
const tier = TIER_VOICES[tierOf(this.rep)];
|
||||
this._tierCue(tier); // G2
|
||||
ids.push(tier);
|
||||
}
|
||||
this._withHelp(ids, `S:${this.nodeId}`); // G1:同一等待点第 2 次起纯音频标记
|
||||
this._playSeq(ids, () => {
|
||||
this.voicePlaying = false;
|
||||
if (this.phase === 'ONAIR' || this.phase === 'INTRO') this.playNode(this.nodeId);
|
||||
|
||||
+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