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:
@@ -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('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
|
||||
}
|
||||
@@ -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('新案照样开局,压力测试收工');
|
||||
}
|
||||
Reference in New Issue
Block a user