round4: H1 escape目标带实时方位 + H2 simon轮次占用预告(根治手势两义) + H3 重听账目真实化(诊断=初始发放1次vs承诺2次) + H4 input看门狗5s + H5 归因真话(gd_miss_none) + 20语音 + guide120断言全绿

This commit is contained in:
SoundGame Dev
2026-08-31 02:51:41 +08:00
parent 6be3d29b78
commit 3d37d4656d
55 changed files with 822 additions and 215 deletions
+104 -63
View File
@@ -1,10 +1,12 @@
/**
* 《音序回响》新手会话剧本:教学六音一步一步学(中间故意做错一次听纠错)、
* 收尾听完机会经济句(round3 G5 重录口径:记不住就向上滑重新听);第 1 轮
* 直接答对(途中有双击音就演示「双击=作答」);第 2 轮开局忘序列僵住 10 秒,
* 听到向导「向上滑可以重新听」提示后照做——用掉一次免费重听(round3 G5:
* input 双击永远=作答,重听改绑上滑);第 3 轮凭记忆答对,在**整轮边界**收工
* (round2 剧本修正:不在轮中退出)。
* 《音序回响》新手会话剧本(round4):教学六音一步一步学(中间故意做错一次听纠错)、
* 收尾听完机会经济句;对局段制造 round4 H 组断言点——
* - 含 up 的轮次(R1):序列播完先听占用预告 gd_occ_up,僵 5s 验证被占用轮看门狗
* 只发 nudge 不建议重听(H2),期望恰为 up 时上滑=作答;
* - 含 doubletap 的轮次(R2)与 up+doubletap 双占用轮(R6):gd_occ_dt / gd_occ_both;
* - 无尝试超时(R5/R7):干净轮僵 5s → 看门狗建议上滑重听 → 用掉两次免费重听,
* 第 1 次听到「免费重听还剩一次」(H3:余量句与真实余量一致——第 4 轮完成机会
* +1 到 2 后才重听),第 2 次听到「免费重听用完了」;整轮边界收工。
*/
export function describe(game) {
const GN = { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' };
@@ -16,7 +18,13 @@ export function describe(game) {
: '教学旁白在播,先听着';
case 'announce': return `第 ${game.round} 轮报数呢,听好了`;
case 'playback': return `第 ${game.round} 轮序列演奏中(${game.sequence.length} 个音),记住顺序,先别动手`;
case 'input': return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势(双击就是双击那个音,要重听就向上滑)`;
case 'input': {
const occ = [];
if (game.sequence.includes('up')) occ.push('上滑');
if (game.sequence.includes('doubletap')) occ.push('双击');
const occNote = occ.length ? `(这轮${occ.join('和')}是作答,占用了)` : '';
return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势${occNote}(要重听就向上滑)`;
}
case 'roundend': return '刚答完一轮,播报中';
case 'gameover': return `游戏结束,完成 ${game.round - 1} 轮。单击再来一局,下滑开菜单`;
case 'menu': return '菜单里:单击切选项,双击确认,上滑听规则';
@@ -39,69 +47,102 @@ export async function session(ctx, mock) {
}
await ctx.gesture(G[i]); // 照指令做对
if (i === G.length - 1) {
// 收尾链(t7 两句 + 机会经济句)播完直达对局——一到 input 就接手,
// 收尾链(t7 两句 + 机会经济句)播完直达对局——一到 announce 就接手,
// 不裸 waitIdle(会把 input 的看门狗窗口整段空烧掉,round1 剧本失真根源)
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input'
|| game.phase === 'gameover');
await ctx.waitCond(() => game.phase === 'announce' || game.phase === 'playback'
|| game.phase === 'input' || game.phase === 'gameover');
} else {
await ctx.waitIdle();
}
}
ctx.status('教学讲完了:免费重听两次、用完扣机会,想重听就向上滑');
// 正式对局:完整打完第 3 轮,在轮边界收工
let relistenDone = false;
while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover') {
if (game.phase === 'input') {
// 第 1 轮开局先失忆僵 10 秒(还没动过手,input 看门狗在挂):听向导
// 「向上滑可以重新听一遍序列」,照做——round3 G5:重听已改绑上滑。
// 测试钩子把序列摆成无 up 的两音,保证上滑=重听不被「期望恰为 up」豁免干扰。
if (game.round === 1 && !relistenDone && game.inputIdx === 0) {
relistenDone = true;
if (game.sequence.includes('up')) { game.sequence[0] = 'left'; game.sequence[1] = 'tap'; }
ctx.status('第 1 轮有点懵,先僵一会儿');
ctx.silence(10, '想不起来序列了');
// 10s 看门狗到点:nudge + gd_relisten。直接等提示语音开播(input 态无
// BGM、无别的语音,voiceBusy=提示在播;别用「等满 10.6s」——定时刻度会
// 恰好迈过语音结束点,等成第二次升级)
await ctx.waitCond(() => ctx.voiceBusy());
ctx.status('听到提示:需要的话,向上滑可以重新听一遍序列');
ctx.note('照提示向上滑,用掉一次免费重听');
await ctx.gesture('up');
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input');
continue;
}
// 第 2 轮开头演示「期望恰为 up 时上滑=作答」(00 §5.3 豁免同款口径)
if (game.round === 2 && !game.__upDemoDone && game.inputIdx === 0) {
game.__upDemoDone = true;
game.sequence[0] = 'up';
ctx.status('这个是上行音——上滑作答(期望恰为 up 时上滑=作答)');
await ctx.gesture('up');
continue;
}
if (game.round === 3 && game.inputIdx === game.sequence.length - 1) {
// round3 G5 演示钩子(13 号文档允许直读/改实例字段):把最后一个期望音
// 摆成 doubletap,保证转写演示到「input 双击永远=作答」
if (game.sequence[game.inputIdx] !== 'doubletap' && !game.__dtDemoDone) {
game.__dtDemoDone = true;
game.sequence[game.inputIdx] = 'doubletap';
}
ctx.status(game.sequence[game.inputIdx] === 'doubletap'
? '这个是双击音——双击作答(round3:input 双击永远=作答)'
: '最后一轮最后一个音,打完这轮就收');
} else {
ctx.status('轮到我复现了');
}
await ctx.gesture(game.sequence[game.inputIdx]);
continue;
// 测试钩子(13 号文档允许直改实例字段):每轮报数时点把序列摆成剧本需要的
// 形状——占用音(up/doubletap)摆进指定轮,其余用不含占用音的手势补齐。
const fixSeq = (highlights) => {
const fill = ['down', 'right', 'tap', 'left'];
for (let i = 0; i < game.sequence.length; i++) {
game.sequence[i] = i < highlights.length ? highlights[i] : fill[i % fill.length];
}
// 整轮边界收尾:第 3 轮答完(roundend),不等第 4 轮报数
if (game.phase === 'roundend' && game.round === 3) break;
// announce/playback/roundend:等阶段推进即可,不裸 waitIdle(防吞掉 input 窗口)
const ph = game.phase;
const r = game.round;
await ctx.waitCond(() => game.phase !== ph || game.round !== r);
}
};
const waitAnnounce = () => ctx.waitCond(() => game.phase === 'announce' || game.phase === 'gameover');
const answerRound = async () => {
await ctx.waitCond(() => game.phase === 'input' || game.phase === 'gameover');
while (game.phase === 'input') await ctx.gesture(game.sequence[game.inputIdx]);
};
// 「无尝试超时」剧本点(干净轮):僵到 input 看门狗 5s 首级的提示语音开播
//(input 态无 BGM 无别的语音,voiceBusy=提示在播;别盲等固定时长——harness 的
// advance 没有时间上限,会恰好迈过提示语音结束点、一路空烧到 25s 升级)
const stallForAdvice = async (why) => {
ctx.silence(5, why);
await ctx.waitCond(() => ctx.voiceBusy());
};
ctx.status(`收工:完整打完 ${game.round} 轮,在轮与轮的边界停手(免费重听剩 ${game.chances} 次)`);
// R1:含 up 的轮次——占用预告 + 被占用轮看门狗只 nudge(H2)+ 上滑恰为作答
await waitAnnounce();
fixSeq(['up', 'tap']);
await ctx.waitCond(() => game.phase === 'input');
ctx.status('序列一播完就听到「这一轮,向上滑是作答」——重听键这轮被占了');
ctx.silence(5, '第一轮先不动手,看看它催不催');
// 被占用轮 5s 级没有提示语音可等:僵到 25s 升级(turn+confirm 震)作为里程碑
await ctx.waitVirtual(25600);
ctx.note('5 秒一声轻提醒、25 秒又一声加确认震——两级提醒都没建议「向上滑重听」,它知道这轮上滑是作答');
ctx.status('先来一发上滑——期望恰为 up 时上滑就是作答');
await answerRound();
// R2:含 doubletap 的轮次——占用预告 dt(重听建议不受影响,不僵)
await waitAnnounce();
fixSeq(['down', 'doubletap', 'left']);
await ctx.waitCond(() => game.phase === 'input');
ctx.status('这轮预告的是「双击是作答」');
await answerRound();
// R3/R4:无占用轮直答(第 4 轮完成 → 机会 +1,免费重听凑满两次)
await waitAnnounce();
fixSeq([]);
await ctx.waitCond(() => game.phase === 'input');
ctx.status('这轮开场没有占用预告——上滑还能当重听键用');
await answerRound();
await waitAnnounce();
fixSeq([]);
await ctx.waitCond(() => game.phase === 'input');
ctx.status('机会加一之后,免费重听真的有两次了');
await answerRound();
// R5:无尝试超时 + 第 1 次免费重听(余量如实报「还剩一次」,H3)
await waitAnnounce();
fixSeq([]);
await ctx.waitCond(() => game.phase === 'input');
ctx.status('第 5 轮序列有点长,先不动手');
await stallForAdvice('想不起来序列了');
ctx.status('5 秒就来了提示:需要的话,向上滑可以重新听一遍序列');
ctx.note('照提示向上滑——第 1 次免费重听用掉,跟着听到「免费重听还剩一次」');
await ctx.gesture('up');
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input');
await answerRound();
// R6:up+doubletap 双占用轮——gd_occ_both + 被占用轮看门狗只 nudge
await waitAnnounce();
fixSeq(['up', 'doubletap']);
await ctx.waitCond(() => game.phase === 'input');
ctx.status('这轮说「向上滑和双击都是作答」——两个键都被占用了');
ctx.silence(5, '轮到我了,先心里过一遍序列');
await ctx.waitVirtual(25600); // 同 R1:两级提醒都只发 earcon/标记,无重听建议
ctx.note('又是两级提醒都没有重听建议——占用轮里那句建议自动闭嘴');
await answerRound();
// R7:无尝试超时 + 第 2 次免费重听(余量归 0,H3)
await waitAnnounce();
fixSeq([]);
await ctx.waitCond(() => game.phase === 'input');
await stallForAdvice('长序列,又想不起来了');
ctx.status('又是那句提示:向上滑可以重新听一遍序列');
ctx.note('向上滑——第 2 次免费重听用掉,这回听到「免费重听用完了。之后重听会花一次机会」');
await ctx.gesture('up');
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input');
await answerRound();
// 轮边界收工
await ctx.waitCond(() => game.phase === 'roundend' || game.phase === 'gameover');
ctx.status(`收工:打完 ${game.round} 轮在轮边界停手(免费重听剩 ${game.chances} 次,账目和播的对上了)`);
}