Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
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 置完成。
2026-10-02 12:33:20 +08:00

1319 lines
66 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《星环舰长》无头试玩测试(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');