四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
316 lines
15 KiB
JavaScript
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('压力测试收工');
|
|
}
|