feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
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 置完成。
This commit is contained in:
@@ -27,10 +27,10 @@ export function describe(game) {
|
||||
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑新救援」的询问';
|
||||
if (game.phase === 'MENU') {
|
||||
const names = ['回到水下', '重新开始这次救援', '保存并回大厅'];
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`;
|
||||
}
|
||||
if (game.phase === 'INTRO') {
|
||||
return game.voicePlaying ? '甲板简报正在播(规矩就一条)' : '简报播完了,等单击下潜';
|
||||
return game.voicePlaying ? '甲板简报正在播(就一句目标)' : '简报播完了,等单击下潜';
|
||||
}
|
||||
if (game.phase === 'DIVE') {
|
||||
if (game.searchArmed) return '声呐静了,轮到你了:单击发一枚脉冲';
|
||||
@@ -57,11 +57,11 @@ export function describe(game) {
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
ctx.note('开局:听完甲板简报——每段链末单击继续,第一潜开始后声呐会静下来');
|
||||
ctx.note('开局:第一句就让我点屏幕——点完听目标,第一潜开始后声呐会静下来');
|
||||
let firstPing = false;
|
||||
let firstEcho = false;
|
||||
let invalidTried = false;
|
||||
let menuDone = false;
|
||||
let menuVisited = false;
|
||||
let walkedAway = false;
|
||||
let choiceIdx = 0;
|
||||
|
||||
@@ -110,53 +110,58 @@ export async function session(ctx, mock) {
|
||||
ctx.note(pick.why + '——但先上滑听一遍潜次和氧气');
|
||||
await ctx.gesture('up');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('听完状态链(第几潜+氧气档+口令),局面复述回来了');
|
||||
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;
|
||||
if (!game.choiceArmed) continue; // 没回到武装态就再等一轮
|
||||
}
|
||||
ctx.status('选项念完了,做决定');
|
||||
ctx.note(pick.why);
|
||||
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧看门狗)
|
||||
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧提示三档)
|
||||
choiceIdx++;
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'ENDING') {
|
||||
await ctx.waitCond(() => game.awaitingRestart);
|
||||
continue;
|
||||
await ctx.waitCond(() => game.awaitingRestart && !game.voicePlaying);
|
||||
if (!menuVisited) {
|
||||
// 第一局打完,下滑菜单此刻才真正解锁(教学句挂在结局链尾)
|
||||
menuVisited = true;
|
||||
ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('菜单说明和第一项「回到水下」报完了');
|
||||
await ctx.gesture('right'); // 右滑下一项报名(不单击,绝不误触确认)
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('听完「重新开始这次救援」的报名,不动它');
|
||||
await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」)
|
||||
await ctx.waitCond(() => game.awaitingRestart && !game.voicePlaying);
|
||||
ctx.status('菜单关了,结局链复述了一遍局面,回到结算');
|
||||
}
|
||||
break; // 会话收尾(结局听完、菜单逛过)
|
||||
}
|
||||
if (game.phase === 'SURFACE') {
|
||||
// 上浮结算自动链(扣账/补给/档位)——播完自动进下一潜或签收,不插手
|
||||
// 上浮结算自动链(扣账/补给/档位)——播完自动进下一潜;签收链中段
|
||||
// (g9_knot → p9)是链末等待,单击继续
|
||||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
|
||||
if (!game.voicePlaying && !game.choiceArmed && !game.searchArmed) {
|
||||
await ctx.gesture('tap'); // 链真停了(非句间):签收链中段单击继续
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// COMMS 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
|
||||
// 不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual 会
|
||||
// 一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
|
||||
// 不要在这里 waitVirtual 小憩——等待点挂着静默提示定时器,waitVirtual 会
|
||||
// 一口气跳到下一档提示(13s full),剧本就变成「每句干晾到提示响」。
|
||||
if (!walkedAway && game.phase === 'COMMS' && !game.choiceArmed) {
|
||||
walkedAway = true;
|
||||
ctx.silence(25, '听到一半走了神,在想氧气还剩几格');
|
||||
await ctx.waitVirtual(25600); // 触发看门狗:nudge +「单击屏幕,继续」
|
||||
ctx.status('听到一声轻响和「单击屏幕,继续」,马上点');
|
||||
ctx.silence(14, '听到一半走了神,在想氧气还剩几格');
|
||||
await ctx.waitVirtual(14600); // 触发等待点第一档提示:「单击,继续。」
|
||||
ctx.status('听到一句轻提「单击,继续」,马上点');
|
||||
await ctx.gesture('tap');
|
||||
continue;
|
||||
}
|
||||
await ctx.gesture('tap'); // 链末单击 = 继续
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
|
||||
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart && !game.voicePlaying);
|
||||
ctx.status('结局链走完,尾声逐人点到我每一潜的救与错过');
|
||||
ctx.note('船钟出水,五个人都在甲板上——会话结束(E1 海燕归钟)');
|
||||
}
|
||||
@@ -192,15 +197,15 @@ export async function sessionStress(ctx, mock) {
|
||||
}
|
||||
if (game.phase === 'MENU') {
|
||||
menuOpenCount++;
|
||||
await ctx.gesture('tap'); // 乱切换
|
||||
await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名,不确认防误触)
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」)
|
||||
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);
|
||||
@@ -213,8 +218,11 @@ export async function sessionStress(ctx, mock) {
|
||||
break;
|
||||
}
|
||||
if (game.phase === 'SURFACE') {
|
||||
// 结算自动链:等它自己走完(播句中乱点=跳句,也无妨)
|
||||
// 结算自动链:等它自己走完;签收链中段链末等待同样乱点一下(零静默)
|
||||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
|
||||
if (!game.voicePlaying && !game.choiceArmed && !game.searchArmed) {
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// COMMS 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
|
||||
|
||||
+149
-116
@@ -1,27 +1,49 @@
|
||||
/**
|
||||
* 《星环舰长》新手会话剧本。
|
||||
* 《星环舰长》新手会话剧本(26 号重构版:叙事+玩法混合型,radio 范式)。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位新舰长的首次巡逻全弧线——标题开始 → DRILL D0..D4 跟练
|
||||
* (巡检/听检测链/预览+驰援下令/再巡检)→ S1 收尾进 DOCK → S2 航线选择+陨石迎战
|
||||
* → DOCK 修理磨损 → S3 海盗迎战(支援演出)→ S4 辐射规避 → 结算链 → 单击返航。
|
||||
* - `sessionStress`(乱按压力):标题乱划、教学跳过、巡航乱按乱滑(被看门狗抢救)、
|
||||
* 菜单乱逛只敢下滑关、危机全部超时走默认结局、DOCK 挂着不操作吃无进展三级提示——
|
||||
* 一路走到结算(或弃舰),全程零死锁。
|
||||
* - `session`(主线):一位新舰长的首次巡逻全弧线——开场第一句「点一下屏幕」就
|
||||
* 动手 → D0 上滑巡检(四系统音+账目档+解锁句)→ D1 听求救检测链 → D2 预览+下令
|
||||
* 驰援(第一次成功 pa_chime+报酬)→ D3 再巡检 → 段末靠港启航 → S2 航线选择+
|
||||
* 陨石迎战 → S3 海盗(大副支援)→ S4 辐射规避 → 结算链 → 单击返航;危机前
|
||||
* 选项过完耳朵、犹豫时主动重听一次选项(阶段 3 口径由阶梯解锁状态决定)。
|
||||
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、检测链乱滑
|
||||
* (被解释并续完)、菜单里只换项不乱确认、危机挂到超时走默认结局——一路走到
|
||||
* 结算(或弃舰),全程零死锁、零静默死局。
|
||||
*
|
||||
* 剧本失真红线(参照 detective/dungeon 剧本先例):
|
||||
* - 玩家不抢话:出手前等语音链/判定链收尾(waitStep 有界等待状态谓词);
|
||||
* - 玩家靠耳朵玩:危机前先听检测链,下令前左右滑把选项过完耳朵;
|
||||
* 剧本失真红线(参照 radio/dungeon 剧本先例):
|
||||
* - 玩家不抢话:出手前等真正等待态(floor.state === 'YOUR_TURN' 或玩法 ACTION 态
|
||||
* 静默),检测链(纯聆听)绝不出手打断了事;voiceBusy 是句级信号不作门控;
|
||||
* - 玩家靠耳朵玩:危机先听检测链(签名→类型→方位),下令前左右滑把选项过完
|
||||
* 耳朵,巡检靠上滑不靠背;
|
||||
* - 危机倒计时是真的:决策窗内动作紧凑,宁可超时也不抢检测链;
|
||||
* - 菜单里只「下滑关闭」,绝不双击执行(防误触「保存并回大厅」结束会话)。
|
||||
* - 菜单里只「下滑关闭」,破坏项(放弃巡逻)绝不确认(防误触结束会话)。
|
||||
*/
|
||||
|
||||
const DIR_LABEL = { port: '左舷', star: '右舷', bow: '舰首', stern: '舰尾' };
|
||||
const SYS_LABEL = { engine: '引擎', shield: '护盾', scan: '扫描', weapon: '武器' };
|
||||
const OPT_LABEL = { evade: '规避机动', fight: '迎战', breach: '硬闯', aid: '驰援', ignore: '按兵不动' };
|
||||
|
||||
/** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */
|
||||
const atWait = (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._menu.open) return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭';
|
||||
if (game._tourState) return '巡检链播报中(左右滑无效,单击跳段,下滑菜单)';
|
||||
if (game._goingHome) return '已确认保存并回大厅';
|
||||
if (game._menu.open) {
|
||||
const names = ['回到舰桥', '保存并回大厅', '放弃本次巡逻'];
|
||||
return `菜单里,当前项「${names[game._menu.idx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`;
|
||||
}
|
||||
if (game._tourState) return '巡检链播报中(四系统音+账目档):单击跳段,上滑重播全链';
|
||||
if (game.state === 'CRISIS' && game._crisis) {
|
||||
const c = game._crisis;
|
||||
if (c.subphase === 'detect') return '检测链播报中:听签名音、类型、方位(先听完再动)';
|
||||
if (c.subphase === 'decide') {
|
||||
const opt = { meteor: ['evade', 'fight', 'breach'], pirate: ['fight', 'evade', 'ignore'], distress: ['aid', 'ignore'], radiation: ['evade', 'breach'] }[c.type][c.optIdx];
|
||||
return `决策窗(当前预览:${OPT_LABEL[opt]}):左右滑听选项,单击下令,倒计时要紧`;
|
||||
}
|
||||
return '危机结算链播报中,先听着';
|
||||
}
|
||||
switch (game.state) {
|
||||
case 'TITLE': return game.run
|
||||
? '检测到未完成的巡逻:单击继续,右滑进星港,上滑听舰船概况'
|
||||
@@ -31,18 +53,9 @@ export function describe(game) {
|
||||
? '选航线:稳妥线(采集平平)。右滑听矿富线,单击选定'
|
||||
: '选航线:矿富线(采得多磨损也多)。左滑回稳妥线,单击选定';
|
||||
case 'CRUISE': return game._drill
|
||||
? `教学第 ${game._drill.step} 步,跟着旁白做(下滑可跳过)`
|
||||
: '巡航中:上滑巡检全舰,单击重播最近告警,下滑菜单';
|
||||
case 'CRISIS': {
|
||||
const c = game._crisis;
|
||||
if (!c) return '危机结算中,先听着';
|
||||
if (c.subphase === 'detect') return '检测链播报中:听签名音、类型、方位(手势静默保护)';
|
||||
const opt = { evade: '规避机动', fight: '迎战', breach: '硬闯', aid: '驰援', ignore: '按兵不动' }[c.type === 'meteor' ? ['evade', 'fight', 'breach'][c.optIdx]
|
||||
: c.type === 'pirate' ? ['fight', 'evade', 'ignore'][c.optIdx]
|
||||
: c.type === 'distress' ? ['aid', 'ignore'][c.optIdx] : ['evade', 'breach'][c.optIdx]];
|
||||
return `决策窗(当前预览:${opt}):左右滑听选项,单击下令,上滑简链,倒计时要紧`;
|
||||
}
|
||||
case 'DOCK': return `靠港整备:${game._dock.items.map((i) => i.kind === 'go' ? '启航' : '修理' + SYS_LABEL[i.sys]).join(' / ')}——左右滑看项目,单击执行`;
|
||||
? `教学第 ${game._drill.step} 步(${game._drill.step === 0 || game._drill.step === 3 ? '等上滑巡检' : '听/做'})`
|
||||
: '巡航中:上滑巡检全舰(我在哪),单击重播最近告警';
|
||||
case 'DOCK': return `靠港整备:${game._dock.items.map((i) => (i.kind === 'go' ? '启航' : '修理' + SYS_LABEL[i.sys])).join(' / ')}——左右滑看项目,单击执行`;
|
||||
case 'REPORT': return game._report && !game._report.done
|
||||
? '结算链播报中,手势全被吞(链锁)'
|
||||
: '结算完了:单击返航,上滑重听评级';
|
||||
@@ -63,12 +76,17 @@ async function waitStep(ctx, mock, cond, maxMs = 30000) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 等当前语音链/计时空闲(有界)。 */
|
||||
/** 等链收/巡检收尾(有界)。 */
|
||||
async function waitQuiet(ctx, mock, game, maxMs = 60000) {
|
||||
await waitStep(ctx, mock, () => !game.audio?.isVoicePlaying?.() && !game._tourState, maxMs);
|
||||
await waitStep(ctx, mock, () => !game.audio.isVoicePlaying() && !game._tourState, maxMs);
|
||||
}
|
||||
|
||||
/** 在决策窗内把选项循环到目标下标(每步听一句)。 */
|
||||
/** 等真正的等待态(话轮已交)或巡航 ACTION 静默。 */
|
||||
async function waitTurn(ctx, mock, game, maxMs = 60000) {
|
||||
await waitStep(ctx, mock, () => atWait(game) || (game.state === 'CRUISE' && !game._tourState && !game.floor.speaking), maxMs);
|
||||
}
|
||||
|
||||
/** 决策窗内把选项循环到目标下标(每步听一句报名)。 */
|
||||
async function navOpt(ctx, game, targetIdx, nOpts) {
|
||||
let guard = 0;
|
||||
while (game._crisis && game._crisis.optIdx !== targetIdx && guard++ < 6) {
|
||||
@@ -81,7 +99,7 @@ async function navOpt(ctx, game, targetIdx, nOpts) {
|
||||
/** 危机决策:等检测链落决策窗 → 选项过耳朵 → 单击下令。 */
|
||||
async function decide(ctx, mock, game, targetIdx, note) {
|
||||
const c = game._crisis;
|
||||
const nOpts = c.type === 'meteor' ? 3 : c.type === 'pirate' ? 3 : c.type === 'distress' ? 2 : 2;
|
||||
const nOpts = c.type === 'meteor' || c.type === 'pirate' ? 3 : 2;
|
||||
await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000);
|
||||
await ctx.waitVirtual(1500); // 首选项预览落声
|
||||
await navOpt(ctx, game, targetIdx, nOpts);
|
||||
@@ -100,11 +118,12 @@ async function rideCrisis(ctx, mock, game, targetIdx, plan) {
|
||||
await waitQuiet(ctx, mock, game, 40000);
|
||||
}
|
||||
|
||||
/** 巡航到段末:途中巡检一次,然后等危机。 */
|
||||
/** 巡航到段末:途中巡检一次(我在哪),然后等危机。 */
|
||||
async function cruiseAndCrisis(ctx, mock, game, tourNote, plan) {
|
||||
await waitStep(ctx, mock, () => game.state === 'CRUISE', 30000);
|
||||
await waitTurn(ctx, mock, game, 20000);
|
||||
ctx.note(tourNote);
|
||||
await ctx.gesture('up'); // 巡检
|
||||
await ctx.gesture('up'); // 巡检(四系统音+舰体档+一个动作)
|
||||
await ctx.waitVirtual(20000);
|
||||
await rideCrisis(ctx, mock, game, plan.optIdx, plan);
|
||||
}
|
||||
@@ -135,20 +154,20 @@ async function dockAndGo(ctx, mock, game) {
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// —— 标题:单击开始首次巡逻 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'TITLE');
|
||||
ctx.status('星环舰长:一条巡逻航线,四个系统各守一方');
|
||||
ctx.note('单击,新巡逻启航');
|
||||
// —— 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 交出话轮 → 第一下单击 ——
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('开场第一句就是指令,不是规则念白');
|
||||
ctx.note('第一句就让我动手——点一下');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// —— DRILL D0:等开场链,上滑巡检 ——
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.armed, 40000);
|
||||
// —— D0:等教学链(轮到你了+巡检指令)→ 上滑巡检 ——
|
||||
await waitStep(ctx, mock, () => wpId(game) === 'inspect', 40000);
|
||||
ctx.note('上滑——听全舰状态(四个音都稳)');
|
||||
await ctx.gesture('up');
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 1, 60000);
|
||||
ctx.status('巡检学会了:四个系统各 2 秒,顺序固定');
|
||||
ctx.status('巡检学会了:四个系统各 2 秒,顺序固定;链尾教了「想知道自己在哪,就向上滑」');
|
||||
|
||||
// —— D1/D2:求救检测链 → 预览 → 下令驰援 ——
|
||||
// —— D1/D2:求救检测链(纯聆听)→ 预览 → 下令驰援 ——
|
||||
await rideCrisis(ctx, mock, game, 0, {
|
||||
type: '求救信号', optIdx: 0,
|
||||
note: '驰援靠扫描——我们的扫描稳得很。单击,下令驰援!',
|
||||
@@ -157,16 +176,20 @@ export async function session(ctx, mock) {
|
||||
ctx.status('驰援成功:资源+2,医师和大副记下了——听声辨型后下令,就是这样');
|
||||
|
||||
// —— D3:再巡检(听船员三档)→ D4 完成 ——
|
||||
await waitStep(ctx, mock, () => game._drill?.armed, 20000);
|
||||
await waitStep(ctx, mock, () => wpId(game) === 'inspect', 20000);
|
||||
ctx.note('上滑——再巡检一遍,听听船员档位');
|
||||
await ctx.gesture('up');
|
||||
await waitStep(ctx, mock, () => game.state === 'DOCK', 90000);
|
||||
ctx.status('教学完了,本段结束,靠港整备');
|
||||
await dockAndGo(ctx, mock, game);
|
||||
|
||||
// —— S2 陨石:稳妥线,迎战轰开 ——
|
||||
// —— S2 陨石:听听矿富线再回稳妥,迎战轰开 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000);
|
||||
ctx.note('稳妥航线,太平——单击选定');
|
||||
ctx.note('矿富航线听听——算了,稳妥线。单击');
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1500);
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitVirtual(1200);
|
||||
await ctx.gesture('tap');
|
||||
await cruiseAndCrisis(ctx, mock, game, '上滑巡检:磨损的扫描在左舷发失锁双哔', {
|
||||
type: '陨石群', optIdx: 1, note: '迎战,靠武器——单击,轰开它',
|
||||
@@ -175,11 +198,7 @@ export async function session(ctx, mock) {
|
||||
|
||||
// —— S3 海盗:迎战(可能触发大副支援)——
|
||||
await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000);
|
||||
ctx.note('矿富航线听听——算了,稳妥线。单击');
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1500);
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitVirtual(1200);
|
||||
ctx.note('稳妥航线,太平——单击选定');
|
||||
await ctx.gesture('tap');
|
||||
await cruiseAndCrisis(ctx, mock, game, '上滑巡检:舰体带伤没有?资源还够用', {
|
||||
type: '海盗', optIdx: 0, note: '敌船引擎声,右舷来的——迎战!单击',
|
||||
@@ -198,7 +217,7 @@ export async function session(ctx, mock) {
|
||||
|
||||
// —— 结算链 → 单击返航 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'REPORT', 90000);
|
||||
ctx.status('巡逻归来:评级与功勋在播,手势先别动');
|
||||
ctx.status('巡逻归来:评级与功勋在播,手势先别动(链锁=吞)');
|
||||
await waitStep(ctx, mock, () => game._report && game._report.done, 60000);
|
||||
ctx.status(`评级 ${game._report.rate}——星港期待下一次巡逻`);
|
||||
ctx.note('单击,返航');
|
||||
@@ -208,101 +227,115 @@ export async function session(ctx, mock) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 乱按压力剧本
|
||||
// 目标:全程零死锁、零静默死局,一样能走到结算。
|
||||
// 剧本特征:不读提示、快速跳句、检测链乱滑(被打断后续完)、菜单只换项下滑关、
|
||||
// 危机挂到超时走默认结局——但保留「最终能通关」的底线。
|
||||
|
||||
export async function sessionStress(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
let routeN = 0;
|
||||
let seed = 20260914;
|
||||
|
||||
let seed = 20261002;
|
||||
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);
|
||||
}
|
||||
// ① 开场指令句是 critical:抢拍只会拿到 busy 回执,等它播完再动手
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// ② 教学:乱按几下(绝不带 doubletap——菜单开着时双击会真执行),然后下滑跳过
|
||||
await waitStep(ctx, mock, () => game.state === 'CRUISE' && game._drill?.armed, 40000);
|
||||
let dg = 0;
|
||||
while (game._drill && dg++ < 30) {
|
||||
if (game._menu.open) { // 兜底:菜单开着只敢下滑关
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitVirtual(1200);
|
||||
continue;
|
||||
}
|
||||
if (rng() < 0.5) {
|
||||
await ctx.gesture(['tap', 'left', 'right'][Math.floor(rng() * 3)]);
|
||||
await ctx.waitVirtual(450);
|
||||
continue;
|
||||
}
|
||||
ctx.note('下滑,跳过教学');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitVirtual(1500);
|
||||
}
|
||||
await waitStep(ctx, mock, () => !game._drill, 25000);
|
||||
if (game._menu.open) { await ctx.gesture('down'); await ctx.waitVirtual(1200); }
|
||||
ctx.status('教学被跳过了(tut_skip 已播)');
|
||||
|
||||
// ③ S1:巡航乱按 40s 虚拟(看门狗 L1 该来催了),S1 危机不理会 → 超时默认结局
|
||||
const t0 = mock.now;
|
||||
while (game.state === 'CRUISE' && game.run?.seg === 1 && mock.now - t0 < 40000) {
|
||||
if (game._menu.open) { await ctx.gesture('down'); await ctx.waitVirtual(1200); continue; }
|
||||
// ② D0 教学:乱按几下(tap/滑都是 invalid 回执+一次解释),最后照做上滑
|
||||
await waitStep(ctx, mock, () => wpId(game) === 'inspect', 40000);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await ctx.gesture(['tap', 'left', 'right'][Math.floor(rng() * 3)]);
|
||||
if (rng() < 0.12) {
|
||||
ctx.note('手痒开菜单乱逛——只敢下滑关掉');
|
||||
if (!game._menu.open) await ctx.gesture('down');
|
||||
await ctx.waitVirtual(2000);
|
||||
if (game._menu.open) await ctx.gesture('down');
|
||||
await ctx.waitVirtual(1200);
|
||||
continue;
|
||||
}
|
||||
await ctx.waitVirtual(1200);
|
||||
await ctx.waitVirtual(450);
|
||||
}
|
||||
ctx.status('S1 求救来了也不下令——等它超时(信号消失,零损失)');
|
||||
await waitStep(ctx, mock, () => game.state !== 'CRISIS', 60000);
|
||||
await waitStep(ctx, mock, () => game.state === 'DOCK', 60000);
|
||||
// DOCK 挂着不动 → 无进展三级提示(45s turn_tick / 150s 自动预览启航)
|
||||
ctx.status('靠港了偏不整备——听它三级提示');
|
||||
await ctx.waitVirtual(160000);
|
||||
await ctx.gesture('tap'); // 自动预览已选启航项 → 单击走你
|
||||
await waitStep(ctx, mock, () => game.state === 'ROUTE' || game.state === 'CRUISE', 20000);
|
||||
ctx.note('被咚了几下,还有一句「先向上滑」——照做');
|
||||
await ctx.gesture('up');
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 1, 60000);
|
||||
|
||||
// ④ S2 起:每个危机都干等超时,乱滑几下测试静默保护
|
||||
// ③ S1 求救:检测链乱滑(invalid+解释+续完剩余句,保护链);决策窗不下令 → 超时
|
||||
await waitStep(ctx, mock, () => game.state === 'CRISIS', 120000);
|
||||
ctx.status('检测链:乱滑也不掐链——它解释一句,把剩下的类型方位接着讲完');
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitVirtual(600);
|
||||
await ctx.gesture('right');
|
||||
await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000);
|
||||
ctx.status('求救来了也不下令——等它超时(信号消失,零损失)');
|
||||
await waitStep(ctx, mock, () => game._crisis === null, 60000);
|
||||
await waitStep(ctx, mock, () => game.state === 'DOCK', 90000); // 教学收束(D3 兜底替播巡检)
|
||||
await dockAndGo(ctx, mock, game);
|
||||
|
||||
// ④ S2 起:一半危机干等超时,一半随手点第一项;巡航乱按乱滑听回执
|
||||
let seg = 2;
|
||||
let dockRounds = 0;
|
||||
let routeRounds = 0;
|
||||
while (game.state !== 'REPORT' && mock.now < 40 * 60000) {
|
||||
if (game.state === 'CRISIS' && game._crisis && game._crisis.subphase === 'detect') {
|
||||
ctx.status('检测链:左滑右滑都不理我(纯聆听保护)');
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitVirtual(600);
|
||||
await ctx.gesture('right');
|
||||
await waitStep(ctx, mock, () => !game._crisis, 60000);
|
||||
await waitStep(ctx, mock, () => ['DOCK', 'REPORT', 'CRUISE', 'ROUTE', 'CRISIS'].includes(game.state), 30000);
|
||||
if (game.state === 'CRISIS' && game._crisis) {
|
||||
const c = game._crisis;
|
||||
if (c.subphase === 'detect') {
|
||||
await waitStep(ctx, mock, () => !game._crisis || game._crisis.subphase !== 'detect', 60000);
|
||||
continue;
|
||||
}
|
||||
if (c.subphase === 'decide') {
|
||||
if (rng() < 0.5) {
|
||||
ctx.note('这次不下令,等超时默认结局');
|
||||
await waitStep(ctx, mock, () => game._crisis === null, 60000);
|
||||
} else {
|
||||
await ctx.gesture('tap'); // 预览到哪个就下哪令(鲁莽但合法)
|
||||
await waitStep(ctx, mock, () => game._crisis === null, 60000);
|
||||
}
|
||||
await waitQuiet(ctx, mock, game, 40000);
|
||||
continue;
|
||||
}
|
||||
await ctx.waitVirtual(1500);
|
||||
continue;
|
||||
}
|
||||
if (game.state === 'DOCK') {
|
||||
ctx.note('乱按修理/启航');
|
||||
for (let i = 0; i < 3; i++) { await ctx.gesture('right'); await ctx.waitVirtual(800); }
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(2500);
|
||||
// 乱按修理/启航(无资源时 rep_poor 真话账);逛几轮仍出不去就走收敛整备
|
||||
if (dockRounds++ < 3) {
|
||||
ctx.note('乱按修理/启航');
|
||||
for (let i = 0; i < 3; i++) { await ctx.gesture('right'); await ctx.waitVirtual(800); }
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(2500);
|
||||
continue;
|
||||
}
|
||||
dockRounds = 0;
|
||||
ctx.note('瞎逛够了,按项目顺序整备一遍');
|
||||
await dockAndGo(ctx, mock, game);
|
||||
continue;
|
||||
}
|
||||
if (game.state === 'ROUTE') {
|
||||
// 确定性交替:一半机率选定(rng 序列可能长期不出 tap,烧光虚拟预算)
|
||||
routeN = (routeN + 1) % 2;
|
||||
await ctx.gesture(routeN === 0 ? 'tap' : 'swiperight');
|
||||
await ctx.waitVirtual(1200);
|
||||
// 乱划航线(每次报名);逛两轮后在话轮上单击选定(报名播完 tap 才是选定)
|
||||
routeRounds++;
|
||||
if (routeRounds < 3) {
|
||||
await ctx.gesture(rng() < 0.5 ? 'right' : 'left');
|
||||
await ctx.waitVirtual(1200);
|
||||
continue;
|
||||
}
|
||||
routeRounds = 0;
|
||||
await waitStep(ctx, mock, () => atWait(game), 30000);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(1500);
|
||||
continue;
|
||||
}
|
||||
if (game.state === 'CRUISE') {
|
||||
if (game._menu.open) { await ctx.gesture('down'); await ctx.waitVirtual(1200); continue; }
|
||||
await ctx.gesture(['tap', 'up', 'left'][Math.floor(rng() * 3)]);
|
||||
// 巡航乱按:单击重播告警、乱滑吃 invalid 回执、偶尔开菜单只敢下滑关
|
||||
const roll = rng();
|
||||
if (roll < 0.1 && !game._tourState) {
|
||||
ctx.note('手痒开菜单乱逛——只敢下滑关掉');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitVirtual(2000);
|
||||
if (game._menu.open) await ctx.gesture('down');
|
||||
await ctx.waitVirtual(1200);
|
||||
continue;
|
||||
}
|
||||
await ctx.gesture(['tap', 'up', 'left', 'right'][Math.floor(rng() * 4)]);
|
||||
await ctx.waitVirtual(1500);
|
||||
continue;
|
||||
}
|
||||
if (game.state === 'REPORT') break;
|
||||
await ctx.waitVirtual(1500);
|
||||
}
|
||||
|
||||
await waitStep(ctx, mock, () => game.state === 'REPORT' && game._report?.done, 180000);
|
||||
if (game._report) {
|
||||
ctx.status(`乱按局也走完了:评级 ${game._report.rate},全程零死锁`);
|
||||
|
||||
+208
-164
@@ -1,56 +1,71 @@
|
||||
/**
|
||||
* 《暗码》新手会话剧本。
|
||||
* 《暗码》新手会话剧本(26 号重构版:叙事+解码型,radio 范式)。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位夜班报务员的完整弧线——听完开场白与规矩,逐封截获
|
||||
* 四封密电(听摩尔斯 → 切候选听词义 → 双击重听/慢放 → 两段确认定词),
|
||||
* 处置抉择(p2 报北风 / p4 呈报 / p6 留书 / p8 装傻 / p9 发最后一电),
|
||||
* 听完尾声逐封点名,以完整 E1「最后一电」收尾。
|
||||
* - `sessionStress`(乱按压力):好奇宝宝乱划乱点、菜单里乱逛、死命翻密码本,
|
||||
* 一样要能走到 ENDING,全程零死锁、零静默死局。
|
||||
* - `session`(主线):一位夜班报务员的完整弧线——开场第一句「点一下屏幕。」就动手 →
|
||||
* 「轮到你了」状态教学 → 第一封电文摩尔斯首播(BUSY 不可跳)→ 浏览候选
|
||||
* (左右滑自动预听对照档 → 静默一拍 → 词义句)→ 单击进确认(电文原速重播)→
|
||||
* 再单击定下;四封电文各做一次处置抉择(p2 报北风 / p4 呈报 / p6 留书 /
|
||||
* p8 装傻 / p9 发最后一电),中途主动重听一次选项(「再说一遍:」)、
|
||||
* 上滑问一次状态(第几封 → 密码本余页 → 暴露档)、走神一次听三档提示、
|
||||
* 结局后逛一圈菜单(阶段 2 下滑此时才真解锁),以完整 E1「最后一电」收尾。
|
||||
* - `sessionStress`(乱按压力):好奇宝宝乱划乱点、确认态反复进出、菜单里乱逛、
|
||||
* 阶段 3 双击把电文听到慢放,一样要能走到 ENDING,全程零死锁、零静默死局。
|
||||
*
|
||||
* 剧本失真红线(参照 radio/abyss 剧本先例):
|
||||
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
|
||||
* - 玩家不干晾自己:只在浏览/链末等待态偶尔走神触发一次看门狗;
|
||||
* 剧本失真红线(参照 radio/storyline 剧本先例):
|
||||
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
|
||||
* 出手——BUSY 演出段(电文摩尔斯首播 / T4 danger 拍前置 2s / 确认电文重播)
|
||||
* 真机里是 busy 回执,剧本同样等它走完;
|
||||
* - 玩家不干晾自己:只在叙事等待态偶尔走神触发一次等待点提示;
|
||||
* - RX/NIGHT 有自动链与定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
|
||||
* - DECODE 里每步都是真输入:切候选、重听、慢放、确认,像真人一样先听后按;
|
||||
* - RELAY 叙事链是「链末=继续」等待态:推进靠单击(像 tapUntil 一样有界循环),
|
||||
* 抉择则等提示句播完武装后再滑(盲玩公平,不抢话)。
|
||||
* - DECODE 里每步都是真输入:切候选听对照、进确认、定下,像真人一样先听后按
|
||||
* (旧「双击重听/慢放、长按翻密码本」口径已废止:双击=阶段 3,翻本走菜单项);
|
||||
* - RELAY 叙事链是「链末=继续」等待态:推进靠单击(有界循环),抉择则等提示句
|
||||
* 播完武装后再滑(盲玩公平,不抢话)。
|
||||
*/
|
||||
|
||||
const TEL_LABEL = ['一', '二', '三', '四'];
|
||||
|
||||
/** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */
|
||||
const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
|
||||
const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null);
|
||||
const atChoice = (game) => atWait(game) && wpId(game) === 'choice';
|
||||
const atSettle = (game) => game.phase === 'ENDING' && game.awaitingRestart;
|
||||
|
||||
export function describe(game) {
|
||||
if (game.leaving) return '已确认保存并回大厅';
|
||||
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑新夜班」的询问';
|
||||
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑开始新的夜班」的询问';
|
||||
if (game.phase === 'MENU') {
|
||||
const names = ['回到电波', '翻密码本', '重新开始今晚', '保存并回大厅'];
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`;
|
||||
}
|
||||
if (game.phase === 'INTRO') {
|
||||
return game.voicePlaying ? '开场白正在播(今晚的目标和处长的夜令)' : '开场白播完了,等第一封电文';
|
||||
if (game.floor.speaking) {
|
||||
return game.runs <= 1 ? '开场指令正在播:「点一下屏幕。」' : '新夜班口播正在播';
|
||||
}
|
||||
return '等待声在响:点一下,接第一封电文';
|
||||
}
|
||||
if (game.phase === 'RX') {
|
||||
return game._rxGuard
|
||||
? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳过`
|
||||
? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳(手势只有 busy 回执)`
|
||||
: `第 ${TEL_LABEL[game.tel - 1]} 封电文截获中`;
|
||||
}
|
||||
if (game.phase === 'DECODE') {
|
||||
if (game.confirming) return '进确认了:再单击就定下来,左右滑可以取消';
|
||||
return game.voicePlaying
|
||||
? `第 ${TEL_LABEL[game.decodeTel - 1]} 封:正在听候选词/电文`
|
||||
: `第 ${TEL_LABEL[game.decodeTel - 1]} 封:浏览候选中,左右滑听候选词,双击重听电文,长按翻密码本`;
|
||||
if (game.confirming) return '进确认了:电文正在原速重播,再单击就定下来,左右滑可以取消';
|
||||
return game.floor.speaking
|
||||
? `第 ${TEL_LABEL[game.decodeTel - 1]} 封:正在听候选词(摩尔斯对照档 → 一拍 → 词义)`
|
||||
: `第 ${TEL_LABEL[game.decodeTel - 1]} 封:浏览候选中,左右滑听对照档,单击进确认`;
|
||||
}
|
||||
if (game.choiceArmed) {
|
||||
return '抉择念完了:向左或向右滑动做出你的决定';
|
||||
if (atChoice(game)) {
|
||||
return `抉择念完了(第 ${TEL_LABEL[game.tel - 1]} 封处置):向左或向右滑做出你的决定`;
|
||||
}
|
||||
if (game.phase === 'RELAY') {
|
||||
return game.voicePlaying ? '处置叙事中,正在听' : '说完了,等我接话:单击继续';
|
||||
return game.floor.speaking ? '处置叙事中,正在听' : '说完了,等我接话:单击继续';
|
||||
}
|
||||
if (game.phase === 'NIGHT') return '夜结算中,挂钟和雨声,马上来下一封';
|
||||
if (game.phase === 'ENDING') {
|
||||
return game.awaitingRestart
|
||||
? '结局和尾声点名都听完了。单击再来一局,下滑开菜单'
|
||||
return atSettle(game)
|
||||
? '结局和尾声点名都听完了。单击开新夜班,下滑开菜单'
|
||||
: '结局播报中';
|
||||
}
|
||||
return '……';
|
||||
@@ -58,217 +73,246 @@ export function describe(game) {
|
||||
|
||||
// ---------------------------------------------------------------- 有界推进助手
|
||||
|
||||
/** 等当前语音链播完(有界等待,绝不裸空转)。 */
|
||||
async function waitVoice(ctx) {
|
||||
await ctx.waitCond(() => !ctx.game.voicePlaying);
|
||||
}
|
||||
|
||||
/** RELAY 链推进到抉择武装:语音在播等它播完,链末等待态单击继续(不抢话)。 */
|
||||
async function untilChoice(ctx, maxSteps = 40) {
|
||||
const { game } = ctx;
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
if (game.choiceArmed) return;
|
||||
if (atChoice(game)) return;
|
||||
if (game.phase === 'DECODE') throw new Error('untilChoice 遇 DECODE(需先解码)');
|
||||
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('untilChoice 遇菜单/询问');
|
||||
if (game.voicePlaying) { await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying); continue; }
|
||||
await ctx.waitCond(() => atChoice(game) || atWait(game));
|
||||
if (atChoice(game)) return;
|
||||
await ctx.gesture('tap'); // 链末 = 继续
|
||||
await ctx.waitCond(() => game.choiceArmed || game.voicePlaying || game.phase !== 'RELAY');
|
||||
}
|
||||
throw new Error(`untilChoice 超步限(${game.phase}/${game.nodeId})`);
|
||||
}
|
||||
|
||||
/** 从任意叙事态单击推进到 DECODE 浏览武装(穿越 RX/NIGHT 自动链与 RELAY 等待段)。 */
|
||||
/**
|
||||
* 从任意叙事态推进到 DECODE 浏览武装(穿越 RX 首播摩尔斯 / NIGHT 自动链 /
|
||||
* RELAY 继续等待段;T4 danger 拍前置 2s 的 BUSY 演出窗也在这里等过去)。
|
||||
*/
|
||||
async function toDecode(ctx, maxSteps = 60) {
|
||||
const { game } = ctx;
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
if (game.phase === 'DECODE' && game.browseArmed) return;
|
||||
if (game.choiceArmed) throw new Error('toDecode 遇未应答抉择');
|
||||
for (let i = 0; i < maxSteps && !(game.phase === 'DECODE' && game.browseArmed); i++) {
|
||||
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('toDecode 遇菜单/询问');
|
||||
if (game.voicePlaying) { await ctx.waitCond(() => !game.voicePlaying); continue; }
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game.voicePlaying || game.phase !== 'RX'); // 首播保护窗内 tap 被吞,等真实推进
|
||||
if (game.phase === 'ENDING') throw new Error('toDecode 遇 ENDING');
|
||||
if (game.phase === 'DECODE') { await ctx.waitCond(() => game.browseArmed); continue; }
|
||||
if (atWait(game)) { await ctx.gesture('tap'); continue; }
|
||||
// 自动流(摩尔斯首播/夜链/danger 前置):话轮不在手上,有界等推进或回到等待态
|
||||
await ctx.waitCond(() => (game.phase === 'DECODE' && game.browseArmed) || atWait(game));
|
||||
}
|
||||
if (!(game.phase === 'DECODE' && game.browseArmed)) {
|
||||
throw new Error(`toDecode 超步限(${game.phase}/${game.nodeId})`);
|
||||
}
|
||||
throw new Error(`toDecode 超步限(${game.phase}/${game.nodeId})`);
|
||||
}
|
||||
|
||||
/** 从 p9 抉择后单击推进到 ENDING 结算态(d9 结果链含链末等待段)。 */
|
||||
/** 从 p9 抉择后推进到 ENDING 结算态(d9 结果链 → 结局链 → 尾声点名,自动流转)。 */
|
||||
async function toEnding(ctx, maxSteps = 40) {
|
||||
const { game } = ctx;
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
if (game.phase === 'ENDING' && game.awaitingRestart) return;
|
||||
if (game.voicePlaying) { await ctx.waitCond(() => !game.voicePlaying); continue; }
|
||||
if (game.phase !== 'RELAY' && game.phase !== 'ENDING') {
|
||||
await ctx.waitCond(() => game.phase === 'ENDING' || game.voicePlaying);
|
||||
continue;
|
||||
}
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game.voicePlaying || game.phase === 'ENDING');
|
||||
for (let i = 0; i < maxSteps && !atSettle(game); i++) {
|
||||
if (game.phase === 'ENDING') { await ctx.waitCond(() => atSettle(game)); continue; }
|
||||
if (atWait(game)) { await ctx.gesture('tap'); continue; }
|
||||
await ctx.waitCond(() => atSettle(game) || atWait(game) || game.phase === 'ENDING');
|
||||
}
|
||||
throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`);
|
||||
if (!atSettle(game)) throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主线剧本
|
||||
/** 等候选词义句听完(自动预听链:候选摩尔斯 → 静默一拍 → 词义句;被让路则自然跳过)。 */
|
||||
async function listenCand(ctx) {
|
||||
const { game } = ctx;
|
||||
await ctx.waitCond(() => game.floor.speaking || atWait(game));
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
}
|
||||
|
||||
/** DECODE 小局:慢放听清 → 切候选听词义 → 选 idx → 两段确认。 */
|
||||
/**
|
||||
* DECODE 小局(26 号口径):听完当前候选预听 → 左右滑把候选对到 idx(每切自动
|
||||
* 预听对照档)→ 单击进确认(电文原速重播)→ 再单击定下。
|
||||
*/
|
||||
async function decodeWord(ctx, idx, why) {
|
||||
const { game } = ctx;
|
||||
await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying);
|
||||
ctx.note(`${why}——先双击听电文,再双击放慢逐字母听`);
|
||||
await ctx.gesture('doubletap');
|
||||
await waitVoice(ctx);
|
||||
await ctx.gesture('doubletap'); // 慢放 0.5×
|
||||
await ctx.waitVirtual(400); // 慢放播一会(摩尔斯走合成不占语音时钟)
|
||||
// 逐候选听词义(从当前候选循环到目标)
|
||||
const cur = game.candIdx;
|
||||
const delta = (idx - cur + 3) % 3;
|
||||
await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed && atWait(game));
|
||||
ctx.note(`${why}——先把候选一个一个对耳朵`);
|
||||
await listenCand(ctx);
|
||||
const delta = (idx - game.candIdx + 3) % 3;
|
||||
for (let i = 0; i < delta; i++) {
|
||||
ctx.note('左右滑,听密码本上的候选词');
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
await ctx.gesture('right'); // 切候选:候选摩尔斯对照档(620Hz)→ 一拍 → 词义句
|
||||
await listenCand(ctx);
|
||||
}
|
||||
ctx.note('就这个词——单击选定');
|
||||
await ctx.gesture('tap'); // 进确认
|
||||
await waitVoice(ctx);
|
||||
ctx.note('再单击,定下来');
|
||||
await ctx.gesture('tap'); // 定下判定
|
||||
await waitVoice(ctx);
|
||||
ctx.note('就这个词——单击,进确认(电文会再拍一遍原速)');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => wpId(game) === 'confirm' && atWait(game));
|
||||
ctx.note('对上了。再单击,定下来');
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
|
||||
/** 等抉择武装并选择 dir(盲玩公平:提示句播完才滑)。 */
|
||||
async function relayChoice(ctx, dir, why) {
|
||||
const { game } = ctx;
|
||||
await untilChoice(ctx);
|
||||
ctx.note(why);
|
||||
await ctx.gesture(dir);
|
||||
await waitVoice(ctx);
|
||||
const { game } = ctx;
|
||||
await ctx.waitCond(() => game.floor.speaking || !game.phase.startsWith('RELAY') || atWait(game));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主线剧本
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 开场:INTRO 自动流(夜班开场 + 规矩 + 目标 + 处长夜令)→ RX(1) → DECODE
|
||||
await ctx.waitCond(() => game.phase === 'RX');
|
||||
ctx.status('开场白播完了,第一封电文进频道了');
|
||||
ctx.note('电文滴答声进来了,这是摩尔斯,首播不可跳,听完它');
|
||||
// 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 风铃 + 等待声 → 第一下单击
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('开场第一句就是指令「点一下屏幕。」,没有规则念白');
|
||||
ctx.note('等待声就是「轮到你了」——点一下,接第一封');
|
||||
await ctx.gesture('tap');
|
||||
ctx.status('「听到这个声音,就是轮到你了。」说完,调谐声里进来了第一封电文');
|
||||
await toDecode(ctx);
|
||||
ctx.status('轮到我选词了:双击重听、左右滑听候选');
|
||||
ctx.status('第一封摩尔斯拍完了,教程只教一句:向右滑,听下一个词');
|
||||
|
||||
// T1 船期(教学封:BAY)
|
||||
await decodeWord(ctx, 0, '教学封:先学规矩——听不清就再听一遍');
|
||||
// 教学封若误选会回浏览(剧本内不会,保险循环)
|
||||
for (let i = 0; i < 6 && game.phase === 'DECODE'; i++) await decodeWord(ctx, 0, '重新选');
|
||||
await relayChoice(ctx, 'left', '药品船丑时过江——报给北风,江上有人等这船药');
|
||||
ctx.status('第一封处置完了,夜里结算');
|
||||
// T1 船期(教学封:BAY;译错=即时归因+无限重选零惩罚,本线一次译对)
|
||||
await decodeWord(ctx, 0, '教学封 BAY:货船。B,一长三短');
|
||||
ctx.status('「货船,译对了。」链尾还教了「想知道自己在哪,就向上滑」');
|
||||
await untilChoice(ctx);
|
||||
ctx.note('药品船丑时过江——先点一下,让它把选项再说一遍');
|
||||
await ctx.gesture('tap'); // 抉择点空点 = 主动重听(「再说一遍:」+ 完整句)
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上');
|
||||
await ctx.waitCond(() => atChoice(game));
|
||||
ctx.note('报给北风,江上有人等这船药');
|
||||
await ctx.gesture('left');
|
||||
ctx.status('第一封处置完了,夜结算把开场没讲的规矩补在链首');
|
||||
|
||||
// T2 名单(TOP)
|
||||
await toDecode(ctx);
|
||||
ctx.status('第二封电文:内部通报,手法很急');
|
||||
ctx.note('上滑问一句「我在哪」——第几封、密码本还剩几页、今晚危不危险');
|
||||
await ctx.gesture('up');
|
||||
await ctx.waitCond(() => atWait(game) || game.phase !== 'DECODE');
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('状态链念完:第二封 · 密码本三页 · 还没人看我第二眼');
|
||||
await decodeWord(ctx, 0, '黑名单。头一个代号是码头——我的交通员');
|
||||
await relayChoice(ctx, 'right', '名单按流程呈报处长——码头,自己多保重');
|
||||
ctx.status('第二封处置完了');
|
||||
ctx.status('第二封处置完了,卷宗里落了笔');
|
||||
|
||||
// T3 交接(HAND)
|
||||
// T3 交接(HAND):走神一次,听三档提示的第一档
|
||||
await toDecode(ctx);
|
||||
ctx.status('第三封电文:很短,手法有点抖');
|
||||
await decodeWord(ctx, 0, '今夜交接,丑时老码头——H 和 S 只差一个滴答,幸好放慢了');
|
||||
await relayChoice(ctx, 'right', '留书暗号,让雨替我跑这一趟');
|
||||
ctx.status('第三封处置完了');
|
||||
await decodeWord(ctx, 0, '今夜交接,丑时老码头——H 和 S 只差一个滴答');
|
||||
await untilChoice(ctx);
|
||||
ctx.silence(15, '犹豫要不要亲自跑一趟,在想值班室空岗的后果');
|
||||
await ctx.waitVirtual(15600); // continue 等待点 13s 档:「单击,继续。」
|
||||
ctx.status('听到一声「单击,继续」,马上点');
|
||||
await ctx.gesture('tap');
|
||||
ctx.note('留书暗号,让雨替我跑这一趟');
|
||||
await ctx.gesture('right');
|
||||
ctx.status('第三封处置完了,交接赶上了');
|
||||
|
||||
// T4 处置令(SPY · 高潮)
|
||||
// T4 处置令(SPY · 高潮:danger 拍前置 2s,脚步声渐强,手势全是 busy)
|
||||
await toDecode(ctx);
|
||||
ctx.status('第四封电文:快手七的手法——门外的脚步声都近了');
|
||||
await decodeWord(ctx, 0, '内线确认,今夜三时收网,代号耳机——收网的是我');
|
||||
ctx.note('揭晓前的两秒,只有渐强的脚步声:这是危机拍,什么手势都打断不了');
|
||||
await relayChoice(ctx, 'right', '手离开放键器。我只是个值机的——什么都不做');
|
||||
ctx.status('处长的试探应付过去了,频道要收了');
|
||||
|
||||
// 签收:g9_in → g9_knot → p9
|
||||
await untilChoice(ctx);
|
||||
await relayChoice(ctx, 'left', '开机,发最后一电——三年前我译错了一封,这次不能错');
|
||||
ctx.note('开机,发最后一电——三年前我译错了一封,这次不能错');
|
||||
await ctx.gesture('left');
|
||||
await toEnding(ctx);
|
||||
ctx.status('结局播完了:尾声把今晚每一封都点了名');
|
||||
|
||||
// 第一局打完:下滑菜单此刻才真解锁(教学句挂在结局链尾),进去逛一圈
|
||||
ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitCond(() => game.phase === 'MENU' && atWait(game));
|
||||
ctx.status('菜单名和第一项「回到电波」报完了');
|
||||
await ctx.gesture('right'); // 下一项报名:「翻密码本」(长按捷径废止后的正门)
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('听完「翻密码本」的报名,不动它');
|
||||
await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」)
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
ctx.status('菜单关了,结局链复述了一遍局面,回到结算');
|
||||
|
||||
ctx.note('单击,收工——但下一班夜班,还想再来');
|
||||
await ctx.gesture('tap');
|
||||
await waitVoice(ctx);
|
||||
ctx.status('新的夜班开始了(再来一局)');
|
||||
await ctx.waitCond(() => game.floor.speaking || atWait(game));
|
||||
ctx.status('新的夜班开始了(再来一局,不再教「点一下屏幕」)');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 乱按压力剧本
|
||||
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
|
||||
// 剧本特征:不读提示、快速跳句、确认态反复进出、菜单里乱逛、错手势试探,
|
||||
// 但仍保留「最终能通关」的底线(菜单里绝不单击确认,避免误触「重开/回大厅」)。
|
||||
|
||||
export async function sessionStress(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 开场就乱按:tap/doubletap/方向全来一遍(RX 首播保护窗内也应零死锁)
|
||||
await ctx.waitCond(() => game.phase === 'RX' && game._rxGuard);
|
||||
ctx.note('好奇宝宝:首播中乱点乱滑——都应该是轻震,不判无效');
|
||||
for (const gg of ['tap', 'left', 'right', 'doubletap', 'tap']) {
|
||||
await ctx.gesture(gg);
|
||||
await ctx.waitVirtual(300);
|
||||
}
|
||||
await toDecode(ctx);
|
||||
let seed = 20260902;
|
||||
const rng = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
|
||||
|
||||
// T1:翻密码本翻到手软(免费页 + 3 页 + 翻空),再译对
|
||||
ctx.note('先把密码本翻烂:长按翻页,翻到一页不剩');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await ctx.gesture('longpress');
|
||||
await waitVoice(ctx);
|
||||
await ctx.waitVirtual(200);
|
||||
}
|
||||
ctx.note('乱切候选二十次');
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await ctx.gesture(i % 3 === 0 ? 'left' : 'right');
|
||||
await ctx.waitVirtual(250);
|
||||
}
|
||||
ctx.note('双击重听连打到慢放');
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await ctx.gesture('doubletap');
|
||||
await ctx.waitVirtual(300);
|
||||
}
|
||||
// 菜单乱逛
|
||||
ctx.note('下滑开菜单,乱切几项再关掉');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitCond(() => game.phase === 'MENU');
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(250);
|
||||
}
|
||||
await ctx.gesture('up'); // 菜单内帮助
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
await ctx.gesture('down'); // 关闭(不管停在哪个项,下滑永远安全)
|
||||
await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed);
|
||||
let menuOpenCount = 0;
|
||||
let choiceCount = 0;
|
||||
let decodeCount = 0;
|
||||
let noiseCount = 0;
|
||||
let browseIdle = 0; // 浏览态连续没进展的步数(防乱按卡死在某一封)
|
||||
|
||||
// 两段确认中途取消再重选
|
||||
ctx.note('单击进确认,再左滑取消,折腾两回');
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await ctx.gesture('tap');
|
||||
await waitVoice(ctx);
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
}
|
||||
// 译对(免费提示早用过了,凭耳朵+慢放兜底)
|
||||
await decodeWord(ctx, 0, '翻到提示也用完了,凭慢放听出来的:货船');
|
||||
for (let i = 0; i < 6 && game.phase === 'DECODE'; i++) await decodeWord(ctx, 0, '重选');
|
||||
await relayChoice(ctx, 'right', '压进值机日志——这船的事得看清楚再说');
|
||||
ctx.status('第一封处置完,压力测试过了第一道');
|
||||
// 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// 剩下三封:正常打但保留一次乱按(每封浏览期乱滑 10 次)
|
||||
const picks = [0, 0, 0];
|
||||
const dirs = ['left', 'right', 'right'];
|
||||
for (let tel = 2; tel <= 4; tel++) {
|
||||
await toDecode(ctx);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(200);
|
||||
for (let i = 0; i < 400 && !game.leaving; i++) {
|
||||
// 话轮不在手上(摩尔斯首播/danger 前置/自动链):有界等,绝不空转
|
||||
await ctx.waitCond(() => atWait(game) || game.phase === 'ENDING');
|
||||
|
||||
if (game.leaving) break;
|
||||
if (game.phase === 'MENU') {
|
||||
menuOpenCount++;
|
||||
await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名)
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
await ctx.gesture('down'); // 下滑关闭(不单击确认,绝不误触「重开/回大厅」)
|
||||
continue;
|
||||
}
|
||||
await decodeWord(ctx, picks[tel - 2], `第 ${tel} 封:慢放听清再定`);
|
||||
await relayChoice(ctx, dirs[tel - 2], ['报北风/呈报/留书'][0] || '处置掉');
|
||||
if (atSettle(game)) break;
|
||||
if (game.phase === 'ENDING') {
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
continue;
|
||||
}
|
||||
if (atChoice(game)) {
|
||||
choiceCount++;
|
||||
if (rng() < 0.35) {
|
||||
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听选项/问状态/无效)
|
||||
noiseCount++;
|
||||
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
}
|
||||
await ctx.gesture(pick(['left', 'right'])); // 乱选也要选,剧情照走
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'DECODE') {
|
||||
// 浏览:切候选/问状态/阶段 3 双击重听电文原速→慢放,都可乱来;
|
||||
// 确认态:单击=定下(乱按也会收敛——T1 译错即时归因无限重选)
|
||||
decodeCount++;
|
||||
if (game.confirming || browseIdle >= 4 || rng() < 0.4) {
|
||||
browseIdle = 0;
|
||||
await ctx.gesture('tap');
|
||||
} else {
|
||||
browseIdle++;
|
||||
await ctx.gesture(pick(['left', 'right', 'up', 'doubletap', 'tap']));
|
||||
}
|
||||
// 电文重播/预听链是有界自动流:等它落回等待态或进下一阶段
|
||||
await ctx.waitCond(() => atWait(game) || game.phase === 'RELAY' || game.phase === 'NIGHT');
|
||||
continue;
|
||||
}
|
||||
// 叙事等待态 / RX 外的静默帧:乱划一下(无效手势也要有反馈,零静默)
|
||||
noiseCount++;
|
||||
await ctx.gesture(pick(['tap', 'left', 'right', 'up', 'down', 'doubletap']));
|
||||
}
|
||||
// 签收 p9(stress 也发最后一电)
|
||||
await untilChoice(ctx);
|
||||
ctx.note('开机,把今晚发出去——乱按一晚,正经事不含糊');
|
||||
await ctx.gesture('left');
|
||||
await waitVoice(ctx);
|
||||
await toEnding(ctx);
|
||||
ctx.status('乱按一整晚也走到了结局,零死锁零静默死局');
|
||||
await ctx.gesture('tap'); // 再来一局
|
||||
await waitVoice(ctx);
|
||||
|
||||
await ctx.waitCond(() => atSettle(game) || game.leaving);
|
||||
ctx.status(`乱按剧本收尾:解码小局 ${decodeCount} 步、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
|
||||
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
|
||||
}
|
||||
|
||||
+248
-222
@@ -1,161 +1,191 @@
|
||||
/**
|
||||
* 《雨夜专案》新手会话剧本。
|
||||
* 《雨夜专案》新手会话剧本(26 号重构版:叙事+听证词型,storyline/radio 范式)。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位新手探长的完整破案弧线——标题开始 → DRILL 七步跟练
|
||||
* (换卷宗/听取/重听/状态/长按指证/练习判定)→ 证词板翻听全部六份卷宗 →
|
||||
* 逐对比对指证出全部四处矛盾(周先生四连谎)→ 结案陈词先错指小顺听不在场
|
||||
* 证明,再正指周先生、答对凶器与动机 → 认罪链与法槌 → 评级结算 → 单击接
|
||||
* 新案(pack 翻转,变体真凶小顺)短打一处指证收尾。
|
||||
* - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、教学乱按、
|
||||
* 证词板狂点乱滑(零进展被看门狗三级抢救)、菜单乱逛只下滑关闭、结案乱答
|
||||
* 再修正——一样走到 VERDICT+SETTLE 与新案开局,全程零死锁。
|
||||
* - `session`(主线):一位新手探长的完整破案弧线——开场第一句「点一下屏幕」就
|
||||
* 动手,教学寄生于真实案卷(听老葛 → 教右滑+解锁上滑 → 听小顺 →「滑到指证,
|
||||
* 点一下」→ 练习指证零账目),再把六份卷宗听过耳朵,逐对比对出全部四处矛盾
|
||||
* (周先生四连谎),结案陈词先错指小顺听不在场证明,再正指周先生、答对凶器与
|
||||
* 动机 → 认罪链与法槌 → 评级结算 → 下滑逛一圈菜单(阶段 2 此刻才真解锁)→
|
||||
* 单击接新案(pack 翻转,变体真凶小顺)短打一处指证收尾。
|
||||
* - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、证词板狂点
|
||||
* 乱滑(零进展被无进展轨三级抢救:earcon → 短句 → 保底矛盾对)、菜单乱逛只
|
||||
* 下滑关闭、结案乱答再修正——一样走到 VERDICT+SETTLE 与新案开局,全程零死锁。
|
||||
*
|
||||
* 剧本失真红线(参照 dungeon/radio 剧本先例):
|
||||
* - 玩家不抢话:出手前等语音链/判定链收尾(waitStep 有界等待状态谓词);
|
||||
* 剧本失真红线(参照 radio/rpg 剧本先例):
|
||||
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
|
||||
* 出手;听取(ACTION)是玩法态,可以随时点停/切条目——那本来就是玩家的事;
|
||||
* - 玩家靠耳朵玩:指证前先翻听卷宗,导航只把「听到的内容」翻译成手势;
|
||||
* - BOARD 常驻无进展轮询定时器:禁止裸 waitIdle 空等,必须 waitStep/waitVirtual;
|
||||
* - 菜单里只「下滑关闭」,绝不双击执行(防误触「保存并回大厅」结束会话)。
|
||||
* 上滑问一次状态(阶段 1 解锁后才用;双击留到阶段 3,未解锁前不碰);
|
||||
* - BOARD 常驻无进展轮询定时器:禁止裸 waitIdle 空等,必须 waitStep 有界等待;
|
||||
* - 菜单里只「下滑关闭」,绝不单击确认(防误触「保存并回大厅」结束会话)。
|
||||
*/
|
||||
|
||||
const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore'];
|
||||
const ITEM_ORDER = [...LISTEN_ITEMS, 'case'];
|
||||
const ITEM_ORDER = [...LISTEN_ITEMS, 'cf', 'case'];
|
||||
|
||||
const ITEM_LABEL = {
|
||||
w1: '门卫老葛的证词', w2: '学徒小顺的证词', w3: '账房周先生的证词', w4: '房东温太太的证词',
|
||||
scene: '现场勘验', fore: '法医报告', case: '结案陈词',
|
||||
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) return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭';
|
||||
if (game._play) return `正在听${ITEM_LABEL[ITEM_ORDER[game._play.itemIdx]]}(单击停止,双击重新听当前句)`;
|
||||
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 'DRILL': return game._drill
|
||||
? `教学第 ${game._drill.step} 步,跟着旁白做(下滑可跳过)`
|
||||
: '教学中';
|
||||
? '检测到未结的案子:单击继续,右滑换一桩新案'
|
||||
: '开场指令:「点一下屏幕。」——第一句就让我动手';
|
||||
case 'BOARD': {
|
||||
const label = ITEM_LABEL[ITEM_ORDER[game.itemIdx]];
|
||||
return `证词板上是「${label}」:左右滑换卷宗,单击听取,按住进入指证,上滑状态,下滑菜单`;
|
||||
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 '判定播报中,先听着';
|
||||
const ph = { item1: '选第一份证词', line1: '第一份里逐句听', confirm1: '就这句?单击定下来',
|
||||
item2: '选与它矛盾的那份', line2: '第二份里逐句听', confirm2: '就这句?单击定下来' }[cf.phase];
|
||||
return `指证中:${ph}(左右滑换,单击确认,双击重听)`;
|
||||
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: '指认真凶:四位证人选一个', whoConfirm: '就指认他?单击定下来',
|
||||
weapon: '第二问:凶器是什么', motive: '第三问:动机是什么' }[fin.phase];
|
||||
return `结案三问——${ph}(左右滑选,单击定)`;
|
||||
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 翻转)';
|
||||
case 'SETTLE': return '结算:评级与账目播完——单击接新案(pack 翻转),上滑重听评级,下滑菜单';
|
||||
default: return '……';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 会话工具
|
||||
|
||||
/** 有界等待:条件满足或超时即返回。 */
|
||||
async function waitStep(ctx, mock, cond, maxMs = 30000) {
|
||||
/** 有界等待:条件满足或超时即返回(BOARD 常驻轮询定时器,禁裸 waitIdle)。 */
|
||||
async function waitStep(ctx, mock, cond, maxMs = 60000) {
|
||||
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);
|
||||
}
|
||||
while (!cond() && mock.now - t0 < maxMs) await ctx.waitVirtual(150);
|
||||
}
|
||||
|
||||
/** 滑到指定卷宗条目(最短环绕方向)。 */
|
||||
async function navToItem(ctx, game, itemId) {
|
||||
/** 等真话轮(链收+话轮已交,含指证/结案/菜单各等待点)。 */
|
||||
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 ctx.waitVirtual(200);
|
||||
await waitStep(ctx, mock, () => atTurn(game), 15000);
|
||||
}
|
||||
await ctx.waitVirtual(700); // 签名 earcon → 条目名落声
|
||||
await turn(ctx, mock);
|
||||
}
|
||||
|
||||
/** 完整听取当前卷宗(单击 → 等链播完回空闲)。 */
|
||||
async function listenItem(ctx, mock, game, itemId) {
|
||||
await navToItem(ctx, game, itemId);
|
||||
ctx.note(`单击,听取${ITEM_LABEL[itemId]}`);
|
||||
/** 完整听取当前卷宗(单击 → 等证词链播完回到可操作)。 */
|
||||
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.state === 'BOARD', 85000);
|
||||
await waitStep(ctx, mock, () => game._play === null && game.floor.state === 'YOUR_TURN', 150000);
|
||||
}
|
||||
|
||||
/** 指证内选一句:滑到条目 → 单击 → 滑到句 → 单击确认 → 听完「就这句?」单击定下。 */
|
||||
async function cfSelectLine(ctx, mock, game, caseData, lineId) {
|
||||
/** 指证内选一句:滑到条目 → 单击进选句 → 滑到句 → 单击进确认 → 单击定下。 */
|
||||
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 ctx.waitVirtual(200);
|
||||
await waitStep(ctx, mock, () => atTurn(game), 15000);
|
||||
}
|
||||
await ctx.waitVirtual(700);
|
||||
await turn(ctx, mock);
|
||||
ctx.note(`选定「${ITEM_LABEL[itemId]}」——单击确认`);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(400);
|
||||
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 ctx.waitVirtual(400); // 一句一听
|
||||
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'), 25000);
|
||||
await ctx.waitVirtual(17000); // 重播该句 + 「就这句?」听完
|
||||
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');
|
||||
}
|
||||
|
||||
/** 从证词板发起一次完整指证(按住进入 + 两侧选句),等判定链收尾回板。
|
||||
* 长按带重试: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);
|
||||
/** 从证词板发起一次完整指证(滑到「指证」条目 + 单击进入 + 两侧选句),等判定链收尾回板。 */
|
||||
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 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(R2 F5:先过确认闸 fin_pre,闸内再单击才进三问)。 */
|
||||
async function enterFinish(ctx, mock, game) {
|
||||
await navToItem(ctx, game, 'case');
|
||||
ctx.note('翻到结案陈词——单击过确认闸(「就此结案?」),再单击进三问');
|
||||
await turn(ctx, mock);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(3000); // fin_pre 闸句落声
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主线剧本
|
||||
@@ -163,190 +193,184 @@ async function waitQuestion(ctx, mock, game, phase) {
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// —— 标题:单击开始(首局无存档) ——
|
||||
await waitStep(ctx, mock, () => game.state === 'TITLE');
|
||||
ctx.status('雨夜专案:一桩雨夜命案等着一双好耳朵');
|
||||
ctx.note('单击,开始');
|
||||
// —— 开场(§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('新案开卷:一句案情 → 签名 +「老葛的证词」→「单击,听取」→ 顺带教了「轮到你」这个状态声');
|
||||
|
||||
// —— 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('教学完了——「指证就是这样,真矛盾要靠耳朵找」');
|
||||
// —— 教学寄生在真实案卷:听第一份(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 cs = game._case;
|
||||
for (const id of LISTEN_ITEMS) {
|
||||
await listenItem(ctx, mock, game, id);
|
||||
// —— 首次指证 = 练习(判定截获,零账目污染) ——
|
||||
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 ctx.waitVirtual(26000);
|
||||
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 (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 坐实——真凶的反应一次比一次慌`);
|
||||
for (const [a, b, na, nb] of pairs) {
|
||||
await confront(ctx, mock, cs, a, b, na, nb);
|
||||
ctx.status(`疑点 ${game._doubtCount()}/4 坐实——真凶的反应慌在刚戳中的那件事上`);
|
||||
}
|
||||
ctx.status('四处疑点齐了——「疑点齐了,去结案陈词」');
|
||||
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);
|
||||
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, game);
|
||||
await waitQuestion(ctx, mock, game, 'who');
|
||||
ctx.note('证据都指向周先生——右滑两下选他');
|
||||
await ctx.gesture('right');
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(800);
|
||||
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 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');
|
||||
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('第三问:动机是什么?');
|
||||
{
|
||||
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 answerOption(ctx, mock, cs.layout.mvOpts, cs.questions.motive.answer); // 监守自盗——账上短的东西才是引线
|
||||
|
||||
// —— 裁决:认罪链 + 法槌 + 结算(链锁,手势全吞) ——
|
||||
await waitStep(ctx, mock, () => game.state === 'VERDICT', 30000);
|
||||
// —— 裁决:认罪链 + 法槌 + 结算(critical 链锁,手势全吞) ——
|
||||
await waitStep(ctx, mock, () => game.state === 'VERDICT', 40000);
|
||||
ctx.status('证据链闭合——周先生认罪三段,雨声渐强又落下,法槌落定');
|
||||
await waitStep(ctx, mock, () => game.state === 'SETTLE', 150000);
|
||||
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?.pack === 1, 30000);
|
||||
ctx.status('新案开卷:pack 翻转——这回的真凶换了人');
|
||||
|
||||
// —— 变体案短打:一处指证(小顺的谎)收尾 ——
|
||||
await confront(ctx, mock, game, 'b_shun_1', 'wen_2',
|
||||
'小顺说「我走的时候熄了灯」——变体案的第一句谎', '温太太还是那句话「灯还亮着」——对上了!');
|
||||
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)];
|
||||
|
||||
// ① 标题页乱划,然后开始
|
||||
for (let i = 0; i < 8 && game.state === 'TITLE'; i++) {
|
||||
// ① 标题页乱划(含未解锁手势——拿到 invalid 回执/解释句也不卡流程;误开菜单
|
||||
// 就顺手下滑关掉),最后收口把案子开起来(乱按的底线是流程照走)
|
||||
for (let i = 0; i < 6 && game.state === 'TITLE'; i++) {
|
||||
ctx.status('标题页乱划一气');
|
||||
await ctx.gesture(rng() < 0.5 ? 'left' : 'doubletap');
|
||||
await ctx.waitVirtual(450);
|
||||
await ctx.gesture(pick(['left', 'right', 'up', 'down', 'doubletap']));
|
||||
await ctx.waitVirtual(400);
|
||||
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(600); }
|
||||
}
|
||||
await ctx.gesture('tap');
|
||||
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);
|
||||
|
||||
// ② 教学:乱按几下,然后下滑跳过(跟不下来也要能走完)
|
||||
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');
|
||||
// ② 教学期乱按几下(起停/乱滑/问状态;在指证、结案两项上绝不单击,不误入),
|
||||
// 乱按走不完就把练习照实做掉(首次指证=练习,零账目,乱按也毕业)
|
||||
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);
|
||||
}
|
||||
await waitStep(ctx, mock, () => game.state === 'BOARD', 25000);
|
||||
ctx.status('总算从教学里滚出来了(可能一路错过来的)');
|
||||
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('教学在乱按里也走完了(练习指证零账目,不会把自己教坏)');
|
||||
|
||||
// ③ 证词板乱按 185s(零进展——被看门狗三级抢救:earcon → 短句 → 保底矛盾对;
|
||||
// 菜单乱逛的挂起时长会被无进展轨剔除,故墙钟跑满 185s 保证 L3 落窗)。
|
||||
// 起停对=同拍双 tap(与语音时长解耦:一句都不完成=确定性零进展)。
|
||||
// ③ 证词板乱按 160s(零进展——被无进展轨三级抢救:45s earcon → 90s 短句 →
|
||||
// 150s 保底直接给可指证矛盾对)。起停对=跨过 400ms 防连点窗的双 tap
|
||||
// (首句没听完就停=确定性零进展;heardLines 只记到完整句)。
|
||||
const t0 = mock.now;
|
||||
const ROT = ['pair', 'right', 'left', 'doubletap', 'left', 'right'];
|
||||
const ROT = ['pair', 'right', 'left', 'right', 'left'];
|
||||
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;
|
||||
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.gesture('tap'); // 立刻停止——一句都听不完
|
||||
await ctx.waitVirtual(700); // 跨过防连点窗(第二下不再被 busy 吞)
|
||||
await ctx.gesture('tap'); // 立刻停止——首句没听完
|
||||
} else if (atCase) {
|
||||
await ctx.gesture('left'); // 不误入结案陈词
|
||||
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.waitVirtual(1200);
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitVirtual(600);
|
||||
await ctx.waitVirtual(500);
|
||||
continue;
|
||||
}
|
||||
await ctx.waitVirtual(ri++ % ROT.length === ROT.length - 1 ? 3600 : 1100);
|
||||
await ctx.waitVirtual(1100);
|
||||
}
|
||||
ctx.status('乱按也被抢救:听到了保底提示直接给出可指证的矛盾对');
|
||||
|
||||
// ④ 顺着保底提示真指证一处(零死锁底线:核心动词始终可达)
|
||||
const cs = game._case;
|
||||
if (game.state === 'BOARD' && game._doubtCount() < 4) {
|
||||
if (game.state === 'BOARD') {
|
||||
const contra = cs.contradictions.find((c) => c.hint) || cs.contradictions[0];
|
||||
await confront(ctx, mock, game, contra.a, contra.b,
|
||||
await confront(ctx, mock, cs, contra.a, contra.b,
|
||||
`照着提示指证:${contra.a} 这句`, `对上 ${contra.b} 这句`);
|
||||
ctx.status(`乱按局也能坐实疑点(${game._doubtCount()}/4)`);
|
||||
}
|
||||
@@ -354,31 +378,33 @@ export async function sessionStress(ctx, mock) {
|
||||
// ⑤ 结案:乱答一次再答对(错指无限再机会 + 问题不重答只记账)
|
||||
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); }
|
||||
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 ctx.waitVirtual(18000);
|
||||
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'whoConfirm' && atTurn(game), 25000);
|
||||
await ctx.gesture('tap');
|
||||
if (game.state === 'FINISH' && game._fin?.phase !== 'who') {
|
||||
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 waitQuestion(ctx, mock, game, phase);
|
||||
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(300); }
|
||||
for (let i = 0; i < idx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
|
||||
await turn(ctx, mock);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(1500);
|
||||
}
|
||||
}
|
||||
await waitStep(ctx, mock, () => game.state !== 'FINISH', 40000);
|
||||
// 等本轮回合彻底落定(错指=回板;答对=裁决链播完进 SETTLE)——裁决链播报期
|
||||
// 手势全吞,绝不提前进下一轮在链上乱点(SETTLE 的单击=接新案,误触即翻包)
|
||||
await waitStep(ctx, mock, () => game.state !== 'FINISH' && game.state !== 'VERDICT', 120000);
|
||||
}
|
||||
await waitStep(ctx, mock, () => game.state === 'SETTLE', 150000);
|
||||
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, 30000);
|
||||
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run && atTurn(game), 40000);
|
||||
ctx.status('新案照样开局,压力测试收工');
|
||||
}
|
||||
|
||||
+468
-358
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 《围猎》新手会话剧本(26 号重构版):清空进度走首局教学房——T0 点一下 →
|
||||
* T1 朝狼嚎滑砍(先故意滑错听一次「在另一边。」)→ T2 呼喊听 bot 应答 →
|
||||
* T3 长按问路;接第 1 波滑砍+呼喊+长按状态,波间休整开一次菜单(左右换项、
|
||||
* 单击确认),第 2 波后收终局结算,单击再来一局收工。
|
||||
*
|
||||
* 联机注记:转写环境无真实服务器——会话注入内存回路 WS mock(23 §6 协议),
|
||||
* join/act 由 mini 服务器应答(spawn/kill/miss/callout/wave/end);手势经
|
||||
* makeInputPipeline 管道(与 index.html 同一接线,回执/打断/防连点均参与)。
|
||||
*/
|
||||
import { makeInputPipeline } from '../../engine/pipeline.js';
|
||||
|
||||
export function describe(game) {
|
||||
if (game._menuOpen) return `菜单里第 ${game._menuIdx + 1}/3 项(左右滑换项,单击确认)`;
|
||||
switch (game.phase) {
|
||||
case 'title': return '围猎大厅,点一下进入匹配';
|
||||
case 'matching': return '正在匹配队友';
|
||||
case 'lobby': return '房间已开好,点一下立即开局(也可以按住听在场人数)';
|
||||
case 'teach':
|
||||
return game._tutDoneWaiting ? '教学讲完了,等第一波'
|
||||
: ['教学 T0:点一下', '教学 T1:朝狼嚎的方向滑', '教学 T2:点一下呼喊队友',
|
||||
'教学 T3:按住屏幕听营地'][game._tut] || '教学中';
|
||||
case 'wave':
|
||||
return game._inRest() ? `第 ${game.wave} 波守住了,休整 8 秒(下滑开菜单)`
|
||||
: `第 ${game.wave} 波战斗中:朝声音滑=出击,点一下=呼喊,按住=我在哪`;
|
||||
case 'end': return '结算播完了,单击再猎一局(按住重听最佳战绩)';
|
||||
case 'lost': return '掉线回不去了,点一下回大厅';
|
||||
default: return '……';
|
||||
}
|
||||
}
|
||||
|
||||
/** 内存回路 WS mock + mini 服务器(对 act 应答 ev;23 §6 字段契约)。 */
|
||||
function makeAutoServer(log) {
|
||||
const conns = [];
|
||||
const arcs = { N: ['NW', 'N', 'NE'], E: ['NE', 'E', 'SE'], S: ['SE', 'S', 'SW'], W: ['SW', 'W', 'NW'] };
|
||||
const srv = { mobs: new Map(), nextId: 1, kills: 0, started: false, wave: 0 };
|
||||
const send = (conn, msg) => conn.handlers.onmessage(msg);
|
||||
const transport = (url, handlers) => {
|
||||
const conn = {
|
||||
url, handlers, opened: false, closed: false,
|
||||
send(s) {
|
||||
let m = null;
|
||||
try { m = JSON.parse(s); } catch (_) { return; }
|
||||
if (!m || m.t !== 'act') return;
|
||||
if (m.kind === 'start' && !srv.started) {
|
||||
srv.started = true;
|
||||
setTimeout(() => send(conn, { t: 'ev', kind: 'wave', ts: Date.now(), wave: 1 }), 400);
|
||||
} else if (m.kind === 'attack') {
|
||||
const hit = [...srv.mobs.values()].find((x) => (arcs[m.dir] || []).includes(x.dir));
|
||||
if (hit) {
|
||||
srv.mobs.delete(hit.id);
|
||||
srv.kills++;
|
||||
setTimeout(() => send(conn, { t: 'ev', kind: 'kill', ts: Date.now(), id: hit.id, slot: 0, dir: hit.dir, mkind: hit.mkind }), 120);
|
||||
} else {
|
||||
setTimeout(() => send(conn, { t: 'ev', kind: 'miss', ts: Date.now(), slot: 0, dir: m.dir }), 120);
|
||||
}
|
||||
} else if (m.kind === 'callout') {
|
||||
setTimeout(() => send(conn, { t: 'ev', kind: 'botline', ts: Date.now(), slot: 2, line: 'help_ack' }), 1500);
|
||||
}
|
||||
},
|
||||
close() { if (!conn.closed) { conn.closed = true; handlers.onclose(); } },
|
||||
};
|
||||
conns.push(conn);
|
||||
return conn;
|
||||
};
|
||||
return {
|
||||
transport, srv, conns,
|
||||
last: () => conns[conns.length - 1],
|
||||
open: (conn) => { conn.opened = true; conn.handlers.onopen(); },
|
||||
inject: send,
|
||||
joined: (conn, over = {}) => send(conn, {
|
||||
t: 'joined', room: 'LIVE', slot: 0, resume: 'r0', char: 'yan',
|
||||
sector: [['N', 'NE', 'E'], ['SE', 'S', 'SW'], ['W', 'NW']][0], seed: 7,
|
||||
state: { phase: 'lobby', wave: 0, dur: 10 },
|
||||
roster: [
|
||||
{ slot: 0, name: '试玩者', bot: false, char: 'yan', sector: ['N', 'NE', 'E'] },
|
||||
{ slot: 1, name: '小筝', bot: true, char: 'zheng', sector: ['SE', 'S', 'SW'] },
|
||||
{ slot: 2, name: '老周', bot: true, char: 'zhou', sector: ['W', 'NW'] },
|
||||
],
|
||||
...over,
|
||||
}),
|
||||
spawn: (conn, mkind, dir, eta) => {
|
||||
const id = 'm' + srv.nextId++;
|
||||
srv.mobs.set(id, { id, mkind, dir });
|
||||
send(conn, { t: 'ev', kind: 'spawn', ts: Date.now(), id, mkind, dir, eta });
|
||||
return id;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 首局新手:清进度 + 全新手阶梯(双击折算单击),手势接管为 26 号管道
|
||||
try {
|
||||
localStorage.removeItem('soundgame.progress.hunt');
|
||||
localStorage.removeItem('soundgame.gestures');
|
||||
} catch (_) { /* mock 缺 localStorage 时静默 */ }
|
||||
const pipe = makeInputPipeline({
|
||||
floor: game.floor, confusion: game.confusion, receipts: game.receipts,
|
||||
invalidHints: game.invalidHints, game, ladder: game.ladder,
|
||||
opts: game.PIPELINE_OPTS || {},
|
||||
});
|
||||
const rawGesture = ctx.gesture;
|
||||
ctx.gesture = async (g) => { pipe(g); await mock.flush(); };
|
||||
|
||||
// 注入内存服务器(transcribe mock 环境无 WS 服务器;真实浏览器走默认传输连 /ws/hunt)
|
||||
if (mock) {
|
||||
const server = makeAutoServer((x) => ctx.note(x));
|
||||
game._transport = server.transport;
|
||||
|
||||
ctx.status('大厅:篝火夜的音乐,先听一耳朵');
|
||||
await ctx.gesture('tap'); // TITLE 单击 → 匹配
|
||||
const conn = server.last();
|
||||
server.open(conn);
|
||||
await ctx.waitCond(() => server.conns.includes(conn) && conn.opened);
|
||||
ctx.note('点了开始匹配,教开局的那两句在播——试着点了一下,它说稍等(critical 期 busy)');
|
||||
await rawGesture.call(ctx, 'tap'); // critical 期点按 → busy 回执(不打断指令)
|
||||
await ctx.waitIdle();
|
||||
|
||||
ctx.status('进教学房了:第一句就是「点一下屏幕」');
|
||||
server.joined(conn, { state: { phase: 'teach', wave: 0, dur: 10 } });
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN' && game._tut === 0);
|
||||
await ctx.gesture('tap'); // T0 作答 → T1
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN' && game._tut === 1);
|
||||
server.spawn(conn, 'wolf', 'N', 12); // 教学狼从北边叫了一声
|
||||
ctx.note('北边一声狼嚎——指令说「朝叫声,滑」,我偏先往左滑一下试试');
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitCond(() => game.phase === 'teach' && (game._tut === 1));
|
||||
ctx.note('挥空了:一声风声加「在另一边。」——真话归因,不糊弄');
|
||||
await ctx.waitVirtual(3000); // 过出击冷却(miss 归因句也在这段里播完)
|
||||
await ctx.gesture('up'); // 朝叫声滑 → 首杀
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN' && game._tut === 2);
|
||||
ctx.status('杀掉了:它说「对,就是这样。」然后教呼喊');
|
||||
server.spawn(conn, 'wolf', 'E', 10);
|
||||
await ctx.gesture('tap'); // T2 呼喊(等待点上作答)
|
||||
ctx.note('我喊了一嗓子,老周从西边应了我一声——协作闭环');
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN' && game._tut === 3);
|
||||
await ctx.gesture('longpress'); // T3 长按问路
|
||||
await ctx.waitCond(() => game._tutDoneWaiting);
|
||||
ctx.status('按住听了下营地状态:固若金汤,朝叫声滑就行');
|
||||
|
||||
// 第 1 波:滑砍 + 呼喊 + 长按
|
||||
server.inject(conn, { t: 'ev', kind: 'wave', ts: Date.now(), wave: 1 });
|
||||
await ctx.waitCond(() => game.phase === 'wave');
|
||||
server.spawn(conn, 'wolf', 'NE', 9);
|
||||
server.spawn(conn, 'wolf', 'W', 10);
|
||||
await ctx.waitVirtual(5000); // 听 loop 从两侧逼近
|
||||
await ctx.gesture('up');
|
||||
ctx.note('朝东北那声滑了一刀(冷却 2.5 秒,节奏感出来了)');
|
||||
await ctx.waitVirtual(2600);
|
||||
await ctx.gesture('left');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.gesture('longpress');
|
||||
await ctx.waitVirtual(2000);
|
||||
|
||||
// 波间休整:开一次菜单,换项后确认回猎场
|
||||
server.inject(conn, { t: 'ev', kind: 'waveclear', ts: Date.now(), wave: 1 });
|
||||
server.srv.mobs.clear();
|
||||
await ctx.waitCond(() => game._inRest() && game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('这一波守住了,8 秒休整——下滑开菜单');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitCond(() => game._menuOpen && game.floor.state === 'YOUR_TURN');
|
||||
await ctx.gesture('right'); // 换到「重新听帮助」
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
await ctx.gesture('left'); // 换回「回到猎场」
|
||||
await ctx.waitCond(() => !ctx.voiceBusy() && game.floor.state === 'YOUR_TURN');
|
||||
await ctx.gesture('tap'); // 单击确认关闭
|
||||
ctx.note('菜单是左右滑换项、单击确认——比旧的「双击确认」顺手,不会误触');
|
||||
await ctx.waitVirtual(4000);
|
||||
|
||||
// 第 2 波 → 终局
|
||||
server.inject(conn, { t: 'ev', kind: 'wave', ts: Date.now(), wave: 2 });
|
||||
await ctx.waitCond(() => game.phase === 'wave' && !game._inRest());
|
||||
server.spawn(conn, 'wolf', 'S', 8);
|
||||
server.spawn(conn, 'bear', 'SE', 11);
|
||||
await ctx.waitVirtual(6000);
|
||||
await ctx.gesture('down');
|
||||
await ctx.gesture('up');
|
||||
ctx.note('熊的脚步沉得多,还从身后发闷——南边压力最大');
|
||||
await ctx.waitVirtual(6000);
|
||||
server.inject(conn, { t: 'ev', kind: 'end', ts: Date.now(), win: true, dur: 7, grade: 'B', kills: { '0': server.srv.kills, '1': 6, '2': 5 } });
|
||||
await ctx.waitCond(() => game.phase === 'end');
|
||||
ctx.status('天亮了:评级 B 的结算链在播,尾句教了「向下滑可以打开菜单」');
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
await ctx.gesture('longpress'); // 重听最佳战绩
|
||||
await ctx.waitCond(() => !ctx.voiceBusy() && game.floor.state === 'YOUR_TURN');
|
||||
await ctx.gesture('tap'); // 再来一局
|
||||
await ctx.waitCond(() => game.phase === 'matching');
|
||||
ctx.status('收工:单击再猎一局,回匹配了');
|
||||
} else {
|
||||
ctx.note('真实浏览器环境:WS 走默认传输连 /ws/hunt,本剧本只覆盖 mock 转写场景');
|
||||
}
|
||||
}
|
||||
+148
-137
@@ -1,51 +1,66 @@
|
||||
/**
|
||||
* 《守塔人》新手会话剧本。
|
||||
* 《守塔人》新手会话剧本(26 号重构版:叙事型,storyline/radio 范式)。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位守塔人的完整风暴夜——听完开场(塔名/规矩/目标)、
|
||||
* 三个幕内接满电台抉择(全走心线:接住人→翻暗格→口诀→真话→堵漏→读完缺页
|
||||
* →出海接人→循渔歌→说出真相),三段回忆插叙全听,以完整 E1「灯与归人」收尾。
|
||||
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
|
||||
* 一样要能走到 ENDING,全程零死锁、零静默死局。
|
||||
* - `session`(主线):一位守塔人的完整风暴夜——开场第一句「点一下屏幕」就动手 →
|
||||
* 听完台标与今夜目标接进 C1 → 阿澈求救后每个抉择都做选择(p1 接住人 / p2 翻暗格
|
||||
* 听插叙1 / p3 口诀 / p4 真话 / p5 堵漏 / p6 读完缺页听插叙3 / p7 满发条出海 /
|
||||
* p8 循渔歌 / p9 说出),中途主动重听一次选项、走神一次听三档提示、
|
||||
* 插叙里安安静静听完(回忆没有选择)、结局后逛一圈菜单(阶段 2 下滑此刻才真解锁),
|
||||
* 以完整 E1「灯与归人」收尾。
|
||||
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛、
|
||||
* 插叙里忍不住乱滑,一样要能走到 ENDING,全程零死锁、零静默死局。
|
||||
*
|
||||
* 剧本失真红线(参照 radio/abyss 剧本先例):
|
||||
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
|
||||
* - 玩家不干晾自己:只在 WATCH 链末等待态偶尔走神触发一次看门狗,链一停就接话;
|
||||
* - CALM 幕间有喘息定时器、MEMORY 有进入拍定时器,禁止裸 waitIdle 空等,
|
||||
* 必须 waitCond 有界等待;
|
||||
* - 叙事链每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
|
||||
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
|
||||
* 出手——voiceBusy 是句级信号、链内句间会闪断,不能当门控;插叙/幕间/危机拍
|
||||
* 前置窗是 BUSY 演出段,真机里是 busy 回执,剧本同样等它走完(回忆没有选择,
|
||||
* 插叙里一个手势都不出);
|
||||
* - 玩家不干晾自己:只在 WATCH 叙事等待态偶尔走神触发一次等待点提示;
|
||||
* - 幕间有喘息定时器、抉择点挂着提示计时,禁止裸 waitIdle 空等,必须 waitCond
|
||||
* 有界等待;
|
||||
* - 通话里每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
|
||||
*/
|
||||
|
||||
const ACT_LABEL = ['一', '二', '三'];
|
||||
|
||||
function actLabel(n) {
|
||||
return n >= 1 && n <= 3 ? ACT_LABEL[n - 1] : '·';
|
||||
}
|
||||
|
||||
/** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */
|
||||
const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
|
||||
const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null);
|
||||
const atChoice = (game) => atWait(game) && wpId(game) === 'choice';
|
||||
const atSettle = (game) => game.phase === 'ENDING' && game.awaitingRestart;
|
||||
|
||||
export function describe(game) {
|
||||
if (game.leaving) return '已确认保存并回大厅';
|
||||
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑开始新的风暴夜」的询问';
|
||||
if (game.phase === 'MENU') {
|
||||
const names = ['回到值守', '重新开始今夜', '保存并回大厅'];
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`;
|
||||
}
|
||||
if (game.phase === 'INTRO') {
|
||||
return game.voicePlaying ? '塔名、规矩和今晚目标正在播' : '开场白播完了,等我单击开始值守';
|
||||
return game.floor.speaking
|
||||
? '开场指令正在播:「点一下屏幕。」'
|
||||
: '等待声在响:点一下,听今晚的塔';
|
||||
}
|
||||
if (game.phase === 'MEMORY') {
|
||||
const era = game.timeline === 'mem_f' ? '父亲的最后一晚(十二年前)' : '祖父的年代(四十年前)';
|
||||
return game.voicePlaying
|
||||
? `回忆插叙中(${era}):唱针落下,老唱片底噪,听完自动回到现在`
|
||||
: `回忆插叙的静默拍(${era})`;
|
||||
return game.floor.speaking
|
||||
? '老唱片在转(回忆里没有选择,唱针会自己抬起)'
|
||||
: '回忆的间隙';
|
||||
}
|
||||
if (game.phase === 'CALM') {
|
||||
return `第${ACT_LABEL[(game.act || 1) - 1]}幕结束,幕间喘息:雾角 + 岸台通报${game.voicePlaying ? '正在播' : ',等我单击或自动进下一幕'}`;
|
||||
if (game.phase === 'CALM') return '幕间喘息:雾角、岸台通报、账目,马上进下一幕';
|
||||
if (atChoice(game)) {
|
||||
return `选项念完了(第${actLabel(game.act)}幕):向左或向右滑做出你的决定`;
|
||||
}
|
||||
if (game.choiceArmed) {
|
||||
return `「请选择」念完了(第${ACT_LABEL[(game.act || 1) - 1]}幕):向左或向右滑动做出你的决定`;
|
||||
}
|
||||
if (game.voicePlaying) return `第${ACT_LABEL[(game.act || 1) - 1]}幕值守中,正在听电台/岸台说话`;
|
||||
if (game.phase === 'WATCH') return `对方说完了,等我接话(第${ACT_LABEL[(game.act || 1) - 1]}幕):单击继续`;
|
||||
if (game.floor.speaking) return `第${actLabel(game.act)}幕通话中,正在听频道那头`;
|
||||
if (game.phase === 'WATCH') return `对方说完了,等我接话(第${actLabel(game.act)}幕):单击继续`;
|
||||
if (game.phase === 'ENDING') {
|
||||
return game.awaitingRestart
|
||||
? '结局和尾声回响都听完了。单击再来一夜,下滑开菜单'
|
||||
: '结局播报中(岸台记一笔:这一晚后来怎样了)';
|
||||
return atSettle(game)
|
||||
? '结局和尾声回响都听完了。单击开新的风暴夜,下滑开菜单'
|
||||
: '结局播报中';
|
||||
}
|
||||
return '……';
|
||||
}
|
||||
@@ -55,119 +70,120 @@ export function describe(game) {
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 开场:INTRO 自动流(塔名 + 规矩 + 目标)→ 链末单击 → C1
|
||||
await ctx.waitCond(() => game.phase === 'WATCH' || game.choiceArmed);
|
||||
ctx.status('开场口令和塔名播完了,等我单击开始值守');
|
||||
ctx.note('雾角两长一短——塔在,灯在。单击,接过这一夜');
|
||||
// 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 交出话轮 → 第一下单击
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('开场第一句就是指令,不是规则念白');
|
||||
ctx.note('风暴底噪里一声雾角,然后它让我点一下屏幕——点');
|
||||
await ctx.gesture('tap');
|
||||
ctx.status('进入第一幕,电台里有动静');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('岸台说频道里有动静,像隔着整片海');
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('台标、雾角的承诺、今夜的目标听完了,风还在拍塔');
|
||||
|
||||
let invalidTried = false;
|
||||
let menuDone = false;
|
||||
let relistened = false;
|
||||
let walkedAway = false;
|
||||
let helpDone = false;
|
||||
let menuVisited = false;
|
||||
let inMemorySeen = false;
|
||||
let choiceIdx = 0;
|
||||
|
||||
// 抉择路径:全走心线(接住人/翻暗格/口诀/真话/堵漏/读完/出海/循渔歌/说出 → e1)
|
||||
// 抉择路径(剧本 A:信任满·全真相·出海接人·说出 → e1)
|
||||
const choices = [
|
||||
{ dir: 'left', why: '他的声音在抖——先接住这个人,船位待会儿再要' },
|
||||
{ dir: 'left', why: '他提到四十年前……去翻灯座下的暗格,旧账该见光了' },
|
||||
{ dir: 'left', why: '他快撑不住了——用守塔人的口诀带他稳船' },
|
||||
{ dir: 'left', why: '他问起我的人……告诉他真话:我父亲也是守塔人' },
|
||||
{ dir: 'left', why: '船板裂了——让他回舱堵漏,守住电台' },
|
||||
{ dir: 'left', why: '祖父的最后一页就在灯下——今晚读完它' },
|
||||
{ dir: 'left', why: '浪墙压过来了——给灯上满发条,驾接驳艇去接他' },
|
||||
{ dir: 'left', why: '海上没有灯——循着他哼的渔歌找他' },
|
||||
{ dir: 'left', why: '他问起四十年前的灯……把缺页的事,连根说给他' },
|
||||
{ dir: 'left', why: '他在抖。先接住这个人,船位回头再要' },
|
||||
{ dir: 'left', why: '「四十年前」——去翻灯座下的暗格' },
|
||||
{ dir: 'left', why: '守塔人的口诀,带他稳住船' },
|
||||
{ dir: 'left', why: '他问了,就告诉他真话' },
|
||||
{ dir: 'left', why: '回舱堵漏,人守在电台边' },
|
||||
{ dir: 'left', why: '祖父缺的那一页,今晚读完它' },
|
||||
{ dir: 'left', why: '满发条,灯能自己转到天亮——去接他' },
|
||||
{ dir: 'left', why: '循着他哼的渔歌找过去' },
|
||||
{ dir: 'left', why: '把缺页的事,连根说给他' },
|
||||
];
|
||||
|
||||
for (let i = 0; i < 300 && !game.leaving; i++) {
|
||||
// 只等当前语音链播完或抉择武装;CALM/MEMORY 有定时器,绝不用裸 waitIdle
|
||||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
||||
for (let i = 0; i < 400 && !game.leaving; i++) {
|
||||
// 只等当前语音链播完或抉择武装;幕间/插叙/危机拍有定时器,绝不用裸 waitIdle
|
||||
await ctx.waitCond(() => atWait(game) || atSettle(game));
|
||||
|
||||
if (game.leaving) break;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
ctx.status('选项念完了,做决定');
|
||||
ctx.note(pick.why);
|
||||
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧看门狗)
|
||||
choiceIdx++;
|
||||
if (atSettle(game) && !menuVisited) {
|
||||
// 第一局打完,下滑菜单此刻才真正解锁(教学句挂在结局链尾)
|
||||
menuVisited = true;
|
||||
ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('菜单名和第一项「回到值守」报完了');
|
||||
await ctx.gesture('right'); // 下一项报名
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('听完「重新开始今夜」的报名,不动它');
|
||||
await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」)
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
ctx.status('菜单关了,结局链复述了一遍局面,回到结算');
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'ENDING') {
|
||||
await ctx.waitCond(() => game.awaitingRestart);
|
||||
if (atSettle(game)) break; // E1 收尾
|
||||
if (game.phase === 'MEMORY') {
|
||||
// 回忆没有选择:唱针落下就安静听完(一个手势都不出,链末自动回返)
|
||||
if (!inMemorySeen) {
|
||||
inMemorySeen = true;
|
||||
ctx.status('唱针落下、风声退后——这是回忆(rev_teach 教过我)');
|
||||
ctx.note('老唱片里是祖父/父亲的声音,听完就回到风暴里');
|
||||
}
|
||||
await ctx.waitCond(() => game.phase !== 'MEMORY');
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'CALM') {
|
||||
ctx.status(`第${ACT_LABEL[(game.act || 1) - 1]}幕过去,雾角报平安,岸台通报风暴进程`);
|
||||
ctx.note('幕间喘口气,听风往哪走');
|
||||
ctx.status('幕间:雾角、岸台通报、这一程的账,喘口气');
|
||||
await ctx.waitVirtual(2600);
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'MEMORY') {
|
||||
ctx.note('唱针落下了……这是回忆,别慌,听完风声就回来');
|
||||
await ctx.waitCond(() => game.phase !== 'MEMORY');
|
||||
ctx.status('风声回来了,回到现在');
|
||||
if (atChoice(game)) {
|
||||
const pick = choices[choiceIdx];
|
||||
if (!pick) break; // 抉择走完(理论上 p9 后应已进 ENDING)
|
||||
|
||||
// p2 起上滑已解锁(阶段 1):犹豫时点一下重听选项(原则 9 主动重听)
|
||||
if (!relistened && choiceIdx >= 1) {
|
||||
relistened = true;
|
||||
ctx.note(pick.why + '——没听清,先点一下,让它「再说一遍」');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上');
|
||||
await ctx.waitCond(() => atChoice(game));
|
||||
}
|
||||
ctx.status('选项念完了,做决定');
|
||||
ctx.note(pick.why);
|
||||
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧提示三档)
|
||||
if (choiceIdx === 0) {
|
||||
ctx.status('链头一声「对。」,链尾教了「想知道自己在哪,就向上滑」');
|
||||
ctx.note('做完第一件事才教下一件事,一句话只教一个');
|
||||
}
|
||||
choiceIdx++;
|
||||
continue;
|
||||
}
|
||||
// WATCH 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
|
||||
// 注意不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual
|
||||
// 会一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
|
||||
if (!walkedAway && game.phase === 'WATCH' && !game.choiceArmed) {
|
||||
// 注意不要在这里 waitVirtual 小憩——等待态挂着提示定时器,waitVirtual
|
||||
// 会一口气跳到下一个定时器 tick(13s 提示档),剧本就变成「每句干晾 13s」。
|
||||
if (!walkedAway && game.phase === 'WATCH' && wpId(game) === 'continue') {
|
||||
walkedAway = true;
|
||||
ctx.silence(25, '听到一半走了神,在想父亲那支手电');
|
||||
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
|
||||
ctx.status('听到一声轻提醒和「单击屏幕,继续」,马上点');
|
||||
ctx.silence(15, '听到一半走了神,在想灯座下的暗格');
|
||||
await ctx.waitVirtual(15600); // 触发等待点提示 full 档(13s):「单击,继续。」
|
||||
ctx.status('听到一声「单击,继续」,马上点');
|
||||
await ctx.gesture('tap');
|
||||
continue;
|
||||
}
|
||||
if (!helpDone && game.nodeId === 'amo_c') {
|
||||
helpDone = true;
|
||||
ctx.note('他爷爷四十年前在这片海没的……这句话有分量,双击重听一遍');
|
||||
await ctx.gesture('doubletap');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('重听完,接着值守');
|
||||
}
|
||||
await ctx.gesture('tap'); // 链末单击 = 继续
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
|
||||
ctx.status('结局链走完,尾声把这一夜逐项点名——阿澈、灯的记录簿、祖父的日志、父亲的班次');
|
||||
ctx.note('听完岸台那笔账——人和真相都接住了(E1 灯与归人),会话结束');
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
ctx.status('结局链走完,尾声把这一晚的每一笔都点给我听');
|
||||
ctx.note('雾角最后响了一声——航道平安。会话结束(E1 灯与归人)');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 乱按压力剧本
|
||||
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
|
||||
// 剧本特征:不读提示、快速跳句、菜单里乱逛、错手势试探,但仍保留「最终能通关」的底线。
|
||||
// 剧本特征:不读提示、快速跳句、插叙里忍不住乱滑(invalid 后恢复重播)、菜单里
|
||||
// 乱逛,但仍保留「最终能通关」的底线。
|
||||
|
||||
export async function sessionStress(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
let seed = 20260914;
|
||||
let seed = 20261002;
|
||||
const rng = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
@@ -177,62 +193,57 @@ export async function sessionStress(ctx, mock) {
|
||||
|
||||
let menuOpenCount = 0;
|
||||
let choiceCount = 0;
|
||||
let answered = 0;
|
||||
let memPoke = 0;
|
||||
let noiseCount = 0;
|
||||
let memCount = 0;
|
||||
|
||||
// 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
||||
await ctx.waitCond(() => atWait(game) || atSettle(game));
|
||||
|
||||
if (game.leaving) break;
|
||||
if (atSettle(game)) break;
|
||||
if (game.phase === 'MENU') {
|
||||
menuOpenCount++;
|
||||
await ctx.gesture('tap'); // 乱切换
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」)
|
||||
await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名)
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
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);
|
||||
if (game.phase === 'MEMORY') {
|
||||
// 好奇宝宝在回忆里忍不住动一下(插叙无选择 → invalid + 重播当前回忆句)
|
||||
if (rng() < 0.4) {
|
||||
memPoke++;
|
||||
await ctx.gesture(pick(['left', 'right', 'tap']));
|
||||
await ctx.waitCond(() => game.phase === 'MEMORY' || game.phase === 'WATCH');
|
||||
continue;
|
||||
}
|
||||
await ctx.gesture(pick(['left', 'right']));
|
||||
await ctx.waitCond(() => game.phase !== 'MEMORY');
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'CALM') {
|
||||
await ctx.waitVirtual(2600);
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'MEMORY') {
|
||||
memCount++;
|
||||
if (rng() < 0.4) {
|
||||
// 回忆里也手痒:双击重听当前回忆节点 / 下滑开菜单再关
|
||||
if (atChoice(game)) {
|
||||
choiceCount++;
|
||||
if (rng() < 0.35) {
|
||||
// 犹豫时乱试一个不推进剧情的手势(重听/问状态/无效)
|
||||
noiseCount++;
|
||||
await ctx.gesture(rng() < 0.5 ? 'doubletap' : 'down');
|
||||
await ctx.waitCond(() => !game.voicePlaying || game.phase !== 'MEMORY');
|
||||
if (game.phase === 'MENU') {
|
||||
await ctx.gesture('down'); // 关掉,回忆还在原地等我
|
||||
await ctx.waitCond(() => game.phase === 'MEMORY' || game.phase === 'WATCH');
|
||||
}
|
||||
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
}
|
||||
await ctx.waitCond(() => game.phase !== 'MEMORY');
|
||||
await ctx.gesture(pick(['left', 'right']));
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'ENDING') {
|
||||
await ctx.waitCond(() => game.awaitingRestart);
|
||||
break;
|
||||
}
|
||||
// WATCH 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
|
||||
noiseCount++;
|
||||
answered++;
|
||||
await ctx.gesture(pick(gestures));
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
|
||||
ctx.status(`乱按剧本收尾:${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次、穿过 ${memCount} 段回忆`);
|
||||
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
ctx.status(`乱按剧本收尾:${choiceCount} 个抉择、插叙里乱试 ${memPoke} 次、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
|
||||
ctx.note('全程没有任何一次手势被无声吞掉,回忆里乱滑也只是被温柔挡回来——结局链照样走完');
|
||||
}
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
/**
|
||||
* 《不眠频率》新手会话剧本。
|
||||
* 《不眠频率》新手会话剧本(26 号重构版:叙事型,storyline 范式)。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位夜间主播的完整弧线——听完开场白、接完五通电话、
|
||||
* 每个抉择点都做选择(C1 走心 / C2 劝上车 / C3 问她+真话 / C4 带话+留在直播 /
|
||||
* C5 陪聊 / p9 直面),听完五句尾声回响,以完整 E1「天亮之前」收尾。
|
||||
* - `session`(主线):一位夜间主播的完整弧线——开场第一句「点一下屏幕」就动手 →
|
||||
* 听热线灯教学接第一通 → 五通电话每个抉择都做选择(C1 走心 / C2 劝上车 /
|
||||
* C3 问她+真话 / C4 带话+留在直播 / C5 陪聊 / p9 直面),中途主动重听一次选项、
|
||||
* 走神一次听三档提示、结局后逛一圈菜单(阶段 2 下滑此时才真解锁),
|
||||
* 以完整 E1「天亮之前」收尾。
|
||||
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
|
||||
* 一样要能走到 ENDING,全程零死锁、零静默死局。
|
||||
*
|
||||
* 剧本失真红线(参照 storyline/escape 剧本先例):
|
||||
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
|
||||
* - 玩家不干晾自己:只在 ONAIR 叙事等待态偶尔走神触发一次看门狗,链一停就接话;
|
||||
* - RING/BRIDGE 有铃声/喘息定时器循环,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
|
||||
* 剧本失真红线(参照 storyline/rpg 剧本先例):
|
||||
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
|
||||
* 出手——voiceBusy 是句级信号、链内句间会闪断,不能当门控;BUSY 演出段
|
||||
* (危机拍前置窗/幕间)真机里是 busy 回执,剧本同样等它走完;
|
||||
* - 玩家不干晾自己:只在 ONAIR 叙事等待态偶尔走神触发一次等待点提示;
|
||||
* - RING/BRIDGE 有铃声循环/喘息定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
|
||||
* - 通话里每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
|
||||
*/
|
||||
|
||||
const CALL_LABEL = ['一', '二', '三', '四', '五'];
|
||||
|
||||
/** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */
|
||||
const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
|
||||
const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null);
|
||||
const atChoice = (game) => atWait(game) && wpId(game) === 'choice';
|
||||
const atSettle = (game) => game.phase === 'ENDING' && wpId(game) === 'settle';
|
||||
|
||||
export function describe(game) {
|
||||
if (game.leaving) return '已确认保存并回大厅';
|
||||
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑新夜晚」的询问';
|
||||
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑开始新的夜晚」的询问';
|
||||
if (game.phase === 'MENU') {
|
||||
const names = ['回到直播', '重新开始今晚', '保存并回大厅'];
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`;
|
||||
}
|
||||
if (game.phase === 'INTRO') {
|
||||
return game.voicePlaying ? '台标和规矩正在播' : '开场白播完了,等第一声铃';
|
||||
return game.floor.speaking
|
||||
? '开场指令正在播:「点一下屏幕。」'
|
||||
: '等待声在响:点一下,进第一通';
|
||||
}
|
||||
if (game.phase === 'RING') return `第 ${CALL_LABEL[game.callN - 1]} 通电话打进来:单击接听`;
|
||||
if (game.choiceArmed) {
|
||||
return `「请选择」念完了(第 ${CALL_LABEL[game.callN - 1]} 通):向左或向右滑动做出你的决定`;
|
||||
if (game.phase === 'RING') return `第 ${CALL_LABEL[game.callN - 1]} 通电话打进来:铃声就是提示,点一下接听`;
|
||||
if (atChoice(game)) {
|
||||
return `选项念完了(第 ${CALL_LABEL[game.callN - 1]} 通):向左或向右滑做出你的决定`;
|
||||
}
|
||||
if (game.voicePlaying) return `第 ${CALL_LABEL[game.callN - 1]} 通通话中,正在听对方说话`;
|
||||
if (game.floor.speaking) return `第 ${CALL_LABEL[game.callN - 1]} 通通话中,正在听对方说话`;
|
||||
if (game.phase === 'ONAIR') return `对方说完了,等我接话(第 ${CALL_LABEL[game.callN - 1]} 通):单击继续`;
|
||||
if (game.phase === 'BRIDGE') return '幕间喘息,垫乐播放中,马上接下一通';
|
||||
if (game.phase === 'ENDING') {
|
||||
return game.awaitingRestart
|
||||
? '结局和五条回响都听完了。单击再来一局,下滑开菜单'
|
||||
return atSettle(game)
|
||||
? '结局和五条回响都听完了。单击开新的夜班,下滑开菜单'
|
||||
: '结局播报中';
|
||||
}
|
||||
return '……';
|
||||
@@ -47,18 +59,20 @@ export function describe(game) {
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 开场:INTRO 自动流(台标口播 + 一句规矩 + 今晚目标)→ 第一声铃
|
||||
await ctx.waitCond(() => game.phase === 'RING');
|
||||
ctx.status('开场口令和台标播完了,第一通电话响了');
|
||||
ctx.note('铃一响就接——接听是单击');
|
||||
// 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 交出话轮 → 第一下单击
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('开场第一句就是指令,不是规则念白');
|
||||
ctx.note('第一句就让我动手——点一下');
|
||||
await ctx.gesture('tap');
|
||||
ctx.status('线路接通了');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('导播报完线,老人开口了');
|
||||
await ctx.waitCond(() => game.phase === 'RING');
|
||||
ctx.status('「热线灯亮了。点一下,接听。」听完,铃声就是等待声');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('线路接通了,导播报完线,老人开口了');
|
||||
|
||||
let invalidTried = false;
|
||||
let menuDone = false;
|
||||
let relistened = false;
|
||||
let walkedAway = false;
|
||||
let menuVisited = false;
|
||||
let choiceIdx = 0;
|
||||
|
||||
// 抉择路径:C1 走心、C2 劝上车、C3 问她+真话、C4 带话+留在直播、C5 陪聊、p9 直面
|
||||
@@ -76,47 +90,55 @@ export async function session(ctx, mock) {
|
||||
|
||||
for (let i = 0; i < 200 && !game.leaving; i++) {
|
||||
// 只等当前语音链播完或抉择武装;RING/BRIDGE 有定时器,绝不用裸 waitIdle
|
||||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
|
||||
if (game.leaving) break;
|
||||
if (game.phase === 'RING') {
|
||||
ctx.status(`第 ${CALL_LABEL[game.callN - 1]} 通电话响了,接`);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
continue;
|
||||
}
|
||||
if (game.choiceArmed) {
|
||||
if (atChoice(game)) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
// p2 起上滑已解锁(阶段 1):犹豫时点一下重听选项(原则 9 主动重听)
|
||||
if (!relistened && choiceIdx >= 1) {
|
||||
relistened = true;
|
||||
ctx.note(pick.why + '——没听清,先点一下,让它「再说一遍」');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上');
|
||||
await ctx.waitCond(() => atChoice(game));
|
||||
}
|
||||
ctx.status('选项念完了,做决定');
|
||||
ctx.note(pick.why);
|
||||
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧看门狗)
|
||||
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧提示三档)
|
||||
if (choiceIdx === 0) {
|
||||
ctx.status('链头一声「对。」,链尾教了「想知道自己在哪,就向上滑」');
|
||||
ctx.note('做完第一件事才教下一件事,一句话只教一个');
|
||||
}
|
||||
choiceIdx++;
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'ENDING') {
|
||||
await ctx.waitCond(() => game.awaitingRestart);
|
||||
if (!menuVisited && atSettle(game)) {
|
||||
// 第一局打完,下滑菜单此刻才真正解锁(教学句挂在结局链尾)
|
||||
menuVisited = true;
|
||||
ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('菜单名和第一项「回到直播」报完了');
|
||||
await ctx.gesture('right'); // 下一项报名
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('听完「重新开始今晚」的报名,不动它');
|
||||
await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」)
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
ctx.status('菜单关了,结局链复述了一遍局面,回到结算');
|
||||
continue;
|
||||
}
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'BRIDGE') {
|
||||
@@ -125,20 +147,20 @@ export async function session(ctx, mock) {
|
||||
continue;
|
||||
}
|
||||
// ONAIR 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
|
||||
// 注意不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual
|
||||
// 会一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
|
||||
if (!walkedAway && game.phase === 'ONAIR' && !game.choiceArmed) {
|
||||
// 注意不要在这里 waitVirtual 小憩——等待态挂着提示定时器,waitVirtual
|
||||
// 会一口气跳到下一个定时器 tick(18s 提示档),剧本就变成「每句干晾 18s」。
|
||||
if (!walkedAway && game.phase === 'ONAIR') {
|
||||
walkedAway = true;
|
||||
ctx.silence(25, '听到一半走了神,在想老人的老花镜');
|
||||
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
|
||||
ctx.status('听到一声轻响和「单击屏幕,继续」,马上点');
|
||||
await ctx.waitVirtual(25600); // 触发等待点提示 full 档(18s):「单击,继续。」
|
||||
ctx.status('听到一声「单击,继续」,马上点');
|
||||
await ctx.gesture('tap');
|
||||
continue;
|
||||
}
|
||||
await ctx.gesture('tap'); // 链末单击 = 继续
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
|
||||
await ctx.waitCond(() => game.phase === 'ENDING' && atSettle(game));
|
||||
ctx.status('结局链走完,五条回响一句句点到我今晚的每一次选择');
|
||||
ctx.note('听完尾声那笔账——今晚的人都接住了,会话结束(E1 天亮之前)');
|
||||
}
|
||||
@@ -163,8 +185,12 @@ export async function sessionStress(ctx, mock) {
|
||||
let answered = 0;
|
||||
let noiseCount = 0;
|
||||
|
||||
// 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
for (let i = 0; i < 300; i++) {
|
||||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
|
||||
if (game.leaving) break;
|
||||
if (game.phase === 'RING') {
|
||||
@@ -174,18 +200,18 @@ export async function sessionStress(ctx, mock) {
|
||||
}
|
||||
if (game.phase === 'MENU') {
|
||||
menuOpenCount++;
|
||||
await ctx.gesture('tap'); // 乱切换
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」)
|
||||
await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名)
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
await ctx.gesture('down'); // 下滑关闭(不单击确认,避免误触「重开/回大厅」)
|
||||
continue;
|
||||
}
|
||||
if (game.choiceArmed) {
|
||||
if (atChoice(game)) {
|
||||
choiceCount++;
|
||||
if (rng() < 0.35) {
|
||||
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势
|
||||
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听/问状态/无效)
|
||||
noiseCount++;
|
||||
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
|
||||
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
}
|
||||
await ctx.gesture(pick(['left', 'right']));
|
||||
continue;
|
||||
@@ -195,7 +221,7 @@ export async function sessionStress(ctx, mock) {
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'ENDING') {
|
||||
await ctx.waitCond(() => game.awaitingRestart);
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
break;
|
||||
}
|
||||
// ONAIR 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
|
||||
@@ -203,7 +229,7 @@ export async function sessionStress(ctx, mock) {
|
||||
await ctx.gesture(pick(gestures));
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
|
||||
await ctx.waitCond(() => game.phase === 'ENDING' && atSettle(game));
|
||||
ctx.status(`乱按剧本收尾:接听 ${answered} 次、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
|
||||
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* 《节拍行者》鼓手模式新手会话剧本:首局完整走「摸鼓认区 → 教学 T1-T3 →
|
||||
* 曲目一(90 BPM)」全弧线——探索阶段把六区摸遍(区名每区只播一次)、曲中
|
||||
* 下滑逛菜单再恢复(gd_count + 4 拍底鼓 count-in 进场)、正片第一乐句故意
|
||||
* 整句漏掉(连 2 Miss → 向导 gd_miss)、之后逐音跟打(含终曲加花)到结算。
|
||||
* 《节拍行者》鼓手模式新手会话剧本(26 号深度重构版,docs/design/26-apply-rhythm.md):
|
||||
* 首局完整走「开场指令 → 摸鼓认区(六区摸遍,区名每区一次)→ 教学 T1-T3 跟打 →
|
||||
* 选曲等待点(单击开始)→ 曲目一(90 BPM)」。曲中下滑逛菜单(左右滑换项、
|
||||
* 单击确认;恢复 = gd_count + 4 拍底鼓 count-in 进场)、正片第一乐句故意整句漏掉
|
||||
* (连 2 Miss → gd_miss 一次),之后逐音跟打(含终曲加花)到结算。
|
||||
* 分区敲击带坐标注入(引擎坐标透传契约,24 号文档 §0.3)。
|
||||
*/
|
||||
const ZC = {
|
||||
@@ -12,22 +13,23 @@ const ZC = {
|
||||
|
||||
export function describe(game) {
|
||||
switch (game.phase) {
|
||||
case 'intro': return '开场旁白在播(认识你的鼓),单击可以跳过';
|
||||
case 'intro': return '开场指令「点一下屏幕。」在播(critical,抢拍只给 busy 回执)';
|
||||
case 'tut':
|
||||
if (game._tutStage === 'probe') {
|
||||
return `摸鼓认区:随便摸,摸到 3 个不同的地方就开始(已摸 ${game._probeHeard.size} 区)`;
|
||||
return `摸鼓认区(ACTION):随便摸,摸到 3 个不同的地方就开始(已摸 ${game._probeHeard.size} 区)`;
|
||||
}
|
||||
if (game._tutStage === 'idle') return '区名播完,教学第一步即将开始';
|
||||
return game._cues.length
|
||||
? `教学:听领奏,下一小节照样敲(${game._cues.map((c) => c.zone).join('→')})${game._tutRate < 1 ? '(领奏已放慢 25%)' : ''}`
|
||||
? `教学跟打:听领奏,下一小节照样敲(${game._cues.map((c) => c.zone).join('→')})${game._tutRate < 1 ? '(领奏已放慢 25%)' : ''}`
|
||||
: '教学准备中,听数拍';
|
||||
case 'select': return '选曲:左右滑换曲,单击开始,下滑回大厅';
|
||||
case 'select': return '选曲(YOUR_TURN):左右滑换曲听报名,单击开始当前曲';
|
||||
case 'main': {
|
||||
const left = game._cues.filter((c) => !c.judged).length;
|
||||
const fillNext = game._timed.some((e) => e.fn && game._song?.units?.some((u) => u.fill));
|
||||
return `演奏中(${game._song?.title ?? ''}):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了${fillNext ? ';有一段加花要连打' : ''}`;
|
||||
return `演奏中(${game._song?.title ?? ''},ACTION):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了${fillNext ? ';有一段加花要连打' : ''}`;
|
||||
}
|
||||
case 'menu': return '暂停菜单:单击切选项,双击确认,上滑帮助,下滑恢复演奏';
|
||||
case 'end': return '结算播报中:听完单击再来一遍,右滑下一首,下滑回大厅';
|
||||
case 'menu': return '暂停菜单(YOUR_TURN):左右滑换项听报名,单击确认,下滑关闭恢复';
|
||||
case 'end': return '结算(YOUR_TURN):单击同曲再来,右滑下一首(未解锁会说明)';
|
||||
default: return '开场,稍等';
|
||||
}
|
||||
}
|
||||
@@ -42,15 +44,12 @@ export async function session(ctx, mock) {
|
||||
await mock.flush();
|
||||
};
|
||||
|
||||
// 开场:welcome → tut_map → tut_probe 三连旁白,逐句跳过
|
||||
await ctx.waitCond(() => game.phase === 'intro' && ctx.voiceBusy());
|
||||
ctx.status('开场旁白:我是鼓手,整个屏幕都是鼓。单击跳过');
|
||||
for (let i = 0; i < 3; i++) { await ctx.gesture('tap'); await ctx.waitVirtual(600); }
|
||||
// 开场(§4.10):第一句就是 critical 指令「点一下屏幕。」→ 摸鼓探针(ACTION)
|
||||
await ctx.waitCond(() => game.phase === 'tut' && game._tutStage === 'probe');
|
||||
ctx.status('进入摸鼓认区:先四处摸摸,听到哪面鼓就报哪面鼓的名');
|
||||
ctx.note('背景音乐《启程》无鼓伴奏已在轻声循环——拍子网格的底,鼓由我来打');
|
||||
ctx.status('听到「点一下屏幕。」——整面屏幕都是鼓,随便摸');
|
||||
ctx.note('背景《启程》无鼓伴奏轻声循环——拍子网格的底,鼓由我来打');
|
||||
|
||||
// T0 探索:六区全摸一遍(区名每区一次;重复摸只出声),再摸到 3 区进阶
|
||||
// T0 探索:六区全摸一遍(区名每区一次;重复摸只出声),摸到 3 区进阶
|
||||
for (const zone of ['TL', 'TC', 'TR', 'BL', 'BC', 'BR']) await hit(zone);
|
||||
ctx.status('六面鼓摸遍了:左上踩镲、上中高嗵、右上吊镲、左下军鼓、下中底鼓、右下低嗵');
|
||||
await hit('TL'); // 重复摸:只出声不报名
|
||||
@@ -64,7 +63,7 @@ export async function session(ctx, mock) {
|
||||
for (let step = 0; step < 3; step++) {
|
||||
await ctx.waitCond(() => game._tutIdx === step && game.phase === 'tut'
|
||||
&& game._cues.length > 0 && game._tutAttempts.length === 0);
|
||||
ctx.status(`教学第 ${step + 1}/3 步:听数拍和领奏,下一小节照着敲`);
|
||||
ctx.status(`教学第 ${step + 1}/3 步:听数拍和领奏(示范期点按只给 busy 回执),下一小节照着敲`);
|
||||
for (const cue of game._cues) {
|
||||
await ctx.waitCond(() => t() >= cue.expected || cue.judged);
|
||||
if (cue.judged) continue;
|
||||
@@ -72,30 +71,37 @@ export async function session(ctx, mock) {
|
||||
}
|
||||
await ctx.waitCond(() => game._tutIdx > step || game.phase !== 'tut');
|
||||
}
|
||||
await ctx.waitCond(() => game.phase === 'main');
|
||||
ctx.status('教学完毕,曲目一《启程》90 拍开始。先听四下底鼓数拍,然后是「现在跟着我打」');
|
||||
ctx.note('《启程》无鼓伴奏 BGM 起,四下底鼓数拍对齐小节');
|
||||
await ctx.waitCond(() => game.phase === 'select');
|
||||
ctx.status('教学完毕,进选曲:左右滑换曲听报名,单击开始');
|
||||
ctx.note('左右滑换曲、单击开始——新手期没有双击手势');
|
||||
|
||||
// 曲中逛菜单:下滑呼出(模态暂停)→ 听选项 → 下滑恢复
|
||||
//(gd_count「四拍之后,继续」+ 4 拍底鼓 count-in 进场)
|
||||
// 选曲:单击开始曲目一
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game.phase === 'main');
|
||||
ctx.status('曲目一《启程》90 拍开始。先听四下底鼓数拍;首个应答窗前会教「轮到你了」这个声音');
|
||||
ctx.note('《启程》无鼓伴奏 BGM 起,四下底鼓数拍对齐小节;演奏期 = ACTION,点按即敲');
|
||||
|
||||
// 曲中逛菜单:下滑呼出(模态暂停)→ 左右滑换项听报名 → 下滑恢复
|
||||
//(恢复 = gd_count「四拍之后,继续」+ 4 拍底鼓 count-in 进场)
|
||||
await ctx.waitCond(() => t() >= game._bgmT0 + 1.5);
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitCond(() => game.phase === 'menu');
|
||||
ctx.status('曲中暂停,进了菜单');
|
||||
ctx.status('曲中暂停,进了菜单(左右滑换项,单击确认)');
|
||||
ctx.note('BGM 在断点冻住——谱面、判定、音乐全部暂停');
|
||||
await ctx.waitVirtual(1100); // 菜单 1s 去抖独占期
|
||||
await ctx.gesture('tap'); // 切换选项(听到「回大厅」)
|
||||
ctx.note('还是想打完这首,下滑回去');
|
||||
await ctx.gesture('down'); // 恢复
|
||||
await ctx.gesture('left'); // 换到「回大厅」听报名
|
||||
ctx.note('还是想打完这首,右滑切回「继续演奏」,再下滑回去');
|
||||
await ctx.gesture('right');
|
||||
await ctx.gesture('down'); // 关闭恢复
|
||||
await ctx.waitCond(() => game.phase === 'main');
|
||||
ctx.status('听到「四拍之后,继续」,跟着四下底鼓数拍进来了');
|
||||
ctx.note('BGM 从断点续播,节拍网格整体后移了一小节');
|
||||
|
||||
// 第一乐句故意整句不打:连 2 次 Miss → 向导 gd_miss
|
||||
// 第一乐句故意整句不打:连 2 次 Miss → gd_miss 一次(同曲一次,曲尾 3s 不插)
|
||||
const firstPhrase = game._cues.filter((c) => c.phrase === 0);
|
||||
ctx.silence(5, '还在数拍子,第一句没敢出手');
|
||||
await ctx.waitCond(() => firstPhrase.every((c) => c.judged));
|
||||
ctx.status('第一句全漏了,听到「慢了半拍,下一小节跟上我就行」');
|
||||
ctx.status('第一句全漏了,听到一次「慢了半拍,下一小节跟上我就行」');
|
||||
|
||||
// 之后逐音跟打(含终曲加花)
|
||||
for (const cue of game._cues) {
|
||||
@@ -106,7 +112,6 @@ export async function session(ctx, mock) {
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'end');
|
||||
await ctx.waitIdle();
|
||||
ctx.status('曲子打完了(加花收尾是一记吊镲),结算播报中');
|
||||
ctx.note('曲目 BGM 淡出,结算语音链播完会淡入大厅音乐');
|
||||
ctx.status('曲子打完了(加花收尾是一记吊镲),结算播报中(单击同曲再来,右滑下一首)');
|
||||
ctx.note('曲目 BGM 淡出,结算语音链播完会淡入大厅音乐,然后交出结算等待点');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 《剑与声》新手会话剧本:听完序章进村 → 村里把选项听一圈 → 出村去小路
|
||||
* (顺路调查一次捡钱)→ 岔路口转去溪流触发教学战 → 完整打一场
|
||||
* (教学四步:识招→攻击→格挡→防御,再自由击杀),战后收工。
|
||||
* 《剑与声》新手会话剧本(26 号重构版):critical 开场指令「点一下屏幕」→
|
||||
* 序章 → 进村把选项听一圈 → 出村去小路(顺路调查一次捡钱)→ 岔路口转去溪流
|
||||
* 触发教学战 → 完整打一场(四步:识招→攻击→格挡[窗口内单击]→防御,再自由
|
||||
* 击杀),战后收工。26 号口径:格挡 = 前摇窗口内**再单击**(旧「双击格挡」废止);
|
||||
* 新手期无双击重听(上滑=状态、菜单=通关第二章解锁,此处均不使用)。
|
||||
*
|
||||
* 种子 RNG:让教学狼首击必不掉(HP 6、伤害恒 5),保证四步教学完整走完。
|
||||
*/
|
||||
@@ -19,37 +21,42 @@ export function describe(game) {
|
||||
return `战斗中:你的血量${BAND[game._hpBand(game.player.hp, game.maxHp)]},`
|
||||
+ `敌方血量${BAND[game._hpBand(b.enemy.hp, b.enemy.maxHp)]},等你选招式(左右滑切换,单击出手,下滑逃跑)`;
|
||||
}
|
||||
if (b.state === 'ENEMY_TELEGRAPH') return '战斗中:敌方前摇声响起了——双击,格挡!';
|
||||
if (b.state === 'ENEMY_TELEGRAPH') return '战斗中:敌方前摇声响起了——点一下,格挡!';
|
||||
return '战斗播报中,先听着';
|
||||
}
|
||||
switch (game.phase) {
|
||||
case 'ask': return '检测到旅程记录:单击继续,右滑开新档';
|
||||
case 'intro': return '序章旁白在播,讲我怎么用耳朵走路';
|
||||
case 'explore': return `【${SCENE_NAME[game.scene] ?? game.scene}】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单`;
|
||||
case 'intro': return game._introTapped ? '序章旁白在播(单击可跳句)' : '开场指令在播:等风铃响起点一下';
|
||||
case 'explore': return `【${SCENE_NAME[game.scene] ?? game.scene}】:左右滑切选项,单击确认,上滑查状态`;
|
||||
case 'shop': return '商店里:左右滑看货,单击买,下滑离开';
|
||||
case 'menu': return '菜单里:左右滑切选项,单击确认';
|
||||
case 'menu': return '菜单里:左右滑切选项,单击确认,下滑关闭';
|
||||
default: return '……';
|
||||
}
|
||||
}
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
const CMDS = ['attack', 'skill', 'defend', 'item'];
|
||||
|
||||
// 开场:critical 指令「点一下屏幕。」播完(风铃 + 等待声)→ 第一下单击
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('开场指令听完了,点一下');
|
||||
ctx.note('第一句就让我动手,还给了「轮到你了」的一声风铃');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// 序章听完 → 进村
|
||||
await ctx.waitCond(() => game.phase === 'explore');
|
||||
ctx.status('进村了,先听场景描述和选项');
|
||||
|
||||
// 村里(选项:去小路/商店/休息/听状态):把选项听一圈,回绕到首项再出发。
|
||||
// 确认(tap)前必须等选项名念完——voice 播放中单击是「跳过本句」,会把手势吃掉
|
||||
// 确认(tap)前等选项名念完——报名在播时单击是「跳过这句」,会慢半拍
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1800); // 听选项名
|
||||
}
|
||||
ctx.status('选项听了一圈');
|
||||
await ctx.gesture('right'); // 从末项回绕到首个选项——听到「回到第一个」(round2 F7)
|
||||
await ctx.gesture('right'); // 从末项回绕到首个选项——「回到第一个」
|
||||
ctx.note('滑到头再滑就兜回来了,有人提醒「回到第一个」,不用自己数');
|
||||
await ctx.waitCond(() => !ctx.voiceBusy()); // 选项名 + 回绕提示念完
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
await ctx.gesture('tap'); // → path
|
||||
|
||||
// 小路(去岔路口/调查/返回):先调查一次捡钱,再左滑回「去岔路口」
|
||||
@@ -59,8 +66,6 @@ export async function session(ctx, mock) {
|
||||
await ctx.waitVirtual(1800);
|
||||
ctx.note('翻找了一阵,捡到一点钱');
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => !ctx.voiceBusy()); // 捡钱播报念完
|
||||
await ctx.gesture('left'); // 回「去岔路口」
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
await ctx.gesture('tap'); // → fork
|
||||
@@ -72,10 +77,11 @@ export async function session(ctx, mock) {
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
await ctx.gesture('tap'); // go_stream → 溪流 → 触发教学战
|
||||
|
||||
// 教学战:四步(识招 → 攻击 → 格挡 → 防御)+ 自由击杀
|
||||
// 教学战:四步(识招 → 攻击 → 格挡[窗口内单击] → 防御)+ 自由击杀
|
||||
await ctx.waitCond(() => game.phase === 'battle' && game._battle?.state === 'PLAYER_SELECT');
|
||||
ctx.status('遭遇教学狼,战斗开始');
|
||||
|
||||
const CMDS = ['attack', 'skill', 'defend', 'item'];
|
||||
const selectCmd = async (cmd) => {
|
||||
const b = game._battle;
|
||||
while (b && b.cmdIdx !== CMDS.indexOf(cmd)) {
|
||||
@@ -91,17 +97,17 @@ export async function session(ctx, mock) {
|
||||
await ctx.waitVirtual(1300);
|
||||
ctx.status('招式名听熟了');
|
||||
|
||||
// T1:攻击一次
|
||||
// T1:选攻击,单击出剑
|
||||
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT');
|
||||
await selectCmd('attack');
|
||||
ctx.note('单击出手,攻击');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// T2:格挡(前摇窗口内双击)——挡住会先听到「挡得漂亮」(round2 F4)再进防御教学
|
||||
// T2:格挡(前摇窗口内再单击)——先听「它要扑了,听前摇」「就是现在,点一下挡住」
|
||||
await ctx.waitCond(() => game._battle?.state === 'ENEMY_TELEGRAPH');
|
||||
ctx.status('前摇声响起了');
|
||||
ctx.note('双击格挡——这回干净挡下了,听到「挡得漂亮」');
|
||||
await ctx.gesture('doubletap');
|
||||
ctx.note('前摇一响就点一下——这回干净挡下了,听到「挡得漂亮」');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// T3:防御一次
|
||||
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT');
|
||||
@@ -109,14 +115,14 @@ export async function session(ctx, mock) {
|
||||
ctx.note('这回合先防御,减半伤害');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// 自由阶段:攻击到狼死(中间照常听前摇、双击格挡)
|
||||
// 自由阶段:攻击到狼死(中间照常听前摇、单击格挡)
|
||||
for (let i = 0; i < 8 && game.phase === 'battle'; i++) {
|
||||
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT'
|
||||
|| game._battle?.state === 'ENEMY_TELEGRAPH' || game.phase !== 'battle');
|
||||
if (game.phase !== 'battle') break;
|
||||
if (game._battle.state === 'ENEMY_TELEGRAPH') {
|
||||
ctx.note('双击格挡');
|
||||
await ctx.gesture('doubletap');
|
||||
ctx.note('前摇响了,单击格挡');
|
||||
await ctx.gesture('tap');
|
||||
continue;
|
||||
}
|
||||
await selectCmd('attack');
|
||||
@@ -125,5 +131,5 @@ export async function session(ctx, mock) {
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'explore');
|
||||
ctx.status('狼打完了,胜利播报完回到溪流边。一场完整战斗到此收工');
|
||||
ctx.status('狼打完了,胜利播报(链尾还教了一句「想知道自己在哪,就向上滑」)完回到溪流边。一场完整战斗到此收工');
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* 《盲航》新手会话剧本:首局完整演练 D1-D4(第二颗故意超时,听当场重教)→
|
||||
* 正式对局躲过保护期(20s/45s 的加速/护盾引入都照做)→ 每颗陨石都撑到 loud
|
||||
* 警报档(撞击前 500ms:警报 + dir_* 方向呼喊同时响,round3 G7)才拨开 →
|
||||
* 保护期结束后故意漏两颗(第 1 次听归因+向导「同向滑动就能拨开」,第 2 次听
|
||||
* gd_last_life「最后一次机会」——round2 F2.2:受击经济透明)→ 之后全部躲开,
|
||||
* 保护期结束后再撑 20 秒以上收工。
|
||||
* 《盲航》新手会话剧本(26 号 playbook 重构版):首局完整演练 D1-D4(第二颗故意
|
||||
* 超时听「没拨到」真话归因 + 当场重教,第三颗错向听纠错)→ 正式对局躲过保护期
|
||||
* (新手期无双击/无加速:单击只听一次「现在只要滑。」;挥空听 whiff 轻叹与首次
|
||||
* 解释)→ 保护期结束后故意漏两颗(第 1/2 次被锁血吸收仍给方位归因;第 3 次起
|
||||
* 实打实掉血听 hit_<dir>,第 4 次追加 hit_last「最后一命」)→ 之后每颗都同向拨开,
|
||||
* 再撑 20 秒以上收工(撑到结算的话单击再飞)。
|
||||
*/
|
||||
export function describe(game) {
|
||||
const DIR = { left: '左边', right: '右边', up: '上面', down: '下面' };
|
||||
switch (game.phase) {
|
||||
case 'reopen': return '问我继续上次的航程还是重开:单击继续,右滑重开';
|
||||
case 'ask': return '问我直接出发还是重听演练:单击出发,上滑重听';
|
||||
case 'drill': return game.drillOb
|
||||
? `演练:${DIR[game.drillOb.dir]}有陨石在逼近,${DIR[game.drillOb.dir]}滑把它拨开`
|
||||
: '演练旁白在播,先听';
|
||||
: '演练指令在播,先听';
|
||||
case 'playing': {
|
||||
const n = game.obstacles.size;
|
||||
const protect = performance.now() < game.protectionUntil;
|
||||
@@ -20,9 +21,9 @@ export function describe(game) {
|
||||
? `对局中:${n} 颗陨石在逼近,听方位同向滑开${protect ? '(保护期,撞了也不掉血)' : ''}`
|
||||
: `对局中:引擎声平稳,等下一颗${protect ? '(保护期)' : ''}`;
|
||||
}
|
||||
case 'feature': return game.featureKind === 'boost' ? '暂停了刷怪教新功能:单击试试加速' : '暂停了刷怪教新功能:双击展开护盾';
|
||||
case 'menu': return '暂停菜单:单击切选项,双击确认,下滑恢复';
|
||||
case 'over': return `坠毁了,存活数据播报中。单击再飞一次,下滑回大厅`;
|
||||
case 'over': return '坠毁了,存活数据播报中。单击再飞一次,下滑开菜单';
|
||||
case 'menu': return '暂停菜单:左右滑切换,单击确认';
|
||||
case 'leaving': return '回大厅确认句在播';
|
||||
default: return '开场,稍等';
|
||||
}
|
||||
}
|
||||
@@ -30,77 +31,84 @@ export function describe(game) {
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 演练 D1-D4:逼近音一响就同向拨开(第二颗故意不出手,听当场重教)。
|
||||
// 演练 D1-D4:逼近音一响就同向拨开。
|
||||
// D2 故意不出手(听「没拨到。声音一响就滑。」+ 当场重教);
|
||||
// D3 先滑错方向(听纠错句),重教后马上同向滑。
|
||||
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
|
||||
ctx.status(`演练 ${i + 1}/4:${{ left: '左', right: '右', up: '上', down: '下' }[game.drillOb.dir]}侧有陨石`);
|
||||
if (i === 1) {
|
||||
ctx.note('这颗犹豫了,没拨开——听当场重教');
|
||||
await ctx.waitCond(() => game.phase === 'drill' && !game.drillOb); // 超时 → drill_retry
|
||||
ctx.note('这颗犹豫了,没拨开——听真话归因「没拨到」和当场重教');
|
||||
await ctx.waitCond(() => game.phase === 'drill' && !game.drillOb); // 超时 → miss + retry
|
||||
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
|
||||
ctx.note('重教听完,这回马上同向滑');
|
||||
await ctx.gesture(game.drillOb.dir);
|
||||
continue;
|
||||
}
|
||||
if (i === 2) {
|
||||
ctx.note('先滑了个反方向——听一句纠错');
|
||||
await ctx.gesture('left'); // D3 是 up:错向当场重教
|
||||
await ctx.waitCond(() => game.phase === 'drill' && !game.drillOb);
|
||||
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
|
||||
}
|
||||
ctx.note('逼近音一响,同向滑开');
|
||||
await ctx.gesture(game.drillOb.dir);
|
||||
}
|
||||
ctx.status('演练做完了,要出发了');
|
||||
|
||||
// 正式对局:保护期 60s + 结束后再撑 20s 以上收工。
|
||||
// 每颗陨石都撑到 loud 警报档(撞击前 500ms:第二段警报 + 「左!/右!…」方向
|
||||
// 呼喊同时响,round3 G7)才同向拨开——仍在 1.5s 判定窗口内,稳。呼喊与警报
|
||||
// 同一刻先落,随即滑开(=500ms 阈值恰在 loud 警报定时器触发点上)。
|
||||
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
|
||||
// 新手期单击/双击都不可用:单击只听一次「现在只要滑。」;乱滑的方向听 whiff
|
||||
// 轻叹与首次解释;保护期撞了听方位教学(不掉血)。
|
||||
await ctx.waitCond(() => game.phase === 'playing');
|
||||
ctx.status('出发。引擎声起来了,保护期里先练手');
|
||||
if (!ctx.voiceBusy()) {
|
||||
ctx.note('手贱点了一下屏幕——一声「现在只要滑。」,明白了,只管滑');
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
const dodged = new Set();
|
||||
const victims = []; // 保护期结束后故意不躲的(前两颗:听归因 + gd_last_life)
|
||||
const victims = []; // 保护期结束后故意不躲的(前两次锁血 + 后两次实撞)
|
||||
let eaten = 0;
|
||||
while ((game.phase === 'playing' || game.phase === 'feature')
|
||||
while ((game.phase === 'playing' || game.phase === 'over')
|
||||
&& performance.now() < game.protectionUntil + 21000) {
|
||||
if (game.phase === 'feature') {
|
||||
ctx.status('游戏停下来教新功能');
|
||||
// 引入句听完就照做(不裸 waitIdle——feature 态之后的刷怪链是自驱动定时器,
|
||||
// 裸等会把整局对局连本带利烧穿到坠毁)
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
ctx.note(game.featureKind === 'boost' ? '照它说的单击加速' : '照它说的双击开护盾');
|
||||
await ctx.gesture(game.featureKind === 'boost' ? 'tap' : 'doubletap');
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'over') break;
|
||||
// 被撞的那颗消失 → 归因链听完了,继续
|
||||
const eatenIdx = victims.findIndex((v) => !game.obstacles.has(v));
|
||||
if (eatenIdx >= 0) {
|
||||
victims.splice(eatenIdx, 1);
|
||||
ctx.note(eaten === 2 && victims.length === 0
|
||||
? '第二次被撞:先听方位归因,又听到「这是最后一次机会,撑住」——这就是全部家底了'
|
||||
: '被撞了。先听到方位归因,又听到「同向滑动就能拨开」');
|
||||
ctx.note(eaten <= 2
|
||||
? '被撞了但没掉血(新手锁血)——方位归因照样说真话,告诉该往哪边滑'
|
||||
: '实打实挨了一下:方位归因 + 「船体只剩最后一次」——这是全部家底了');
|
||||
continue;
|
||||
}
|
||||
// 撑到 loud 警报档(撞击前 0.5s,第二段警报 + dir 方向呼喊同窗)再出手
|
||||
//(=500ms 恰在 loud 警报定时器触发点:呼喊先落,同一拍跟着滑开)
|
||||
const target = [...game.obstacles].find((o) => !dodged.has(o)
|
||||
&& o.impactAt - performance.now() <= 500);
|
||||
// 呼啸一响就同向拨开(呼喊「左!/右!…」与呼啸同窗,round4 R1)
|
||||
const target = [...game.obstacles].find((o) => !dodged.has(o));
|
||||
if (target) {
|
||||
if (eaten < 2 && performance.now() >= game.protectionUntil) {
|
||||
if (eaten < 4 && performance.now() >= game.protectionUntil) {
|
||||
eaten++;
|
||||
victims.push(target);
|
||||
dodged.add(target); // 标记 = 故意不躲
|
||||
ctx.status(eaten === 1
|
||||
? '保护期结束了,这颗我故意没动'
|
||||
: '又有一颗,也故意不躲——想听听第二次被撞会说什么');
|
||||
ctx.status(eaten <= 2
|
||||
? '保护期结束了,这颗我故意不动(新手锁血,听归因)'
|
||||
: '又一颗,也不躲——想听听真的挨撞会说什么');
|
||||
continue;
|
||||
}
|
||||
dodged.add(target);
|
||||
ctx.note('听到第二段警报和方向呼喊,同向滑开');
|
||||
ctx.note('呼啸一起,同向滑开');
|
||||
await ctx.gesture(target.dir);
|
||||
continue;
|
||||
}
|
||||
await ctx.waitCond(() => game.phase !== 'playing'
|
||||
|| victims.some((v) => !game.obstacles.has(v))
|
||||
|| [...game.obstacles].some((o) => !dodged.has(o) && o.impactAt - performance.now() <= 500));
|
||||
|| [...game.obstacles].some((o) => !dodged.has(o)));
|
||||
}
|
||||
if (game.phase === 'over') {
|
||||
ctx.status('坠毁了——结算在播:存活时段、躲避段、单击再飞一次');
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.note('单击,再飞一次');
|
||||
await ctx.gesture('tap');
|
||||
return;
|
||||
}
|
||||
const aliveSec = Math.round((performance.now() - game.runStart) / 1000);
|
||||
ctx.status(`保护期结束后撑了 ${aliveSec - 60} 秒、挨了两下还活着;每回撞击前半秒都能听到方向呼喊,先到这`);
|
||||
ctx.status(`保护期结束后撑了 ${aliveSec - 60} 秒、挨了四下还活着;每次撞击都有方位真话,先到这`);
|
||||
}
|
||||
|
||||
@@ -1,104 +1,246 @@
|
||||
/**
|
||||
* 《声命线》新手会话剧本:像真人一样等语音播完再动、偶尔走神(触发看门狗,
|
||||
* 听到轻响后 5 秒内回应——round2 修正剧本失真:round1 的「干晾 85 秒」是剧本
|
||||
* 问题不是游戏问题)、在非抉择节点试一次无效的左滑(触发向导纠错)、中途演示
|
||||
* 一次语速 2.2 切换(F9:设置下一句即时生效,转写状态行可见语速档)、菜单逛
|
||||
* 一圈再出来、抉择点一律选左,一路打到结局。
|
||||
* 《声命线》新手会话剧本(26 号重构版,radio/keeper 范式)。
|
||||
*
|
||||
* 交互口径(web/storyline/game.js 26 号状态×手势表):
|
||||
* - 发言权管理器:整条旁白链 = SPEAKING(单击跳句由管道处理,剧本不抢话);
|
||||
* 链完 → yieldTurn:交接提示音 → 等待声 → 三档提示。
|
||||
* - 单击 = 继续;抉择点单击 = 「再说一遍:」重听选项;结局后单击 = 重新开始。
|
||||
* - 左/右滑 = 抉择(旁白没听完就滑,只跳去选项不当场盲选);上滑 = 「我在哪」
|
||||
* (阶段 1 解锁);下滑 = 菜单(阶段 2,一局结束才解锁);双击 = 重听(阶段 3)。
|
||||
* - 开场(§4.10):第一句 = critical 指令「点一下屏幕。」,砍 26 秒规则念白;
|
||||
* 第一下单击 → 旁白 + 「轮到你了」状态教学 → 第二下单击进正片。
|
||||
* - 菜单:单击换一项、右滑确认(第 1 项重新开始 / 第 2 项回大厅 / 第 3 项返回故事)。
|
||||
* 旧 voicePlaying/choiceArmed/awaitingRestart API 已废,话轮判据 = floor 等待点。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):林晚的完整弧线——开场第一句就动手 → 逐节点听完再接话,
|
||||
* 七个抉择都给出人味理由(查收音机 / 听哨声 / 带不走孩子先藏好 / 走涵洞 /
|
||||
* 打过狗群 / 加密频段),以 E1「黎明」收尾:中途主动重听一次选项、上滑问一次
|
||||
* 「我在哪」、走神一次听三档提示、演示一次语速 2.2 切换、结局后逛一圈菜单
|
||||
* (下滑此刻才解锁),最后单击回到故事开始的地方。
|
||||
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳节点、菜单里乱逛、
|
||||
* 无效手势乱试探,一样要走到一个结局,全程零死锁、零静默死局。
|
||||
*
|
||||
* 剧本失真红线(参照 radio/keeper 剧本先例):
|
||||
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN 且链已收,链级判据)
|
||||
* 之后才出手——voiceBusy/isVoicePlaying 是句级信号、链内句间会闪断,不能当门控;
|
||||
* - 玩家不干晾自己:只在叙事等待态偶尔走神触发一次等待点提示,链一停就接话;
|
||||
* - 等待点挂着三档提示定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
|
||||
* - 菜单里右滑=确认:只确认第 3 项「返回故事」,绝不碰「重新开始/回大厅」
|
||||
* (回大厅会结束会话);开场前奏(nodeId 尚空)不出上下滑——那两个手势
|
||||
* 在没有节点时会落进未定义状态。
|
||||
*/
|
||||
|
||||
// ---------------- 26 号 API:话轮判据(等待点 id:intro/continue/choice/settle/menu/reopen)
|
||||
|
||||
const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
|
||||
const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null);
|
||||
const atChoice = (game) => atWait(game) && wpId(game) === 'choice';
|
||||
const atSettle = (game) => atWait(game) && wpId(game) === 'settle';
|
||||
|
||||
export function describe(game) {
|
||||
if (game.mode === 'MENU') return '菜单里:左滑重开、右滑或单击返回剧情、上滑听帮助';
|
||||
if (game.awaitingRestart) return '结局播完了。单击屏幕,可以回到故事开始的地方';
|
||||
if (game.choiceArmed) return `「请选择」念完了(节点 ${game.nodeId}):左滑或右滑,做出你的决定`;
|
||||
if (game.voicePlaying) return '旁白正在播。单击跳过本句,双击重听整个节点';
|
||||
return `剧情等着你(节点 ${game.nodeId}):单击继续,双击重听`;
|
||||
if (game.mode === 'MENU') {
|
||||
return '菜单里:单击换一项,右滑确认(第1项重新开始/第2项回大厅/第3项返回故事),上滑重听说明';
|
||||
}
|
||||
if (game.mode === 'REOPEN') return '检测到存档:单击继续上次,右滑从头开始,上滑听「我在哪」';
|
||||
if (game.prelude === 0) {
|
||||
return game.floor.speaking ? '开场指令正在播:「点一下屏幕。」' : '等待声在响:点一下,开始';
|
||||
}
|
||||
if (game.nodeId == null) return '开场教学听完了:单击,正式进剧情';
|
||||
if (game.floor.speaking) return `旁白正在播(${game.nodeId ?? '开场'}),先听完`;
|
||||
if (atChoice(game)) return `「请选择」念完了(节点 ${game.nodeId}):向左或向右滑做出你的决定`;
|
||||
if (atSettle(game)) return '结局听完了。单击回到故事开始的地方,下滑开菜单(刚解锁)';
|
||||
return `剧情停下来了(节点 ${game.nodeId}):单击继续,上滑听「我在哪」`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主线剧本
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
let invalidTried = false;
|
||||
let menuDone = false;
|
||||
|
||||
// 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 交出话轮 → 第一下单击
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('开场第一句就是指令,不是 26 秒规则念白');
|
||||
ctx.note('心跳声里一句「点一下屏幕」——点');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// 第一段旁白 + 「听到这个声音,就是轮到你了」状态教学 → 单击进正片
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('旁白两句 + 状态教学:等待声就是「轮到你了」');
|
||||
ctx.note('大停电第三十天——睁眼,单击继续');
|
||||
await ctx.gesture('tap'); // → n00(链尾教「单击可跳过旁白」)
|
||||
|
||||
let relistened = false;
|
||||
let askedWhere = false;
|
||||
let walkedAway = false;
|
||||
let menuVisited = false;
|
||||
let speedDemoDone = false;
|
||||
let speedBackDone = false;
|
||||
let choiceIdx = 0;
|
||||
|
||||
// 开场:gd_brief 先给操作口令(round2 F8),n00 全听完,再愣一会儿——看门狗 25s 才说话
|
||||
await ctx.waitCond(() => !game.voicePlaying); // 只等链播完(不空烧看门狗窗口)
|
||||
ctx.status('开场口令和旁白播完了,屏幕黑着,走神想剧情去了');
|
||||
ctx.silence(25, '在入戏,不是迷路(round2:剧情类等待默认低频)');
|
||||
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
|
||||
ctx.status('听到一声轻响和「轻触屏幕,继续」,马上点一下');
|
||||
await ctx.gesture('tap'); // 听到提示 ≤5s 内反应
|
||||
// 抉择路径(E1 黎明):p1 查收音机 / p2 听哨声(知陷阱)/ p3 循哭声 /
|
||||
// p4 藏好孩子过江回来接 / p5 涵洞 / p6 打过狗群 / p8 加密频段
|
||||
const choices = [
|
||||
{ dir: 'left', why: '先查收音机——林晨的暗号可能在频道里' },
|
||||
{ dir: 'right', why: '哨声不对劲。屏住呼吸,听它想干什么' },
|
||||
{ dir: 'left', why: '哭声。装作没听见,我做不到' },
|
||||
{ dir: 'right', why: '断桥扛不住两个人的重量——把她藏好,过江回来接' },
|
||||
{ dir: 'left', why: '涵洞又冷又臭,但狗不会跟下水' },
|
||||
{ dir: 'right', why: '狗群就在塔下。坐标在我脑子里,打过去' },
|
||||
{ dir: 'right', why: '猎狗在听公开频道——用加密频段' },
|
||||
];
|
||||
|
||||
for (let i = 0; i < 60 && !game.awaitingRestart && mock.now < 20 * 60 * 1000; i++) {
|
||||
// 只等语音链播完(真人不会抢话)。不能裸 waitIdle——会把等待态看门狗的两级
|
||||
// 提醒整段空烧完才动手(round1 剧本失真根源),链一停就该轮到玩家了
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
for (let i = 0; i < 200 && !menuVisited; i++) {
|
||||
// 只等当前语音链播完、话轮交出;等待点挂着提示定时器,绝不用裸 waitIdle
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
|
||||
if (game.awaitingRestart) break;
|
||||
|
||||
// 中途进一次菜单,逛一圈再出来
|
||||
if (!menuDone && game.nodeId === 'n04') {
|
||||
menuDone = true;
|
||||
ctx.note('想看看还有什么可按的,下滑呼出菜单');
|
||||
// 结局:先逛一圈菜单(下滑此刻才真正解锁,教学句挂在结局链尾)
|
||||
if (atSettle(game)) {
|
||||
menuVisited = true;
|
||||
ctx.note('结局链尾教了「向下滑,可以打开菜单」——去看看');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitIdle();
|
||||
ctx.status('菜单里');
|
||||
await ctx.gesture('up'); // 听帮助 + 进度
|
||||
await ctx.waitIdle();
|
||||
await ctx.gesture('doubletap'); // 重听菜单项
|
||||
await ctx.waitIdle();
|
||||
ctx.note('听懂了,先回去继续剧情');
|
||||
await ctx.gesture('tap'); // 关菜单(回剧情并重播当前节点)
|
||||
continue;
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('菜单第一项「重新开始」——单击换一项听听');
|
||||
await ctx.gesture('tap'); // → 第 2 项「回大厅」
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('「回大厅」——不去;再换');
|
||||
await ctx.gesture('tap'); // → 第 3 项「返回故事」
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.note('「返回故事」——右滑确认,回结局');
|
||||
await ctx.gesture('right'); // 确认第 3 项 → 关菜单重播结局
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
ctx.status('菜单关了,结局又听了一遍——离开时什么都没碰坏');
|
||||
break;
|
||||
}
|
||||
|
||||
// F9:演示一次语速切换——2.2 档对下一句即时生效,听两段再调回 1.0
|
||||
if (!speedDemoDone && game.nodeId === 'n10') {
|
||||
speedDemoDone = true;
|
||||
ctx.note('旁白有点慢,去设置把语速调到 2.2(下一句生效)');
|
||||
ctx.settings.set({ speed: 2.2 });
|
||||
if (game.choiceArmed) {
|
||||
ctx.note('选了左边(循声而去)——后面几步都是 2.2 档');
|
||||
await ctx.gesture('left'); // n10 抉择 → n11,2.2 倍速播
|
||||
} else {
|
||||
if (atChoice(game)) {
|
||||
const pick = choices[choiceIdx];
|
||||
if (!pick) break; // 抉择走完(p8 之后应已进结局)
|
||||
|
||||
// p2 起上滑已解锁:犹豫时点一下重听选项(原则 9 主动重听)
|
||||
if (!relistened && choiceIdx >= 1) {
|
||||
relistened = true;
|
||||
ctx.note(pick.why + '——没听清选项,点一下,让它「再说一遍」');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上');
|
||||
await ctx.waitCond(() => atChoice(game));
|
||||
}
|
||||
await ctx.waitIdle();
|
||||
ctx.status('语速 2.2 听着赶趟');
|
||||
continue;
|
||||
}
|
||||
if (speedDemoDone && !speedBackDone && game.nodeId === 'n12') {
|
||||
speedBackDone = true;
|
||||
ctx.note('听了两段,把语速调回 1.0');
|
||||
ctx.settings.set({ speed: 1.0 });
|
||||
}
|
||||
|
||||
if (game.choiceArmed) {
|
||||
ctx.status('抉择点,提示音念完了');
|
||||
if (mock.now % 2 < 1) await ctx.waitVirtual(3000); // 偶尔犹豫一下
|
||||
ctx.note('选了左边');
|
||||
await ctx.gesture('left'); // 抉择点一律选左
|
||||
// F9:演示一次语速切换——2.2 档对下一句即时生效,听到断桥就调回 1.0
|
||||
if (!speedDemoDone && game.nodeId === 'n10') {
|
||||
speedDemoDone = true;
|
||||
ctx.note('旁白有点慢,去设置把语速调到 2.2(下一句生效)');
|
||||
ctx.settings.set({ speed: 2.2 });
|
||||
}
|
||||
ctx.status('选项念完了,做决定');
|
||||
ctx.note(pick.why);
|
||||
const atBridge = game.nodeId === 'n15'; // 滑出去之后节点就换了,先记住
|
||||
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧提示三档)
|
||||
if (choiceIdx === 0) {
|
||||
ctx.status('第一个小目标达成——下一段链尾教了「想知道自己在哪,就向上滑」');
|
||||
ctx.note('做完第一件事才教下一件事,一句话只教一个');
|
||||
}
|
||||
if (speedDemoDone && !speedBackDone && atBridge) {
|
||||
speedBackDone = true; // 听了两段(n11-n15),把语速调回 1.0 再过江
|
||||
ctx.note('雨声听不清了——把语速调回 1.0');
|
||||
ctx.settings.set({ speed: 1.0 });
|
||||
}
|
||||
choiceIdx++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// W1:叙事节点播完,静默等待
|
||||
ctx.status('剧情停下来了,在等我');
|
||||
if (!invalidTried) {
|
||||
invalidTried = true;
|
||||
ctx.note('试着左滑了一下——这里用不上,向导纠正了我');
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitVirtual(2000); // 让纠错句播完(不空烧看门狗窗口)
|
||||
} else if (i % 4 === 2) {
|
||||
ctx.silence(25, '又走神想剧情去了');
|
||||
await ctx.waitVirtual(25600); // 偶尔挂机,触发看门狗轻提醒(25s 才响,不轰炸)
|
||||
ctx.status('又听到那声轻响,5 秒内想起该点屏幕了');
|
||||
// 上滑 = 「我在哪、我该干嘛」(第一幕解锁后用一次):位置 → 目标 → 动作
|
||||
if (!askedWhere && game.nodeId === 'n06') {
|
||||
askedWhere = true;
|
||||
ctx.note('向上一滑——我在哪、我该干嘛');
|
||||
await ctx.gesture('up');
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('「第一幕,地铁站。找林晨。单击继续。」——一句话说全了');
|
||||
continue;
|
||||
}
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
|
||||
// 注意不要在这里 waitVirtual 小憩——等待点挂着三档提示定时器,waitVirtual
|
||||
// 会一口气跳到下一档提示(18s full),剧本就变成「每句干晾到提示响」。
|
||||
if (!walkedAway && game.nodeId === 'n05') {
|
||||
walkedAway = true;
|
||||
ctx.silence(18, '听到一半走了神,在想林晨的暗号');
|
||||
await ctx.waitVirtual(18600); // 触发等待点提示 full 档(18s):「单击,继续。」
|
||||
ctx.status('一句「单击,继续」把我拉回来——点');
|
||||
await ctx.gesture('tap');
|
||||
continue;
|
||||
}
|
||||
await ctx.gesture('tap'); // 链末单击 = 继续
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('结局');
|
||||
ctx.note('听完结局,单击重开一次看看别的走向');
|
||||
await ctx.gesture('tap'); // 结算态单击重开
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('回到开头,会话结束');
|
||||
// 尾声:单击回到故事开始的地方(重开不再念教学——做对过的事不教第二遍)
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
ctx.status('结局链走完——「这座城市,是从一个女人的声音开始,重新活过来的」');
|
||||
ctx.note('单击,回到故事开始的地方');
|
||||
await ctx.gesture('tap'); // settle → _restart → n00
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.status('回到地铁站,开场教学没有再念一遍——会话收工(E1 黎明)');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 乱按压力剧本
|
||||
// 目标:全程零死锁、零静默死局,一样要走到一个结局。
|
||||
// 剧本特征:不读提示、快速乱划、菜单里乱逛、无效手势乱试探,但保留「最终能通关」底线。
|
||||
|
||||
export async function sessionStress(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
let seed = 20261002;
|
||||
const rng = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
|
||||
|
||||
let menuOpenCount = 0;
|
||||
let choiceCount = 0;
|
||||
let noiseCount = 0;
|
||||
let settled = false;
|
||||
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await ctx.waitCond(() => atWait(game)); // 链级门控:不抢话
|
||||
|
||||
// 菜单:乱逛换项,最后停在第 3 项「返回故事」右滑确认退出
|
||||
// (右滑=确认:绝不碰第 1 项「重新开始」之外的确认位与第 2 项「回大厅」)
|
||||
if (game.mode === 'MENU') {
|
||||
menuOpenCount++;
|
||||
while (game.mode === 'MENU' && game._menuIdx !== 2) {
|
||||
await ctx.gesture('tap'); // 单击换一项(乱逛)
|
||||
await ctx.waitCond(() => atWait(game) || game.mode !== 'MENU');
|
||||
}
|
||||
await ctx.gesture('right'); // 确认第 3 项:关菜单回剧情(重播当前节点)
|
||||
await ctx.waitCond(() => atWait(game) || game.mode !== 'MENU');
|
||||
continue;
|
||||
}
|
||||
if (atSettle(game)) { settled = true; break; }
|
||||
if (atChoice(game)) {
|
||||
choiceCount++;
|
||||
if (rng() < 0.35) {
|
||||
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听/问状态/乱点)
|
||||
noiseCount++;
|
||||
await ctx.gesture(pick(['tap', 'up', 'doubletap']));
|
||||
await ctx.waitCond(() => atWait(game) || game.mode === 'MENU');
|
||||
continue;
|
||||
}
|
||||
await ctx.gesture(pick(['left', 'right']));
|
||||
continue;
|
||||
}
|
||||
// 开场前奏(nodeId 尚空):只点/滑安全键——上滑下滑此刻会落进未定义状态
|
||||
if (game.nodeId == null) {
|
||||
noiseCount++;
|
||||
await ctx.gesture(pick(['tap', 'tap', 'left']));
|
||||
continue;
|
||||
}
|
||||
// 叙事等待态:乱划一下(无效手势也要出手,零静默;down 可能误入菜单,下轮兜住)
|
||||
noiseCount++;
|
||||
await ctx.gesture(pick(['tap', 'left', 'right', 'up', 'down', 'doubletap']));
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => atSettle(game));
|
||||
ctx.status(`乱按剧本收尾:${choiceCount} 个抉择、乱试 ${noiseCount} 次、误入菜单 ${menuOpenCount} 次${settled ? '' : '(收尾补等结局)'}`);
|
||||
ctx.note('全程没有任何一次手势把游戏按死——结局链照样走完');
|
||||
}
|
||||
|
||||
+196
-173
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* 《深夜茶馆》新手会话剧本。
|
||||
* 《深夜茶馆》新手会话剧本(26 号重构版:经营+时机型,radio/storyline 范式)。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位新掌柜的完整首晚弧线——标题开张 → 教学七步跟练
|
||||
* (看茶柜/选定/提壶/出汤/端茶/状态)→ 自由营业三位客人(邮差/苏晚/陈拾,
|
||||
* 各泡一盏)→ 打烊对账 → 单击睡下 → 第二晚开场收束。
|
||||
* - `sessionStress`(乱按压力):一位手忙脚乱的新掌柜——标题乱划、教学下滑跳过、
|
||||
* 灶前乱按乱切茶罐(零进展被看门狗三级抢救)、菜单乱逛只下滑关闭、乱手势
|
||||
* 不误判错步——照样泡完一盏茶走到打烊,全程零死锁。
|
||||
* - `session`(主线):一位新掌柜的完整首晚弧线——开场第一句「点一下屏幕」就动手 →
|
||||
* 教学七步跟练(左右滑看柜/单击选定/听水声单击提壶/听滴答单击出汤/单击端茶/
|
||||
* 上滑状态)→ 自由营业三位客人(邮差/苏晚/陈拾,各泡一盏,靠耳朵掐时机)→
|
||||
* 打烊对账 → 上滑重听账目 → 单击睡下 → 第二晚开场收束。
|
||||
* - `sessionStress`(乱按压力):一位手忙脚乱的新掌柜——标题乱划、教学中乱试探
|
||||
* (教学不可跳过,只能跟做)、心急提前提壶被真话归因重教、菜单乱逛只下滑关闭、
|
||||
* 乱手势不误判错步——照样泡完一盏茶走到打烊,全程零死锁。
|
||||
*
|
||||
* 剧本失真红线(detective 剧本先例):
|
||||
* - 玩家不抢话:出手前等语音链收尾(waitStep 有界等待状态谓词);
|
||||
* - 玩家靠耳朵玩:候汤听泡、闷泡听滴答,时机手势跟着声音走;
|
||||
* - 营业态常驻自动推进:禁止裸 waitIdle 空等,必须 waitStep/waitVirtual;
|
||||
* - 菜单里只「下滑关闭」,绝不双击执行(防误触「保存并回大厅」结束会话)。
|
||||
* 剧本失真红线(radio/storyline 剧本先例):
|
||||
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN)之后才出手;
|
||||
* critical 句(教学指令/结算链)播完再动,抢拍只会听到 busy;
|
||||
* - 玩家靠耳朵玩:候汤听水声、闷泡听滴答,时机手势跟着声音走(单击即时,无双击);
|
||||
* - 营业态常驻自动推进:禁止裸 waitIdle 空等,必须 waitCond/waitVirtual 有界等待;
|
||||
* - 菜单里只「下滑关闭」,绝不确认「保存并回大厅」(防误触结束会话)。
|
||||
*/
|
||||
|
||||
const TEA_NAME = {
|
||||
@@ -21,77 +23,64 @@ const TEA_NAME = {
|
||||
dianhong: '滇红', puer: '普洱', yinzhen: '白毫银针',
|
||||
};
|
||||
|
||||
/** 26 号 API:真等待态(链收+话轮已交)。 */
|
||||
const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
|
||||
|
||||
export function describe(game) {
|
||||
if (game._menuOpen) {
|
||||
if (game._shopOpen) return '打烊置办里:左右滑看项目,单击买定,下滑返回';
|
||||
return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭';
|
||||
if (game._shopOpen) return '打烊置办里:左右滑看项目,单击买定,下滑返回菜单';
|
||||
return '菜单里:左右滑切项,单击确认,上滑重听说明,下滑关闭';
|
||||
}
|
||||
switch (game.state) {
|
||||
case 'TITLE': return game.run
|
||||
? '检测到没打烊的茶馆:单击继续,右滑两次重开新店'
|
||||
: '标题页:单击开店,上滑听店铺概况';
|
||||
case 'OPENING': return '开晚场:点灯与晚数句播报中(单击可跳句)';
|
||||
: '标题页:单击开店(第一句就是动手指令),上滑听店铺概况';
|
||||
case 'OPENING': return '开晚场:晚数句播报中(单击跳句,滑动打断并推进)';
|
||||
case 'SERVE': {
|
||||
if (game._chain) return '堂前:客人进门/点单/反应播报中(单击跳句,双击重听)';
|
||||
return '堂前空闲:单击进灶前,上滑状态,下滑菜单';
|
||||
if (game.floor.speaking) return '堂前:客人进门/点单/反应播报中(单击跳句)';
|
||||
return '堂前空档:上滑问状态,下滑菜单(自动流马上进灶)';
|
||||
}
|
||||
case 'BREW': {
|
||||
const b = game._brew;
|
||||
if (!b) return '灶前';
|
||||
if (game._drill) return `教学中(第 ${game._drill.step} 步):跟着旁白做,下滑可跳过`;
|
||||
if (b.sub === 'TEA') return '选茶:左右滑换茶罐,单击选定,双击重听茶名句';
|
||||
if (b.sub === 'WATER') return '候汤:听水声,泡泡细密时双击提壶';
|
||||
return '闷泡:听滴答,滴答密起来时双击出汤';
|
||||
if (game._drill) return `教学中(第 ${game._drill.step} 步):跟着旁白做(教学不可跳)`;
|
||||
if (b.sub === 'TEA') return '选茶:左右滑换茶罐(茶名句随身),单击选定';
|
||||
if (b.sub === 'WATER') return '候汤:听水声,泡泡密起来时单击提壶';
|
||||
return '闷泡:听滴答,滴答密起来时单击出汤';
|
||||
}
|
||||
case 'CLOSE': return game._closeChainActive
|
||||
? '打烊结算链播报中(账目/房租/章节),手势全部被吞'
|
||||
: '打烊:账报完了,单击睡下,下滑可打烊置办';
|
||||
? '打烊结算链播报中(账目/房租/章节),手势全部 busy 被吞'
|
||||
: '打烊:账报完了,单击睡下,上滑重听账目,下滑菜单(含打烊置办)';
|
||||
default: return '……';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 会话工具
|
||||
|
||||
/** 有界等待:条件满足或超时即返回。 */
|
||||
async function waitStep(ctx, mock, cond, maxMs = 40000) {
|
||||
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 quiet(ctx, mock, game, maxMs = 60000) {
|
||||
await waitStep(ctx, mock, () => !game.audio.isVoicePlaying() && !game._chain, maxMs);
|
||||
}
|
||||
|
||||
/** 泡当前客人:选茶(循环到目标)→ 候汤窗内双击 → 闷泡窗内双击。 */
|
||||
async function brewTea(ctx, mock, game, tea, note) {
|
||||
await waitStep(ctx, mock, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill, 90000);
|
||||
/** 泡当前客人:选茶(循环到目标)→ 候汤窗内单击提壶 → 闷泡窗内单击出汤。 */
|
||||
async function brewTea(ctx, game, tea, note) {
|
||||
await ctx.waitCond(() => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill);
|
||||
const shelf = game._shelf();
|
||||
let guard = 0;
|
||||
while (game._brew && shelf[game._brew.teaIdx] !== tea && guard++ < 10) {
|
||||
while (game._brew && game._brew.teaIdx >= 0 && shelf[game._brew.teaIdx] !== tea && guard++ < 10) {
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(2500); // 茶罐名落声
|
||||
await ctx.waitVirtual(2500); // 茶罐名句落声
|
||||
}
|
||||
ctx.note(`茶柜上选定${TEA_NAME[tea]}——单击`);
|
||||
await ctx.gesture('tap');
|
||||
await waitStep(ctx, mock, () => game._brew && game._brew.sub === 'WATER', 20000);
|
||||
await ctx.waitCond(() => game._brew && game._brew.sub === 'WATER');
|
||||
// 候汤:将沸茶 ~3.5s / 滚透茶 ~6.5s(首晚 ×1.3 更宽);听泡变密再提壶
|
||||
const simmer = tea === 'longjing' || tea === 'yinzhen';
|
||||
await ctx.waitVirtual(simmer ? 4200 : 6800);
|
||||
ctx.note(note || '水声对了——双击提壶');
|
||||
await ctx.gesture('doubletap');
|
||||
await waitStep(ctx, mock, () => game._brew && game._brew.sub === 'STEEP', 20000);
|
||||
ctx.note(note || '水声对了——单击提壶');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game._brew && game._brew.sub === 'STEEP');
|
||||
// 闷泡:快 7s / 中 11s / 久 15s,滴答密起入窗
|
||||
const steepAt = { longjing: 7000, moli: 7000, yinzhen: 11000, tieguanyin: 11000, dianhong: 15000, puer: 15000 }[tea] || 7000;
|
||||
await ctx.waitVirtual(steepAt - 800);
|
||||
ctx.note('滴答密了——双击出汤');
|
||||
await ctx.gesture('doubletap');
|
||||
await quiet(ctx, mock, game, 90000); // 判定链+反应链收尾
|
||||
ctx.note('滴答密了——单击出汤');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => !game.audio.isVoicePlaying() && !game.floor.speaking); // 判定链+反应链收尾
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主线剧本
|
||||
@@ -99,178 +88,212 @@ async function brewTea(ctx, mock, game, tea, note) {
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// —— 标题:单击开店(首局无存档) ——
|
||||
await waitStep(ctx, mock, () => game.state === 'TITLE', 20000);
|
||||
// —— 开场(§4.10):第一句就是动手指令「点一下屏幕。」——
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('深夜茶馆:巷子最深处,一盏亮到后半夜的灯');
|
||||
ctx.note('单击,开店');
|
||||
await ctx.gesture('tap');
|
||||
ctx.note('第一句就让我动手——点一下');
|
||||
await ctx.gesture('tap'); // 开店 → 「轮到你了」状态声 → OPENING
|
||||
|
||||
// —— 开场 + 教学 DRILL 七步 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'BREW' && game._drill && game._drill.step === 0, 90000);
|
||||
ctx.status('头一晚。开场三句讲清了:泡茶三步,耳朵说了算');
|
||||
await quiet(ctx, mock, game, 30000);
|
||||
// —— 教学 DRILL 七步(单击版:跟旁白做,一次一个动作)——
|
||||
await ctx.waitCond(() => game.state === 'BREW' && game._drill && game._drill.step === 0
|
||||
&& game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('头一晚。老周进门要一盏龙井——旁白带着我一步一步来');
|
||||
ctx.note('右滑——先看茶柜,一罐一罐听');
|
||||
await ctx.gesture('right'); // D0
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 1, 15000);
|
||||
await ctx.waitVirtual(6000);
|
||||
await ctx.gesture('right'); // D0:换罐即学「单击选定」
|
||||
await ctx.waitCond(() => game._drill && game._drill.step === 1);
|
||||
ctx.note('滑回龙井——老周要的就是它');
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitVirtual(2500);
|
||||
ctx.note('单击,选定这罐龙井');
|
||||
await ctx.gesture('tap'); // D1 → 自动坐水
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 2, 15000);
|
||||
await ctx.waitVirtual(8000); // tut_water 听完
|
||||
await ctx.gesture('tap'); // D1 → 教学句说完自动坐水
|
||||
await ctx.waitCond(() => game._drill && game._drill.step === 2);
|
||||
await ctx.waitVirtual(3600); // 听觉分层:教学句说完水声才起,等泡泡密起来
|
||||
ctx.status('灶上坐水了。听——泡泡细密起来,就是提壶的时候');
|
||||
await ctx.waitVirtual(2000); // ≈4.2s(首晚宽窗)
|
||||
ctx.note('泡泡细密了——双击,提壶');
|
||||
await ctx.gesture('doubletap'); // D2
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 3, 20000);
|
||||
await ctx.waitVirtual(8000); // tut_steep 听完 + 闷泡头段
|
||||
ctx.note('滴答声密起来——双击,出汤');
|
||||
await ctx.gesture('doubletap'); // D3
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 4, 30000);
|
||||
ctx.note('泡泡细密了——单击,提壶');
|
||||
await ctx.gesture('tap'); // D2(窗内)
|
||||
await ctx.waitCond(() => game._drill && game._drill.step === 3);
|
||||
await ctx.waitVirtual(6400); // 闷泡滴答从密到缓,龙井快冲 ~7s
|
||||
ctx.note('滴答声密起来——单击,出汤');
|
||||
await ctx.gesture('tap'); // D3(窗内)
|
||||
await ctx.waitCond(() => game._drill && game._drill.step === 4);
|
||||
ctx.status('这一盏泡得地道。茶好了,端出去');
|
||||
ctx.note('单击,端出去');
|
||||
await ctx.gesture('tap'); // D4
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 5, 60000);
|
||||
await ctx.waitVirtual(9000); // 反应链 + tut_status
|
||||
await ctx.gesture('tap'); // D4 → 反应链 → 第一盏茶送出
|
||||
await ctx.waitCond(() => game._drill && game._drill.step === 5
|
||||
&& game.floor.state === 'YOUR_TURN');
|
||||
ctx.status('链尾教了「想知道自己在哪,就向上滑」');
|
||||
ctx.note('上滑——听听今晚的局面');
|
||||
await ctx.gesture('up'); // D5
|
||||
await waitStep(ctx, mock, () => game._drill === null, 60000);
|
||||
await ctx.gesture('up'); // D5:状态链(点单句+客人序+口碑+存款)
|
||||
await ctx.waitCond(() => game._drill === null);
|
||||
ctx.status('都会了。教学完成,往后每一晚都这么泡');
|
||||
|
||||
// —— 自由营业:邮差(茉莉)/ 苏晚(滇红)/ 陈拾(茉莉) ——
|
||||
await brewTea(ctx, mock, game, 'moli', '邮差要滚烫的茉莉——双击提壶');
|
||||
// —— 自由营业:邮差(茉莉)/ 苏晚(滇红)/ 陈拾(茉莉)——
|
||||
await brewTea(ctx, game, 'moli', '邮差要滚烫的茉莉——水滚透再提');
|
||||
ctx.status(`邮差喝得很慢,多搁下两文。进账 ${game.run ? game.run.incomeTonight : '—'} 文`);
|
||||
await brewTea(ctx, mock, game, 'dianhong', '苏晚要暖的滇红——滚透再冲');
|
||||
await brewTea(ctx, game, 'dianhong', '苏晚要暖的滇红——滚透再冲');
|
||||
ctx.status(`苏晚说像被窝里塞了个汤婆子。进账 ${game.run ? game.run.incomeTonight : '—'} 文`);
|
||||
await brewTea(ctx, mock, game, 'moli', '陈拾要香的茉莉提神——双击提壶');
|
||||
await brewTea(ctx, game, 'moli', '陈拾要香的茉莉提神');
|
||||
ctx.status(`陈拾把早饭钱花在了这盏茶上。进账 ${game.run ? game.run.incomeTonight : '—'} 文`);
|
||||
|
||||
// —— 上滑对账,然后打烊 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'CLOSE' && game._closeChainActive, 60000);
|
||||
ctx.status('客人走尽了。收壶,打烊——今晚进账听账目链');
|
||||
await waitStep(ctx, mock, () => game._closeDone, 90000);
|
||||
await ctx.waitVirtual(6000); // settle_night 落声
|
||||
// —— 打烊:账目链 → 上滑重听 → 单击睡下 ——
|
||||
await ctx.waitCond(() => game.state === 'CLOSE' && game._closeDone);
|
||||
await ctx.waitCond(() => atWait(game)); // settle_night 落声 + 睡下等待点武装
|
||||
ctx.status('客人走尽了。账报完了——今晚进账都在链上');
|
||||
ctx.note('上滑,再听一遍账目');
|
||||
await ctx.gesture('up');
|
||||
await quiet(ctx, mock, game, 30000);
|
||||
await ctx.waitCond(() => atWait(game));
|
||||
ctx.note('单击,睡下');
|
||||
await ctx.gesture('tap'); // 睡下 → N2
|
||||
await waitStep(ctx, mock, () => game.state === 'OPENING', 20000);
|
||||
await ctx.gesture('tap'); // 睡下 → N2(链尾教「向下滑,可以打开菜单」)
|
||||
await ctx.waitCond(() => game.state === 'OPENING');
|
||||
ctx.status('第二晚。老客们已经认得这条巷子了——今晚的营业收工');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 乱按压力剧本
|
||||
// 目标:全程零死锁、零静默死局,一样能走到打烊睡下。
|
||||
// 剧本特征:乱划乱点、抢拍跳句、心急提前提壶、菜单乱逛,但仍保留「最终能通关」的底线。
|
||||
|
||||
export async function sessionStress(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
let seed = 20260914;
|
||||
const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||||
|
||||
// ① 标题乱划,然后开店
|
||||
// ① 标题乱划(上滑/下滑还没解锁——只听到 invalid 回执和一句解释),然后开店
|
||||
for (let i = 0; i < 6 && game.state === 'TITLE'; i++) {
|
||||
ctx.status('标题页乱划一气');
|
||||
await ctx.gesture(rng() < 0.5 ? 'left' : 'up');
|
||||
await ctx.waitVirtual(600);
|
||||
}
|
||||
ctx.note('不划了——点一下,开店');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// ② 教学:乱按几下,然后下滑跳过
|
||||
await waitStep(ctx, mock, () => game.state === 'BREW' && game._drill, 60000);
|
||||
let dg = 0;
|
||||
while (game._drill && dg++ < 12) {
|
||||
if (rng() < 0.55) {
|
||||
await ctx.gesture(['tap', 'doubletap', 'left', 'right'][Math.floor(rng() * 4)]);
|
||||
await ctx.waitVirtual(700);
|
||||
continue;
|
||||
}
|
||||
ctx.note('下滑,跳过教学');
|
||||
// ② 教学:乱试探几下(教学不可跳过,下滑只会拿到无效回执),然后跟做
|
||||
await ctx.waitCond(() => game.state === 'BREW' && game._drill && game._drill.step === 0
|
||||
&& game.floor.state === 'YOUR_TURN');
|
||||
for (let i = 0; i < 5 && game._drill && game._drill.step === 0; i++) {
|
||||
ctx.status('教学中乱按——旁白不理我,还在等我跟做');
|
||||
await ctx.gesture(['doubletap', 'down', 'left', 'tap', 'right'][i]);
|
||||
await ctx.waitVirtual(900);
|
||||
}
|
||||
ctx.status('教学跳不过去,只能照做——一次一个动作');
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
if (game._drill && game._drill.step === 0) {
|
||||
await ctx.gesture('right'); // D0
|
||||
await ctx.waitCond(() => game._drill && game._drill.step === 1 && game.floor.state === 'YOUR_TURN');
|
||||
}
|
||||
if (game._drill && game._drill.step === 1) {
|
||||
// 心急提前点/乱滑几下也无妨(D1 只认单击选定)
|
||||
await ctx.gesture(rng() < 0.5 ? 'left' : 'right');
|
||||
await ctx.waitVirtual(2000);
|
||||
await ctx.gesture('tap'); // D1
|
||||
}
|
||||
await ctx.waitCond(() => game._drill && game._drill.step === 2);
|
||||
// ③ 心急提前提壶:窗外单击 → 真话归因 + 重教,再来一次点对
|
||||
ctx.note('水还没响透就点——听归因怎么说');
|
||||
await ctx.waitVirtual(1200);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||||
if (game._drill && game._drill.step === 2 && game._brew && game._brew.water === null) {
|
||||
ctx.status('偏早被真话归因了:这一步偏了,水声重起——听密了再提');
|
||||
await ctx.waitVirtual(3600);
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
await ctx.waitCond(() => game._drill && (game._drill.step === 3 || game._drill.step === 4));
|
||||
if (game._drill && game._drill.step === 3) {
|
||||
await ctx.waitVirtual(6600);
|
||||
ctx.note('滴答密了——单击,出汤');
|
||||
await ctx.gesture('tap'); // D3
|
||||
}
|
||||
await ctx.waitCond(() => game._drill && game._drill.step === 4 && game.floor.state === 'YOUR_TURN');
|
||||
ctx.note('单击,端出去');
|
||||
await ctx.gesture('tap'); // D4
|
||||
await ctx.waitCond(() => game._drill === null || (game._drill && game._drill.step === 5));
|
||||
if (game._drill && game._drill.step === 5) {
|
||||
ctx.note('上滑,把状态链听完');
|
||||
await ctx.gesture('up'); // D5
|
||||
await ctx.waitCond(() => game._drill === null);
|
||||
}
|
||||
ctx.status('乱按也被带着走完了教学——核心动词始终可达');
|
||||
|
||||
// ④ 自由营业第一位:手痒开菜单乱逛(只敢下滑关掉),照客人要的泡
|
||||
await ctx.waitCond(() => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill
|
||||
&& game.floor.state === 'YOUR_TURN');
|
||||
if (rng() < 0.8) {
|
||||
ctx.note('手痒开菜单乱逛——只敢下滑关掉');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitVirtual(12000); // F2:听完 tut_skip → sys_help 兜底链再动手
|
||||
await ctx.waitVirtual(2500);
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(2200);
|
||||
await ctx.gesture('down'); // 关菜单回灶前
|
||||
await ctx.waitVirtual(800);
|
||||
}
|
||||
await quiet(ctx, mock, game, 20000);
|
||||
ctx.status('跳过教学,自由营业(可能一路错过来的)');
|
||||
|
||||
// ③ 灶前乱按 150s(零进展——被无进展轨三级抢救:earcon → 口诀 → 重播点单)
|
||||
await waitStep(ctx, mock, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill, 90000);
|
||||
const t0 = mock.now;
|
||||
let ri = 0;
|
||||
while (game.state === 'BREW' && mock.now - t0 < 150000) {
|
||||
const gg = ['left', 'right', 'tap', 'doubletap', 'left', 'right'][ri++ % 6];
|
||||
if (gg === 'tap' && game._brew && game._brew.sub !== 'TEA') {
|
||||
await ctx.gesture('doubletap'); // 时机步:乱双击也算出手
|
||||
} else {
|
||||
await ctx.gesture(gg);
|
||||
}
|
||||
if (rng() < 0.10) {
|
||||
ctx.note('手痒开菜单乱逛——只敢下滑关掉');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitVirtual(2500);
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitVirtual(800);
|
||||
continue;
|
||||
}
|
||||
await ctx.waitVirtual(ri % 6 === 5 ? 4200 : 1300);
|
||||
const want = game._guest ? game._guest.tea : 'longjing';
|
||||
const shelf = game._shelf();
|
||||
let guard = 0;
|
||||
while (game._brew && game._brew.sub === 'TEA' && shelf[game._brew.teaIdx] !== want && guard++ < 10) {
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(2500);
|
||||
}
|
||||
ctx.status('乱按也被抢救:听到了口诀和点单句重播——核心动词始终可达');
|
||||
|
||||
// ④ 顺着提示认真泡完当前这盏(零死锁底线)
|
||||
if (game.state === 'BREW' && game._brew) {
|
||||
const want = game._guest ? game._guest.tea : 'longjing';
|
||||
const shelf = game._shelf();
|
||||
let guard = 0;
|
||||
while (game._brew && game._brew.sub === 'TEA' && shelf[game._brew.teaIdx] !== want && guard++ < 10) {
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(2500);
|
||||
}
|
||||
if (game._brew && game._brew.sub === 'TEA') {
|
||||
ctx.note(`不折腾了,照客人要的${TEA_NAME[want]}泡`);
|
||||
await ctx.gesture('tap');
|
||||
await waitStep(ctx, mock, () => game._brew && game._brew.sub !== 'TEA', 20000);
|
||||
}
|
||||
if (game._brew && game._brew.sub === 'WATER') {
|
||||
await ctx.waitVirtual((want === 'longjing' || want === 'yinzhen') ? 4200 : 6800);
|
||||
await ctx.gesture('doubletap');
|
||||
await waitStep(ctx, mock, () => game._brew && game._brew.sub === 'STEEP', 20000);
|
||||
}
|
||||
if (game._brew && game._brew.sub === 'STEEP' && game._brew.steep === null) {
|
||||
await ctx.waitVirtual(6500);
|
||||
await ctx.gesture('doubletap');
|
||||
}
|
||||
await quiet(ctx, mock, game, 90000);
|
||||
ctx.status(`这盏泡${game.run && game.run.results[game.run.results.length - 1] === 'good' ? '得地道' : '过去了'}——故事照样走`);
|
||||
if (game._brew && game._brew.sub === 'TEA') {
|
||||
ctx.note(`不折腾了,照客人要的${TEA_NAME[want]}泡`);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game._brew && game._brew.sub !== 'TEA');
|
||||
}
|
||||
if (game._brew && game._brew.sub === 'WATER') {
|
||||
// 时机窗内乱滑不判错步(分层铁律:只有回执声,水声照走)——滑两下再提
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitVirtual(400);
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual((want === 'longjing' || want === 'yinzhen') ? 4200 : 6800);
|
||||
ctx.note('水声对了——单击提壶');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game._brew && game._brew.sub === 'STEEP');
|
||||
}
|
||||
if (game._brew && game._brew.sub === 'STEEP' && game._brew.steep === null) {
|
||||
const steepAt = { longjing: 7000, moli: 7000, yinzhen: 11000, tieguanyin: 11000, dianhong: 15000, puer: 15000 }[want] || 7000;
|
||||
await ctx.waitVirtual(steepAt - 800);
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
await ctx.waitCond(() => !game.audio.isVoicePlaying() && !game.floor.speaking);
|
||||
ctx.status(`这盏泡${game.run && game.run.results[game.run.results.length - 1] === 'good' ? '得地道' : '过去了'}——故事照样走`);
|
||||
|
||||
// ⑤ 后续客人:全部尽快端茶(水闷听天由命),走到打烊
|
||||
let sg = 0;
|
||||
while (game.state !== 'CLOSE' && sg++ < 4) {
|
||||
await waitStep(ctx, mock, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill, 90000);
|
||||
// 最后一位泡完会直接进 CLOSE:等待条件必须带上这个出口
|
||||
await ctx.waitCond(() => (game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill)
|
||||
|| game.state === 'CLOSE');
|
||||
if (game.state !== 'BREW') break;
|
||||
const want = game._guest ? game._guest.tea : 'longjing';
|
||||
const shelf = game._shelf();
|
||||
let guard = 0;
|
||||
while (game._brew && game._brew.sub === 'TEA' && shelf[game._brew.teaIdx] !== want && guard++ < 10) {
|
||||
const w2 = game._guest ? game._guest.tea : 'longjing';
|
||||
const sh2 = game._shelf();
|
||||
let g2 = 0;
|
||||
while (game._brew && game._brew.sub === 'TEA' && sh2[game._brew.teaIdx] !== w2 && g2++ < 10) {
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(2200);
|
||||
}
|
||||
await ctx.gesture('tap');
|
||||
await waitStep(ctx, mock, () => game._brew && game._brew.sub === 'WATER', 20000);
|
||||
await ctx.waitVirtual((want === 'longjing' || want === 'yinzhen') ? 4200 : 6800);
|
||||
await ctx.gesture('doubletap');
|
||||
await waitStep(ctx, mock, () => game._brew && game._brew.sub === 'STEEP', 20000);
|
||||
await ctx.waitCond(() => game._brew && game._brew.sub === 'WATER');
|
||||
await ctx.waitVirtual((w2 === 'longjing' || w2 === 'yinzhen') ? 4200 : 6800);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => game._brew && game._brew.sub === 'STEEP');
|
||||
await ctx.waitVirtual(6500);
|
||||
await ctx.gesture('doubletap');
|
||||
await quiet(ctx, mock, game, 90000);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitCond(() => !game.audio.isVoicePlaying() && !game.floor.speaking);
|
||||
}
|
||||
|
||||
// ⑥ 打烊:死窗+链锁内乱按(全被吞),链完单击睡下
|
||||
await waitStep(ctx, mock, () => game.state === 'CLOSE' && game._closeChainActive, 90000);
|
||||
ctx.status('打烊链在播——乱按一气(应该全被吞掉)');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await ctx.gesture(['tap', 'down', 'left', 'doubletap'][i % 4]);
|
||||
// ⑥ 打烊:结算链播报中乱按(链锁全吞),链完单击睡下
|
||||
await ctx.waitCond(() => game.state === 'CLOSE');
|
||||
ctx.status('打烊链在播——乱按一气(链锁内应该全被吞掉)');
|
||||
// 只在链播中乱按(closeDone 即停:等待态里 tap/doubletap 会真的睡下)
|
||||
for (let i = 0; i < 5 && game._closeChainActive; i++) {
|
||||
await ctx.gesture(['left', 'up', 'down', 'tap', 'right'][i % 5]);
|
||||
await ctx.waitVirtual(500);
|
||||
}
|
||||
await waitStep(ctx, mock, () => game._closeDone, 120000);
|
||||
await ctx.waitVirtual(6000);
|
||||
await ctx.gesture('tap'); // 睡下
|
||||
await waitStep(ctx, mock, () => game.state === 'OPENING', 30000);
|
||||
await ctx.waitCond(() => game._closeDone);
|
||||
await ctx.waitCond(() => atWait(game)); // settle_night 落声 + 睡下等待点武装
|
||||
if (game.state === 'CLOSE') {
|
||||
ctx.note('单击,睡下');
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
await ctx.waitCond(() => game.state === 'OPENING');
|
||||
ctx.status('乱按一路也睡下了:结算链完整、零死锁,压力测试收工');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user