storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行 (发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/ 菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。 游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio / dungeon / abyss / detective / cipher / teahouse / keeper / bridge / hunt(联机切片): - 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0); 四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt 旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore) - 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动 原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿 - 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/ 台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写 引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record 前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前 状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在 speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。 语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110 废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器 tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a 落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。 大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README / HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
411 lines
23 KiB
JavaScript
411 lines
23 KiB
JavaScript
/**
|
||
* 《雨夜专案》新手会话剧本(26 号重构版:叙事+听证词型,storyline/radio 范式)。
|
||
*
|
||
* 剧本:
|
||
* - `session`(主线):一位新手探长的完整破案弧线——开场第一句「点一下屏幕」就
|
||
* 动手,教学寄生于真实案卷(听老葛 → 教右滑+解锁上滑 → 听小顺 →「滑到指证,
|
||
* 点一下」→ 练习指证零账目),再把六份卷宗听过耳朵,逐对比对出全部四处矛盾
|
||
* (周先生四连谎),结案陈词先错指小顺听不在场证明,再正指周先生、答对凶器与
|
||
* 动机 → 认罪链与法槌 → 评级结算 → 下滑逛一圈菜单(阶段 2 此刻才真解锁)→
|
||
* 单击接新案(pack 翻转,变体真凶小顺)短打一处指证收尾。
|
||
* - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、证词板狂点
|
||
* 乱滑(零进展被无进展轨三级抢救:earcon → 短句 → 保底矛盾对)、菜单乱逛只
|
||
* 下滑关闭、结案乱答再修正——一样走到 VERDICT+SETTLE 与新案开局,全程零死锁。
|
||
*
|
||
* 剧本失真红线(参照 radio/rpg 剧本先例):
|
||
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
|
||
* 出手;听取(ACTION)是玩法态,可以随时点停/切条目——那本来就是玩家的事;
|
||
* - 玩家靠耳朵玩:指证前先翻听卷宗,导航只把「听到的内容」翻译成手势;
|
||
* 上滑问一次状态(阶段 1 解锁后才用;双击留到阶段 3,未解锁前不碰);
|
||
* - BOARD 常驻无进展轮询定时器:禁止裸 waitIdle 空等,必须 waitStep 有界等待;
|
||
* - 菜单里只「下滑关闭」,绝不单击确认(防误触「保存并回大厅」结束会话)。
|
||
*/
|
||
|
||
const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore'];
|
||
const ITEM_ORDER = [...LISTEN_ITEMS, 'cf', 'case'];
|
||
|
||
const ITEM_LABEL = {
|
||
w1: '门卫老葛的证词', w2: '学徒小顺的证词', w3: '账房周先生的证词', w4: '房东温太太的证词',
|
||
scene: '现场勘验', fore: '法医报告', cf: '指证', case: '结案陈词',
|
||
};
|
||
const MENU_LABEL = {
|
||
sys_menu_resume: '回到证词板', sys_menu_save: '保存并回大厅',
|
||
sys_menu_restart: '重新查这桩案子', sys_menu_help: '重新听帮助',
|
||
};
|
||
const WHO_LABEL = { w1: '老葛', w2: '小顺', w3: '周先生', w4: '温太太' };
|
||
|
||
/** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */
|
||
const atTurn = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
|
||
const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null);
|
||
|
||
export function describe(game) {
|
||
if (game.menuOpen) {
|
||
const names = game._menuItems.map((id) => MENU_LABEL[id] || id).join('、');
|
||
return `菜单里(${names}):当前项「${MENU_LABEL[game._menuItems[game._menuIdx]] || '?'}」——左右滑换项,单击确认,上滑重听说明,下滑关闭`;
|
||
}
|
||
if (game._play) return `正在听${ITEM_LABEL[ITEM_ORDER[game._play.itemIdx]]}(点一下停止;左右滑换卷宗;上滑问状态)`;
|
||
switch (game.state) {
|
||
case 'TITLE': return game.run
|
||
? '检测到未结的案子:单击继续,右滑换一桩新案'
|
||
: '开场指令:「点一下屏幕。」——第一句就让我动手';
|
||
case 'BOARD': {
|
||
const label = ITEM_LABEL[ITEM_ORDER[game.itemIdx]];
|
||
const gate = game._caseGate ? '(确认闸已挂:再单击才进三问,滑走撤闸)' : '';
|
||
return `证词板上是「${label}」:左右滑换卷宗,单击听取${game.itemIdx === 6 ? '(在指证项上,点一下进入指证)' : ''}${game.itemIdx === 7 ? '(在结案陈词上)' : ''}${gate};上滑问状态,下滑菜单`;
|
||
}
|
||
case 'CONFRONT': {
|
||
const cf = game._cf;
|
||
if (!cf) return '指证中';
|
||
if (cf.phase === 'judged') return '判定播报中,先听着(链锁,手势只给 busy)';
|
||
const ph = {
|
||
item1: '选第一份证词(左右滑换,单击确认)', line1: '第一份里逐句听(左右滑换句,单击进确认)',
|
||
confirm1: '就这句?点一下定下来(左右滑=取消回预览)',
|
||
item2: '再选与它矛盾的那份', line2: '第二份里逐句听', confirm2: '就这句?点一下定下来',
|
||
}[cf.phase];
|
||
return `指证中:${ph}`;
|
||
}
|
||
case 'FINISH': {
|
||
const fin = game._fin;
|
||
if (!fin) return '结案中';
|
||
const ph = {
|
||
who: `第一问指认真凶(当前 ${WHO_LABEL[['w1', 'w2', 'w3', 'w4'][fin.optIdx]]}):左右滑换人,单击进确认`,
|
||
whoConfirm: '就指认这位?单击定下来(左右滑取消)',
|
||
weapon: '第二问:凶器是什么(左右滑听选项,单击定)',
|
||
motive: '第三问:动机是什么(左右滑听选项,单击定)',
|
||
}[fin.phase];
|
||
return `结案三问——${ph}`;
|
||
}
|
||
case 'VERDICT': return '裁决链播报中:认罪三段与法槌,手势全部被吞(链锁)';
|
||
case 'SETTLE': return '结算:评级与账目播完——单击接新案(pack 翻转),上滑重听评级,下滑菜单';
|
||
default: return '……';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 会话工具
|
||
|
||
/** 有界等待:条件满足或超时即返回(BOARD 常驻轮询定时器,禁裸 waitIdle)。 */
|
||
async function waitStep(ctx, mock, cond, maxMs = 60000) {
|
||
const t0 = mock.now;
|
||
while (!cond() && mock.now - t0 < maxMs) await ctx.waitVirtual(150);
|
||
}
|
||
|
||
/** 等真话轮(链收+话轮已交,含指证/结案/菜单各等待点)。 */
|
||
async function turn(ctx, mock, maxMs = 60000) {
|
||
await waitStep(ctx, mock, () => atTurn(ctx.game), maxMs);
|
||
}
|
||
|
||
/** 滑到指定卷宗条目(最短环绕方向;签名 earcon → 条目名 → 交话轮)。 */
|
||
async function navToItem(ctx, mock, itemId) {
|
||
const game = ctx.game;
|
||
const target = ITEM_ORDER.indexOf(itemId);
|
||
let guard = 0;
|
||
while (game.itemIdx !== target && guard++ < 10) {
|
||
const d = (target - game.itemIdx + ITEM_ORDER.length) % ITEM_ORDER.length;
|
||
await ctx.gesture(d * 2 <= ITEM_ORDER.length ? 'right' : 'left');
|
||
await waitStep(ctx, mock, () => atTurn(game), 15000);
|
||
}
|
||
await turn(ctx, mock);
|
||
}
|
||
|
||
/** 完整听取当前卷宗(单击 → 等证词链播完回到可操作)。 */
|
||
async function listenItem(ctx, mock, itemId) {
|
||
const game = ctx.game;
|
||
await navToItem(ctx, mock, itemId);
|
||
ctx.note(`点一下,听取${ITEM_LABEL[itemId]}`);
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game._play === null && game.floor.state === 'YOUR_TURN', 150000);
|
||
}
|
||
|
||
/** 指证内选一句:滑到条目 → 单击进选句 → 滑到句 → 单击进确认 → 单击定下。 */
|
||
async function cfSelectLine(ctx, mock, caseData, lineId) {
|
||
const game = ctx.game;
|
||
const itemId = caseData.lineItem[lineId];
|
||
const target = LISTEN_ITEMS.indexOf(itemId);
|
||
let guard = 0;
|
||
while ((game._cf?.idx ?? -1) !== target && guard++ < 8) {
|
||
const d = (target - game._cf.idx + LISTEN_ITEMS.length) % LISTEN_ITEMS.length;
|
||
await ctx.gesture(d * 2 <= LISTEN_ITEMS.length ? 'right' : 'left');
|
||
await waitStep(ctx, mock, () => atTurn(game), 15000);
|
||
}
|
||
await turn(ctx, mock);
|
||
ctx.note(`选定「${ITEM_LABEL[itemId]}」——单击确认`);
|
||
await ctx.gesture('tap'); // 确认条目 → 进选句(首选项句自动播)
|
||
await waitStep(ctx, mock, () => game._cf && (game._cf.phase === 'line1' || game._cf.phase === 'line2') && atTurn(game), 20000);
|
||
const ids = caseData.layout.lines[itemId];
|
||
const li = ids.indexOf(lineId);
|
||
let lg = 0;
|
||
while ((game._cf?.lineIdx ?? -1) !== li && lg++ < 8) {
|
||
const d = (li - game._cf.lineIdx + ids.length) % ids.length;
|
||
await ctx.gesture(d * 2 <= ids.length ? 'right' : 'left');
|
||
await waitStep(ctx, mock, () => atTurn(game), 15000);
|
||
}
|
||
await turn(ctx, mock);
|
||
ctx.note('听到那句对不上的了——单击,进入确认');
|
||
await ctx.gesture('tap'); // 进确认(重播该句 + 「就这句?」)
|
||
await waitStep(ctx, mock, () => game._cf && (game._cf.phase === 'confirm1' || game._cf.phase === 'confirm2') && atTurn(game), 20000);
|
||
await turn(ctx, mock);
|
||
ctx.note('单击,定下这句');
|
||
await ctx.gesture('tap');
|
||
}
|
||
|
||
/** 从证词板发起一次完整指证(滑到「指证」条目 + 单击进入 + 两侧选句),等判定链收尾回板。 */
|
||
async function confront(ctx, mock, caseData, lineA, lineB, noteA, noteB) {
|
||
const game = ctx.game;
|
||
await navToItem(ctx, mock, 'cf');
|
||
ctx.note(noteA || '滑到「指证」——点一下,进入指证');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game.state === 'CONFRONT' && game._cf && game._cf.phase === 'item1', 15000);
|
||
await turn(ctx, mock); // cf_start(+练习句)→ 条目报名
|
||
await cfSelectLine(ctx, mock, caseData, lineA);
|
||
await waitStep(ctx, mock, () => game._cf && game._cf.phase === 'item2', 25000);
|
||
await turn(ctx, mock); // 「再选与它矛盾的那份」+ 第一份自动重播(记忆契约)
|
||
ctx.note(noteB || '换那份对不上的——同样两句定下来');
|
||
await cfSelectLine(ctx, mock, caseData, lineB);
|
||
await waitStep(ctx, mock, () => game.state === 'BOARD' && !game._play && atTurn(game), 120000);
|
||
}
|
||
|
||
/** 从证词板走到结案三问(滑到结案陈词 → 单击过确认闸 → 闸内再单击进三问)。 */
|
||
async function enterFinish(ctx, mock) {
|
||
const game = ctx.game;
|
||
await navToItem(ctx, mock, 'case');
|
||
ctx.note('翻到结案陈词——单击过确认闸(「就此结案?」)');
|
||
await ctx.gesture('tap');
|
||
await turn(ctx, mock, 20000); // fin_pre 闸句
|
||
ctx.note('闸内再单击,进三问');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game.state === 'FINISH' && atTurn(game), 30000); // fin_start + 首人名
|
||
}
|
||
|
||
/** 结案某问里滑到指定选项并单击定下(who 问多一段确认)。 */
|
||
async function answerOption(ctx, mock, opts, want) {
|
||
const game = ctx.game;
|
||
const idx = opts.indexOf(want);
|
||
for (let i = 0; i < idx; i++) {
|
||
await ctx.gesture('right');
|
||
await waitStep(ctx, mock, () => atTurn(game), 15000);
|
||
}
|
||
await turn(ctx, mock);
|
||
await ctx.gesture('tap');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 主线剧本
|
||
|
||
export async function session(ctx, mock) {
|
||
const { game } = ctx;
|
||
|
||
// —— 开场(§4.10):第一句 =「点一下屏幕。」(critical),第一下单击开新案 ——
|
||
await waitStep(ctx, mock, () => atTurn(game), 30000);
|
||
ctx.status('雨夜专案开场:一句指令就让我动手,没有规则念白');
|
||
ctx.note('点一下屏幕');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game.run && atTurn(game), 40000);
|
||
ctx.status('新案开卷:一句案情 → 签名 +「老葛的证词」→「单击,听取」→ 顺带教了「轮到你」这个状态声');
|
||
|
||
// —— 教学寄生在真实案卷:听第一份(8~10 秒第一次成功=听到真证词) ——
|
||
ctx.note('点一下,听老葛怎么说');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game._play === null && atTurn(game), 150000);
|
||
ctx.status('一份真证词过完耳朵——「向右滑,下一份。」还教了「想知道自己在哪,就向上滑」');
|
||
ctx.note('右滑——换小顺的证词');
|
||
await ctx.gesture('right');
|
||
await turn(ctx, mock);
|
||
ctx.note('再点一下,听小顺的');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game._play === null && atTurn(game), 150000);
|
||
ctx.status('两份听完——「滑到指证,点一下。」指证是案卷列表里的一项,不用再记什么长按');
|
||
|
||
// —— 首次指证 = 练习(判定截获,零账目污染) ——
|
||
const cs0 = game._case;
|
||
await confront(ctx, mock, cs0, cs0.layout.lines.fore[0], cs0.layout.lines.w1[0],
|
||
'滑到「指证」——点一下,进去练一次', '练习:再任选一份的任意一句(不算数)');
|
||
ctx.status('「指证就是这样。练习不算数,真矛盾要靠耳朵找。」——教学收官,回板自由查案');
|
||
|
||
// —— 自由调查:翻听其余四份卷宗(上滑问一次状态) ——
|
||
for (const id of ['w3', 'w4', 'scene', 'fore']) {
|
||
await listenItem(ctx, mock, id);
|
||
ctx.status(`已听 ${game.run.heard.length}/6 份——${ITEM_LABEL[id]}过完耳朵`);
|
||
}
|
||
ctx.note('上滑核对进度:六份都听完了,疑点还没坐实');
|
||
await ctx.gesture('up');
|
||
await turn(ctx, mock, 40000);
|
||
ctx.status('状态链:已问六份 → 疑点零 →「滑到指证,去比对」');
|
||
|
||
// —— 指证:四处矛盾逐对比对(周先生四连谎) ——
|
||
const cs = game._case;
|
||
const pairs = [
|
||
['z_2', 'wen_2', '周先生说「顺手熄了灯」——先定下这句', '温太太说「十点二十灯还亮着」——对上了!'],
|
||
['z_3', 'w2_3', '周先生说「钥匙贴身带着」', '小顺说「钥匙平日挂柴房铜钩」——对不上!'],
|
||
['z_4', 'fo_3', '周先生说「伤是松木劈柴砸的」', '法医说「创口嵌有铜屑,凶器是铜器」——对不上!'],
|
||
['z_4', 'sc_4', '还是周先生那句「铜秤卖给收废品的了」', '现场勘验说「铜秤立在架上」——对不上!'],
|
||
];
|
||
for (const [a, b, na, nb] of pairs) {
|
||
await confront(ctx, mock, cs, a, b, na, nb);
|
||
ctx.status(`疑点 ${game._doubtCount()}/4 坐实——真凶的反应慌在刚戳中的那件事上`);
|
||
}
|
||
ctx.status('四处疑点齐了——链尾直接给了一句「疑点齐了,去结案陈词」');
|
||
|
||
// —— 结案三问:先错指一次听不在场证明,再正指 ——
|
||
await enterFinish(ctx, mock);
|
||
ctx.note('先怀疑小顺——右滑选他,单击,再单击确认');
|
||
await answerOption(ctx, mock, ['w1', 'w2', 'w3', 'w4'], 'w2');
|
||
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'whoConfirm' && atTurn(game), 20000);
|
||
await ctx.gesture('tap'); // 正式指认(错)
|
||
await waitStep(ctx, mock, () => game.state === 'BOARD' && atTurn(game), 60000);
|
||
ctx.status('错怪了——小顺宿在城南刘掌柜家,不在场证明成立,回到证词板(无限再机会,错指记档)');
|
||
|
||
await enterFinish(ctx, mock);
|
||
ctx.note('证据都指向周先生——右滑两下选他,单击,再单击确认');
|
||
await answerOption(ctx, mock, ['w1', 'w2', 'w3', 'w4'], 'w3');
|
||
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'whoConfirm' && atTurn(game), 20000);
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'weapon' && atTurn(game), 40000);
|
||
ctx.status('第二问:凶器是什么?左右滑一个个听');
|
||
await answerOption(ctx, mock, cs.layout.wpOpts, cs.questions.weapon.answer); // 铜秤砣——铜屑不答应别的答案
|
||
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'motive' && atTurn(game), 40000);
|
||
ctx.status('第三问:动机是什么?');
|
||
await answerOption(ctx, mock, cs.layout.mvOpts, cs.questions.motive.answer); // 监守自盗——账上短的东西才是引线
|
||
|
||
// —— 裁决:认罪链 + 法槌 + 结算(critical 链锁,手势全吞) ——
|
||
await waitStep(ctx, mock, () => game.state === 'VERDICT', 40000);
|
||
ctx.status('证据链闭合——周先生认罪三段,雨声渐强又落下,法槌落定');
|
||
await waitStep(ctx, mock, () => game.state === 'SETTLE' && atTurn(game), 300000);
|
||
const gi = game._gradeInfo;
|
||
ctx.status(`案件归档,雨停了——评级 ${gi ? gi.grade : '?'}:疑点 ${gi ? gi.outcome.doubts : '?'}/4,错指 ${gi ? gi.outcome.wrongWho : '?'} 次`);
|
||
ctx.note('链尾教了「向下滑,可以打开菜单」——去逛一圈');
|
||
|
||
// —— 菜单(阶段 2 此刻才真解锁):左右滑换项,只下滑关闭 ——
|
||
await ctx.gesture('down');
|
||
await waitStep(ctx, mock, () => game.menuOpen && atTurn(game), 20000);
|
||
ctx.status('菜单名和第一项「回到证词板」报完了');
|
||
await ctx.gesture('right'); // 下一项报名
|
||
await turn(ctx, mock);
|
||
ctx.status('听完「保存并回大厅」的报名——不动它');
|
||
await ctx.gesture('down'); // 下滑关闭(不单击确认)
|
||
await waitStep(ctx, mock, () => !game.menuOpen && atTurn(game), 20000);
|
||
ctx.status('菜单关了,回到结算');
|
||
|
||
// —— 接新案(pack 翻转)+ 变体案短打一处指证收尾 ——
|
||
ctx.note('单击——接新案(同一场雨里,还有别的案子)');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run && game.run.pack === 1 && atTurn(game), 40000);
|
||
ctx.status('新案开卷:pack 翻转——这回的真凶换了人(不再教学,做对过的事不教第二遍)');
|
||
await confront(ctx, mock, game._case, 'b_shun_1', 'wen_2',
|
||
'小顺说「我走的时候熄了灯」——变体案的第一句谎', '温太太还是那句「灯还亮着」——对上了!');
|
||
ctx.status(`变体案疑点 ${game._doubtCount()}/4——真凶另有其人,收工`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 乱按压力剧本
|
||
// 目标:全程零死锁、零静默死局,一样能走到 SETTLE。
|
||
// 剧本特征:不读提示、起停乱点、乱滑换卷宗、菜单乱逛只下滑关闭、结案乱答再修正。
|
||
|
||
export async function sessionStress(ctx, mock) {
|
||
const { game } = ctx;
|
||
let seed = 20260909;
|
||
const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
|
||
|
||
// ① 标题页乱划(含未解锁手势——拿到 invalid 回执/解释句也不卡流程;误开菜单
|
||
// 就顺手下滑关掉),最后收口把案子开起来(乱按的底线是流程照走)
|
||
for (let i = 0; i < 6 && game.state === 'TITLE'; i++) {
|
||
ctx.status('标题页乱划一气');
|
||
await ctx.gesture(pick(['left', 'right', 'up', 'down', 'doubletap']));
|
||
await ctx.waitVirtual(400);
|
||
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(600); }
|
||
}
|
||
let sg = 0;
|
||
while (game.state === 'TITLE' && sg++ < 8) {
|
||
await ctx.gesture('tap'); // 单击 = 继续存档 / 开新案(误进菜单就再关掉重试)
|
||
await waitStep(ctx, mock, () => game.state !== 'TITLE' || game.menuOpen, 8000);
|
||
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(600); }
|
||
}
|
||
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run, 60000);
|
||
|
||
// ② 教学期乱按几下(起停/乱滑/问状态;在指证、结案两项上绝不单击,不误入),
|
||
// 乱按走不完就把练习照实做掉(首次指证=练习,零账目,乱按也毕业)
|
||
let tg = 0;
|
||
while (game.state === 'BOARD' && !game.meta.drillDone && tg++ < 8) {
|
||
const atSpecial = game.itemIdx >= ITEM_ORDER.length - 2;
|
||
await ctx.gesture(atSpecial ? 'left' : pick(['tap', 'left', 'right', 'up']));
|
||
await ctx.waitVirtual(1200);
|
||
}
|
||
if (!game.meta.drillDone && game.state === 'BOARD' && game._case) {
|
||
await waitStep(ctx, mock, () => game.state === 'BOARD' && !game._play && atTurn(game), 60000);
|
||
const csP = game._case;
|
||
await confront(ctx, mock, csP, csP.layout.lines.fore[0], csP.layout.lines.w1[0],
|
||
'照着提示把练习做掉:任选一份证词', '再任选一份任意一句——练习不算数');
|
||
}
|
||
ctx.status('教学在乱按里也走完了(练习指证零账目,不会把自己教坏)');
|
||
|
||
// ③ 证词板乱按 160s(零进展——被无进展轨三级抢救:45s earcon → 90s 短句 →
|
||
// 150s 保底直接给可指证矛盾对)。起停对=跨过 400ms 防连点窗的双 tap
|
||
// (首句没听完就停=确定性零进展;heardLines 只记到完整句)。
|
||
const t0 = mock.now;
|
||
const ROT = ['pair', 'right', 'left', 'right', 'left'];
|
||
let ri = 0;
|
||
while (game.state === 'BOARD' && mock.now - t0 < 160000) {
|
||
const gg = ROT[ri++ % ROT.length];
|
||
const atCase = game.itemIdx >= ITEM_ORDER.length - 2; // 不误入指证/结案陈词
|
||
if (gg === 'pair' && !atCase) {
|
||
await ctx.gesture('tap'); // 起播
|
||
await ctx.waitVirtual(700); // 跨过防连点窗(第二下不再被 busy 吞)
|
||
await ctx.gesture('tap'); // 立刻停止——首句没听完
|
||
} else if (atCase) {
|
||
await ctx.gesture('left');
|
||
} else {
|
||
await ctx.gesture(gg);
|
||
}
|
||
if (rng() < 0.12) {
|
||
ctx.note('手痒开菜单乱逛——只敢下滑关掉');
|
||
await ctx.gesture('down');
|
||
await ctx.waitVirtual(1200);
|
||
await ctx.gesture('down');
|
||
await ctx.waitVirtual(500);
|
||
continue;
|
||
}
|
||
await ctx.waitVirtual(1100);
|
||
}
|
||
ctx.status('乱按也被抢救:听到了保底提示直接给出可指证的矛盾对');
|
||
|
||
// ④ 顺着保底提示真指证一处(零死锁底线:核心动词始终可达)
|
||
const cs = game._case;
|
||
if (game.state === 'BOARD') {
|
||
const contra = cs.contradictions.find((c) => c.hint) || cs.contradictions[0];
|
||
await confront(ctx, mock, cs, contra.a, contra.b,
|
||
`照着提示指证:${contra.a} 这句`, `对上 ${contra.b} 这句`);
|
||
ctx.status(`乱按局也能坐实疑点(${game._doubtCount()}/4)`);
|
||
}
|
||
|
||
// ⑤ 结案:乱答一次再答对(错指无限再机会 + 问题不重答只记账)
|
||
let fg = 0;
|
||
while (game.state !== 'SETTLE' && fg++ < 12) {
|
||
await enterFinish(ctx, mock);
|
||
const whoIdx = fg === 1 ? Math.floor(rng() * 4) : ['w1', 'w2', 'w3', 'w4'].indexOf(cs.culprit);
|
||
for (let i = 0; i < whoIdx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
|
||
await turn(ctx, mock);
|
||
ctx.note(fg === 1 ? '先胡乱指一个听听反应' : '这回指着正主');
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'whoConfirm' && atTurn(game), 25000);
|
||
await ctx.gesture('tap');
|
||
if (game.state === 'FINISH' && game._fin && game._fin.phase !== 'who') {
|
||
for (const [phase, want] of [['weapon', cs.questions.weapon.answer], ['motive', cs.questions.motive.answer]]) {
|
||
await waitStep(ctx, mock, () => game._fin && game._fin.phase === phase && atTurn(game), 40000);
|
||
const opts = phase === 'weapon' ? cs.layout.wpOpts : cs.layout.mvOpts;
|
||
const idx = opts.indexOf(want);
|
||
for (let i = 0; i < idx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
|
||
await turn(ctx, mock);
|
||
await ctx.gesture('tap');
|
||
}
|
||
}
|
||
// 等本轮回合彻底落定(错指=回板;答对=裁决链播完进 SETTLE)——裁决链播报期
|
||
// 手势全吞,绝不提前进下一轮在链上乱点(SETTLE 的单击=接新案,误触即翻包)
|
||
await waitStep(ctx, mock, () => game.state !== 'FINISH' && game.state !== 'VERDICT', 120000);
|
||
}
|
||
await waitStep(ctx, mock, () => game.state === 'SETTLE' && atTurn(game), 300000);
|
||
ctx.status('乱按一路也把案子结了:裁决链完整、评级如实记账');
|
||
|
||
// ⑥ 接新案短程收尾(压力全程零死锁)
|
||
await ctx.gesture('tap');
|
||
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run && atTurn(game), 40000);
|
||
ctx.status('新案照样开局,压力测试收工');
|
||
}
|