四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
1153 lines
55 KiB
JavaScript
1153 lines
55 KiB
JavaScript
/**
|
||
* 《星环舰长》无头试玩测试(docs/design/22-bridge.md §12 断言清单 14 组)。
|
||
*
|
||
* 以玩家身份模拟:mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟)
|
||
* 与音频引擎接口(播放日志 + voice onComplete 按虚拟时钟推进语音链),加载真实
|
||
* game.js,手势直接注入 game.onGesture。mock 层机制对齐 web/detective/test/playtest.mjs
|
||
* (navigator.maxTouchPoints=1 手机 persona;guide:'off' 回归纯玩法逻辑)。
|
||
*
|
||
* 资产契约:引擎对缺失资产只 console.error 不崩——本测试在资产未落盘时也必须
|
||
* 全绿(每句 8s 兜底续链推进),故 plays() 查询不过滤 missing 项;资产齐套后同样
|
||
* 全绿(顺序/次数断言与时长无关)。资产审计(双格式/双性别集合/pan 派生)做信息性
|
||
* 报告不判失败,硬格式断言只针对「必须存在」的键(场景 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);
|
||
|
||
// ================================ 资产存在性 ================================
|
||
|
||
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;
|
||
}
|
||
isVoicePlaying() { return this._voice !== null; }
|
||
setRateBoostEnabled() {}
|
||
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; }
|
||
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]);
|
||
const HIT_PATTERN = JSON.stringify([80]);
|
||
function vibrated(pattern, fromIdx = 0) {
|
||
return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === pattern);
|
||
}
|
||
|
||
// ================================ 游戏装配 ================================
|
||
|
||
const { BridgeGame } = await import('../game.js');
|
||
const { settings } = await import('../../engine/settings.js');
|
||
|
||
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' });
|
||
|
||
function newGame(opts = {}) {
|
||
const audio = new MockAudioEngine();
|
||
const game = new BridgeGame(audio, {}, opts);
|
||
return { game, audio };
|
||
}
|
||
const g = (game, gesture) => game.onGesture(gesture);
|
||
|
||
async function waitState(pred, maxMs = 30000) {
|
||
const t0 = vnow;
|
||
while (!pred() && vnow - t0 < maxMs) await tick(100);
|
||
return pred();
|
||
}
|
||
|
||
/** 巡检链等链播完的通用等待:无 voice 在播且无进行中的 tone 段。 */
|
||
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;
|
||
}
|
||
|
||
// ================================ 场景 1:TITLE / PORT / 经济骨架 ================================
|
||
|
||
async function scenario1() {
|
||
origConsoleLog('\n=== 场景 1:TITLE/PORT/概况链/存档摘要 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.start();
|
||
await tick(400);
|
||
assert(game.state === 'TITLE' && !game.run, '启动进 TITLE(无存档)');
|
||
assert(plays(audio).has('bgm/menu'), 'TITLE bgm/menu');
|
||
assert(game._graceUntil === 0, 'REPORT 死窗戳初始为 0');
|
||
|
||
// 无档双击 → intro 链(22 §15-R2 裁定)
|
||
let mark = audio.log.length;
|
||
g(game, 'doubletap');
|
||
await waitState(() => plays(audio, mark).has('voice/intro_5'), 20000);
|
||
assert(plays(audio, mark).orderOk('voice/intro_1', 'voice/intro_5'), '无档双击重听 intro_1..5');
|
||
await waitIdle(audio, game);
|
||
|
||
// 概况链:无改装 → 仅功勋档
|
||
mark = audio.log.length;
|
||
g(game, '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(game, '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');
|
||
g(game, 'tap');
|
||
await tick(1400);
|
||
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(game, 'right'); await tick(1200); }
|
||
assert(game._port.idx === 0, 'PORT 5 项+返回共 6 位循环回 0(armor2 依赖未出现)');
|
||
g(game, 'right'); g(game, 'right'); g(game, 'right'); g(game, 'right');
|
||
await tick(1200);
|
||
assert(game._port.idx === 4, '右滑 4 次到第 5 项');
|
||
g(game, 'right');
|
||
await tick(1200);
|
||
assert(plays(audio).has('voice/port_back'), '末项=port_back');
|
||
g(game, 'tap');
|
||
await tick(300);
|
||
assert(game.state === 'TITLE', '返回项回 TITLE');
|
||
// armor2 依赖:未购 armor1 时列表无 armor2
|
||
game.meta.unlocks = ['spare'];
|
||
g(game, '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 出现');
|
||
// 购买成功 + 已购预览 upg_done + 不重复购买
|
||
game.meta.merit = 100;
|
||
game._port.idx = 1; // harvest
|
||
mark = audio.log.length;
|
||
g(game, '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(game, 'doubletap');
|
||
await tick(200);
|
||
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})`);
|
||
ok('场景 1 通过');
|
||
}
|
||
|
||
// ================================ 场景 2:首局 DRILL 全链 + 学期零惩罚 ================================
|
||
|
||
async function scenario2() {
|
||
origConsoleLog('\n=== 场景 2:TITLE→DRILL D0..D4 + S1 驰援成功链 + 二周目不再进 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.start();
|
||
await tick(400);
|
||
let mark = audio.log.length;
|
||
g(game, 'tap'); // 新巡逻
|
||
await waitState(() => game.run && game.state === 'CRUISE' && game._drill?.armed, 40000);
|
||
assert(game.run.seed === 42, 'opts.seed=42 注入新巡逻');
|
||
assert(plays(audio, mark).orderOk('voice/sys_new_patrol', 'voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/intro_4', 'voice/intro_5', 'voice/tut_d0'),
|
||
'D0 序:sys_new_patrol → intro_1..5 → tut_d0');
|
||
assert(plays(audio).has('bgm/cruise'), '巡航 bgm/cruise');
|
||
assert(game.run.rp === 2, 'S1 采集 +2(稳妥线,首局固定)');
|
||
assert(game._drill.step === 0 && game._drill.armed, 'D0 已武装(链播完才武装)');
|
||
|
||
// D0:上滑 → 巡检全链(全 ok → tut_all_ok 尾句)
|
||
mark = audio.log.length;
|
||
g(game, '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).orderOk('voice/tut_all_ok', 'voice/tut_crisis'), 'D0→D1 衔接 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');
|
||
assert(plays(audio, mark).orderOk('voice/cr_decide', 'voice/opt_aid'), '决策窗自动预览第一选项 opt_aid');
|
||
assert(plays(audio, mark).has('bgm/crisis'), '危机 bgm/crisis');
|
||
|
||
// D2:右滑预览 → 左滑回 aid → 单击下令驰援
|
||
mark = audio.log.length;
|
||
g(game, 'right');
|
||
await tick(200);
|
||
assert(plays(audio, mark).has('voice/opt_ignore'), '右滑预览第二选项 opt_ignore');
|
||
g(game, 'left');
|
||
await tick(200);
|
||
assert(plays(audio, mark).has('voice/opt_aid'), '左滑循环回 opt_aid');
|
||
const hullBefore = game.run.hull, rpBefore = game.run.rp;
|
||
mark = audio.log.length;
|
||
g(game, '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._drill?.armed, 20000); // 等 tut_loyal 落声武装
|
||
mark = audio.log.length;
|
||
g(game, '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 完成后不在决策窗下令 → tut_done 收束 → DOCK + drillDone 持久化
|
||
storageMap.clear();
|
||
const g3 = newGame({ seed: 7 });
|
||
g3.game.start();
|
||
await tick(300);
|
||
g(g3.game, 'tap');
|
||
await waitState(() => g3.game._drill?.armed, 40000);
|
||
g(g3.game, 'up'); // D0 巡检
|
||
await waitState(() => g3.game._drill?.step === 1, 60000);
|
||
await waitState(() => g3.game.state === 'CRISIS', 120000);
|
||
const m3 = g3.audio.log.length;
|
||
await waitState(() => g3.game.state === 'DOCK', 210000); // 20s 窗超时 → 收束(D3 90s 自动演示)→ 段末
|
||
assert(plays(g3.audio, m3).has('voice/cr_timeout'), 'R11:教学危机超时走默认结局(真话)');
|
||
assert(plays(g3.audio, m3).has('voice/tut_done'), 'R11:收束播 tut_done');
|
||
assert(g3.game.meta.drillDone && g3.game.run.drillDone, 'R11:drillDone 持久化(次局不再进教学)');
|
||
ok('场景 2 通过(首局 DRILL 全链 + R11 超时收束)');
|
||
}
|
||
|
||
// ================================ 场景 3:seed 复现与首局约束 ================================
|
||
|
||
async function scenario3() {
|
||
origConsoleLog('\n=== 场景 3:seed 复现 / 首局约束 / 非首局 S1≠辐射 ===');
|
||
storageMap.clear();
|
||
const mk = () => { const { game } = newGame({ seed: 42 }); game.meta.patrols = 0; return 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 gm = newGame({ seed });
|
||
gm.game.meta.patrols = 1;
|
||
const run = gm.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 { game, audio } = newGame({ seed: 7 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
g(game, 'tap'); // 稳妥线
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
// 注入两系统异常(scan dmg / engine off)
|
||
game.run.sys.scan = 'dmg';
|
||
game.run.sys.engine = 'off';
|
||
game.run.offlineEver = true;
|
||
let mark = audio.log.length;
|
||
g(game, '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'), '异常句按系统固定序');
|
||
// 中断保护:左右滑被忽略;单击跳段(语音段→链作废);再开全链
|
||
mark = audio.log.length;
|
||
g(game, 'up');
|
||
await tick(300);
|
||
const t0 = vnow;
|
||
g(game, 'left'); g(game, 'right');
|
||
await tick(500);
|
||
assert(!vibrated(ERROR_PATTERN, vibrateLog.length - 2), '巡检链播中左右滑静默忽略(无 error 震)');
|
||
g(game, 'tap'); // 第一拍:tone 段 → 跳语音段
|
||
await tick(11000); // 语音段开播
|
||
g(game, 'tap'); // 第二拍:作废语音链
|
||
await tick(500);
|
||
assert(!game._tourState, '单击跳段后巡检链关闭');
|
||
ok('场景 4 通过');
|
||
}
|
||
|
||
// ================================ 场景 5:危机矩阵(§4.2 全组合注入) ================================
|
||
|
||
/** 矩阵注入:给定类型/选项/系统态,结算并断言语音序与账目差分。 */
|
||
async function matrixCase(type, opt, st, expect) {
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
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}`);
|
||
}
|
||
|
||
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 } = newGame({ seed: 42 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
|
||
// 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');
|
||
const t0 = vnow;
|
||
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(['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 进入告警清空(结算独占)');
|
||
ok('场景 6 通过(仲裁 + 双场景并发)');
|
||
}
|
||
|
||
// ================================ 场景 7:忠诚账本 / 支援 / 离心 ================================
|
||
|
||
async function scenario7() {
|
||
origConsoleLog('\n=== 场景 7:忠诚账本差分 / band 句 / 支援触发 / sup_none ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
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 g2 = newGame({ seed: 42 });
|
||
g2.game.meta.patrols = 1; g2.game.meta.drillDone = true;
|
||
g2.game.start();
|
||
await tick(300);
|
||
g(g2.game, 'tap');
|
||
await waitState(() => g2.game.state === 'ROUTE', 10000);
|
||
g(g2.game, 'tap');
|
||
await waitState(() => g2.game.state === 'CRUISE', 10000);
|
||
g2.game.run.seg = 2;
|
||
g2.game.run.sys.engine = 'dmg';
|
||
g2.game.run.hullLostSeg = 1;
|
||
mark = g2.audio.log.length;
|
||
g2.game._endSeg();
|
||
await waitState(() => g2.game.state === 'DOCK', 60000);
|
||
assert(g2.audio && plays(g2.audio, mark).has('voice/sup_none'), 'sup_none:条件不满足(band<2)支援缺席');
|
||
g2.game.run.seg = 3;
|
||
g2.game.run.hullLostSeg = 1;
|
||
mark = g2.audio.log.length;
|
||
g2.game.state = 'CRUISE';
|
||
g2.game._endSeg();
|
||
await waitState(() => g2.game.state === 'DOCK', 60000);
|
||
assert(!plays(g2.audio, mark).has('voice/sup_none'), 'sup_none 每局限 1 次(第二次静默)');
|
||
ok('场景 7 通过');
|
||
}
|
||
|
||
// ================================ 场景 8:评级验算(剧本 A/B/失败,§8.4-8.6) ================================
|
||
|
||
async function reportCase(mutate, expect) {
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
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');
|
||
}
|
||
|
||
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 { game, audio } = newGame({ seed: 42 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
g(game, 'right'); // 矿富线
|
||
await tick(200);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
assert(game.run.rp === 4, '矿富线采集 +4/段');
|
||
assert(plays(audio).has('voice/st_rp_2'), 'RP 0→4 跨档播 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', 10000);
|
||
g(game, 'tap'); // 稳妥线:2+1=3
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
assert(game.run.rp === 7, '采集协议:稳妥 2+1=3(RP 4→7,跨档 st_rp_3)');
|
||
assert(plays(audio).has('voice/st_rp_3'), 'RP 4→7 跨档播 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 tick(6800); // dock_open + 首项预览播完
|
||
const nItems = game._dock.items.length;
|
||
assert(game._dock.items.filter((i) => i.kind === 'repair').length === 1, 'DOCK 修理项=受损/离线系统各一项');
|
||
// 选中 weapon 修理项(首项即 weapon)
|
||
let mark = audio.log.length;
|
||
g(game, '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(game, '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 tick(300);
|
||
g(game, 'tap');
|
||
await tick(200);
|
||
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(st_hull 档按新比例)/ 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 g2 = newGame({ seed: 9 });
|
||
g2.game.start();
|
||
await tick(300);
|
||
assert(g2.game._hullMax() === 7, 'armor1 舰体上限 6→7');
|
||
g2.game.meta.patrols = 1; // 非首局(无 DRILL)
|
||
g(g2.game, 'tap');
|
||
await waitState(() => g2.game.state === 'ROUTE', 10000);
|
||
g(g2.game, 'tap');
|
||
await waitState(() => g2.game.state === 'CRUISE', 10000);
|
||
assert(g2.game.run.rp === 4, 'spare 开局 RP+2(+采集 2 = 4)');
|
||
assert(g2.game.run.crew.xo === 1 && g2.game.run.crew.eng === 1 && g2.game.run.crew.doc === 1, 'vet 开局全员忠诚+1');
|
||
// aegis:每段首次伤害 −1(减至 0 无伤播报)
|
||
const r2 = g2.game.run;
|
||
r2.hull = 7; r2.seg = 2;
|
||
const out = g2.game._hurt(1, 'meteor');
|
||
assert(r2.hull === 7 && out[0] === 'res_clean', 'aegis:每段首次受创减至 0 → 无伤');
|
||
const out2 = g2.game._hurt(1, 'meteor');
|
||
assert(r2.hull === 6 && out2[0] === 'res_hurt', 'aegis 只挡每段第一刀');
|
||
ok('场景 9 通过');
|
||
}
|
||
|
||
// ================================ 场景 10:结构兜底(45s 自动线 / DOCK 无进展 / DRILL 自动演示) ================================
|
||
|
||
async function scenario10() {
|
||
origConsoleLog('\n=== 场景 10:硬计时兜底(结构性无真空) ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
await tick(49000); // 45s 无选择 + 选项句落声
|
||
assert(game.state === 'CRUISE' && game.run.route === 'safe', 'ROUTE 45s 自动稳妥线兜底');
|
||
assert(plays(audio).has('voice/opt_route_safe'), '自动线播 opt_route_safe');
|
||
// DOCK 无进展轨:45s turn_tick → 150s 自动预览启航项(不替玩家花资源)
|
||
game.run.seg = 2; game.run.sys.scan = 'dmg'; game.run.rp = 5;
|
||
game._enterReport !== null;
|
||
game.state = 'CRUISE';
|
||
game._endSeg();
|
||
await waitState(() => game.state === 'DOCK', 60000);
|
||
game._dock.idx = 0;
|
||
const mark = audio.log.length;
|
||
await tick(46000);
|
||
assert(plays(audio, mark).has('sfx/turn_tick'), 'DOCK 无进展 L1(45s)turn_tick');
|
||
await tick(105000); // 到 150s
|
||
assert(game._dock.idx === game._dock.items.length - 1, 'DOCK 无进展 L3:自动预览启航项(选择移到 go,不替玩家花资源)');
|
||
assert(game.run.sys.scan === 'dmg' && game.run.rp === 5, 'L3 零资源消耗(真话)');
|
||
|
||
// DRILL 自动演示:90s 无进展自动通过 D0
|
||
storageMap.clear();
|
||
const g2 = newGame({ seed: 42 });
|
||
g2.game.start();
|
||
await tick(300);
|
||
g(g2.game, 'tap');
|
||
await waitState(() => g2.game._drill && g2.game._drill.step === 0, 20000);
|
||
await waitState(() => g2.game._drill && g2.game._drill.step === 1, 100000);
|
||
assert(true, 'DRILL 30/60/90 自动演示通过 D0(无进展不卡死)');
|
||
ok('场景 10 通过');
|
||
}
|
||
|
||
// ================================ 场景 11:中断复述与恢复 ================================
|
||
|
||
async function scenario11() {
|
||
origConsoleLog('\n=== 场景 11:存档重进 → 中断复述链 → 当前段 ROUTE ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.meta.patrols = 2;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap'); // 继续(无 run → 新巡逻?有档与否:无 run)
|
||
await waitState(() => game.run && game.state === 'ROUTE', 20000);
|
||
g(game, 'tap'); // 稳妥线
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
game.run.sys.engine = 'dmg';
|
||
game.run.hull = 4;
|
||
game._save();
|
||
// 新实例模拟重进
|
||
const g2 = newGame({ seed: 42 });
|
||
g2.game.meta.patrols = 2;
|
||
g2.game.meta.drillDone = true;
|
||
g2.game.start();
|
||
await tick(500);
|
||
assert(g2.game.state === 'TITLE' && g2.game.run, '有档重进 TITLE 且 run 恢复');
|
||
assert(plays(g2.audio).has('voice/sys_continue_ask'), 'TITLE 播 sys_continue_ask');
|
||
let mark = g2.audio.log.length;
|
||
g(g2.game, 'tap'); // 继续
|
||
await waitState(() => g2.game.state === 'ROUTE', 30000);
|
||
const P = plays(g2.audio, mark);
|
||
assert(P.orderOk('voice/st_hull_2', 'voice/sys_engine_dmg', 'voice/st_seg_1', 'voice/recap_go'),
|
||
'中断复述链:舰体档→最紧要异常→巡航段→recap_go');
|
||
assert(g2.game.run.sys.engine === 'dmg' && g2.game.run.hull === 4, '恢复后账目一致(复用 plan 不重洗)');
|
||
ok('场景 11 通过');
|
||
}
|
||
|
||
// ================================ 场景 12:REPORT 死窗 + 链锁 + 死亡链 ================================
|
||
|
||
async function scenario12() {
|
||
origConsoleLog('\n=== 场景 12:REPORT 800ms 死窗 / 链锁 / 链完单击返航 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
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(game, 'tap'); // 死窗内
|
||
await tick(300);
|
||
g(game, 'tap'); // +300ms 仍在死窗(800 全盖)
|
||
await tick(600);
|
||
g(game, 'down'); g(game, '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(game, 'tap'); // 链完单击=返航(settle_hook 言行一致)
|
||
await tick(300);
|
||
assert(game.state === 'TITLE', '链完单击返航 TITLE');
|
||
g(game, 'up');
|
||
await tick(200);
|
||
assert(plays(audio, nLog).has('voice/merit_3'), '上滑重听评级段(rate+merit)');
|
||
ok('场景 12 通过');
|
||
}
|
||
|
||
// ================================ 场景 13:模态菜单 ================================
|
||
|
||
async function scenario13() {
|
||
origConsoleLog('\n=== 场景 13:菜单循环/确认/冻结/保存/放弃 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
// 危机中开菜单冻结倒计时
|
||
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);
|
||
const remainBefore = c.remainMs - (vnow - c.startedAt);
|
||
g(game, 'down'); // 开菜单(冻结)
|
||
await tick(6800); // sys_menu + 首项 + settings_hint 三句链播完
|
||
assert(game._menu.open && plays(audio).orderOk('voice/sys_menu', 'voice/sys_menu_resume', 'voice/settings_hint'), '菜单打开播 sys_menu+首项+settings_hint');
|
||
await tick(20000); // 冻结期走过远超剩余窗长
|
||
assert(c.subphase === 'decide', '菜单冻结倒计时(零超时触发)');
|
||
g(game, 'tap'); // 循环到 save
|
||
await tick(1400);
|
||
assert(plays(audio).has('voice/sys_menu_save'), '单击循环切换菜单项(resume→save)');
|
||
g(game, 'tap'); // 循环到 help
|
||
await tick(1400);
|
||
g(game, 'up'); // 帮助(上滑=sys_help)
|
||
await tick(1400);
|
||
assert(plays(audio).has('voice/sys_help'), '菜单内上滑=sys_help');
|
||
g(game, 'down'); // 关闭
|
||
await tick(1400);
|
||
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(game, 'down');
|
||
await tick(6800);
|
||
g(game, 'tap'); // resume → save
|
||
await tick(1400);
|
||
g(game, 'doubletap'); // 确认 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 g2 = newGame({ seed: 42 });
|
||
g2.game.meta.patrols = 3;
|
||
g2.game.meta.merit = 21;
|
||
g2.game.start();
|
||
await tick(300);
|
||
g(g2.game, 'tap');
|
||
await waitState(() => g2.game.run && g2.game.state === 'ROUTE', 20000);
|
||
g2.game._menuAbortPatrol();
|
||
await tick(300);
|
||
assert(g2.game.run === null && g2.game.meta.merit === 21 && g2.game.state === 'TITLE', '放弃巡逻:run 清空、meta 保留、回 TITLE');
|
||
|
||
// —— R2 F5:结算链(转移承载链)播中开菜单 → 顺延不掐;转移完成后补开,零死锁
|
||
storageMap.clear();
|
||
const g3 = newGame({ seed: 42 });
|
||
g3.game.meta.patrols = 1;
|
||
g3.game.meta.drillDone = true;
|
||
g3.game.start();
|
||
await tick(300);
|
||
g(g3.game, 'tap');
|
||
await waitState(() => g3.game.state === 'ROUTE', 10000);
|
||
g(g3.game, 'tap');
|
||
await waitState(() => g3.game.state === 'CRUISE', 10000);
|
||
const r3 = g3.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 };
|
||
g3.game.state = 'CRISIS';
|
||
g3.game._crisis = c3;
|
||
g3.game._settleCrisis(c3, 'evade'); // res_clean 结算链(带 _afterCrisis 转移)
|
||
await tick(150); // 链在播
|
||
g(g3.game, 'down'); // 开菜单请求——应顺延(转移链 live)
|
||
assert(!g3.game._menu.open, 'F5:结算链播中菜单不立即开(顺延)');
|
||
const settled = await waitState(() => g3.game._menu.open, 60000);
|
||
assert(settled, 'F5:转移链完成后菜单补开(零死锁)');
|
||
assert(g3.game.state === 'DOCK', 'F5:段末转移照常落地(DOCK)');
|
||
g(g3.game, 'down'); // 关菜单
|
||
await tick(1500);
|
||
assert(!g3.game._menu.open, 'F5:菜单正常关闭');
|
||
ok('场景 13 通过(含 F5 转移链保护)');
|
||
}
|
||
|
||
// ================================ 场景 14:E1/E2/键盘变体/资产齐套 ================================
|
||
|
||
async function scenario14() {
|
||
origConsoleLog('\n=== 场景 14:E1 重播告警 / E2 决策窗长按 / 键盘资产 / 资产齐套 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.meta.patrols = 1;
|
||
game.meta.drillDone = true;
|
||
game.start();
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'ROUTE', 10000);
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'CRUISE', 10000);
|
||
// E1:无近期告警 → cruise_ping
|
||
let mark = audio.log.length;
|
||
g(game, 'tap');
|
||
await tick(100);
|
||
assert(plays(audio, mark).has('sfx/cruise_ping'), 'E1:60s 内无告警 → cruise_ping(空态绝不静默)');
|
||
// 有近期告警 → 重播
|
||
game._noteAlarm('alm_engine');
|
||
game._lastAlarmPlay.at = vnow - 10000;
|
||
mark = audio.log.length;
|
||
g(game, 'tap');
|
||
await tick(100);
|
||
assert(plays(audio, mark).has('sfx/alm_engine'), 'E1:60s 窗内单击重播最近告警');
|
||
// E2:决策窗长按=error
|
||
game.state = 'CRISIS';
|
||
const c = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, startedAt: vnow, echo: false };
|
||
game._crisis = c;
|
||
const vib = vibrateLog.length;
|
||
g(game, 'longpress');
|
||
await tick(100);
|
||
assert(vibrated(ERROR_PATTERN, vib), 'E2:决策窗长按=error 震(选择语境不作输入)');
|
||
// 键盘变体资产:key/ 目录 10 对(两性别一致且 ⊆ 主集由 check_all_assets 锁)
|
||
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 === 10 && keyM.length === 10, `键盘变体 key/ 两性别各 10(实际 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 集合一致');
|
||
// 资产齐套:旁白 132 基表 ×2 性别 + NPC 12 平铺 + sfx 31 基 + 8 pan 派生 + bgm 3
|
||
const ids = (dir) => fs.readdirSync(dir).filter((f) => f.endsWith('.ogg')).map((f) => f.replace('.ogg', ''));
|
||
const base = ids(path.join(audioDir, 'female'));
|
||
const male = ids(path.join(audioDir, 'male'));
|
||
assert(base.length === 132 && male.length === 132, `旁白基表 132×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)');
|
||
ok('场景 14 通过');
|
||
}
|
||
|
||
// ================================ 主流程 ================================
|
||
|
||
const scenarios = [
|
||
scenario1, scenario2, scenario3, scenario4, scenario5, scenario6, scenario7,
|
||
scenario8, scenario9, scenario10, scenario11, scenario12, scenario13, scenario14,
|
||
];
|
||
|
||
for (const s of scenarios) {
|
||
try {
|
||
await s();
|
||
} catch (e) {
|
||
failures.push(`${s.name} 抛异常: ${e.stack}`);
|
||
origConsoleError(` ✗ ${s.name} 抛异常: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// ---- 资产信息性审计(不判失败) ----
|
||
{
|
||
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} 缺配对`);
|
||
}
|
||
}
|
||
origConsoleLog(`\n资产审计:缺失配对 ${missing.length}(信息性)`);
|
||
for (const m of missing.slice(0, 10)) origConsoleLog(' - ' + m);
|
||
}
|
||
|
||
origConsoleLog(`\n========================================`);
|
||
origConsoleLog(`playtest 断言通过 ${passCount},失败 ${failures.length}`);
|
||
if (failures.length) {
|
||
for (const f of failures) origConsoleError('FAIL: ' + f);
|
||
process.exit(1);
|
||
}
|
||
origConsoleLog('ALL GREEN');
|