feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付: - cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档 - teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0 - keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4 - bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行 公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、 gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00) 验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿 遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 《星环舰长》新手会话剧本。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位新舰长的首次巡逻全弧线——标题开始 → DRILL D0..D4 跟练
|
||||
* (巡检/听检测链/预览+驰援下令/再巡检)→ S1 收尾进 DOCK → S2 航线选择+陨石迎战
|
||||
* → DOCK 修理磨损 → S3 海盗迎战(支援演出)→ S4 辐射规避 → 结算链 → 单击返航。
|
||||
* - `sessionStress`(乱按压力):标题乱划、教学跳过、巡航乱按乱滑(被看门狗抢救)、
|
||||
* 菜单乱逛只敢下滑关、危机全部超时走默认结局、DOCK 挂着不操作吃无进展三级提示——
|
||||
* 一路走到结算(或弃舰),全程零死锁。
|
||||
*
|
||||
* 剧本失真红线(参照 detective/dungeon 剧本先例):
|
||||
* - 玩家不抢话:出手前等语音链/判定链收尾(waitStep 有界等待状态谓词);
|
||||
* - 玩家靠耳朵玩:危机前先听检测链,下令前左右滑把选项过完耳朵;
|
||||
* - 危机倒计时是真的:决策窗内动作紧凑,宁可超时也不抢检测链;
|
||||
* - 菜单里只「下滑关闭」,绝不双击执行(防误触「保存并回大厅」结束会话)。
|
||||
*/
|
||||
|
||||
const DIR_LABEL = { port: '左舷', star: '右舷', bow: '舰首', stern: '舰尾' };
|
||||
const SYS_LABEL = { engine: '引擎', shield: '护盾', scan: '扫描', weapon: '武器' };
|
||||
|
||||
export function describe(game) {
|
||||
if (game._menu.open) return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭';
|
||||
if (game._tourState) return '巡检链播报中(左右滑无效,单击跳段,下滑菜单)';
|
||||
switch (game.state) {
|
||||
case 'TITLE': return game.run
|
||||
? '检测到未完成的巡逻:单击继续,右滑进星港,上滑听舰船概况'
|
||||
: '标题页:单击开始新巡逻,右滑进星港,上滑听概况';
|
||||
case 'PORT': return '星港里:左右滑看改装,单击购买,末项返回';
|
||||
case 'ROUTE': return game._route.idx === 0
|
||||
? '选航线:稳妥线(采集平平)。右滑听矿富线,单击选定'
|
||||
: '选航线:矿富线(采得多磨损也多)。左滑回稳妥线,单击选定';
|
||||
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(' / ')}——左右滑看项目,单击执行`;
|
||||
case 'REPORT': return game._report && !game._report.done
|
||||
? '结算链播报中,手势全被吞(链锁)'
|
||||
: '结算完了:单击返航,上滑重听评级';
|
||||
default: return '……';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 会话工具
|
||||
|
||||
/** 有界等待:条件满足或超时即返回。 */
|
||||
async function waitStep(ctx, mock, cond, maxMs = 30000) {
|
||||
const t0 = mock.now;
|
||||
let spins = 0;
|
||||
const spinCap = Math.max(60, Math.floor(maxMs / 50));
|
||||
while (!cond() && mock.now - t0 < maxMs && spins < spinCap) {
|
||||
spins++;
|
||||
await ctx.waitVirtual(100);
|
||||
}
|
||||
}
|
||||
|
||||
/** 等当前语音链/计时空闲(有界)。 */
|
||||
async function waitQuiet(ctx, mock, game, maxMs = 60000) {
|
||||
await waitStep(ctx, mock, () => !game.audio?.isVoicePlaying?.() && !game._tourState, maxMs);
|
||||
}
|
||||
|
||||
/** 在决策窗内把选项循环到目标下标(每步听一句)。 */
|
||||
async function navOpt(ctx, game, targetIdx, nOpts) {
|
||||
let guard = 0;
|
||||
while (game._crisis && game._crisis.optIdx !== targetIdx && guard++ < 6) {
|
||||
const d = (targetIdx - game._crisis.optIdx + nOpts) % nOpts;
|
||||
await ctx.gesture(d * 2 <= nOpts ? 'right' : 'left');
|
||||
await ctx.waitVirtual(1400);
|
||||
}
|
||||
}
|
||||
|
||||
/** 危机决策:等检测链落决策窗 → 选项过耳朵 → 单击下令。 */
|
||||
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;
|
||||
await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000);
|
||||
await ctx.waitVirtual(1500); // 首选项预览落声
|
||||
await navOpt(ctx, game, targetIdx, nOpts);
|
||||
ctx.note(note);
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
|
||||
/** 一次完整危机:从巡航等检测链到结算链收尾。 */
|
||||
async function rideCrisis(ctx, mock, game, targetIdx, plan) {
|
||||
await waitStep(ctx, mock, () => game.state === 'CRISIS', 150000);
|
||||
const c = game._crisis;
|
||||
ctx.status(`检测链:签名音→「${plan.type}」→方位${DIR_LABEL[c.dir]}${c.trap ? '——医师提醒:求救码后面有引擎声!' : ''}`);
|
||||
await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000);
|
||||
await decide(ctx, mock, game, targetIdx, plan.note);
|
||||
await waitStep(ctx, mock, () => game._crisis === null, 60000);
|
||||
await waitQuiet(ctx, mock, game, 40000);
|
||||
}
|
||||
|
||||
/** 巡航到段末:途中巡检一次,然后等危机。 */
|
||||
async function cruiseAndCrisis(ctx, mock, game, tourNote, plan) {
|
||||
await waitStep(ctx, mock, () => game.state === 'CRUISE', 30000);
|
||||
ctx.note(tourNote);
|
||||
await ctx.gesture('up'); // 巡检
|
||||
await ctx.waitVirtual(20000);
|
||||
await rideCrisis(ctx, mock, game, plan.optIdx, plan);
|
||||
}
|
||||
|
||||
/** DOCK 整备:修一遍能修的,然后启航。 */
|
||||
async function dockAndGo(ctx, mock, game) {
|
||||
await waitStep(ctx, mock, () => game.state === 'DOCK', 60000);
|
||||
ctx.status('靠港整备:左右滑听听项目');
|
||||
const items = game._dock.items;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = game._dock.items[game._dock.idx];
|
||||
if (it.kind !== 'go') {
|
||||
ctx.note(`修理${SYS_LABEL[it.sys]}(升一级,两资源)`);
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(4000);
|
||||
}
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1200);
|
||||
if (game._dock.items[game._dock.idx]?.kind === 'go') break;
|
||||
}
|
||||
ctx.note('启航,下一段巡航');
|
||||
await ctx.gesture('tap');
|
||||
await waitStep(ctx, mock, () => game.state === 'ROUTE' || game.state === 'CRUISE' || game.state === 'REPORT', 20000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主线剧本
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// —— 标题:单击开始首次巡逻 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'TITLE');
|
||||
ctx.status('星环舰长:一条巡逻航线,四个系统各守一方');
|
||||
ctx.note('单击,新巡逻启航');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// —— DRILL D0:等开场链,上滑巡检 ——
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.armed, 40000);
|
||||
ctx.note('上滑——听全舰状态(四个音都稳)');
|
||||
await ctx.gesture('up');
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 1, 60000);
|
||||
ctx.status('巡检学会了:四个系统各 2 秒,顺序固定');
|
||||
|
||||
// —— D1/D2:求救检测链 → 预览 → 下令驰援 ——
|
||||
await rideCrisis(ctx, mock, game, 0, {
|
||||
type: '求救信号', optIdx: 0,
|
||||
note: '驰援靠扫描——我们的扫描稳得很。单击,下令驰援!',
|
||||
});
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 3, 40000);
|
||||
ctx.status('驰援成功:资源+2,医师和大副记下了——听声辨型后下令,就是这样');
|
||||
|
||||
// —— D3:再巡检(听船员三档)→ D4 完成 ——
|
||||
await waitStep(ctx, mock, () => game._drill?.armed, 20000);
|
||||
ctx.note('上滑——再巡检一遍,听听船员档位');
|
||||
await ctx.gesture('up');
|
||||
await waitStep(ctx, mock, () => game.state === 'DOCK', 90000);
|
||||
ctx.status('教学完了,本段结束,靠港整备');
|
||||
await dockAndGo(ctx, mock, game);
|
||||
|
||||
// —— S2 陨石:稳妥线,迎战轰开 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000);
|
||||
ctx.note('稳妥航线,太平——单击选定');
|
||||
await ctx.gesture('tap');
|
||||
await cruiseAndCrisis(ctx, mock, game, '上滑巡检:磨损的扫描在左舷发失锁双哔', {
|
||||
type: '陨石群', optIdx: 1, note: '迎战,靠武器——单击,轰开它',
|
||||
});
|
||||
await dockAndGo(ctx, mock, game);
|
||||
|
||||
// —— 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);
|
||||
await ctx.gesture('tap');
|
||||
await cruiseAndCrisis(ctx, mock, game, '上滑巡检:舰体带伤没有?资源还够用', {
|
||||
type: '海盗', optIdx: 0, note: '敌船引擎声,右舷来的——迎战!单击',
|
||||
});
|
||||
await dockAndGo(ctx, mock, game);
|
||||
|
||||
// —— S4 辐射:规避 → 结算 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000);
|
||||
ctx.note('末段了。矿富线搏一把——单击选定');
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1500);
|
||||
await ctx.gesture('tap');
|
||||
await cruiseAndCrisis(ctx, mock, game, '上滑巡检:倒计时最紧的一段,听清楚再动', {
|
||||
type: '辐射云', optIdx: 0, note: '计数急响——规避机动,靠引擎。单击!',
|
||||
});
|
||||
|
||||
// —— 结算链 → 单击返航 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'REPORT', 90000);
|
||||
ctx.status('巡逻归来:评级与功勋在播,手势先别动');
|
||||
await waitStep(ctx, mock, () => game._report && game._report.done, 60000);
|
||||
ctx.status(`评级 ${game._report.rate}——星港期待下一次巡逻`);
|
||||
ctx.note('单击,返航');
|
||||
await ctx.gesture('tap');
|
||||
await waitStep(ctx, mock, () => game.state === 'TITLE', 10000);
|
||||
ctx.status('回到标题页:首次巡逻闭环');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 乱按压力剧本
|
||||
|
||||
export async function sessionStress(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
let routeN = 0;
|
||||
let seed = 20260914;
|
||||
const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||||
|
||||
// ① 标题页乱划,然后开始
|
||||
for (let i = 0; i < 8 && game.state === 'TITLE'; i++) {
|
||||
ctx.status('标题页乱划一气');
|
||||
await ctx.gesture(rng() < 0.5 ? 'left' : 'doubletap');
|
||||
await ctx.waitVirtual(450);
|
||||
}
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// ② 教学:乱按几下(绝不带 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; }
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
// ④ S2 起:每个危机都干等超时,乱滑几下测试静默保护
|
||||
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);
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
if (game.state === 'ROUTE') {
|
||||
// 确定性交替:一半机率选定(rng 序列可能长期不出 tap,烧光虚拟预算)
|
||||
routeN = (routeN + 1) % 2;
|
||||
await ctx.gesture(routeN === 0 ? 'tap' : 'swiperight');
|
||||
await ctx.waitVirtual(1200);
|
||||
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)]);
|
||||
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},全程零死锁`);
|
||||
await ctx.gesture('tap');
|
||||
await waitStep(ctx, mock, () => game.state === 'TITLE', 10000);
|
||||
} else {
|
||||
ctx.status(`乱按局超出虚拟时长上限(${Math.round(mock.now / 60000)} 分钟),当前状态 ${game.state}——如实记录收尾`);
|
||||
}
|
||||
ctx.status('压力测试收工');
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 《暗码》新手会话剧本。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位夜班报务员的完整弧线——听完开场白与规矩,逐封截获
|
||||
* 四封密电(听摩尔斯 → 切候选听词义 → 双击重听/慢放 → 两段确认定词),
|
||||
* 处置抉择(p2 报北风 / p4 呈报 / p6 留书 / p8 装傻 / p9 发最后一电),
|
||||
* 听完尾声逐封点名,以完整 E1「最后一电」收尾。
|
||||
* - `sessionStress`(乱按压力):好奇宝宝乱划乱点、菜单里乱逛、死命翻密码本,
|
||||
* 一样要能走到 ENDING,全程零死锁、零静默死局。
|
||||
*
|
||||
* 剧本失真红线(参照 radio/abyss 剧本先例):
|
||||
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
|
||||
* - 玩家不干晾自己:只在浏览/链末等待态偶尔走神触发一次看门狗;
|
||||
* - RX/NIGHT 有自动链与定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
|
||||
* - DECODE 里每步都是真输入:切候选、重听、慢放、确认,像真人一样先听后按;
|
||||
* - RELAY 叙事链是「链末=继续」等待态:推进靠单击(像 tapUntil 一样有界循环),
|
||||
* 抉择则等提示句播完武装后再滑(盲玩公平,不抢话)。
|
||||
*/
|
||||
|
||||
const TEL_LABEL = ['一', '二', '三', '四'];
|
||||
|
||||
export function describe(game) {
|
||||
if (game.leaving) return '已确认保存并回大厅';
|
||||
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑新夜班」的询问';
|
||||
if (game.phase === 'MENU') {
|
||||
const names = ['回到电波', '翻密码本', '重新开始今晚', '保存并回大厅'];
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
|
||||
}
|
||||
if (game.phase === 'INTRO') {
|
||||
return game.voicePlaying ? '开场白正在播(今晚的目标和处长的夜令)' : '开场白播完了,等第一封电文';
|
||||
}
|
||||
if (game.phase === 'RX') {
|
||||
return game._rxGuard
|
||||
? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳过`
|
||||
: `第 ${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.choiceArmed) {
|
||||
return '抉择念完了:向左或向右滑动做出你的决定';
|
||||
}
|
||||
if (game.phase === 'RELAY') {
|
||||
return game.voicePlaying ? '处置叙事中,正在听' : '说完了,等我接话:单击继续';
|
||||
}
|
||||
if (game.phase === 'NIGHT') return '夜结算中,挂钟和雨声,马上来下一封';
|
||||
if (game.phase === 'ENDING') {
|
||||
return game.awaitingRestart
|
||||
? '结局和尾声点名都听完了。单击再来一局,下滑开菜单'
|
||||
: '结局播报中';
|
||||
}
|
||||
return '……';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 有界推进助手
|
||||
|
||||
/** 等当前语音链播完(有界等待,绝不裸空转)。 */
|
||||
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 (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.gesture('tap'); // 链末 = 继续
|
||||
await ctx.waitCond(() => game.choiceArmed || game.voicePlaying || game.phase !== 'RELAY');
|
||||
}
|
||||
throw new Error(`untilChoice 超步限(${game.phase}/${game.nodeId})`);
|
||||
}
|
||||
|
||||
/** 从任意叙事态单击推进到 DECODE 浏览武装(穿越 RX/NIGHT 自动链与 RELAY 等待段)。 */
|
||||
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 遇未应答抉择');
|
||||
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 被吞,等真实推进
|
||||
}
|
||||
throw new Error(`toDecode 超步限(${game.phase}/${game.nodeId})`);
|
||||
}
|
||||
|
||||
/** 从 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');
|
||||
}
|
||||
throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主线剧本
|
||||
|
||||
/** DECODE 小局:慢放听清 → 切候选听词义 → 选 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;
|
||||
for (let i = 0; i < delta; i++) {
|
||||
ctx.note('左右滑,听密码本上的候选词');
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
}
|
||||
ctx.note('就这个词——单击选定');
|
||||
await ctx.gesture('tap'); // 进确认
|
||||
await waitVoice(ctx);
|
||||
ctx.note('再单击,定下来');
|
||||
await ctx.gesture('tap'); // 定下判定
|
||||
await waitVoice(ctx);
|
||||
}
|
||||
|
||||
/** 等抉择武装并选择 dir(盲玩公平:提示句播完才滑)。 */
|
||||
async function relayChoice(ctx, dir, why) {
|
||||
const { game } = ctx;
|
||||
await untilChoice(ctx);
|
||||
ctx.note(why);
|
||||
await ctx.gesture(dir);
|
||||
await waitVoice(ctx);
|
||||
}
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 开场:INTRO 自动流(夜班开场 + 规矩 + 目标 + 处长夜令)→ RX(1) → DECODE
|
||||
await ctx.waitCond(() => game.phase === 'RX');
|
||||
ctx.status('开场白播完了,第一封电文进频道了');
|
||||
ctx.note('电文滴答声进来了,这是摩尔斯,首播不可跳,听完它');
|
||||
await toDecode(ctx);
|
||||
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('第一封处置完了,夜里结算');
|
||||
|
||||
// T2 名单(TOP)
|
||||
await toDecode(ctx);
|
||||
ctx.status('第二封电文:内部通报,手法很急');
|
||||
await decodeWord(ctx, 0, '黑名单。头一个代号是码头——我的交通员');
|
||||
await relayChoice(ctx, 'right', '名单按流程呈报处长——码头,自己多保重');
|
||||
ctx.status('第二封处置完了');
|
||||
|
||||
// T3 交接(HAND)
|
||||
await toDecode(ctx);
|
||||
ctx.status('第三封电文:很短,手法有点抖');
|
||||
await decodeWord(ctx, 0, '今夜交接,丑时老码头——H 和 S 只差一个滴答,幸好放慢了');
|
||||
await relayChoice(ctx, 'right', '留书暗号,让雨替我跑这一趟');
|
||||
ctx.status('第三封处置完了');
|
||||
|
||||
// T4 处置令(SPY · 高潮)
|
||||
await toDecode(ctx);
|
||||
ctx.status('第四封电文:快手七的手法——门外的脚步声都近了');
|
||||
await decodeWord(ctx, 0, '内线确认,今夜三时收网,代号耳机——收网的是我');
|
||||
await relayChoice(ctx, 'right', '手离开放键器。我只是个值机的——什么都不做');
|
||||
ctx.status('处长的试探应付过去了,频道要收了');
|
||||
|
||||
// 签收:g9_in → g9_knot → p9
|
||||
await untilChoice(ctx);
|
||||
await relayChoice(ctx, 'left', '开机,发最后一电——三年前我译错了一封,这次不能错');
|
||||
await toEnding(ctx);
|
||||
ctx.status('结局播完了:尾声把今晚每一封都点了名');
|
||||
ctx.note('单击,收工——但下一班夜班,还想再来');
|
||||
await ctx.gesture('tap');
|
||||
await waitVoice(ctx);
|
||||
ctx.status('新的夜班开始了(再来一局)');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 乱按压力剧本
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 两段确认中途取消再重选
|
||||
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('第一封处置完,压力测试过了第一道');
|
||||
|
||||
// 剩下三封:正常打但保留一次乱按(每封浏览期乱滑 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);
|
||||
}
|
||||
await decodeWord(ctx, picks[tel - 2], `第 ${tel} 封:慢放听清再定`);
|
||||
await relayChoice(ctx, dirs[tel - 2], ['报北风/呈报/留书'][0] || '处置掉');
|
||||
}
|
||||
// 签收 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);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 《守塔人》新手会话剧本。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位守塔人的完整风暴夜——听完开场(塔名/规矩/目标)、
|
||||
* 三个幕内接满电台抉择(全走心线:接住人→翻暗格→口诀→真话→堵漏→读完缺页
|
||||
* →出海接人→循渔歌→说出真相),三段回忆插叙全听,以完整 E1「灯与归人」收尾。
|
||||
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
|
||||
* 一样要能走到 ENDING,全程零死锁、零静默死局。
|
||||
*
|
||||
* 剧本失真红线(参照 radio/abyss 剧本先例):
|
||||
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
|
||||
* - 玩家不干晾自己:只在 WATCH 链末等待态偶尔走神触发一次看门狗,链一停就接话;
|
||||
* - CALM 幕间有喘息定时器、MEMORY 有进入拍定时器,禁止裸 waitIdle 空等,
|
||||
* 必须 waitCond 有界等待;
|
||||
* - 叙事链每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
|
||||
*/
|
||||
|
||||
const ACT_LABEL = ['一', '二', '三'];
|
||||
|
||||
export function describe(game) {
|
||||
if (game.leaving) return '已确认保存并回大厅';
|
||||
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑开始新的风暴夜」的询问';
|
||||
if (game.phase === 'MENU') {
|
||||
const names = ['回到值守', '重新开始今夜', '保存并回大厅'];
|
||||
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
|
||||
}
|
||||
if (game.phase === 'INTRO') {
|
||||
return game.voicePlaying ? '塔名、规矩和今晚目标正在播' : '开场白播完了,等我单击开始值守';
|
||||
}
|
||||
if (game.phase === 'MEMORY') {
|
||||
const era = game.timeline === 'mem_f' ? '父亲的最后一晚(十二年前)' : '祖父的年代(四十年前)';
|
||||
return game.voicePlaying
|
||||
? `回忆插叙中(${era}):唱针落下,老唱片底噪,听完自动回到现在`
|
||||
: `回忆插叙的静默拍(${era})`;
|
||||
}
|
||||
if (game.phase === 'CALM') {
|
||||
return `第${ACT_LABEL[(game.act || 1) - 1]}幕结束,幕间喘息:雾角 + 岸台通报${game.voicePlaying ? '正在播' : ',等我单击或自动进下一幕'}`;
|
||||
}
|
||||
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.phase === 'ENDING') {
|
||||
return game.awaitingRestart
|
||||
? '结局和尾声回响都听完了。单击再来一夜,下滑开菜单'
|
||||
: '结局播报中(岸台记一笔:这一晚后来怎样了)';
|
||||
}
|
||||
return '……';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主线剧本
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 开场:INTRO 自动流(塔名 + 规矩 + 目标)→ 链末单击 → C1
|
||||
await ctx.waitCond(() => game.phase === 'WATCH' || game.choiceArmed);
|
||||
ctx.status('开场口令和塔名播完了,等我单击开始值守');
|
||||
ctx.note('雾角两长一短——塔在,灯在。单击,接过这一夜');
|
||||
await ctx.gesture('tap');
|
||||
ctx.status('进入第一幕,电台里有动静');
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('岸台说频道里有动静,像隔着整片海');
|
||||
|
||||
let invalidTried = false;
|
||||
let menuDone = false;
|
||||
let walkedAway = false;
|
||||
let helpDone = false;
|
||||
let choiceIdx = 0;
|
||||
|
||||
// 抉择路径:全走心线(接住人/翻暗格/口诀/真话/堵漏/读完/出海/循渔歌/说出 → 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: '他问起四十年前的灯……把缺页的事,连根说给他' },
|
||||
];
|
||||
|
||||
for (let i = 0; i < 300 && !game.leaving; i++) {
|
||||
// 只等当前语音链播完或抉择武装;CALM/MEMORY 有定时器,绝不用裸 waitIdle
|
||||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
||||
|
||||
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++;
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'ENDING') {
|
||||
await ctx.waitCond(() => game.awaitingRestart);
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'CALM') {
|
||||
ctx.status(`第${ACT_LABEL[(game.act || 1) - 1]}幕过去,雾角报平安,岸台通报风暴进程`);
|
||||
ctx.note('幕间喘口气,听风往哪走');
|
||||
await ctx.waitVirtual(2600);
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'MEMORY') {
|
||||
ctx.note('唱针落下了……这是回忆,别慌,听完风声就回来');
|
||||
await ctx.waitCond(() => game.phase !== 'MEMORY');
|
||||
ctx.status('风声回来了,回到现在');
|
||||
continue;
|
||||
}
|
||||
// WATCH 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
|
||||
// 注意不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual
|
||||
// 会一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
|
||||
if (!walkedAway && game.phase === 'WATCH' && !game.choiceArmed) {
|
||||
walkedAway = true;
|
||||
ctx.silence(25, '听到一半走了神,在想父亲那支手电');
|
||||
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
|
||||
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 灯与归人),会话结束');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 乱按压力剧本
|
||||
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
|
||||
// 剧本特征:不读提示、快速跳句、菜单里乱逛、错手势试探,但仍保留「最终能通关」的底线。
|
||||
|
||||
export async function sessionStress(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
let seed = 20260914;
|
||||
const rng = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
const gestures = ['tap', 'left', 'right', 'up', 'down', 'doubletap'];
|
||||
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
|
||||
|
||||
let menuOpenCount = 0;
|
||||
let choiceCount = 0;
|
||||
let answered = 0;
|
||||
let noiseCount = 0;
|
||||
let memCount = 0;
|
||||
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
||||
|
||||
if (game.leaving) break;
|
||||
if (game.phase === 'MENU') {
|
||||
menuOpenCount++;
|
||||
await ctx.gesture('tap'); // 乱切换
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」)
|
||||
continue;
|
||||
}
|
||||
if (game.choiceArmed) {
|
||||
choiceCount++;
|
||||
if (rng() < 0.35) {
|
||||
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势
|
||||
noiseCount++;
|
||||
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
|
||||
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
|
||||
}
|
||||
await ctx.gesture(pick(['left', 'right']));
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'CALM') {
|
||||
await ctx.waitVirtual(2600);
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 'MEMORY') {
|
||||
memCount++;
|
||||
if (rng() < 0.4) {
|
||||
// 回忆里也手痒:双击重听当前回忆节点 / 下滑开菜单再关
|
||||
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.waitCond(() => game.phase !== 'MEMORY');
|
||||
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('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 《深夜茶馆》新手会话剧本。
|
||||
*
|
||||
* 剧本:
|
||||
* - `session`(主线):一位新掌柜的完整首晚弧线——标题开张 → 教学七步跟练
|
||||
* (看茶柜/选定/提壶/出汤/端茶/状态)→ 自由营业三位客人(邮差/苏晚/陈拾,
|
||||
* 各泡一盏)→ 打烊对账 → 单击睡下 → 第二晚开场收束。
|
||||
* - `sessionStress`(乱按压力):一位手忙脚乱的新掌柜——标题乱划、教学下滑跳过、
|
||||
* 灶前乱按乱切茶罐(零进展被看门狗三级抢救)、菜单乱逛只下滑关闭、乱手势
|
||||
* 不误判错步——照样泡完一盏茶走到打烊,全程零死锁。
|
||||
*
|
||||
* 剧本失真红线(detective 剧本先例):
|
||||
* - 玩家不抢话:出手前等语音链收尾(waitStep 有界等待状态谓词);
|
||||
* - 玩家靠耳朵玩:候汤听泡、闷泡听滴答,时机手势跟着声音走;
|
||||
* - 营业态常驻自动推进:禁止裸 waitIdle 空等,必须 waitStep/waitVirtual;
|
||||
* - 菜单里只「下滑关闭」,绝不双击执行(防误触「保存并回大厅」结束会话)。
|
||||
*/
|
||||
|
||||
const TEA_NAME = {
|
||||
longjing: '龙井', moli: '茉莉花茶', tieguanyin: '铁观音',
|
||||
dianhong: '滇红', puer: '普洱', yinzhen: '白毫银针',
|
||||
};
|
||||
|
||||
export function describe(game) {
|
||||
if (game._menuOpen) {
|
||||
if (game._shopOpen) return '打烊置办里:左右滑看项目,单击买定,下滑返回';
|
||||
return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭';
|
||||
}
|
||||
switch (game.state) {
|
||||
case 'TITLE': return game.run
|
||||
? '检测到没打烊的茶馆:单击继续,右滑两次重开新店'
|
||||
: '标题页:单击开店,上滑听店铺概况';
|
||||
case 'OPENING': return '开晚场:点灯与晚数句播报中(单击可跳句)';
|
||||
case 'SERVE': {
|
||||
if (game._chain) 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 '闷泡:听滴答,滴答密起来时双击出汤';
|
||||
}
|
||||
case 'CLOSE': return game._closeChainActive
|
||||
? '打烊结算链播报中(账目/房租/章节),手势全部被吞'
|
||||
: '打烊:账报完了,单击睡下,下滑可打烊置办';
|
||||
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);
|
||||
const shelf = game._shelf();
|
||||
let guard = 0;
|
||||
while (game._brew && shelf[game._brew.teaIdx] !== tea && guard++ < 10) {
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(2500); // 茶罐名落声
|
||||
}
|
||||
ctx.note(`茶柜上选定${TEA_NAME[tea]}——单击`);
|
||||
await ctx.gesture('tap');
|
||||
await waitStep(ctx, mock, () => game._brew && game._brew.sub === 'WATER', 20000);
|
||||
// 候汤:将沸茶 ~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);
|
||||
// 闷泡:快 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); // 判定链+反应链收尾
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主线剧本
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// —— 标题:单击开店(首局无存档) ——
|
||||
await waitStep(ctx, mock, () => game.state === 'TITLE', 20000);
|
||||
ctx.status('深夜茶馆:巷子最深处,一盏亮到后半夜的灯');
|
||||
ctx.note('单击,开店');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// —— 开场 + 教学 DRILL 七步 ——
|
||||
await waitStep(ctx, mock, () => game.state === 'BREW' && game._drill && game._drill.step === 0, 90000);
|
||||
ctx.status('头一晚。开场三句讲清了:泡茶三步,耳朵说了算');
|
||||
await quiet(ctx, mock, game, 30000);
|
||||
ctx.note('右滑——先看茶柜,一罐一罐听');
|
||||
await ctx.gesture('right'); // D0
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 1, 15000);
|
||||
await ctx.waitVirtual(6000);
|
||||
ctx.note('单击,选定这罐龙井');
|
||||
await ctx.gesture('tap'); // D1 → 自动坐水
|
||||
await waitStep(ctx, mock, () => game._drill && game._drill.step === 2, 15000);
|
||||
await ctx.waitVirtual(8000); // tut_water 听完
|
||||
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.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
|
||||
ctx.note('上滑——听听今晚的局面');
|
||||
await ctx.gesture('up'); // D5
|
||||
await waitStep(ctx, mock, () => game._drill === null, 60000);
|
||||
ctx.status('都会了。教学完成,往后每一晚都这么泡');
|
||||
|
||||
// —— 自由营业:邮差(茉莉)/ 苏晚(滇红)/ 陈拾(茉莉) ——
|
||||
await brewTea(ctx, mock, game, 'moli', '邮差要滚烫的茉莉——双击提壶');
|
||||
ctx.status(`邮差喝得很慢,多搁下两文。进账 ${game.run ? game.run.incomeTonight : '—'} 文`);
|
||||
await brewTea(ctx, mock, game, 'dianhong', '苏晚要暖的滇红——滚透再冲');
|
||||
ctx.status(`苏晚说像被窝里塞了个汤婆子。进账 ${game.run ? game.run.incomeTonight : '—'} 文`);
|
||||
await brewTea(ctx, mock, 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 落声
|
||||
ctx.note('上滑,再听一遍账目');
|
||||
await ctx.gesture('up');
|
||||
await quiet(ctx, mock, game, 30000);
|
||||
ctx.note('单击,睡下');
|
||||
await ctx.gesture('tap'); // 睡下 → N2
|
||||
await waitStep(ctx, mock, () => game.state === 'OPENING', 20000);
|
||||
ctx.status('第二晚。老客们已经认得这条巷子了——今晚的营业收工');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 乱按压力剧本
|
||||
|
||||
export async function sessionStress(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
let seed = 20260914;
|
||||
const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||||
|
||||
// ① 标题乱划,然后开店
|
||||
for (let i = 0; i < 6 && game.state === 'TITLE'; i++) {
|
||||
ctx.status('标题页乱划一气');
|
||||
await ctx.gesture(rng() < 0.5 ? 'left' : 'up');
|
||||
await ctx.waitVirtual(600);
|
||||
}
|
||||
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.gesture('down');
|
||||
await ctx.waitVirtual(12000); // F2:听完 tut_skip → sys_help 兜底链再动手
|
||||
}
|
||||
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);
|
||||
}
|
||||
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' ? '得地道' : '过去了'}——故事照样走`);
|
||||
}
|
||||
|
||||
// ⑤ 后续客人:全部尽快端茶(水闷听天由命),走到打烊
|
||||
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);
|
||||
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) {
|
||||
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.waitVirtual(6500);
|
||||
await ctx.gesture('doubletap');
|
||||
await quiet(ctx, mock, game, 90000);
|
||||
}
|
||||
|
||||
// ⑥ 打烊:死窗+链锁内乱按(全被吞),链完单击睡下
|
||||
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.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);
|
||||
ctx.status('乱按一路也睡下了:结算链完整、零死锁,压力测试收工');
|
||||
}
|
||||
Reference in New Issue
Block a user