feat(new8/batch2): 《深海回音》abyss +《雨夜专案》detective 全套交付(多agent流水线:策划→资产→开发→测试→真资产复跑)

- abyss:game.js 242 断言全绿(剧本A-F六路径/氧气门三形态/sonarGate仲裁/结算死窗/双轨看门狗/radio 8项修复继承);资产 148句+11key变体(电台滤波带内能量47-62%→69-80%)
- detective:game.js 265 断言真资产下全绿(矩阵不变式独立重算/指证全路径/评级12组注入/VERDICT死窗/二周目变体);真资产复跑回炉3处实现修复(无进展轨墙钟化/菜单暂停记账/停止播报真停声——mock时长掩盖的正确性bug)
- 资产:TTS 539条×2格式、Mureka 7首 $0.21(累计$1.08/$2.00)、SYN12+SA3 19件(QC抓出1例AAC过冲并修复)、封面×2、check_all_assets 10游戏全绿(abyss 298/298、detective 279/279)
- 平台接线:大厅双卡片、BUCKETS 增 abyss/detective 桶(H4 断言更新 10 游戏)、voice_text.json 10游戏991句
- 回归:14 套件全绿(10 playtest+guide+engine-voice+engine-input+api-smoke 12/12)
- 设计偏差登记:abyss 7条/detective 9条(全为设计内部冲突按承诺清单/归因真话律取优)
This commit is contained in:
xsl
2026-09-10 01:40:31 +08:00
parent cd4c5ec65a
commit 31372615b2
1183 changed files with 8445 additions and 10 deletions
+228
View File
@@ -0,0 +1,228 @@
/**
* 《深海回音》新手会话剧本。
*
* 剧本:
* - `session`(主线):一位深潜救援员的完整弧线——听完甲板简报、四潜全下
* (每潜:单击发脉冲 → 听回波 → 通讯 → 抉择)、上浮结算与补给照单全收、
* 走双载第四潜,最后用省下的气下去取船钟,以完整 E1「海燕归钟」收尾
* (全右省气 + p9 左 = §4.5 的 P4 友好默认路径)。
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
* 一样要能走到 ENDING,全程零死锁、零静默死局。
*
* 剧本失真红线(参照 radio 剧本先例):
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)或抉择武装后才出手;
* - 玩家不干晾自己:只在 COMMS 叙事等待态偶尔走神触发一次看门狗,链一停就接话;
* - 搜索等待挂着看门狗定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
* - 通讯里每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
*/
const DIVE_LABEL = ['一', '二', '三', '四'];
function diveLabel(n) {
return n >= 1 && n <= 4 ? DIVE_LABEL[n - 1] : '·';
}
export function describe(game) {
if (game.leaving) return '已确认保存并回大厅';
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑新救援」的询问';
if (game.phase === 'MENU') {
const names = ['回到水下', '重新开始这次救援', '保存并回大厅'];
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
}
if (game.phase === 'INTRO') {
return game.voicePlaying ? '甲板简报正在播(规矩就一条)' : '简报播完了,等单击下潜';
}
if (game.phase === 'DIVE') {
if (game.searchArmed) return '声呐静了,轮到你了:单击发一枚脉冲';
return game.voicePlaying ? `第${diveLabel(game.diveN)}潜下潜中,调度在报位` : '……';
}
if (game.choiceArmed) {
return `「请选择」念完了(第${diveLabel(game.diveN)}潜):向左或向右滑做出你的决定`;
}
if (game.voicePlaying) return `第${diveLabel(game.diveN)}潜通讯中,正在听对方说话`;
if (game.phase === 'COMMS') return '对方说完了,等我接话(链末单击继续)';
if (game.phase === 'SURFACE') {
return game.voicePlaying ? '上浮结算播报中(扣账/补给/档位)' : '结算链间隙,等单击继续';
}
if (game.phase === 'ENDING') {
return game.awaitingRestart
? '结局和逐人回响都听完了。单击再来一局,下滑开菜单'
: '结局播报中';
}
return '……';
}
// ---------------------------------------------------------------- 主线剧本
export async function session(ctx, mock) {
const { game } = ctx;
ctx.note('开局:听完甲板简报——每段链末单击继续,第一潜开始后声呐会静下来');
let firstPing = false;
let firstEcho = false;
let invalidTried = false;
let menuDone = false;
let walkedAway = false;
let choiceIdx = 0;
// 抉择路径(全右省气 + p9 左取船钟 → e1 海燕归钟):
// p1 直接上 / p2 不等日志 / p3 快切 / p4 直接上浮 / p5 先带小郑 /
// p6 约定来接 / p7 快浮 / p8 不问 / p9 用最后的气下去取钟
const choices = [
{ dir: 'right', why: '小舟先上来,暗号回头再说' },
{ dir: 'right', why: '日志可以补,人不能等' },
{ dir: 'right', why: '切割枪快切,老白憋住气' },
{ dir: 'right', why: '阀门交给母船,腿要紧' },
{ dir: 'right', why: '小郑重伤先走,陈医官下一潜就是你的' },
{ dir: 'right', why: '四小时内我下来,等我' },
{ dir: 'right', why: '来不及了,直浮!医疗舱的灯先开好' },
{ dir: 'right', why: '纪律就是纪律,拉他上浮' },
{ dir: 'left', why: '最后一潜。不为救人,为个交代——下去取船钟' },
];
for (let i = 0; i < 300 && !game.leaving; i++) {
// 只等当前语音链播完 / 抉择武装 / 搜索轮到自己;绝不用裸 waitIdle
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
if (game.leaving) break;
if (game.searchArmed && game.phase === 'DIVE') {
if (!firstPing) {
firstPing = true;
ctx.status(`第${diveLabel(game.diveN)}潜到深度了,声呐静了`);
ctx.note('调度说了:单击,发一枚脉冲——耳朵交给声呐');
} else {
ctx.status(`第${diveLabel(game.diveN)}潜,再点一枚`);
}
await ctx.gesture('tap');
if (!firstEcho) {
firstEcho = true;
await ctx.waitCond(() => game.phase === 'COMMS' || !game.voicePlaying);
if (game.phase === 'COMMS') ctx.status('金属回波后面有杂音——电台接通了,小舟的声音进来了');
}
continue;
}
if (game.choiceArmed) {
const pick = choices[choiceIdx];
if (!pick) break; // 抉择走完(p9 之后应已进 ENDING)
if (!invalidTried) {
invalidTried = true;
ctx.note(pick.why + '——但先上滑听一遍潜次和氧气');
await ctx.gesture('up');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('听完状态链(第几潜+氧气档+口令),局面复述回来了');
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
if (!game.choiceArmed) continue; // 复述没回到武装态就再等一轮
}
if (!menuDone) {
menuDone = true;
ctx.note('下滑看看菜单有什么');
await ctx.gesture('down');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('菜单里,切一项听听');
await ctx.gesture('tap'); // 切一项听听(报名接在当前报名之后,不截断)
await ctx.waitCond(() => !game.voicePlaying);
ctx.note('听完了,下滑关闭菜单,回到水下');
await ctx.gesture('down'); // 下滑关闭(不双击,绝不误触「重开/回大厅」)
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
if (!game.choiceArmed) continue;
}
ctx.status('选项念完了,做决定');
ctx.note(pick.why);
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧看门狗)
choiceIdx++;
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart);
continue;
}
if (game.phase === 'SURFACE') {
// 上浮结算自动链(扣账/补给/档位)——播完自动进下一潜或签收,不插手
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
continue;
}
// COMMS 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
// 不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual 会
// 一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
if (!walkedAway && game.phase === 'COMMS' && !game.choiceArmed) {
walkedAway = true;
ctx.silence(25, '听到一半走了神,在想氧气还剩几格');
await ctx.waitVirtual(25600); // 触发看门狗:nudge +「单击屏幕,继续」
ctx.status('听到一声轻响和「单击屏幕,继续」,马上点');
await ctx.gesture('tap');
continue;
}
await ctx.gesture('tap'); // 链末单击 = 继续
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
ctx.status('结局链走完,尾声逐人点到我每一潜的救与错过');
ctx.note('船钟出水,五个人都在甲板上——会话结束(E1 海燕归钟)');
}
// ---------------------------------------------------------------- 乱按压力剧本
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
// 剧本特征:不读提示、快速跳句、菜单里乱逛、错手势试探,但保留「最终能通关」底线。
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20260910;
const rng = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const gestures = ['tap', 'left', 'right', 'up', 'down', 'doubletap'];
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
let menuOpenCount = 0;
let choiceCount = 0;
let pingCount = 0;
let noiseCount = 0;
for (let i = 0; i < 500; i++) {
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
if (game.leaving) break;
if (game.searchArmed && game.phase === 'DIVE') {
pingCount++;
await ctx.gesture('tap'); // 声呐静着就一直点——单击恒=发脉冲
continue;
}
if (game.phase === 'MENU') {
menuOpenCount++;
await ctx.gesture('tap'); // 乱切换
await ctx.waitCond(() => !game.voicePlaying);
await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」)
continue;
}
if (game.choiceArmed) {
choiceCount++;
if (rng() < 0.35) {
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势
noiseCount++;
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
}
await ctx.gesture(pick(['left', 'right']));
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart);
break;
}
if (game.phase === 'SURFACE') {
// 结算自动链:等它自己走完(播句中乱点=跳句,也无妨)
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
continue;
}
// COMMS 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
noiseCount++;
await ctx.gesture(pick(gestures));
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
ctx.status(`乱按剧本收尾:脉冲 ${pingCount} 次、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
}
+382
View File
@@ -0,0 +1,382 @@
/**
* 《雨夜专案》新手会话剧本。
*
* 剧本:
* - `session`(主线):一位新手探长的完整破案弧线——标题开始 → DRILL 七步跟练
* (换卷宗/听取/重听/状态/长按指证/练习判定)→ 证词板翻听全部六份卷宗 →
* 逐对比对指证出全部四处矛盾(周先生四连谎)→ 结案陈词先错指小顺听不在场
* 证明,再正指周先生、答对凶器与动机 → 认罪链与法槌 → 评级结算 → 单击接
* 新案(pack 翻转,变体真凶小顺)短打一处指证收尾。
* - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、教学乱按、
* 证词板狂点乱滑(零进展被看门狗三级抢救)、菜单乱逛只下滑关闭、结案乱答
* 再修正——一样走到 VERDICT+SETTLE 与新案开局,全程零死锁。
*
* 剧本失真红线(参照 dungeon/radio 剧本先例):
* - 玩家不抢话:出手前等语音链/判定链收尾(waitStep 有界等待状态谓词);
* - 玩家靠耳朵玩:指证前先翻听卷宗,导航只把「听到的内容」翻译成手势;
* - BOARD 常驻无进展轮询定时器:禁止裸 waitIdle 空等,必须 waitStep/waitVirtual;
* - 菜单里只「下滑关闭」,绝不双击执行(防误触「保存并回大厅」结束会话)。
*/
const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore'];
const ITEM_ORDER = [...LISTEN_ITEMS, 'case'];
const ITEM_LABEL = {
w1: '门卫老葛的证词', w2: '学徒小顺的证词', w3: '账房周先生的证词', w4: '房东温太太的证词',
scene: '现场勘验', fore: '法医报告', case: '结案陈词',
};
export function describe(game) {
if (game.menuOpen) return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭';
if (game._play) return `正在听${ITEM_LABEL[ITEM_ORDER[game._play.itemIdx]]}(单击停止,双击重新听当前句)`;
switch (game.state) {
case 'TITLE': return game.run
? '检测到未结的案子:单击继续,右滑换一桩新案,上滑听案情简介'
: '标题页:单击开始查案,上滑听案情简介';
case 'DRILL': return game._drill
? `教学第 ${game._drill.step} 步,跟着旁白做(下滑可跳过)`
: '教学中';
case 'BOARD': {
const label = ITEM_LABEL[ITEM_ORDER[game.itemIdx]];
return `证词板上是「${label}」:左右滑换卷宗,单击听取,按住进入指证,上滑状态,下滑菜单`;
}
case 'CONFRONT': {
const cf = game._cf;
if (!cf) return '指证中';
if (cf.phase === 'judged') return '判定播报中,先听着';
const ph = { item1: '选第一份证词', line1: '第一份里逐句听', confirm1: '就这句?单击定下来',
item2: '选与它矛盾的那份', line2: '第二份里逐句听', confirm2: '就这句?单击定下来' }[cf.phase];
return `指证中:${ph}(左右滑换,单击确认,双击重听)`;
}
case 'FINISH': {
const fin = game._fin;
if (!fin) return '结案中';
const ph = { who: '指认真凶:四位证人选一个', whoConfirm: '就指认他?单击定下来',
weapon: '第二问:凶器是什么', motive: '第三问:动机是什么' }[fin.phase];
return `结案三问——${ph}(左右滑选,单击定)`;
}
case 'VERDICT': return '裁决链播报中:认罪三段与法槌,手势全部被吞(链锁)';
case 'SETTLE': return '结算:评级与账目播完,单击接新案(pack 翻转)';
default: return '……';
}
}
// ---------------------------------------------------------------- 会话工具
/** 有界等待:条件满足或超时即返回。 */
async function waitStep(ctx, mock, cond, maxMs = 30000) {
const t0 = mock.now;
let spins = 0;
const spinCap = Math.max(60, Math.floor(maxMs / 50));
while (!cond() && mock.now - t0 < maxMs && spins < spinCap) {
spins++;
await ctx.waitVirtual(100);
}
}
/** 滑到指定卷宗条目(最短环绕方向)。 */
async function navToItem(ctx, game, itemId) {
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 ctx.waitVirtual(200);
}
await ctx.waitVirtual(700); // 签名 earcon → 条目名落声
}
/** 完整听取当前卷宗(单击 → 等链播完回空闲)。 */
async function listenItem(ctx, mock, game, itemId) {
await navToItem(ctx, game, itemId);
ctx.note(`单击,听取${ITEM_LABEL[itemId]}`);
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game._play === null && game.state === 'BOARD', 85000);
}
/** 指证内选一句:滑到条目 → 单击 → 滑到句 → 单击确认 → 听完「就这句?」单击定下。 */
async function cfSelectLine(ctx, mock, game, caseData, lineId) {
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 ctx.waitVirtual(200);
}
await ctx.waitVirtual(700);
ctx.note(`选定「${ITEM_LABEL[itemId]}」——单击确认`);
await ctx.gesture('tap');
await ctx.waitVirtual(400);
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 ctx.waitVirtual(400); // 一句一听
}
ctx.note('听到那句对不上的了——单击,进入确认');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game._cf && (game._cf.phase === 'confirm1' || game._cf.phase === 'confirm2'), 25000);
await ctx.waitVirtual(17000); // 重播该句 + 「就这句?」听完
ctx.note('单击,定下这句');
await ctx.gesture('tap');
}
/** 从证词板发起一次完整指证(按住进入 + 两侧选句),等判定链收尾回板。
* 长按带重试:E2 受理条件=无语音在播——播报尾音未排空时补一拍重试。 */
async function confront(ctx, mock, game, lineA, lineB, noteA, noteB) {
ctx.note(noteA);
for (let attempt = 0; attempt < 3 && game.state !== 'CONFRONT'; attempt++) {
await ctx.gesture('longpress');
if (game.state !== 'CONFRONT') await ctx.waitVirtual(1500);
}
await waitStep(ctx, mock, () => game.state === 'CONFRONT' && game._cf && game._cf.phase === 'item1', 12000);
await ctx.waitVirtual(9000); // cf_start 听完
const cs = game._case;
await cfSelectLine(ctx, mock, game, cs, lineA);
await ctx.waitVirtual(17500); // 「再选与它矛盾的那份」+ 第一份自动重播
ctx.note(noteB);
await cfSelectLine(ctx, mock, game, cs, lineB);
await waitStep(ctx, mock, () => game.state === 'BOARD' && !game._play, 95000);
}
/** 从证词板走到结案陈词并单击进 FINISH。 */
async function enterFinish(ctx, mock, game) {
await navToItem(ctx, game, 'case');
ctx.note('翻到结案陈词——单击,进入结案三问');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'FINISH', 12000);
}
/** 等结案问句链播完(busy 解除 + 首选项报名)。 */
async function waitQuestion(ctx, mock, game, phase) {
await waitStep(ctx, mock, () => game._fin && game._fin.phase === phase && !game._fin.busy, 30000);
await ctx.waitVirtual(1000);
}
// ---------------------------------------------------------------- 主线剧本
export async function session(ctx, mock) {
const { game } = ctx;
// —— 标题:单击开始(首局无存档) ——
await waitStep(ctx, mock, () => game.state === 'TITLE');
ctx.status('雨夜专案:一桩雨夜命案等着一双好耳朵');
ctx.note('单击,开始');
await ctx.gesture('tap');
// —— DRILL 七步跟练 ——
await waitStep(ctx, mock, () => game.state === 'DRILL' && game._drill?.step === 0, 20000);
ctx.status('开场三句案情简介 + 「先熟悉证词板」');
await ctx.waitVirtual(37000);
ctx.note('右滑——换一份卷宗听听');
await ctx.gesture('right'); // D0 → D1
await waitStep(ctx, mock, () => game._drill?.step === 1, 8000);
await ctx.waitVirtual(10000); // 「单击,听取当前卷宗」+ 回正老葛报名
ctx.note('单击——听老葛的证词');
await ctx.gesture('tap'); // D1 真实证词链播
await waitStep(ctx, mock, () => game._drill?.step === 2, 80000);
ctx.status('一份证词听完了——「已听一份,还有五份」');
await ctx.waitVirtual(10000); // 「双击,重新听这一句」
ctx.note('双击——最后一句再过一遍耳朵');
await ctx.gesture('doubletap'); // D2 → D3
await waitStep(ctx, mock, () => game._drill?.step === 3, 15000);
await ctx.waitVirtual(10000); // 「上滑,随时听进度」
ctx.note('上滑——听听问了几份、疑点几处');
await ctx.gesture('up'); // D3 → D4
await waitStep(ctx, mock, () => game._drill?.step === 4, 45000);
await ctx.waitVirtual(10000); // 「按住屏幕,进入指证」
ctx.note('按住屏幕——进入指证');
await ctx.gesture('longpress'); // D4 → D5(CONFRONT 练习局)
ctx.status('练习一次指证:判定被教学截获,不算数');
await ctx.waitVirtual(18000); // cf_start + 练习指令
const cs1 = game._case;
await cfSelectLine(ctx, mock, game, cs1, cs1.layout.lines.w2[0]);
await ctx.waitVirtual(17500);
await cfSelectLine(ctx, mock, game, cs1, cs1.layout.lines.scene[0]);
await waitStep(ctx, mock, () => game.state === 'BOARD', 40000);
ctx.status('教学完了——「指证就是这样,真矛盾要靠耳朵找」');
// —— 自由调查:翻听全部六份卷宗 ——
const cs = game._case;
for (const id of LISTEN_ITEMS) {
await listenItem(ctx, mock, game, id);
ctx.status(`已听 ${game.run.heard.length}/6 份——${ITEM_LABEL[id]}过完耳朵`);
}
ctx.note('上滑核对进度:六份都听完了,疑点还没坐实');
await ctx.gesture('up');
await ctx.waitVirtual(26000);
// —— 指证穿插:四处矛盾逐对比对(周先生四连谎) ——
const pairs = [
['z_2', 'wen_2', '周先生说「顺手熄了灯」——先定下这句', '温太太说「十点二十灯还亮着」——对上了!'],
['z_3', 'w2_3', '周先生说「钥匙贴身带着」', '小顺说「钥匙平日挂柴房铜钩」——对不上!'],
['z_4', 'fo_3', '周先生说「伤是松木劈柴砸的」', '法医说「创口嵌有铜屑,凶器是铜器」——对不上!'],
['z_4', 'sc_4', '还是周先生那句「铜秤卖给收废品的了」', '现场勘验说「铜秤立在架上」——对不上!'],
];
for (let i = 0; i < pairs.length; i++) {
const [a, b, na, nb] = pairs[i];
await confront(ctx, mock, game, a, b, na, nb);
ctx.status(`疑点 ${game._doubtCount()}/4 坐实——真凶的反应一次比一次慌`);
}
ctx.status('四处疑点齐了——「疑点齐了,去结案陈词」');
// —— 结案三问:先错指一次听不在场证明,再正指 ——
await enterFinish(ctx, mock, game);
await waitQuestion(ctx, mock, game, 'who');
ctx.note('先怀疑小顺——右滑选他,单击');
await ctx.gesture('right');
await ctx.waitVirtual(800);
await ctx.gesture('tap');
await ctx.waitVirtual(18000); // 「就指认他?」听完
ctx.note('单击,定下来');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'BOARD', 35000);
ctx.status('错怪了——小顺宿在城南刘掌柜家,不在场证明成立,回到证词板(无限再机会)');
await enterFinish(ctx, mock, game);
await waitQuestion(ctx, mock, game, 'who');
ctx.note('证据都指向周先生——右滑两下选他');
await ctx.gesture('right');
await ctx.gesture('right');
await ctx.waitVirtual(800);
await ctx.gesture('tap');
await ctx.waitVirtual(18000);
ctx.note('单击,正式指认');
await ctx.gesture('tap');
await waitQuestion(ctx, mock, game, 'weapon');
ctx.status('第二问:凶器是什么?四个选项一个个听');
{
const wpIdx = cs.layout.wpOpts.indexOf('wp_2'); // 「铜秤砣」——铜屑与不见了的秤砣都指向它
for (let i = 0; i < wpIdx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
ctx.note('铜屑不答应别的答案——选「铜秤砣」,单击');
await ctx.gesture('tap');
}
await waitQuestion(ctx, mock, game, 'motive');
ctx.status('第三问:动机是什么?');
{
const mvIdx = cs.layout.mvOpts.indexOf('mv_1'); // 「监守自盗」——库里短的东西就是引线
for (let i = 0; i < mvIdx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
ctx.note('账上短的东西才是引线——选「监守自盗」,单击');
await ctx.gesture('tap');
}
// —— 裁决:认罪链 + 法槌 + 结算(链锁,手势全吞) ——
await waitStep(ctx, mock, () => game.state === 'VERDICT', 30000);
ctx.status('证据链闭合——周先生认罪三段,雨声渐强又落下,法槌落定');
await waitStep(ctx, mock, () => game.state === 'SETTLE', 150000);
const gi = game._gradeInfo;
ctx.status(`案件归档,雨停了——评级 ${gi ? gi.grade : '?'}:疑点 ${gi ? gi.outcome.doubts : '?'}/4,错指 ${gi ? gi.outcome.wrongWho : '?'} 次`);
ctx.note('单击——接新案(同一场雨里,还有别的案子)');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run?.pack === 1, 30000);
ctx.status('新案开卷:pack 翻转——这回的真凶换了人');
// —— 变体案短打:一处指证(小顺的谎)收尾 ——
await confront(ctx, mock, game, 'b_shun_1', 'wen_2',
'小顺说「我走的时候熄了灯」——变体案的第一句谎', '温太太还是那句话「灯还亮着」——对上了!');
ctx.status(`变体案疑点 ${game._doubtCount()}/4——真凶另有其人,收工`);
}
// ---------------------------------------------------------------- 乱按压力剧本
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20260909;
const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
// ① 标题页乱划,然后开始
for (let i = 0; i < 8 && game.state === 'TITLE'; i++) {
ctx.status('标题页乱划一气');
await ctx.gesture(rng() < 0.5 ? 'left' : 'doubletap');
await ctx.waitVirtual(450);
}
await ctx.gesture('tap');
// ② 教学:乱按几下,然后下滑跳过(跟不下来也要能走完)
await waitStep(ctx, mock, () => game.state === 'DRILL' || game.state === 'BOARD', 25000);
let dg = 0;
while (game.state === 'DRILL' && dg++ < 30) {
if (rng() < 0.5) {
await ctx.gesture(['tap', 'doubletap', 'left', 'right', 'up'][Math.floor(rng() * 5)]);
await ctx.waitVirtual(450);
continue;
}
ctx.note('下滑,跳过教学');
await ctx.gesture('down');
await ctx.waitVirtual(1200);
}
await waitStep(ctx, mock, () => game.state === 'BOARD', 25000);
ctx.status('总算从教学里滚出来了(可能一路错过来的)');
// ③ 证词板乱按 185s(零进展——被看门狗三级抢救:earcon → 短句 → 保底矛盾对;
// 菜单乱逛的挂起时长会被无进展轨剔除,故墙钟跑满 185s 保证 L3 落窗)。
// 起停对=同拍双 tap(与语音时长解耦:一句都不完成=确定性零进展)。
const t0 = mock.now;
const ROT = ['pair', 'right', 'left', 'doubletap', 'left', 'right'];
let ri = 0;
while (game.state === 'BOARD' && mock.now - t0 < 185000) {
const gg = ROT[ri % ROT.length];
const atCase = game.itemIdx === ITEM_ORDER.length - 1;
if (gg === 'pair' && !atCase) {
await ctx.gesture('tap'); // 起播
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(1500);
await ctx.gesture('down');
await ctx.waitVirtual(600);
continue;
}
await ctx.waitVirtual(ri++ % ROT.length === ROT.length - 1 ? 3600 : 1100);
}
ctx.status('乱按也被抢救:听到了保底提示直接给出可指证的矛盾对');
// ④ 顺着保底提示真指证一处(零死锁底线:核心动词始终可达)
const cs = game._case;
if (game.state === 'BOARD' && game._doubtCount() < 4) {
const contra = cs.contradictions.find((c) => c.hint) || cs.contradictions[0];
await confront(ctx, mock, game, 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, game);
await waitQuestion(ctx, mock, game, 'who');
const whoIdx = fg === 1 ? Math.floor(rng() * 4) : LISTEN_ITEMS.indexOf(cs.culprit);
for (let i = 0; i < whoIdx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(300); }
ctx.note(fg === 1 ? '先胡乱指一个听听反应' : '这回指着正主');
await ctx.gesture('tap');
await ctx.waitVirtual(18000);
await ctx.gesture('tap');
if (game.state === 'FINISH' && game._fin?.phase !== 'who') {
for (const [phase, want] of [['weapon', cs.questions.weapon.answer], ['motive', cs.questions.motive.answer]]) {
await waitQuestion(ctx, mock, game, phase);
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(300); }
await ctx.gesture('tap');
await ctx.waitVirtual(1500);
}
}
await waitStep(ctx, mock, () => game.state !== 'FINISH', 40000);
}
await waitStep(ctx, mock, () => game.state === 'SETTLE', 150000);
ctx.status('乱按一路也把案子结了:裁决链完整、评级如实记账');
// ⑥ 接新案短程收尾(压力全程零死锁)
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run, 30000);
ctx.status('新案照样开局,压力测试收工');
}