Files
SoundGame/web/harness/sessions/detective.mjs
T
xsl 31372615b2 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条(全为设计内部冲突按承诺清单/归因真话律取优)
2026-09-10 01:40:31 +08:00

383 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《雨夜专案》新手会话剧本。
*
* 剧本:
* - `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('新案照样开局,压力测试收工');
}