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 置完成。
1319 lines
66 KiB
JavaScript
1319 lines
66 KiB
JavaScript
/**
|
||
* 《星环舰长》无头试玩测试(26 号重构版)。
|
||
*
|
||
* 玩法断言保留适配(22 号 §12 断言清单 14 组:四象限节奏、危机矩阵全组合、
|
||
* R1-R8 仲裁、忠诚/经济/评级三本账、seed 复现、结构兜底、断点复述、REPORT
|
||
* 死窗、模态菜单、E1/E2、资产齐套);交互层全部改走 26 号输入管道
|
||
* (makeInputPipeline + floor/等待点/迷茫检测,game.floor.bedEnabled=false)。
|
||
*
|
||
* 26 号新增覆盖:
|
||
* - §4.10 首 3 分钟:第一句 critical 指令、3~10s 巡检教学、开场规则念白废除;
|
||
* - §4.3 手势阶梯:D0 提前解锁上滑、双击未解锁折算单击、锁定手势首次解释;
|
||
* - §4.1 发言权:跳句/打断、结算链 critical=吞、链间不掉回「轮到你」;
|
||
* - §4.4/§4.5 等待点三档提示 + 迷茫三级接管(含巡航自接管与 L3 替做);
|
||
* - §6.2 乱点机器人:无声操作=0、被动原句重复=0、打断失败=0、首操作/首成功时限。
|
||
*
|
||
* 以玩家身份模拟:mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟)
|
||
* 与音频引擎接口(播放日志 + voice onComplete/skipVoice 按虚拟时钟推进语音链),
|
||
* 加载真实 game.js,手势经 makeInputPipeline 注入(与 index.html 同构)。
|
||
*
|
||
* 资产契约:引擎对缺失资产不崩——新台词(sys_* / hint_* / inv_*)未落盘时同样
|
||
* 全绿(mock 按固定句长推进,顺序/次数断言与时长无关);资产审计做信息性报告
|
||
* 不判失败,硬格式断言只针对「必须存在」的存量键(场景 14)。
|
||
*
|
||
* 运行:tools/node/bin/node web/bridge/test/playtest.mjs
|
||
* 退出码 0 = 全部断言通过。seed 由 opts 注入复现(§4.3)。
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const BRIDGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
// ================================ 虚拟时钟 ================================
|
||
|
||
let vnow = 0; // 虚拟毫秒
|
||
const timers = new Map();
|
||
let nextTimerId = 1;
|
||
|
||
function schedule(ms, fn) {
|
||
const id = nextTimerId++;
|
||
timers.set(id, { at: vnow + ms, fn });
|
||
return id;
|
||
}
|
||
function unschedule(id) { timers.delete(id); }
|
||
|
||
async function flushMicro() {
|
||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||
}
|
||
|
||
/** 推进虚拟时钟 ms 毫秒,期间到期的计时器按序触发(含语音 onComplete 链)。 */
|
||
async function tick(ms) {
|
||
const target = vnow + ms;
|
||
for (let guard = 0; guard < 300000; guard++) {
|
||
await flushMicro();
|
||
let best = null;
|
||
for (const [id, t] of timers) {
|
||
if (t.at <= target && (!best || t.at < best.t.at)) best = { id, t };
|
||
}
|
||
if (!best) break;
|
||
timers.delete(best.id);
|
||
vnow = best.t.at;
|
||
best.t.fn();
|
||
}
|
||
vnow = target;
|
||
await flushMicro();
|
||
}
|
||
|
||
// ================================ 浏览器环境 mock ================================
|
||
|
||
const storageMap = new Map();
|
||
const localStorageMock = {
|
||
getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null),
|
||
setItem: (k, v) => { storageMap.set(k, String(v)); },
|
||
removeItem: (k) => { storageMap.delete(k); },
|
||
clear: () => storageMap.clear(),
|
||
};
|
||
|
||
const vibrateLog = [];
|
||
const fetchLog = [];
|
||
|
||
Object.defineProperty(globalThis, 'performance', { value: { now: () => vnow }, configurable: true });
|
||
globalThis.setTimeout = (fn, ms = 0) => schedule(ms, fn);
|
||
globalThis.clearTimeout = unschedule;
|
||
globalThis.localStorage = localStorageMock;
|
||
globalThis.fetch = (url, opts) => { fetchLog.push({ url, opts }); return Promise.resolve({ ok: true, json: async () => ({}) }); };
|
||
Object.defineProperty(globalThis, 'navigator', {
|
||
value: { maxTouchPoints: 1, vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } },
|
||
configurable: true,
|
||
});
|
||
globalThis.window = { addEventListener: () => {}, location: { href: '' } };
|
||
|
||
const origConsoleError = console.error.bind(console);
|
||
console.error = () => {}; // 资产缺失走信息性报告,不刷屏
|
||
const origConsoleLog = console.log.bind(console);
|
||
|
||
/** 手势解锁阶梯存储(engine/novice.js 的全局键)。 */
|
||
function setLadder(stage, games = 9) {
|
||
storageMap.set('soundgame.gestures', JSON.stringify({ stage, games }));
|
||
}
|
||
|
||
// ================================ 资产存在性 ================================
|
||
|
||
function hasPair(base) {
|
||
return fs.existsSync(base + '.ogg') && fs.existsSync(base + '.m4a');
|
||
}
|
||
|
||
/** voice:旁白双性别齐 或 NPC 平铺;sfx/bgm:双格式。 */
|
||
function assetExists(id) {
|
||
const audioDir = path.join(BRIDGE_DIR, 'audio');
|
||
if (id.startsWith('voice/')) {
|
||
const name = id.slice(6);
|
||
return (hasPair(path.join(audioDir, 'voice', 'female', name))
|
||
&& hasPair(path.join(audioDir, 'voice', 'male', name)))
|
||
|| hasPair(path.join(audioDir, 'voice', name));
|
||
}
|
||
return hasPair(path.join(audioDir, id));
|
||
}
|
||
|
||
// ================================ 音频引擎 mock ================================
|
||
|
||
const VOICE_DUR = 1.0; // 假句长(秒):语音链按虚拟时钟推进(与资产落盘无关)
|
||
|
||
class MockAudioEngine {
|
||
constructor() {
|
||
this.log = [];
|
||
this._voice = null;
|
||
this.bgmNow = null;
|
||
this.unlocked = true;
|
||
}
|
||
isVoicePlaying() { return this._voice !== null; }
|
||
setRateBoostEnabled() {}
|
||
holdDuck() {}
|
||
play(id, opts = {}) {
|
||
const layer = id.split('/')[0];
|
||
const missing = !assetExists(id);
|
||
this.log.push({ t: vnow, layer, id, opts, missing });
|
||
if (layer === 'voice') {
|
||
this._voice = null; // 顶掉当前句(不触发其 onComplete,对齐引擎语义)
|
||
const token = { id, onComplete: opts.onComplete };
|
||
this._voice = token;
|
||
schedule(VOICE_DUR * 1000, () => {
|
||
if (this._voice === token) {
|
||
this._voice = null;
|
||
if (token.onComplete) token.onComplete();
|
||
}
|
||
});
|
||
} else if (layer === 'bgm') {
|
||
this.bgmNow = id;
|
||
}
|
||
}
|
||
stopVoice() { this._voice = null; }
|
||
/** 跳过当前句:视为自然播完(触发 onComplete,链继续走,对齐引擎语义)。 */
|
||
skipVoice() {
|
||
const cur = this._voice;
|
||
if (!cur) return;
|
||
this._voice = null;
|
||
if (cur.onComplete) cur.onComplete();
|
||
}
|
||
stopBgm() { this.log.push({ t: vnow, layer: 'bgm', id: '(stop)', opts: {} }); this.bgmNow = null; }
|
||
stopAll() { this.stopBgm(); this.stopVoice(); }
|
||
prewarm() {}
|
||
}
|
||
|
||
// ================================ 断言设施 ================================
|
||
|
||
let passCount = 0;
|
||
const failures = [];
|
||
function assert(cond, msg) {
|
||
if (cond) { passCount++; }
|
||
else { failures.push(msg); origConsoleError(` ✗ ${msg}`); }
|
||
}
|
||
function ok(msg) { passCount++; origConsoleLog(` ✓ ${msg}`); }
|
||
|
||
/** 播放日志查询(含 missing 项——顺序/次数断言与资产落盘与否无关)。 */
|
||
function plays(audio, from = 0) {
|
||
const L = audio.log.slice(from);
|
||
return {
|
||
has: (id) => L.some((e) => e.id === id),
|
||
count: (id) => L.filter((e) => e.id === id).length,
|
||
orderOk: (...ids) => {
|
||
let pos = -1;
|
||
for (const id of ids) {
|
||
const i = L.findIndex((e, j) => j > pos && e.id === id);
|
||
if (i < 0) return false;
|
||
pos = i;
|
||
}
|
||
return true;
|
||
},
|
||
entries: () => L,
|
||
entryOf: (id) => L.find((e) => e.id === id),
|
||
sfxBetween: (t0, t1, id) => L.filter((e) => e.id === id && e.t >= t0 && e.t < t1),
|
||
};
|
||
}
|
||
|
||
const ERROR_PATTERN = JSON.stringify([60, 40, 60]);
|
||
function vibrated(pattern, fromIdx = 0) {
|
||
return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === pattern);
|
||
}
|
||
|
||
// ================================ 游戏装配(26 号输入管道) ================================
|
||
|
||
const { BridgeGame } = await import('../game.js');
|
||
const { makeInputPipeline } = await import('../../engine/pipeline.js');
|
||
|
||
function newGame(opts = {}) {
|
||
const audio = new MockAudioEngine();
|
||
const game = new BridgeGame(audio, {}, opts);
|
||
game.floor.bedEnabled = false; // 等待声是周期定时器:虚拟时钟测试关掉(防不排水)
|
||
const pipe = makeInputPipeline({
|
||
floor: game.floor,
|
||
confusion: game.confusion,
|
||
receipts: game.receipts,
|
||
invalidHints: game.invalidHints,
|
||
game,
|
||
ladder: game.ladder,
|
||
opts: game.PIPELINE_OPTS || {},
|
||
});
|
||
return { game, audio, pipe };
|
||
}
|
||
const g = (rig, gesture) => rig.pipe(gesture);
|
||
|
||
async function waitState(pred, maxMs = 30000) {
|
||
const t0 = vnow;
|
||
while (!pred() && vnow - t0 < maxMs) await tick(100);
|
||
return pred();
|
||
}
|
||
|
||
/** 语音链/巡检链/教学兜底全空闲(有界)。 */
|
||
async function waitIdle(audio, game, maxMs = 60000) {
|
||
const t0 = vnow;
|
||
while (vnow - t0 < maxMs) {
|
||
await tick(200);
|
||
if (!audio.isVoicePlaying() && !game._tourState) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 标题→新巡逻→(非首局)选航线→稳妥线→巡航 的通用铺轨(等等待点武装再出手)。 */
|
||
async function cruiseRig(seed = 42) {
|
||
const rig = newGame({ seed });
|
||
rig.game.meta.patrols = 1;
|
||
rig.game.meta.drillDone = true;
|
||
setLadder(3);
|
||
rig.game.start();
|
||
await tick(300);
|
||
await waitState(() => rig.game.state === 'TITLE' && rig.game.floor.state === 'YOUR_TURN', 20000);
|
||
g(rig, 'tap'); // 新巡逻(无 run)
|
||
await waitState(() => rig.game.state === 'ROUTE', 20000);
|
||
await waitState(() => rig.game.floor.waitPoint?.conf.id === 'route', 20000); // 入场链播完挂等待点
|
||
g(rig, 'tap'); // 稳妥线
|
||
await waitState(() => rig.game.state === 'CRUISE', 20000);
|
||
await waitIdle(rig.audio, rig.game);
|
||
return rig;
|
||
}
|
||
|
||
// ================================ 场景 1:TITLE / PORT / 经济骨架 ================================
|
||
|
||
async function scenario1() {
|
||
origConsoleLog('\n=== 场景 1:TITLE/PORT/概况链/存档摘要 ===');
|
||
storageMap.clear();
|
||
setLadder(3);
|
||
const rig = newGame({ seed: 42 });
|
||
const { game, audio } = rig;
|
||
game.start();
|
||
await tick(400);
|
||
assert(game.state === 'TITLE' && !game.run, '启动进 TITLE(无存档)');
|
||
assert(plays(audio).has('bgm/menu'), 'TITLE bgm/menu');
|
||
assert(plays(audio).has('voice/sys_first_tap'), '26 §4.10:第一句=critical 指令「点一下屏幕。」');
|
||
assert(game._graceUntil === 0, 'REPORT 死窗戳初始为 0');
|
||
|
||
// 等待话轮交出后测概况链:无改装 → 仅功勋档
|
||
await waitState(() => game.floor.state === 'YOUR_TURN', 20000);
|
||
let mark = audio.log.length;
|
||
g(rig, 'up');
|
||
await waitState(() => plays(audio, mark).has('voice/merit_1'), 10000);
|
||
assert(true, '概况链播功勋档 merit_1(merit 0)');
|
||
await waitIdle(audio, game);
|
||
|
||
// PORT:全部 6 项 + 返回(R9 裁定);功勋不够 → port_poor
|
||
mark = audio.log.length;
|
||
g(rig, 'right');
|
||
await waitState(() => plays(audio, mark).has('voice/upg_spare'), 10000);
|
||
assert(game.state === 'PORT', '右滑进 PORT');
|
||
assert(plays(audio, mark).orderOk('voice/port_open', 'voice/upg_spare'), 'PORT 开场 port_open+首项 upg_spare');
|
||
await waitState(() => game.floor.waitPoint?.conf.id === 'port', 20000);
|
||
g(rig, 'tap');
|
||
await waitState(() => plays(audio).has('voice/port_poor'), 10000);
|
||
assert(plays(audio).has('voice/port_poor') && vibrated(ERROR_PATTERN), '功勋不足购买 → port_poor + error 震');
|
||
assert(game.meta.merit === 0 && game.meta.unlocks.length === 0, '购买失败不扣账');
|
||
// 左右滑循环 + 已购预览路径(造一个已购项)
|
||
game.meta.merit = 100;
|
||
game.meta.unlocks.push('spare');
|
||
for (let i = 0; i < 6; i++) { g(rig, 'right'); await tick(1300); }
|
||
assert(game._port.idx === 0, 'PORT 5 项+返回共 6 位循环回 0(armor2 依赖未出现)');
|
||
for (let i = 0; i < 4; i++) { g(rig, 'right'); await tick(1300); }
|
||
assert(game._port.idx === 4, '右滑 4 次到第 5 项');
|
||
g(rig, 'right');
|
||
await tick(1300);
|
||
assert(plays(audio).has('voice/port_back'), '末项=port_back');
|
||
g(rig, 'tap');
|
||
await tick(300);
|
||
assert(game.state === 'TITLE', '返回项回 TITLE');
|
||
await waitState(() => game.floor.state === 'YOUR_TURN', 20000); // 询问句播完交话轮
|
||
// armor2 依赖:未购 armor1 时列表无 armor2
|
||
game.meta.unlocks = ['spare'];
|
||
g(rig, 'right');
|
||
await tick(200);
|
||
const items = game._portItems().map((u) => u.id);
|
||
assert(!items.includes('armor2'), 'armor2 依赖 armor1(未购不出现)');
|
||
assert(items.length === 5, '未购 armor1 时 PORT 5 项');
|
||
game.meta.unlocks.push('armor1');
|
||
assert(game._portItems().some((u) => u.id === 'armor2'), 'armor1 已购后 armor2 出现');
|
||
await waitState(() => game.floor.waitPoint?.conf.id === 'port', 20000); // 入场链播完挂等待点
|
||
// 购买成功 + 已购预览 upg_done(左右滑报名,非双击)
|
||
game.meta.merit = 100;
|
||
game._port.idx = 1; // harvest
|
||
mark = audio.log.length;
|
||
g(rig, 'tap');
|
||
await tick(200);
|
||
assert(game.meta.unlocks.includes('harvest') && game.meta.merit === 70, '购买 harvest 扣 30 功勋');
|
||
assert(plays(audio, mark).has('voice/upg_buy') && vibrated(JSON.stringify([100])), '购买成功 upg_buy + success 震');
|
||
g(rig, 'right'); // 到下一项
|
||
await tick(1300);
|
||
g(rig, 'left'); // 循环回 harvest(已购)
|
||
await tick(1300);
|
||
assert(plays(audio).has('voice/upg_done'), '已购项报名播 upg_done(不重复购买路径)');
|
||
|
||
// 存档摘要规则(有 run):登录态才有 PUT
|
||
storageMap.set('soundgame.auth', JSON.stringify({ username: 't', token: 'x' }));
|
||
game.run = { ...game._newRun(), seg: 2, hull: 4, rp: 5, sys: { engine: 'dmg', shield: 'ok', scan: 'ok', weapon: 'ok' } };
|
||
game._save();
|
||
const saved = JSON.parse(storageMap.get('soundgame.progress.bridge'));
|
||
assert(saved.summary === '第2巡航段 · 舰体带伤 · 引擎异常 · 资源5', `summary 生成规则(实际 ${saved.summary})`);
|
||
assert(fetchLog.some((f) => f.url === '/api/progress' && f.opts?.method === 'PUT'
|
||
&& JSON.parse(f.opts.body).data.summary === saved.summary), 'PUT 进度必带 summary');
|
||
game.run = null;
|
||
game._save();
|
||
const saved2 = JSON.parse(storageMap.get('soundgame.progress.bridge'));
|
||
assert(/^功勋\d+ · 最佳.+ · 改装\d\/6$/.test(saved2.summary), `巡逻终局 summary(实际 ${saved2.summary})`);
|
||
game.stop();
|
||
ok('场景 1 通过');
|
||
}
|
||
|
||
// ================================ 场景 2:首局 DRILL 全链 + 学期零惩罚 ================================
|
||
|
||
async function scenario2() {
|
||
origConsoleLog('\n=== 场景 2:TITLE→DRILL D0..D4 + S1 驰援成功链 + 二周目不再进 ===');
|
||
storageMap.clear(); // 手势阶梯 stage=0:验证 D0 提前解锁上滑(22 附录 C §3)
|
||
const rig = newGame({ seed: 42 });
|
||
const { game, audio } = rig;
|
||
game.start();
|
||
await tick(400);
|
||
await waitState(() => game.floor.state === 'YOUR_TURN', 20000);
|
||
// 阶段 0:上滑未解锁 → invalid 回执 + 首次解释(§4.3)
|
||
let mark = audio.log.length;
|
||
g(rig, 'up');
|
||
await tick(1500);
|
||
assert(plays(audio, mark).has('voice/inv_up_locked'), 'stage 0 上滑未解锁 → 首次解释 inv_up_locked(回执无 ctx 由管道记账)');
|
||
|
||
mark = audio.log.length;
|
||
g(rig, 'tap'); // 新巡逻(开场第一下单击 = 26 §4.10 首次成功)
|
||
await waitState(() => game.run && game.state === 'CRUISE' && game.floor.waitPoint?.conf.id === 'inspect', 40000);
|
||
assert(game.run.seed === 42, 'opts.seed=42 注入新巡逻');
|
||
assert(plays(audio, mark).orderOk('voice/sys_new_patrol', 'voice/sys_turn_teach', 'voice/hint_inspect_full'),
|
||
'D0 序:sys_new_patrol → 轮到你状态教学 → 巡检指令(规则念白 intro_1..5 废除)');
|
||
assert(!plays(audio, mark).has('voice/intro_1') && !plays(audio, mark).has('voice/tut_d0'), '开场零规则念白(§4.10)');
|
||
assert(plays(audio).has('bgm/cruise'), '巡航 bgm/cruise');
|
||
assert(game.run.rp === 2, 'S1 采集 +2(稳妥线,首局固定)');
|
||
assert(game._drill.step === 0, 'D0 已武装(链播完才挂等待点)');
|
||
assert(game.ladder.stage() >= 1, 'D0 教学期提前解锁上滑(22 附录 C §3:阶段 1 解锁条件=巡检教学)');
|
||
|
||
// D0:上滑 → 巡检全链(全 ok → tut_all_ok + 阶梯教学句尾)
|
||
mark = audio.log.length;
|
||
g(rig, 'up');
|
||
await tick(10300); // tone 段 4×2.4s 播完
|
||
const tones = ['sfx/sys_engine_ok', 'sfx/sys_shield_ok', 'sfx/sys_scan_ok', 'sfx/sys_weapon_ok'];
|
||
assert(plays(audio, mark).orderOk(...tones), 'D0 巡检全链四状态音固定序(引擎→护盾→扫描→武器)');
|
||
{
|
||
const ts = tones.map((id) => plays(audio, mark).entryOf(id).t);
|
||
for (let i = 1; i < 4; i++) {
|
||
assert(Math.abs(ts[i] - ts[i - 1] - 2400) <= 50, `巡检音间隔 2.0s+0.4s(第 ${i} 间距 ${ts[i] - ts[i - 1]}ms)`);
|
||
}
|
||
}
|
||
await waitState(() => game._drill.step === 1, 40000);
|
||
await waitState(() => plays(audio, mark).has('voice/tut_crisis'), 30000);
|
||
assert(plays(audio, mark).has('voice/tut_all_ok'), '全 ok 巡检链尾播 tut_all_ok');
|
||
assert(plays(audio, mark).has('voice/sys_unlock_up'), '巡检链尾挂阶梯教学句 sys_unlock_up(一次只教一个)');
|
||
assert(plays(audio, mark).orderOk('voice/tut_all_ok', 'voice/sys_unlock_up', 'voice/tut_crisis'), 'D0→D1 衔接:tut_all_ok→解锁句→tut_crisis');
|
||
assert(plays(audio, mark).orderOk('voice/st_hull_1', 'voice/st_seg_1', 'voice/st_rp_1', 'voice/st_xo_2', 'voice/st_eng_2', 'voice/st_doc_2'),
|
||
'巡检账目段:舰体/段/资源/三船员档');
|
||
|
||
// D1:等 S1 段计时到 → 求救检测链(首局固定 distress 无陷阱)
|
||
mark = audio.log.length;
|
||
await waitState(() => game.state === 'CRISIS', 95000);
|
||
assert(game._crisis.type === 'distress' && !game._crisis.trap, '首局 S1 危机=求救且无陷阱(§4.3 承诺)');
|
||
const dir0 = game.run.plan.dirs[0];
|
||
const sigId = 'sfx/sig_distress' + ({ port: '_l', star: '_r', bow: '', stern: '' })[dir0];
|
||
assert(plays(audio, mark).has(sigId), `检测链签名音 ${sigId}(方位 ${dir0} 预 pan 派生)`);
|
||
if (dir0 === 'bow') assert(plays(audio, mark).has('sfx/dir_bow'), '舰首方位附 dir_bow 上行尾音');
|
||
if (dir0 === 'stern') assert(plays(audio, mark).has('sfx/dir_stern'), '舰尾方位附 dir_stern 下行尾音');
|
||
assert(!plays(audio, mark).has('voice/trap_hint'), '非陷阱局零 trap_hint');
|
||
await waitState(() => game._crisis && game._crisis.subphase === 'decide' && game._drill?.step === 2, 40000);
|
||
assert(plays(audio, mark).orderOk('voice/evt_distress', 'voice/dir_' + dir0, 'voice/cr_decide'), '检测链:类型句→方位句→cr_decide');
|
||
await waitState(() => plays(audio, mark).has('voice/opt_aid'), 30000);
|
||
assert(plays(audio, mark).orderOk('voice/cr_decide', 'voice/tut_decide', 'voice/tut_aid', 'voice/opt_aid'),
|
||
'决策教学并入首选项链:tut_decide→tut_aid→opt_aid');
|
||
assert(plays(audio).has('bgm/crisis'), '危机 bgm/crisis');
|
||
|
||
// D2:右滑预览 → 左滑回 aid → 单击下令驰援
|
||
mark = audio.log.length;
|
||
g(rig, 'right');
|
||
await tick(1500);
|
||
assert(plays(audio, mark).has('voice/opt_ignore'), '右滑预览第二选项 opt_ignore');
|
||
g(rig, 'left');
|
||
await tick(1500);
|
||
assert(plays(audio, mark).has('voice/opt_aid'), '左滑循环回 opt_aid');
|
||
const hullBefore = game.run.hull, rpBefore = game.run.rp;
|
||
mark = audio.log.length;
|
||
g(rig, 'tap'); // 下令驰援(D2 教学接管)
|
||
await tick(300);
|
||
assert(plays(audio, mark).orderOk('sfx/pa_chime', 'sfx/warp'), '下令链:pa_chime→warp(驰援执行音)');
|
||
await waitState(() => game._drill && game._drill.step === 3, 40000);
|
||
await waitState(() => plays(audio, mark).has('voice/tut_loyal'), 30000);
|
||
assert(plays(audio, mark).orderOk('voice/res_reward', 'voice/loy_aid', 'voice/doc_aid'), '驰援成功链 res_reward→loy_aid→doc_aid');
|
||
assert(plays(audio, mark).has('voice/st_rp_2'), 'RP 2→4 跨档播 st_rp_2(播报值=运行值)');
|
||
assert(game.run.hull === hullBefore, '学期零惩罚:驰援成功无伤');
|
||
assert(game.run.rp === rpBefore + 2 && game.run.aidOk === 1, '驰援入账 +2 RP / aidOk=1');
|
||
assert(game.run.crew.xo === 1 && game.run.crew.doc === 1, '忠诚差分:大副+1 医师+1(§5.1 驰援成功行)');
|
||
assert(plays(audio, mark).orderOk('voice/tut_after', 'voice/tut_loyal'), 'D2→D3:tut_after→tut_loyal');
|
||
|
||
// D3:上滑 → 巡检链 → tut_done → 自由巡航 → 段末 DOCK
|
||
await waitState(() => game.floor.waitPoint?.conf.id === 'inspect', 20000);
|
||
mark = audio.log.length;
|
||
g(rig, 'up');
|
||
await waitState(() => plays(audio, mark).has('voice/tut_done'), 40000);
|
||
await waitState(() => game.state === 'DOCK', 60000);
|
||
assert(plays(audio, mark).has('voice/tut_done'), 'D3→D4 tut_done');
|
||
assert(game.run.drillDone && game.meta.drillDone, 'drillDone 持久化(meta)');
|
||
assert(plays(audio).has('voice/dock_empty') || plays(audio).has('voice/dock_open'), '段末进 DOCK');
|
||
|
||
// —— R11 教学危机超时收束:D0 完成后不在决策窗下令 → 超时默认结局 → D3 L3 替播巡检 → DOCK
|
||
storageMap.clear();
|
||
const rig3 = newGame({ seed: 7 });
|
||
rig3.game.start();
|
||
await tick(300);
|
||
await waitState(() => rig3.game.floor.state === 'YOUR_TURN', 20000);
|
||
g(rig3, 'tap');
|
||
await waitState(() => rig3.game.floor.waitPoint?.conf.id === 'inspect', 40000);
|
||
g(rig3, 'up'); // D0 巡检
|
||
await waitState(() => rig3.game._drill?.step === 1, 60000);
|
||
await waitState(() => rig3.game.state === 'CRISIS', 120000);
|
||
const m3 = rig3.audio.log.length;
|
||
await waitState(() => rig3.game.state === 'DOCK', 240000); // 20s 窗超时→默认结局→D3 L3 替播巡检→tut_done→段末
|
||
assert(plays(rig3.audio, m3).has('voice/cr_timeout'), 'R11:教学危机超时走默认结局(真话)');
|
||
assert(plays(rig3.audio, m3).has('voice/tut_done'), 'R11:收束播 tut_done(D3 由迷茫 L3 替播巡检自动完成)');
|
||
assert(rig3.game.meta.drillDone && rig3.game.run.drillDone, 'R11:drillDone 持久化(次局不再进教学)');
|
||
game.stop(); rig3.game.stop();
|
||
ok('场景 2 通过(首局 DRILL 全链 + R11 超时收束)');
|
||
}
|
||
|
||
// ================================ 场景 3:seed 复现与首局约束 ================================
|
||
|
||
async function scenario3() {
|
||
origConsoleLog('\n=== 场景 3:seed 复现 / 首局约束 / 非首局 S1≠辐射 ===');
|
||
storageMap.clear();
|
||
const mk = () => { const rig = newGame({ seed: 42 }); rig.game.meta.patrols = 0; return rig.game._newRun(); };
|
||
const a = mk(), b = mk();
|
||
assert(JSON.stringify(a.plan) === JSON.stringify(b.plan), '同 seed 两次构造 plan 深相等(类型序/方位/陷阱/磨损/余波)');
|
||
assert(JSON.stringify(a.plan.types) === JSON.stringify(['distress', 'meteor', 'pirate', 'radiation']), '首局固定危机序 求救→陨石→海盗→辐射');
|
||
assert(a.plan.trap === false && a.plan.echo === false && a.plan.wears[0].length === 0, '首局约束:无陷阱/无余波/S1 无磨损');
|
||
// 非首局:洗牌 + S1≠辐射(遍历若干 seed 验证约束恒成立)
|
||
let checked = 0;
|
||
for (let seed = 1; seed <= 25; seed++) {
|
||
const rig = newGame({ seed });
|
||
rig.game.meta.patrols = 1;
|
||
const run = rig.game._newRun();
|
||
assert(run.plan.types[0] !== 'radiation', `seed=${seed} 非首局 S1≠辐射`);
|
||
assert(new Set(run.plan.types).size === 4, `seed=${seed} 四类型各一场`);
|
||
checked++;
|
||
}
|
||
assert(checked === 25, '非首局约束遍历 25 seed');
|
||
ok('场景 3 通过');
|
||
}
|
||
|
||
// ================================ 场景 4:巡检链节奏与异常段 ================================
|
||
|
||
async function scenario4() {
|
||
origConsoleLog('\n=== 场景 4:巡检链节奏 / dmg/off 异常句 / 中断保护与跳段 ===');
|
||
storageMap.clear();
|
||
const rig = await cruiseRig(7);
|
||
const { game, audio } = rig;
|
||
// 注入两系统异常(scan dmg / engine off)
|
||
game.run.sys.scan = 'dmg';
|
||
game.run.sys.engine = 'off';
|
||
game.run.offlineEver = true;
|
||
let mark = audio.log.length;
|
||
g(rig, 'up');
|
||
await tick(10300); // tone 段播完
|
||
const P = plays(audio, mark);
|
||
const tOff = P.entryOf('sfx/sys_offline').t; // slot1 = engine(off)
|
||
const tShield = P.entryOf('sfx/sys_shield_ok').t; // slot2
|
||
const tScan = P.entryOf('sfx/sys_scan_dmg').t; // slot3
|
||
const tWeapon = P.entryOf('sfx/sys_weapon_ok').t; // slot4
|
||
assert(tOff < tShield && tShield < tScan && tScan < tWeapon, '离线=死寂平线签名 sys_offline 占引擎槽位');
|
||
assert(Math.abs(tShield - tOff - 2400) <= 50 && Math.abs(tScan - tShield - 2400) <= 50, '异常态仍保持 2.4s 节奏');
|
||
assert(!P.has('voice/tut_all_ok'), '有异常时零 tut_all_ok');
|
||
await waitState(() => !game._tourState && !audio.isVoicePlaying(), 40000);
|
||
const V = plays(audio, mark);
|
||
assert(V.orderOk('sfx/sys_offline', 'voice/sys_engine_off'), '引擎离线句接在对应 tone 后');
|
||
assert(V.orderOk('sfx/sys_scan_dmg', 'voice/sys_scan_dmg'), '扫描受损句接在对应 tone 后');
|
||
assert(V.orderOk('voice/sys_engine_off', 'voice/sys_scan_dmg'), '异常句按系统固定序');
|
||
|
||
// 中断保护:语音段左右滑=invalid 回执+解释+续完剩余句(保护链,当前句不重播)
|
||
mark = audio.log.length;
|
||
g(rig, 'up');
|
||
await tick(10300); // tone 段
|
||
await waitState(() => audio.isVoicePlaying(), 20000); // 语音段开播
|
||
const tInterrupt = vnow;
|
||
const curId = game._lastVoiceStart.id;
|
||
const curPlays = plays(audio).count(curId);
|
||
const vibBefore = vibrateLog.length;
|
||
g(rig, 'left');
|
||
await tick(200);
|
||
assert(!vibrated(ERROR_PATTERN, vibBefore), '巡检链播中左右滑不产生 error 震(invalid 回执承担反馈)');
|
||
assert(plays(audio, mark).has('voice/inv_tour'), '巡检中断首次解释 inv_tour(并入续播链头)');
|
||
assert(plays(audio).count(curId) === curPlays, `被掐句 ${curId} 不原样重播(续完剩余句,原则 9)`);
|
||
await waitState(() => !game._tourState && !audio.isVoicePlaying(), 40000);
|
||
// 跳段:tone 段单击→语音段;语音段单击=逐句跳(floor consumed)
|
||
mark = audio.log.length;
|
||
g(rig, 'up');
|
||
await tick(3000);
|
||
g(rig, 'tap'); // tone 段 → 跳语音段
|
||
await waitState(() => audio.isVoicePlaying(), 20000);
|
||
let guard = 0;
|
||
while (game._tourState && guard++ < 12) { g(rig, 'tap'); await tick(600); }
|
||
await waitState(() => !game._tourState, 20000);
|
||
assert(!game._tourState, '单击跳句推进巡检链至结束');
|
||
game.stop();
|
||
ok('场景 4 通过');
|
||
}
|
||
|
||
// ================================ 场景 5:危机矩阵(§4.2 全组合注入) ================================
|
||
|
||
/** 矩阵注入:给定类型/选项/系统态,结算并断言语音序与账目差分。 */
|
||
async function matrixCase(type, opt, st, expect) {
|
||
storageMap.clear();
|
||
const { game, audio } = await cruiseRig(42);
|
||
const r = game.run;
|
||
r.seg = 2;
|
||
const needSys = { evade: 'engine', fight: 'weapon', breach: 'shield', aid: 'scan', ignore: null }[opt];
|
||
if (needSys) r.sys[needSys] = st;
|
||
r.hull = 6; r.rp = 5; r.crew = { xo: 0, eng: 0, doc: 0 };
|
||
const c0 = { type, dir: 'port', optIdx: 0, subphase: 'decide', trap: false, remainMs: 15000, echo: false };
|
||
game.state = 'CRISIS';
|
||
game._crisis = c0;
|
||
const mark = audio.log.length;
|
||
game._settleCrisis(c0, opt);
|
||
await waitState(() => game._crisis === null && !audio.isVoicePlaying(), 60000);
|
||
const P = plays(audio, mark);
|
||
const name = `${type}/${opt}/${st}`;
|
||
for (const id of expect.voice || []) {
|
||
assert(P.has('voice/' + id), `[${name}] 语音 ${id}`);
|
||
}
|
||
for (const id of expect.noVoice || []) {
|
||
assert(!P.has('voice/' + id), `[${name}] 不播 ${id}`);
|
||
}
|
||
if (expect.order) assert(P.orderOk(...expect.order.map((x) => 'voice/' + x).concat(expect.orderSfx || [])), `[${name}] 句序`);
|
||
if (expect.hull !== undefined) assert(r.hull === expect.hull, `[${name}] hull=${r.hull} 期望 ${expect.hull}`);
|
||
if (expect.rp !== undefined) assert(r.rp === expect.rp, `[${name}] rp=${r.rp} 期望 ${expect.rp}`);
|
||
if (expect.crew) {
|
||
for (const [k, v] of Object.entries(expect.crew)) assert(r.crew[k] === v, `[${name}] crew.${k}=${r.crew[k]} 期望 ${v}`);
|
||
}
|
||
if (expect.sys) {
|
||
for (const [k, v] of Object.entries(expect.sys)) assert(r.sys[k] === v, `[${name}] sys.${k}=${r.sys[k]} 期望 ${v}`);
|
||
}
|
||
if (expect.aidOk !== undefined) assert(r.aidOk === expect.aidOk, `[${name}] aidOk=${r.aidOk}`);
|
||
if (expect.kills !== undefined) assert(r.kills === expect.kills, `[${name}] kills=${r.kills}`);
|
||
if (expect.offlineFails !== undefined) assert(r.offlineFails === expect.offlineFails, `[${name}] offlineFails=${r.offlineFails}`);
|
||
game.stop(); // 清残留等待点档位定时器(防跨场景污染 30s 提示台账)
|
||
}
|
||
|
||
async function scenario5() {
|
||
origConsoleLog('\n=== 场景 5:危机全类型 × 选项 × 系统态矩阵(§4.2) ===');
|
||
// —— 陨石 ——
|
||
await matrixCase('meteor', 'evade', 'ok', { voice: ['res_clean'], hull: 6 });
|
||
await matrixCase('meteor', 'evade', 'dmg', { voice: ['res_hurt'], hull: 5 });
|
||
await matrixCase('meteor', 'evade', 'off', { voice: ['sys_engine_off', 'res_bad'], order: ['sys_engine_off', 'res_bad'], hull: 4 });
|
||
await matrixCase('meteor', 'fight', 'ok', { voice: ['res_mine', 'loy_fight_win'], rp: 6, crew: { xo: 1 } });
|
||
await matrixCase('meteor', 'fight', 'dmg', { voice: ['res_hurt'], noVoice: ['res_mine', 'loy_fight_win'], hull: 5, rp: 5, crew: { xo: 0 } });
|
||
await matrixCase('meteor', 'fight', 'off', { voice: ['sys_weapon_off', 'res_bad'], order: ['sys_weapon_off', 'res_bad'], hull: 4, offlineFails: 1 });
|
||
await matrixCase('meteor', 'breach', 'ok', { voice: ['sys_shield_dmg', 'loy_breach'], sys: { shield: 'dmg' }, crew: { eng: -1 }, hull: 6 });
|
||
await matrixCase('meteor', 'breach', 'dmg', { voice: ['res_bad'], hull: 4 });
|
||
await matrixCase('meteor', 'breach', 'off', { voice: ['sys_shield_off', 'res_bad'], order: ['sys_shield_off', 'res_bad'], hull: 4 });
|
||
// —— 海盗 ——
|
||
await matrixCase('pirate', 'fight', 'ok', { voice: ['res_hurt', 'res_plunder', 'loy_fight_win', 'xo_win'], hull: 5, rp: 7, kills: 1, crew: { xo: 1 } });
|
||
await matrixCase('pirate', 'evade', 'ok', { voice: ['res_clean', 'loy_evade_pirate'], hull: 6, crew: { xo: -1 } });
|
||
await matrixCase('pirate', 'evade', 'dmg', { voice: ['res_hurt'], hull: 5 });
|
||
await matrixCase('pirate', 'evade', 'off', { voice: ['sys_engine_off', 'res_bad'], order: ['sys_engine_off', 'res_bad'], hull: 4 });
|
||
await matrixCase('pirate', 'ignore', 'ok', { voice: ['res_bad', 'loy_rad'], hull: 4, crew: { doc: -1 } });
|
||
// —— 求救 ——
|
||
await matrixCase('distress', 'aid', 'ok', { voice: ['res_reward', 'loy_aid', 'doc_aid'], rp: 7, aidOk: 1, crew: { xo: 1, doc: 1 } });
|
||
await matrixCase('distress', 'aid', 'off', { voice: ['sys_scan_off'], noVoice: ['res_reward', 'loy_aid'], rp: 5, aidOk: 0, offlineFails: 1 });
|
||
await matrixCase('distress', 'ignore', 'ok', { voice: ['loy_ignore', 'doc_ignore'], rp: 5, crew: { doc: -1 } });
|
||
// —— 辐射 ——
|
||
await matrixCase('radiation', 'evade', 'ok', { voice: ['res_clean'], hull: 6 });
|
||
await matrixCase('radiation', 'evade', 'dmg', { voice: ['res_hurt'], hull: 5 });
|
||
await matrixCase('radiation', 'breach', 'ok', { voice: ['sys_shield_dmg', 'loy_breach'], sys: { shield: 'dmg' }, crew: { eng: -1 }, hull: 6 });
|
||
await matrixCase('radiation', 'breach', 'dmg', { voice: ['res_bad'], hull: 4 });
|
||
ok('场景 5 通过(矩阵核心组合)');
|
||
}
|
||
|
||
// ================================ 场景 6:仲裁 R1-R8 + 双危机 ================================
|
||
|
||
async function scenario6() {
|
||
origConsoleLog('\n=== 场景 6:告警仲裁 R1-R8 / §3.5 并发场景 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = await cruiseRig(42);
|
||
|
||
// R1+R2:双受损 3:1 轮转(shield 优先于 scan)
|
||
game.run.sys.shield = 'dmg';
|
||
game.run.sys.scan = 'dmg';
|
||
let mark = audio.log.length;
|
||
game._almOnDamaged('shield');
|
||
game._almOnDamaged('scan');
|
||
assert(plays(audio, mark).has('sfx/alm_shield'), 'R1:榜首 shield 立即响');
|
||
assert(game._alm.current === 'shield', 'R1 威胁序:shield > scan');
|
||
await tick(15400);
|
||
assert(plays(audio, mark).has('sfx/alm_scan'), 'R2:15s 后让位 scan 5s');
|
||
const shieldPlays = plays(audio, mark).count('sfx/alm_shield');
|
||
await tick(60000);
|
||
const shieldPlays2 = plays(audio, mark).count('sfx/alm_shield');
|
||
assert(shieldPlays2 > shieldPlays, 'R2:轮转回榜首续响(公平不饿死)');
|
||
|
||
// R3:600ms 同 id 去重(手动占窗防轮转干扰)
|
||
mark = audio.log.length;
|
||
game._alm.current = 'shield';
|
||
game._almKick('shield');
|
||
game._almKick('shield');
|
||
await tick(100);
|
||
assert(plays(audio, mark).count('sfx/alm_shield') === 1, 'R3:600ms 窗内同 id 重复调度只响一次');
|
||
|
||
// R4:voice 播出期间零告警调度,链落声+0.5s 恢复
|
||
mark = audio.log.length;
|
||
game._speak(['voice/gd_cruise']);
|
||
await tick(100); // voice 在播
|
||
game._almKick('shield');
|
||
await tick(300);
|
||
assert(plays(audio, mark).count('sfx/alm_shield') === 0, 'R4:voice 在播期间告警零播放(延迟不丢弃)');
|
||
await waitState(() => plays(audio, mark).count('sfx/alm_shield') >= 1, 30000);
|
||
assert(true, 'R4:voice 结束+0.5s 告警恢复');
|
||
|
||
// R5:决策窗内仅危机关联受损系统参与
|
||
game.run.sys.engine = 'dmg';
|
||
const c = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, echo: false };
|
||
game.state = 'CRISIS';
|
||
game._crisis = c;
|
||
game._almStartRotate();
|
||
assert(game._alm.current === 'engine', 'R5/R1①:陨石窗内引擎(关联)居首');
|
||
assert(!game._almCandidates().includes('scan'), 'R5:无关系统 scan 挂起(不参与窗内仲裁)');
|
||
game._crisis = null;
|
||
game.state = 'CRUISE';
|
||
|
||
// R6:双危机 FIFO —— B 检测落在 A 决策窗内 → 排队,A 结算句+2s 前零 B 声
|
||
const cA = { type: 'meteor', dir: 'stern', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, echo: false };
|
||
game.state = 'CRISIS';
|
||
game._crisis = cA;
|
||
mark = audio.log.length;
|
||
game._startCrisis(); // 尝试开 B → 排队
|
||
assert(game._crisisQueue.length === 1, 'R6:A 占窗时 B 进入 FIFO');
|
||
assert(!plays(audio, mark).has('sfx/sig_distress') && !plays(audio, mark).has('sfx/sig_meteor'), 'R6:排队期零 B 检测声');
|
||
game._settleCrisis(cA, 'evade'); // 引擎 dmg → hull−1 res_hurt
|
||
await waitState(() => game._crisis !== null && game._crisis !== cA, 40000);
|
||
assert(game._crisis.type === game.run.plan.types[game.run.seg - 1], 'R6:B 检测链在 A 结算后补播(B 窗从 B 检测链起算)');
|
||
const cB = game._crisis;
|
||
game._settleCrisis(cB, cB.type === 'distress' ? 'ignore' : 'evade');
|
||
await waitState(() => game._crisis === null, 60000);
|
||
assert(true, 'R6:双危机串行消化(无叠加签名)');
|
||
|
||
// R7:修复即止
|
||
game.run.sys.shield = 'ok';
|
||
game._almOnRepaired('shield');
|
||
assert(game._alm.loops.shield === undefined, 'R7:修复后周期循环截断');
|
||
assert(!game._alm.deferred.some((d) => d.sys === 'shield'), 'R7:排队项移除');
|
||
|
||
// R8:菜单冻结 + REPORT 清空
|
||
mark = audio.log.length;
|
||
game._menu.open = true;
|
||
game._almKick('engine');
|
||
await tick(300);
|
||
assert(plays(audio, mark).count('sfx/alm_engine') === 0, 'R8:菜单期告警冻结');
|
||
game._menu.open = false;
|
||
game._enterReport(false);
|
||
mark = audio.log.length;
|
||
await tick(5000);
|
||
assert(plays(audio, mark).count('sfx/alm_engine') === 0 && plays(audio, mark).count('sfx/alm_scan') === 0, 'R8:REPORT 进入告警清空(结算独占)');
|
||
game.stop();
|
||
ok('场景 6 通过(仲裁 + 双场景并发)');
|
||
}
|
||
|
||
// ================================ 场景 7:忠诚账本 / 支援 / 离心 ================================
|
||
|
||
async function scenario7() {
|
||
origConsoleLog('\n=== 场景 7:忠诚账本差分 / band 句 / 支援触发 / sup_none ===');
|
||
storageMap.clear();
|
||
const { game, audio } = await cruiseRig(42);
|
||
const r = game.run;
|
||
r.seg = 2;
|
||
|
||
// clamp −3..+3
|
||
r.crew.xo = 3;
|
||
r.crew.xo = Math.max(-3, Math.min(3, r.crew.xo + 10));
|
||
assert(r.crew.xo === 3, '忠诚 clamp 上限 +3');
|
||
|
||
// 大副信赖 sup_xo:海盗迎战下令瞬间 band≥2 → 战果升一级(hull−1→0)
|
||
r.sys.weapon = 'ok'; r.crew.xo = 2; r.crew.eng = 0; r.crew.doc = 0;
|
||
r.hull = 6; r.rp = 5; r.kills = 0;
|
||
let c = { type: 'pirate', dir: 'star', optIdx: 0, subphase: 'decide', trap: false, remainMs: 15000, echo: false };
|
||
game.state = 'CRISIS'; game._crisis = c;
|
||
let mark = audio.log.length;
|
||
game._settleCrisis(c, 'fight');
|
||
await waitState(() => game._crisis === null && !audio.isVoicePlaying(), 60000);
|
||
assert(r.hull === 6, 'sup_xo:大副信赖 hull−1→0(战果升一级)');
|
||
assert(plays(audio, mark).orderOk('voice/sup_xo', 'voice/xo_fight', 'voice/res_clean', 'voice/res_plunder'), 'sup_xo+xo_fight 播在结果句前');
|
||
assert(r.crew.xo === 3, '迎战胜 大副+1(clamp 3)');
|
||
|
||
// 大副离心:战果降一级(hull−1→−2)
|
||
r.crew.xo = -2; r.crew.eng = 0; r.crew.doc = 0; r.hull = 6; r.kills = 0;
|
||
c = { type: 'pirate', dir: 'star', optIdx: 0, subphase: 'decide', trap: false, remainMs: 15000, echo: false };
|
||
game.state = 'CRISIS'; game._crisis = c;
|
||
game._settleCrisis(c, 'fight');
|
||
await waitState(() => game._crisis === null && !audio.isVoicePlaying(), 60000);
|
||
assert(r.hull === 4, '大副离心:基础 −1 降一级 → −2');
|
||
|
||
// 工程师/医师段末支援:band≥2 + 条件
|
||
r.crew.xo = 0; r.crew.eng = 2; r.crew.doc = 2;
|
||
r.sys.scan = 'dmg'; r.hull = 4; r.hullLostSeg = 2; r.seg = 2;
|
||
mark = audio.log.length;
|
||
game.state = 'CRUISE';
|
||
game._endSeg();
|
||
await waitState(() => game.state === 'DOCK', 60000);
|
||
assert(r.sys.scan === 'ok', 'sup_eng:工程师信赖自动修 1 级(受损→完好)');
|
||
assert(r.hull === 5, 'sup_doc:医师信赖舰体+1(不超上限)');
|
||
assert(plays(audio, mark).orderOk('voice/sup_eng', 'voice/eng_sup', 'voice/sup_doc', 'voice/doc_care', 'voice/evt_dock'), '段末支援链 sup_eng→eng_sup→sup_doc→doc_care→evt_dock');
|
||
|
||
// sup_none 每局限 1 次
|
||
storageMap.clear();
|
||
const rig2 = await cruiseRig(42);
|
||
rig2.game.run.seg = 2;
|
||
rig2.game.run.sys.engine = 'dmg';
|
||
rig2.game.run.hullLostSeg = 1;
|
||
mark = rig2.audio.log.length;
|
||
rig2.game._endSeg();
|
||
await waitState(() => rig2.game.state === 'DOCK', 60000);
|
||
assert(plays(rig2.audio, mark).has('voice/sup_none'), 'sup_none:条件不满足(band<2)支援缺席');
|
||
rig2.game.run.seg = 3;
|
||
rig2.game.run.hullLostSeg = 1;
|
||
mark = rig2.audio.log.length;
|
||
rig2.game.state = 'CRUISE';
|
||
rig2.game._endSeg();
|
||
await waitState(() => rig2.game.state === 'DOCK', 60000);
|
||
assert(!plays(rig2.audio, mark).has('voice/sup_none'), 'sup_none 每局限 1 次(第二次静默)');
|
||
game.stop(); rig2.game.stop();
|
||
ok('场景 7 通过');
|
||
}
|
||
|
||
// ================================ 场景 8:评级验算(剧本 A/B/失败,§8.4-8.6) ================================
|
||
|
||
async function reportCase(mutate, expect) {
|
||
storageMap.clear();
|
||
const { game, audio } = await cruiseRig(42);
|
||
const r = game.run;
|
||
mutate(r, game);
|
||
const mark = audio.log.length;
|
||
game._enterReport(expect.died);
|
||
await waitState(() => game._report?.done, 60000);
|
||
const P = plays(audio, mark);
|
||
assert(P.orderOk(...expect.chain.map((x) => 'voice/' + x)), `结算链序 ${expect.chain.join('→')}`);
|
||
assert(game._report.merit === expect.merit, `功勋=${game._report.merit} 期望 ${expect.merit}(${expect.name})`);
|
||
assert(game.meta.merit === expect.merit, `meta.merit 入账=${game.meta.merit}(${expect.name})`);
|
||
if (expect.bestRate) assert(game.meta.bestRate === expect.bestRate, `bestRate=${game.meta.bestRate}`);
|
||
assert(game.run === null, '巡逻终局 run=null');
|
||
game.stop();
|
||
}
|
||
|
||
async function scenario8() {
|
||
origConsoleLog('\n=== 场景 8:评级公式验算(§8.4 剧本 A / §8.5 剧本 B / §8.6 失败) ===');
|
||
// 剧本 A 终局:4 段/hull6/RP9/驰援1/击退1/无离线 → score=8+6+2+2+1=19 → S;功勋=4+8+3=15
|
||
await reportCase((r) => {
|
||
r.seg = 4; r.hull = 6; r.rp = 9; r.aidOk = 1; r.kills = 1; r.offlineEver = false;
|
||
}, { died: false, merit: 15, bestRate: 'S', name: '剧本A', chain: ['rep_open', 'rate_s', 'merit_3', 'settle_hook'] });
|
||
// 剧本 B:全超时 hull3/RP4/无驰援/无击退/无离线 → 8+3+0+0+1=12 → B;功勋=4+3+1=8
|
||
await reportCase((r) => {
|
||
r.seg = 4; r.hull = 3; r.rp = 4; r.aidOk = 0; r.kills = 0; r.offlineEver = false;
|
||
}, { died: false, merit: 8, bestRate: 'B', name: '剧本B', chain: ['rep_open', 'rate_b', 'merit_2', 'settle_hook'] });
|
||
// 失败路径(§8.6):S3 海盗迎战武器离线 → hull−3→0;timeouts=2 → die_adv_to
|
||
await reportCase((r) => {
|
||
r.seg = 3; r.hull = 0; r.rp = 4; r.lastHit = 'pirate'; r.timeouts = 2; r.offlineFails = 1;
|
||
}, { died: true, merit: 3 + 0 + 1, bestRate: 'D', name: '失败', chain: ['die_intro', 'die_pirate', 'die_adv_to', 'rep_open', 'rate_d', 'merit_1', 'settle_hook'] });
|
||
ok('场景 8 通过(账目直证)');
|
||
}
|
||
|
||
// ================================ 场景 9:经济(采集/修理/折算/改装效果) ================================
|
||
|
||
async function scenario9() {
|
||
origConsoleLog('\n=== 场景 9:经济逐笔 / 修理两级 / 改装效果 ===');
|
||
storageMap.clear();
|
||
const rig = await cruiseRig(42);
|
||
const { game, audio } = rig;
|
||
// 矿富线:回 ROUTE 重选(右滑换线+单击选定)
|
||
game.run.seg = 2; game.run.route = null;
|
||
game._enterSeg(2);
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
await waitState(() => game.floor.waitPoint?.conf.id === 'route', 20000);
|
||
g(rig, 'right'); // 换矿富线
|
||
await tick(1500);
|
||
assert(plays(audio).has('voice/opt_route_rich'), '右滑换线报名 opt_route_rich');
|
||
g(rig, 'tap');
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
assert(game.run.rp === 6, '矿富线采集 +4/段(叠加 cruiseRig 稳妥腿 +2 = 6)');
|
||
assert(plays(audio).has('voice/st_rp_2'), 'RP 2→6 跨档播 st_rp_2');
|
||
// 采集协议 +1
|
||
game.meta.unlocks.push('harvest');
|
||
game.run.seg = 2; game.run.route = null;
|
||
game._enterSeg(2);
|
||
await waitState(() => game.state === 'ROUTE' && game.floor.waitPoint?.conf.id === 'route', 20000);
|
||
g(rig, 'tap'); // 稳妥线:2+1=3
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
assert(game.run.rp === 9, '采集协议:稳妥 2+1=3(RP 6→9,跨档 st_rp_3)');
|
||
assert(plays(audio).has('voice/st_rp_3'), 'RP 6→9 跨档播 st_rp_3');
|
||
game.meta.unlocks = game.meta.unlocks.filter((x) => x !== 'harvest');
|
||
|
||
// 修理两级:离线→受损(2)→完好(2);告警即止;RP 跨档双向
|
||
game.run.sys.weapon = 'off';
|
||
game.run.rp = 5; // 5→3(无跨档)→1(跨档 st_rp_1)
|
||
game.state = 'DOCK';
|
||
game._enterDock();
|
||
await waitState(() => game.floor.waitPoint?.conf.id === 'dock', 20000);
|
||
const nItems = game._dock.items.length;
|
||
assert(game._dock.items.filter((i) => i.kind === 'repair').length === 1, 'DOCK 修理项=受损/离线系统各一项');
|
||
let mark = audio.log.length;
|
||
g(rig, 'tap');
|
||
await tick(3600);
|
||
assert(game.run.sys.weapon === 'dmg' && game.run.rp === 3, '离线→受损一步(2 资源)');
|
||
assert(plays(audio, mark).orderOk('sfx/wrench', 'voice/rep_ok', 'voice/eng_fix'), '修理链 wrench→rep_ok→eng_fix');
|
||
assert(game._dock.items.filter((i) => i.kind === 'repair').length === 1, '修一级后该项仍在(受损→完好第二步)');
|
||
g(rig, 'tap');
|
||
await tick(3600);
|
||
assert(game.run.sys.weapon === 'ok' && game.run.rp === 1, '受损→完好第二步(再 2 资源)');
|
||
assert(plays(audio, mark).has('voice/st_rp_1'), 'RP 3→1 跨档回播 st_rp_1(双向)');
|
||
assert(game._dock.items.length === 1 && game._dock.items[0].kind === 'go', '修复即止:修理项消失,仅剩启航');
|
||
// rep_poor
|
||
game.run.sys.scan = 'dmg'; game.run.rp = 1;
|
||
game._enterDock();
|
||
await waitState(() => game.floor.waitPoint?.conf.id === 'dock', 20000);
|
||
g(rig, 'tap');
|
||
await waitState(() => plays(audio).has('voice/rep_poor'), 10000);
|
||
assert(plays(audio).has('voice/rep_poor') && vibrated(ERROR_PATTERN), '资源<2 修理 → rep_poor + error 震');
|
||
assert(game.run.sys.scan === 'dmg', 'rep_poor 不修理(真话账)');
|
||
|
||
// 改装效果:armor1 上限 6→7 / vet 开局+1 / spare RP+2 / aegis 首伤−1
|
||
game.meta.unlocks = ['armor1', 'vet', 'spare', 'aegis'];
|
||
storageMap.set('soundgame.progress.bridge', JSON.stringify({ meta: game.meta, run: null }));
|
||
const rig2 = newGame({ seed: 9 });
|
||
setLadder(3);
|
||
rig2.game.start();
|
||
await tick(300);
|
||
assert(rig2.game._hullMax() === 7, 'armor1 舰体上限 6→7');
|
||
rig2.game.meta.patrols = 1; // 非首局(无 DRILL)
|
||
await waitState(() => rig2.game.floor.state === 'YOUR_TURN', 20000);
|
||
g(rig2, 'tap');
|
||
await waitState(() => rig2.game.state === 'ROUTE' && rig2.game.floor.waitPoint?.conf.id === 'route', 20000);
|
||
g(rig2, 'tap');
|
||
await waitState(() => rig2.game.state === 'CRUISE', 10000);
|
||
assert(rig2.game.run.rp === 4, 'spare 开局 RP+2(+采集 2 = 4)');
|
||
assert(rig2.game.run.crew.xo === 1 && rig2.game.run.crew.eng === 1 && rig2.game.run.crew.doc === 1, 'vet 开局全员忠诚+1');
|
||
// aegis:每段首次伤害 −1(减至 0 无伤播报)
|
||
const r2 = rig2.game.run;
|
||
r2.hull = 7; r2.seg = 2;
|
||
const out = rig2.game._hurt(1, 'meteor');
|
||
assert(r2.hull === 7 && out[0] === 'res_clean', 'aegis:每段首次受创减至 0 → 无伤');
|
||
const out2 = rig2.game._hurt(1, 'meteor');
|
||
assert(r2.hull === 6 && out2[0] === 'res_hurt', 'aegis 只挡每段第一刀');
|
||
game.stop(); rig2.game.stop();
|
||
ok('场景 9 通过');
|
||
}
|
||
|
||
// ================================ 场景 10:结构兜底(45s 自动线 / 等待点三档+L3 替启航 / DRILL L3 替播) ================================
|
||
|
||
async function scenario10() {
|
||
origConsoleLog('\n=== 场景 10:硬计时兜底 + 等待点三档 + 迷茫 L3 替做 ===');
|
||
storageMap.clear();
|
||
const rig = await cruiseRig(42);
|
||
const { game, audio } = rig;
|
||
game.run.seg = 1; game.run.route = null;
|
||
game._enterSeg(1);
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
await tick(49000); // 45s 无选择 → 自动稳妥线
|
||
assert(game.state === 'CRUISE' && game.run.route === 'safe', 'ROUTE 45s 自动稳妥线兜底(等待点 L3 同义,结构双保险)');
|
||
assert(plays(audio).has('voice/opt_route_safe'), '自动线播 opt_route_safe');
|
||
|
||
// DOCK 无进展:等待点三档(12s full → 30s short)+ 迷茫 L3 替启航(不替玩家花修理资源)
|
||
game.run.seg = 2; game.run.sys.scan = 'dmg'; game.run.rp = 5;
|
||
game.state = 'CRUISE';
|
||
game._endSeg();
|
||
await waitState(() => game.state === 'DOCK' && game.floor.waitPoint?.conf.id === 'dock', 60000);
|
||
game._dock.idx = 0;
|
||
const mark = audio.log.length;
|
||
const t0dock = vnow;
|
||
await waitState(() => plays(audio, mark).has('voice/hint_dock_full'), 30000);
|
||
const tFull = plays(audio, mark).entryOf('voice/hint_dock_full').t;
|
||
assert(Math.abs(tFull - t0dock - 12000) <= 1500, `DOCK 静默 12s 给 full 档(实际 ${tFull - t0dock}ms)`);
|
||
await waitState(() => plays(audio, mark).has('voice/hint_dock_short'), 40000);
|
||
assert(true, 'DOCK 持续静默 30s 降 short 档(26 §4.4)');
|
||
await waitState(() => game.state !== 'DOCK', 120000); // 迷茫 L3 替启航
|
||
assert(game.state === 'ROUTE' || game.state === 'CRUISE', 'DOCK 迷茫 L3 替启航(22 附录 C §5)自动离港');
|
||
assert(game.run.sys.scan === 'dmg' && game.run.rp === 5, 'L3 替启航零修理资源消耗(不替玩家花资源,真话)');
|
||
|
||
// DRILL D0:无进展 → 等待点提示 → 迷茫 L3 替播巡检自动过步
|
||
storageMap.clear();
|
||
const rig2 = newGame({ seed: 42 });
|
||
rig2.game.start();
|
||
await tick(300);
|
||
await waitState(() => rig2.game.floor.state === 'YOUR_TURN', 20000);
|
||
g(rig2, 'tap');
|
||
await waitState(() => rig2.game._drill && rig2.game._drill.step === 0, 20000);
|
||
const m2 = rig2.audio.log.length;
|
||
await waitState(() => rig2.game._drill && rig2.game._drill.step === 1, 120000);
|
||
assert(plays(rig2.audio, m2).orderOk('sfx/sys_engine_ok', 'sfx/sys_shield_ok', 'sfx/sys_scan_ok', 'sfx/sys_weapon_ok'),
|
||
'DRILL D0 无进展 → 迷茫 L3 替播巡检(自动过步不卡死)');
|
||
game.stop(); rig2.game.stop();
|
||
ok('场景 10 通过');
|
||
}
|
||
|
||
// ================================ 场景 11:中断复述与恢复 ================================
|
||
|
||
async function scenario11() {
|
||
origConsoleLog('\n=== 场景 11:存档重进 → 中断复述链 → 当前段 ROUTE ===');
|
||
storageMap.clear();
|
||
const rig1 = await cruiseRig(42);
|
||
rig1.game.run.sys.engine = 'dmg';
|
||
rig1.game.run.hull = 4;
|
||
rig1.game._save();
|
||
// 新实例模拟重进
|
||
const rig2 = newGame({ seed: 42 });
|
||
rig2.game.meta.patrols = 2;
|
||
rig2.game.meta.drillDone = true;
|
||
setLadder(3);
|
||
rig2.game.start();
|
||
await tick(500);
|
||
assert(rig2.game.state === 'TITLE' && rig2.game.run, '有档重进 TITLE 且 run 恢复');
|
||
assert(plays(rig2.audio).has('voice/sys_continue_ask'), 'TITLE 播 sys_continue_ask(回来问继续,§4.12)');
|
||
await waitState(() => rig2.game.floor.state === 'YOUR_TURN', 20000);
|
||
let mark = rig2.audio.log.length;
|
||
g(rig2, 'tap'); // 继续
|
||
await waitState(() => rig2.game.state === 'ROUTE', 30000);
|
||
const P = plays(rig2.audio, mark);
|
||
assert(P.orderOk('voice/st_hull_2', 'voice/sys_engine_dmg', 'voice/st_seg_1', 'voice/recap_go'),
|
||
'中断复述链:舰体档→最紧要异常→巡航段→recap_go');
|
||
assert(rig2.game.run.sys.engine === 'dmg' && rig2.game.run.hull === 4, '恢复后账目一致(复用 plan 不重洗)');
|
||
rig1.game.stop(); rig2.game.stop();
|
||
ok('场景 11 通过');
|
||
}
|
||
|
||
// ================================ 场景 12:REPORT 死窗 + 链锁 + 死亡链 ================================
|
||
|
||
async function scenario12() {
|
||
origConsoleLog('\n=== 场景 12:REPORT 800ms 死窗 / 链锁(critical 吞)/ 链完单击返航 ===');
|
||
storageMap.clear();
|
||
const rig = await cruiseRig(42);
|
||
const { game, audio } = rig;
|
||
const r = game.run;
|
||
r.seg = 4; r.hull = 6; r.rp = 9; r.aidOk = 1; r.kills = 1;
|
||
const mark = audio.log.length;
|
||
game._enterReport(false);
|
||
assert(performance.now() < game._graceUntil, '进 REPORT 盖 800ms 死窗戳');
|
||
g(rig, 'tap'); // 死窗内(critical 链在播 → busy 回执,不算输入)
|
||
await tick(300);
|
||
g(rig, 'tap'); // +300ms 仍在死窗
|
||
await tick(600);
|
||
g(rig, 'down'); g(rig, 'doubletap'); // 死窗内全吞(菜单不开)
|
||
await tick(200);
|
||
assert(game.state === 'REPORT' && !game._menu.open, '死窗内一切手势不产生输入(ghost 不算,回执可)');
|
||
await waitState(() => game._report.done, 60000);
|
||
assert(plays(audio, mark).orderOk('voice/rep_open', 'voice/rate_s', 'voice/merit_3', 'voice/settle_hook'), '结算链完整有序(链锁期未被扰动)');
|
||
const nLog = audio.log.length;
|
||
g(rig, 'tap'); // 链完单击=返航(settle_hook 言行一致)
|
||
await tick(300);
|
||
assert(game.state === 'TITLE', '链完单击返航 TITLE');
|
||
await waitState(() => game.floor.state === 'YOUR_TURN', 20000);
|
||
g(rig, 'up');
|
||
await tick(1500);
|
||
assert(plays(audio, nLog).has('voice/merit_3'), '上滑重听评级段(rate+merit)');
|
||
game.stop();
|
||
ok('场景 12 通过');
|
||
}
|
||
|
||
// ================================ 场景 13:模态菜单(左右滑换项 + 单击确认) ================================
|
||
|
||
async function scenario13() {
|
||
origConsoleLog('\n=== 场景 13:菜单换项/确认/二次确认/冻结/保存/放弃 ===');
|
||
storageMap.clear();
|
||
const rig = await cruiseRig(42);
|
||
const { game, audio } = rig;
|
||
// 危机中开菜单冻结倒计时
|
||
game.state = 'CRISIS';
|
||
const c = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, startedAt: vnow, echo: false };
|
||
game._crisis = c;
|
||
game._armCountdown(c);
|
||
await tick(300);
|
||
g(rig, 'down'); // 开菜单(冻结)——阶段 2 已解锁(cruiseRig 设 stage 3)
|
||
await waitState(() => game._menu.open && game.floor.waitPoint?.conf.id === 'menu', 20000);
|
||
assert(plays(audio).orderOk('voice/sys_menu', 'voice/sys_menu_resume'), '菜单打开播 sys_menu+首项(单击循环菜单废止)');
|
||
await tick(20000); // 冻结期走过远超剩余窗长
|
||
assert(c.subphase === 'decide', '菜单冻结倒计时(零超时触发,R8)');
|
||
g(rig, 'right'); // 换到 save
|
||
await tick(1500);
|
||
assert(plays(audio).has('voice/sys_menu_save'), '右滑换项报名 sys_menu_save(左右滑=换项)');
|
||
g(rig, 'right'); // 换到 restart(破坏性 → 二次确认)
|
||
await tick(1500);
|
||
assert(plays(audio).has('voice/sys_menu_restart'), '第三项=sys_menu_restart');
|
||
g(rig, 'tap');
|
||
await tick(1500);
|
||
assert(plays(audio).has('voice/menu_restart_confirm'), '放弃巡逻单击=二次确认句(原则 6)');
|
||
g(rig, 'up'); // 帮助(重听说明 + 设置提示)
|
||
await tick(2000);
|
||
assert(plays(audio).has('voice/sys_help') && plays(audio).has('voice/settings_hint'), '菜单内上滑=sys_help+settings_hint');
|
||
g(rig, 'down'); // 关闭
|
||
await tick(1500);
|
||
assert(!game._menu.open, '下滑关闭菜单');
|
||
assert(plays(audio).has('voice/opt_evade'), '危机关闭菜单重播当前选项句');
|
||
assert(c.subphase === 'decide', '关闭后倒计时续走(仍未超时)');
|
||
game._settleCrisis(c, 'evade');
|
||
await waitState(() => game.state === 'DOCK', 60000); // 等段末过渡落地再开菜单
|
||
|
||
// 保存并回大厅:PUT summary + 导航
|
||
storageMap.set('soundgame.auth', JSON.stringify({ username: 't', token: 'x' }));
|
||
const putBefore = fetchLog.length;
|
||
g(rig, 'down');
|
||
await waitState(() => game._menu.open && game.floor.waitPoint?.conf.id === 'menu', 20000);
|
||
g(rig, 'right'); // resume → save
|
||
await tick(1500);
|
||
g(rig, 'tap'); // 确认 save
|
||
await waitState(() => game._goingHome, 20000);
|
||
assert(game._goingHome, '保存并回大厅执行');
|
||
assert(fetchLog.length > putBefore, '菜单保存触发 PUT');
|
||
assert(JSON.parse(fetchLog[fetchLog.length - 1].opts.body).data.summary.length > 0, 'PUT data.summary 命中生成规则');
|
||
|
||
// 放弃本次巡逻:run 清空 meta 保留(直接走内部入口,交互路径已由二次确认覆盖)
|
||
storageMap.clear();
|
||
const rig2 = newGame({ seed: 42 });
|
||
rig2.game.meta.patrols = 3;
|
||
rig2.game.meta.merit = 21;
|
||
setLadder(3);
|
||
rig2.game.start();
|
||
await tick(300);
|
||
await waitState(() => rig2.game.floor.state === 'YOUR_TURN', 20000);
|
||
g(rig2, 'tap');
|
||
await waitState(() => rig2.game.run && rig2.game.state === 'ROUTE', 20000);
|
||
rig2.game._menuAbortPatrol();
|
||
await tick(300);
|
||
assert(rig2.game.run === null && rig2.game.meta.merit === 21 && rig2.game.state === 'TITLE', '放弃巡逻:run 清空、meta 保留、回 TITLE');
|
||
|
||
// —— 转移链(段末 evt_dock)播中开菜单 → 掐链不丢流程,关闭后续走,零死锁
|
||
storageMap.clear();
|
||
const rig3 = await cruiseRig(42);
|
||
const r3 = rig3.game.run;
|
||
r3.seg = 2; r3.sys.engine = 'ok'; r3.hull = 6;
|
||
const c3 = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, startedAt: vnow, echo: false };
|
||
rig3.game.state = 'CRISIS';
|
||
rig3.game._crisis = c3;
|
||
const m3 = rig3.audio.log.length;
|
||
rig3.game._settleCrisis(c3, 'evade'); // res_clean 结算链(critical,菜单开不进)
|
||
await waitState(() => plays(rig3.audio, m3).has('voice/evt_dock') && rig3.audio.isVoicePlaying(), 60000);
|
||
g(rig3, 'down'); // evt_dock 转移链播中开菜单(非 critical:允许开,兜底定时器顺延)
|
||
await tick(300);
|
||
assert(rig3.game._menu.open, '转移链播中可开菜单(R8 冻结,链由 _sayFlow 兜底)');
|
||
const settled = await waitState(() => !rig3.game._menu.open || true, 1000);
|
||
g(rig3, 'down'); // 关菜单 → 兜底续走
|
||
await waitState(() => rig3.game.state === 'DOCK', 60000);
|
||
assert(rig3.game.state === 'DOCK', '转移链兜底推进:菜单关闭后段末照常落地(零死锁)');
|
||
game.stop(); rig2.game.stop(); rig3.game.stop();
|
||
ok('场景 13 通过(含转移链兜底)');
|
||
}
|
||
|
||
// ================================ 场景 14:E1/E2/资产齐套 ================================
|
||
|
||
async function scenario14() {
|
||
origConsoleLog('\n=== 场景 14:E1 重播告警 / E2 决策窗长按 / 存量资产 ===');
|
||
storageMap.clear();
|
||
const rig = await cruiseRig(42);
|
||
const { game, audio } = rig;
|
||
// E1:无近期告警 → cruise_ping
|
||
let mark = audio.log.length;
|
||
g(rig, 'tap');
|
||
await tick(100);
|
||
assert(plays(audio, mark).has('sfx/cruise_ping'), 'E1:60s 内无告警 → cruise_ping(空态绝不静默,silent-ok)');
|
||
// 有近期告警 → 重播
|
||
game._noteAlarm('alm_engine');
|
||
game._lastAlarmPlay.at = vnow - 10000;
|
||
mark = audio.log.length;
|
||
g(rig, 'tap');
|
||
await tick(100);
|
||
assert(plays(audio, mark).has('sfx/alm_engine'), 'E1:60s 窗内单击重播最近告警');
|
||
// E2:决策窗长按=invalid 回执+首次解释(管道锁定门,gd_invalid 复用为通用解释句)
|
||
game.state = 'CRISIS';
|
||
const c = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, startedAt: vnow, echo: false };
|
||
game._crisis = c;
|
||
mark = audio.log.length;
|
||
g(rig, 'longpress');
|
||
await tick(1500);
|
||
assert(plays(audio, mark).has('voice/gd_invalid'), 'E2:决策窗长按=invalid 回执+gd_invalid 首次解释(长按新手期无效)');
|
||
|
||
// 存量资产:旁白 144 基表 ×2 性别(26 号合并后) + NPC 12 平铺 + sfx + bgm(新句 sys_*/hint_*/inv_*
|
||
// 未落盘不判失败——由主会话统一生成;这里只锁存量集合不被误删)
|
||
const audioDir = path.join(BRIDGE_DIR, 'audio', 'voice');
|
||
const keyF = fs.readdirSync(path.join(audioDir, 'female', 'key')).filter((f) => f.endsWith('.ogg'));
|
||
const keyM = fs.readdirSync(path.join(audioDir, 'male', 'key')).filter((f) => f.endsWith('.ogg'));
|
||
assert(keyF.length === 31 && keyM.length === 31, `键盘变体 key/ 26 号合并后两性别各 31(实际 f=${keyF.length} m=${keyM.length})`);
|
||
assert(keyF.map((f) => f.replace('.ogg', '')).sort().join() === keyM.map((f) => f.replace('.ogg', '')).sort().join(), 'key 两性别 id 集合一致');
|
||
const ids = (dir) => fs.readdirSync(dir).filter((f) => f.endsWith('.ogg')).map((f) => f.replace('.ogg', ''));
|
||
const base = ids(path.join(audioDir, 'female')).filter((x) => x !== 'key');
|
||
const male = ids(path.join(audioDir, 'male')).filter((x) => x !== 'key');
|
||
assert(base.length === 144 && male.length === 144, `旁白基表 26 号合并后 144×2(实际 f=${base.length} m=${male.length})`);
|
||
assert(base.slice().sort().join() === male.slice().sort().join(), '旁白双性别 id 集合完全一致');
|
||
const flat = ids(audioDir).filter((x) => !['female', 'male'].includes(x));
|
||
assert(flat.length === 12, `NPC 平铺 12(实际 ${flat.length})`);
|
||
for (const need of ['xo_fight', 'xo_win', 'xo_uneasy', 'xo_aid', 'eng_wear', 'eng_fix', 'eng_sup', 'eng_gripe', 'doc_aid', 'doc_ignore', 'doc_care', 'doc_trap']) {
|
||
assert(flat.includes(need), `NPC 平铺 ${need} 在位`);
|
||
}
|
||
const sfx = ids(path.join(BRIDGE_DIR, 'audio', 'sfx'));
|
||
assert(sfx.length === 39, `sfx 39 文件(31 基 + 8 pan 派生,实际 ${sfx.length})`);
|
||
for (const s of ['sig_meteor_l', 'sig_meteor_r', 'sig_pirate_l', 'sig_pirate_r', 'sig_distress_l', 'sig_distress_r', 'sig_radiation_l', 'sig_radiation_r']) {
|
||
assert(sfx.includes(s), `pan 派生 ${s} 在位`);
|
||
}
|
||
const bgm = ids(path.join(BRIDGE_DIR, 'audio', 'bgm'));
|
||
assert(bgm.length === 3 && bgm.includes('menu') && bgm.includes('cruise') && bgm.includes('crisis'), 'bgm 3 首(menu/cruise/crisis)');
|
||
game.stop();
|
||
ok('场景 14 通过');
|
||
}
|
||
|
||
// ================================ 场景 15:§6.2 乱点机器人 ================================
|
||
|
||
/**
|
||
* 急躁新手模拟(26 §6.2 行为表):语音开始 0.5~2s 就单击、连点 2~3 下、
|
||
* 随机方向滑、1s 内没回应再点、发呆 10~30s。跑完首局教学 + S1 + 靠港。
|
||
* 指标:无声操作=0、被动原句重复=0、打断失败=0(§6.4 合格线)。
|
||
*
|
||
* 被动原句重复口径:同一 voice id 30s 内两次,且第二次不处于玩家请求链——
|
||
* 请求链 = floor.speak 起 播 前 12s 内有手势(巡检/选项报名/续播等),或前面
|
||
* 2.5s 内有「再说一遍:」;被动链(hint/feedback 类被动提示)重复即计数。
|
||
*/
|
||
async function scenario15() {
|
||
origConsoleLog('\n=== 场景 15:乱点机器人(§6.2:无声操作 0 / 被动原句重复 0 / 打断失败 0) ===');
|
||
storageMap.clear(); // stage 0 起:D0 提前解锁路径同真人冷启动
|
||
const rig = newGame({ seed: 2026 });
|
||
const { game, audio } = rig;
|
||
|
||
// 回执记录(无声操作指标探针;mock 无 ctx,receipts.play 只记账不出声)
|
||
const receiptLog = [];
|
||
const origReceipt = game.receipts.play.bind(game.receipts);
|
||
game.receipts.play = (kind) => { receiptLog.push({ kind, t: vnow }); return origReceipt(kind); };
|
||
// 语音链记账(被动原句重复指标:链起点 + 是否被动 hint/feedback 类)
|
||
const chainTags = [];
|
||
const origSpeak = game.floor.speak.bind(game.floor);
|
||
game.floor.speak = (ids, opts = {}) => {
|
||
chainTags.push({ t: vnow, ids: [...ids], passive: opts.kind === 'hint' || opts.kind === 'feedback' });
|
||
return origSpeak(ids, opts);
|
||
};
|
||
|
||
let seed = 42;
|
||
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||
|
||
const gestures = [];
|
||
const bot = (gg) => {
|
||
const voiceBefore = audio._voice; // 打断失败指标:手势前的在播句 token
|
||
gestures.push({ g: gg, t: vnow, token: voiceBefore });
|
||
rig.pipe(gg);
|
||
return voiceBefore;
|
||
};
|
||
|
||
game.start();
|
||
await tick(50);
|
||
const t0 = vnow;
|
||
// 确定性首拍:开场指令句播完立刻点(第一次操作 ≤10s,§6.4)
|
||
await waitState(() => game.floor.state === 'YOUR_TURN', 20000);
|
||
bot('tap');
|
||
|
||
const idleChunks = [];
|
||
let firstSuccessMs = null;
|
||
|
||
for (let step = 0; step < 150; step++) {
|
||
if (game.state === 'REPORT' && game._report?.done) break;
|
||
const roll = rnd();
|
||
if (roll < 0.35) {
|
||
// 语音开始后单击(跳句/继续/下令)
|
||
await tick(300 + Math.floor(rnd() * 900));
|
||
bot('tap');
|
||
} else if (roll < 0.55) {
|
||
// 连点 2~3 下(间隔 80~250ms;第二下多落在 400ms 防连点窗 → busy)
|
||
bot('tap');
|
||
await tick(80 + Math.floor(rnd() * 170));
|
||
bot('tap');
|
||
if (rnd() < 0.5) { await tick(80 + Math.floor(rnd() * 170)); bot('tap'); }
|
||
} else if (roll < 0.8) {
|
||
// 随机方向滑(含未解锁的 up/down——锁定门给回执+首次解释)
|
||
const dirs = ['up', 'down', 'left', 'right'];
|
||
bot(dirs[Math.floor(rnd() * 4)]);
|
||
await tick(150);
|
||
} else if (roll < 0.9) {
|
||
// 1 秒没听到回应就再点一次
|
||
await tick(1000);
|
||
bot('tap');
|
||
} else {
|
||
// 发呆 10~30s(虚拟)
|
||
const before = vnow;
|
||
await tick(10000 + Math.floor(rnd() * 20000));
|
||
idleChunks.push(vnow - before);
|
||
}
|
||
if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = vnow - t0;
|
||
await tick(120);
|
||
}
|
||
|
||
// ---- 指标 1:无声操作 = 0(每个手势 100ms 内有回执或内容音)----
|
||
let silentOps = 0;
|
||
for (const ge of gestures) {
|
||
const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100);
|
||
const content = audio.log.some((p) => p.t >= ge.t && p.t <= ge.t + 100);
|
||
if (!receipt && !content) silentOps++;
|
||
}
|
||
assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`);
|
||
|
||
// ---- 指标 2:第一次操作 ≤10s、第一次成功 ≤20s(§6.4)----
|
||
assert(gestures.length > 0 && gestures[0].t - t0 <= 10000, `第一次操作 ≤10s(实际 ${gestures.length ? gestures[0].t - t0 : 'none'}ms)`);
|
||
assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功 ≤20s(实际 ${firstSuccessMs}ms)`);
|
||
|
||
// ---- 指标 3:打断失败 = 0(手势后同一句语音不应还在播;critical 句除外)----
|
||
let interruptFails = 0;
|
||
for (const ge of gestures) {
|
||
if (!ge.token) continue;
|
||
await tick(0);
|
||
if (audio._voice === ge.token) interruptFails++; // 旧句 token 仍存活 = 打断失败
|
||
}
|
||
assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails})`);
|
||
|
||
// ---- 指标 4:被动原句重复 = 0(30s 内同 id 重播且非玩家请求)----
|
||
const chainAt = (t) => {
|
||
let best = null;
|
||
for (const c of chainTags) if (c.t <= t) best = c; else break;
|
||
return best;
|
||
};
|
||
let passiveRepeats = 0;
|
||
const lastPlay = new Map();
|
||
for (const p of audio.log) {
|
||
if (!p.id.startsWith('voice/')) continue;
|
||
const sayAgainAt = audio.log.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500);
|
||
const chain = chainAt(p.t);
|
||
const gestureBefore = gestures.some((gg) => gg.t <= chain.t && chain.t - gg.t <= 12000);
|
||
const requested = !sayAgainAt && chain && !chain.passive && gestureBefore;
|
||
if (!requested && lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt) passiveRepeats++;
|
||
lastPlay.set(p.id, p.t);
|
||
}
|
||
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`);
|
||
|
||
// 行为完整性:机器人确实走了一段完整交互(教学 + 至少一次危机或靠港)
|
||
assert(gestures.length >= 40, `机器人手势量足够(实际 ${gestures.length})`);
|
||
const seen = new Set(audio.log.map((e) => e.id));
|
||
assert(seen.has('voice/sys_first_tap') && seen.has('voice/hint_inspect_full'), '机器人经历了开场指令与巡检教学');
|
||
game.stop();
|
||
ok(`场景 15 通过(${gestures.length} 手势 / 发呆 ${idleChunks.length} 次 / 首成功 ${firstSuccessMs}ms)`);
|
||
}
|
||
|
||
// ================================ 主流程 ================================
|
||
|
||
const scenarios = [
|
||
scenario1, scenario2, scenario3, scenario4, scenario5, scenario6, scenario7,
|
||
scenario8, scenario9, scenario10, scenario11, scenario12, scenario13, scenario14, scenario15,
|
||
];
|
||
|
||
for (const s of scenarios) {
|
||
try {
|
||
await s();
|
||
} catch (e) {
|
||
failures.push(`${s.name} 抛异常: ${e.stack}`);
|
||
origConsoleError(` ✗ ${s.name} 抛异常: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// ---- 资产信息性审计(不判失败;新句 sys_*/hint_*/inv_* 待主会话生成) ----
|
||
{
|
||
const missing = [];
|
||
const audioDir = path.join(BRIDGE_DIR, 'audio');
|
||
for (const gdir of ['female', 'male']) {
|
||
for (const f of fs.readdirSync(path.join(audioDir, 'voice', gdir))) {
|
||
if (!f.endsWith('.ogg')) continue;
|
||
const base = path.join(audioDir, 'voice', gdir, f.slice(0, -4));
|
||
if (!hasPair(base)) missing.push(`voice/${gdir}/${f} 缺配对`);
|
||
}
|
||
}
|
||
const pendingNew = [];
|
||
for (const id of ['sys_first_tap', 'sys_first_tap_short', 'sys_turn_teach', 'sys_say_again',
|
||
'sys_unlock_up', 'sys_unlock_down', 'sys_unlock_dt', 'hint_inspect_full', 'hint_inspect_short',
|
||
'hint_route_full', 'hint_route_short', 'hint_decide_full', 'hint_dock_full', 'hint_dock_short',
|
||
'inv_detect', 'inv_cruise', 'inv_drill', 'inv_title', 'inv_report', 'inv_tour',
|
||
'inv_up_locked', 'inv_down_locked', 'menu_restart_confirm']) {
|
||
if (!assetExists('voice/' + id)) pendingNew.push(id);
|
||
}
|
||
origConsoleLog(`\n资产审计:缺失配对 ${missing.length};26 号新句待生成 ${pendingNew.length}(信息性)`);
|
||
for (const m of missing.slice(0, 10)) origConsoleLog(' - ' + m);
|
||
if (pendingNew.length) origConsoleLog(' 待生成: ' + pendingNew.join(', '));
|
||
}
|
||
|
||
origConsoleLog(`\n========================================`);
|
||
origConsoleLog(`playtest 断言通过 ${passCount},失败 ${failures.length}`);
|
||
if (failures.length) {
|
||
for (const f of failures) origConsoleError('FAIL: ' + f);
|
||
process.exit(1);
|
||
}
|
||
origConsoleLog('ALL GREEN');
|