Files
SoundGame/web/harness/sessions/bridge.mjs
T
xsl 4b39ae2e49 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(压缩候选)
2026-09-14 05:48:13 +08:00

316 lines
15 KiB
JavaScript

/**
* 《星环舰长》新手会话剧本。
*
* 剧本:
* - `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('压力测试收工');
}