Files
SoundGame/web/teahouse/test/playtest.mjs
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

1488 lines
70 KiB
JavaScript
Raw 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.
/**
* 《深夜茶馆》无头试玩仿真测试(docs/design/20-teahouse.md §11 断言清单全录)。
*
* 方案(对齐 web/hunt/test/playtest.mjs):
* - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext;
* - 使用真实 engine/audio.js + settings.js + guide.js;
* - 手势直接注入 game.onGesture;虚拟时钟 patch setTimeout/setInterval/performance.now;
* - 音频资产并行生成中:fetch 对 audio/* 一律合成假 buffer(voice 1.8s / bgm 90s /
* 其余 sfx 按 SFX_DUR 表估),blockedAssets 中的片段强制 404(兜底断言用)。
*
* 运行:tools/node/bin/node web/teahouse/test/playtest.mjs
*/
const SAVE_KEY = 'soundgame.progress.teahouse';
const AUTH_KEY = 'soundgame.auth';
// ---------------------------------------------------------------- 断言收集
const realLog = console.log.bind(console);
let passCount = 0;
const failures = [];
function assert(cond, msg) {
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
else { failures.push(msg); realLog(` ✗ ${msg}`); }
}
const consoleErrs = [];
const consoleWarns = [];
console.error = (...a) => { consoleErrs.push(a.join(' ')); };
console.warn = (...a) => { consoleWarns.push(a.join(' ')); };
console.log = () => {};
// ---------------------------------------------------------------- 虚拟时钟
let now = 0;
let timerSeq = 1;
const timers = new Map();
const intervals = new Set();
const realSetImmediate = setImmediate;
globalThis.setTimeout = (cb, ms = 0, ...args) => {
const id = timerSeq++;
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args });
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
globalThis.setInterval = (cb, ms = 0, ...args) => {
const id = timerSeq++;
const step = (...a) => {
if (!intervals.has(id)) return;
cb(...a);
if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
};
intervals.add(id);
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
return id;
};
globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); };
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
async function advance(cond = null) {
for (let n = 0; n < 200000; n++) {
await flush(2);
if (cond && cond()) return 'cond';
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0) return 'idle';
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb(...t.args);
}
throw new Error('虚拟时钟定时器超上限(疑似死循环)');
}
globalThis.__timers = () => [...timers.entries()].map(([id, t]) => ({ id, in: t.fireAt - now, cb: String(t.cb).slice(0, 60) }));
async function advanceMs(ms) {
const until = now + ms;
while (now < until) {
await flush(2);
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0 || nextAt > until) { now = until; break; }
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb(...t.args);
}
await flush();
}
// ---------------------------------------------------------------- 浏览器 mock
globalThis.window = {
addEventListener: () => {},
location: { protocol: 'http:', host: 'test.local', href: '' },
AudioContext: null,
};
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } },
configurable: true,
});
const SFX_DUR = {
door_bell: 1.0, steps_in: 2.5, steps_out: 2.5,
sig_zhou: 0.45, sig_su: 0.5, sig_shi: 0.6, sig_bai: 0.3, sig_he: 0.55,
tea_jar: 0.35, tea_scoop: 0.35, water_pour: 2.0, boil_blip: 0.3,
boil_simmer: 1.6, boil_roar: 2.0, boil_over: 1.2, kettle_lift: 0.5,
steep_rustle: 2.0, tick_slow: 0.15, tick_fast: 0.12, pour_cup: 2.0,
coin_soft: 0.35, coin_stack: 0.6, rep_chime: 0.55, rep_fall: 0.55,
lamp_light: 0.25, lamp_off: 0.45, shop_buy: 0.8, upg_install: 0.6, turn_tick: 0.12,
};
class MockParam { constructor(v = 1) { this.value = v; } setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {} }
class MockNode { constructor() { this.connectedTo = null; } connect(t) { this.connectedTo = t; } disconnect() {} }
class MockGainNode extends MockNode { constructor() { super(); this.gain = new MockParam(); } }
class MockPannerNode extends MockNode { constructor() { super(); this.pan = new MockParam(0); } }
class MockAudioContext {
constructor() {
this.state = 'running'; this.destination = {}; this.sampleRate = 44100;
this.panners = []; this.gains = []; this.sources = [];
}
get currentTime() { return now / 1000; }
createGain() { const g = new MockGainNode(); this.gains.push(g); return g; }
createStereoPanner() { const p = new MockPannerNode(); this.panners.push(p); return p; }
createDynamicsCompressor() {
const n = new MockGainNode();
n.threshold = new MockParam(); n.ratio = new MockParam();
n.attack = new MockParam(); n.release = new MockParam();
return n;
}
createBufferSource() {
const s = new MockNode();
s.buffer = null; s.loop = false; s.playbackRate = new MockParam();
s.start = () => {}; s.stop = () => {};
this.sources.push(s);
return s;
}
createScriptProcessor() { return new MockNode(); }
createOscillator() {
return { type: 'sine', frequency: new MockParam(), connect() {}, start() {}, stop() {} };
}
decodeAudioData(ab) {
return Promise.resolve({
duration: durationOfUrl(ab && ab.__url),
numberOfChannels: 1, sampleRate: 44100,
getChannelData: () => new Float32Array(16),
});
}
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
function durationOfUrl(u) {
const s = String(u || '');
if (s.includes('/bgm/')) return 90;
if (s.includes('/voice/')) return 1.8;
const base = s.slice(s.lastIndexOf('/') + 1).replace(/\.\w+$/, '');
return SFX_DUR[base] ?? 0.5;
}
const requestedUrls = [];
const blockedAssets = new Set();
const apiPuts = [];
const serverBuckets = new Map();
globalThis.fetch = async (u, opts = {}) => {
const s = String(u);
requestedUrls.push(s);
if (s.startsWith('/api/')) {
if (s.startsWith('/api/progress') && (opts.method || 'GET') === 'PUT') {
let body = null;
try { body = JSON.parse(opts.body); } catch (_) { /* 静默 */ }
apiPuts.push({ body, t: now });
if (body && body.game) serverBuckets.set(body.game, body.data);
return { ok: true, status: 200, json: async () => ({ ok: true, game: body && body.game }) };
}
if (s.startsWith('/api/progress')) {
const gname = new URLSearchParams(s.split('?')[1] || '').get('game');
const data = serverBuckets.get(gname) || null;
return { ok: true, status: 200, json: async () => ({ game: gname, data }) };
}
return { ok: false, status: 404, json: async () => ({}) };
}
const blocked = [...blockedAssets].some((b) => s.includes(b));
if (blocked) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
const ab = new ArrayBuffer(8);
ab.__url = s;
return { ok: true, status: 200, arrayBuffer: async () => ab };
};
// ---------------------------------------------------------------- 加载引擎与游戏
globalThis.__DBG = process.env.DBG === '1';
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { TeahouseGame } = await import('../game.js');
function resetSettings(guide = 'off') {
settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' });
}
resetSettings('off');
storageData.set(AUTH_KEY, JSON.stringify({ username: '试玩掌柜', token: 'tok1' }));
// ---------------------------------------------------------------- 测试工具
const ERR_PATTERN = JSON.stringify([60, 40, 60]);
const CONFIRM_PATTERN = JSON.stringify([20]);
const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]);
const vibCount = (pattern, since = 0) => vibrateLog.filter((v) => JSON.stringify(v.pattern) === pattern && v.t >= since).length;
const playsOf = (playLog, id) => playLog.filter((e) => e.id === id).length;
const allPlayedIds = new Set();
function makeRig(seed = 42) {
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => {
playLog.push({ id, opts: opts || {}, t: now });
allPlayedIds.add(id);
return origPlay(id, opts);
};
const game = new TeahouseGame(audio, {}, { seed });
return { audio, playLog, game };
}
let gestureCount = 0;
async function g(game, gesture) {
gestureCount++;
game.onGesture(gesture);
await flush();
}
function getProgress() {
try { return JSON.parse(storageData.get(SAVE_KEY) || 'null'); } catch (_) { return null; }
}
function chainOrderOk(playLog, ids) {
const seq = playLog.filter((e) => e.id.startsWith('voice/')).map((e) => e.id);
let at = -1;
for (const id of ids) {
const i = seq.indexOf('voice/' + id, at + 1);
if (i < 0) return false;
at = i;
}
return true;
}
/** 链序断言(子日志版)。 */
function chainOrderOk2(log, ids) {
const seq = log.filter((e) => e.id.startsWith('voice/')).map((e) => e.id);
let at = -1;
for (const id of ids) { const i = seq.indexOf('voice/' + id, at + 1); if (i < 0) return false; at = i; }
return true;
}
/** 等语音与链全收尾。 */
async function settle(game, audio) {
await advance(() => !audio.isVoicePlaying() && !game._chain);
}
/** 等进入指定客人/状态。 */
async function waitState(game, cond, maxMs = 120000) {
const t0 = now;
await advance(() => cond() || now - t0 > maxMs);
return cond();
}
/**
* 判定三元组注入(§11-2 逐组合验证):合成局面(老周①要 longjing/simmer/fast,
* 二周目无 ×1.3:水窗 [2000,5000]、闷窗 [6000,8000]),直接拨钟判定,
* 验证 {茶对错 × 水档 × 闷档} → good/plain/wrong 映射与归因句真话。
*/
async function judgeTriple(rig, { tea, waterMs, steepMs }) {
const { game, audio, playLog } = rig;
const wantTea = game._guest.tea;
const mark = playLog.length;
game._chainSeq++;
game._chain = null;
game._schedToken++;
game._brew = { sub: 'TEA', teaIdx: 0, tea, water: null, steep: null, judged: false };
game.run.results.length = 0;
game._schedToken++; // 取消上一结算链尾挂起的自动推进
game._startWater();
game._brewElapsed = waterMs;
game._brewBase = performance.now();
game._waterJudge(false);
await waitState(game, () => game._brew && game._brew.sub === 'STEEP');
game._brewElapsed = steepMs;
game._brewBase = performance.now();
game._steepJudge(false);
const b = game._brew;
const bw = b.water, bs = b.steep;
await settle(game, audio);
const result = game.run.results[game.run.results.length - 1];
const voiced = (id) => playLog.slice(mark).some((e) => e.id === 'voice/' + id);
game._chainSeq++; // 杀掉结算链尾可能挂着的下一位客人调度余波
game._chain = null;
await flush();
return { result, water: bw, steep: bs,
teaOk: tea === wantTea, voiced, mark };
}
/** 当前客人想要的茶。 */
function wantTeaOf(game) {
return game._guest.tea;
}
/** 泡当前客人:选茶(循环到目标)+ 时机注入。 */
async function brewFor(rig, { tea, waterMs, steepMs }) {
const { game } = rig;
await waitState(rig.game, () => rig.game.state === 'BREW' && rig.game._brew && rig.game._brew.sub === 'TEA' && !rig.game._drill);
const shelf = game._shelf();
let guard = 0;
while (shelf[game._brew.teaIdx] !== tea && guard++ < 10) {
await g(game, 'right');
await settle(rig.game, rig.audio);
}
await g(game, 'tap'); // 选定 → 自动坐水
await waitState(game, () => game._brew && game._brew.sub === 'WATER');
game._brewElapsed = waterMs;
game._brewBase = performance.now();
game._waterJudge(false);
await waitState(game, () => game._brew && game._brew.sub === 'STEEP');
game._brewElapsed = steepMs;
game._brewBase = performance.now();
game._steepJudge(false);
await settle(game, rig.audio);
}
// ================================================================ Block 0 · 启动/FTUE/教学 DRILL(guide=off)
realLog('== Block 0 · 启动 → TITLE → OPENING → DRILL D0-D6(guide=off)==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
serverBuckets.delete('teahouse');
const rig = makeRig(42);
const { audio, playLog, game } = rig;
audio.play('voice/intro_1');
await flush();
assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')),
'启动遮罩点按前无音频播放(play 为 no-op)');
await audio.unlock();
playLog.length = 0;
const t0 = now;
game.start();
await flush();
assert(game.state === 'TITLE', '启动进 TITLE');
assert(playLog.some((e) => e.id === 'bgm/menu'), 'TITLE 播放 bgm/menu');
await g(game, 'left');
assert(vibCount(ERR_PATTERN) >= 1, 'TITLE 左滑:error 震');
assert(playsOf(playLog, 'voice/gd_invalid') === 0, 'guide=off:无效手势零向导语音');
await g(game, 'tap'); // 新店 → OPENING
assert(game.state === 'OPENING', 'TITLE 单击(无档)→ OPENING');
assert(playLog.some((e) => e.id === 'sfx/lamp_light'), '开场点灯 lamp_light');
await advance(() => !audio.isVoicePlaying() && !game._chain);
assert(chainOrderOk(playLog, ['night_1', 'intro_1', 'intro_2', 'intro_3']),
'首晚开场链:night_1 → intro_1..3 有序');
assert(now - t0 < 60000, `FTUE 首输入 <60s(开场链听完 ≈${((now - t0) / 1000).toFixed(1)}s)`);
// 开场链可跳(单击跳句)——直接等进第 1 位客人
await waitState(game, () => game.state === 'SERVE' && game._chain);
assert(playLog.some((e) => e.id === 'sfx/door_bell') && playLog.some((e) => e.id === 'sfx/steps_in'),
'客人进门:door_bell + steps_in');
const tFirstInput = now;
await settle(game, audio);
assert(chainOrderOk(playLog, ['zhou_enter', 'zhou_o1']) && playsOf(playLog, 'sfx/sig_zhou') >= 1,
'老周①进门链:sig_zhou(sfx)→ zhou_enter → zhou_o1(明示龙井)');
await waitState(game, () => game.state === 'BREW' && game._drill && game._drill.step === 0);
assert(playsOf(playLog, 'voice/tut_d0') === 1, 'DRILL D0 播 tut_d0');
assert(now - t0 < 60000 * 3, 'FTUE:首正反馈路径在 3 分钟内推进');
// D0:右滑看茶柜 → 茶名句 + tut_tea
await g(game, 'right');
assert(vibCount(CONFIRM_PATTERN) >= 1, 'D0 作答 confirm 震');
await advance(() => game._drill && game._drill.step === 1);
assert(playsOf(playLog, 'sfx/tea_jar') === 1 && playsOf(playLog, 'voice/tea_longjing') === 1,
'D0:tea_jar + 茶名句(龙井,知识随身播报)');
assert(playsOf(playLog, 'voice/tut_tea') === 1, 'D0 通过 → tut_tea');
// D1:单击选定 → scoop + tut_water;断言首晚窗口 ×1.3(龙井将沸窗 [2000,5000]×1.3)
await g(game, 'tap');
assert(playsOf(playLog, 'sfx/tea_scoop') === 1, 'D1 选定:tea_scoop');
await advance(() => game._drill && game._drill.step === 2);
assert(game._brew.sub === 'WATER', 'D1 通过 → 自动坐水(WATER)');
assert(playsOf(playLog, 'voice/tut_water') === 1, 'D1 通过 → tut_water');
const [wlo, whi] = game._waterWindow();
assert(Math.abs(wlo - (3500 - 1950)) < 1 && Math.abs(whi - (3500 + 1950)) < 1,
`首晚窗口 ×1.3(龙井候汤窗实测 [${wlo}, ${whi}] = 3500±1950)`);
// D2:双击提壶(窗内 3500)
game._brewElapsed = 3500;
game._brewBase = performance.now();
await g(game, 'doubletap');
await advance(() => game._drill && game._drill.step === 3);
assert(game._brew.water === 'ok' && playsOf(playLog, 'voice/water_ok') === 1,
'D2 窗内双击 → water_ok');
assert(game._brew.sub === 'STEEP', '水毕自动进闷泡(STEEP)');
assert(playsOf(playLog, 'voice/tut_steep') === 1, 'D2 通过 → tut_steep');
// D3:双击出汤(龙井快 7000,窗 [6000, 8800]×1.3=[5800,11440]... 用中心 7000)
game._brewElapsed = 7000;
game._brewBase = performance.now();
const tBeforeD4 = now;
await g(game, 'doubletap');
await advance(() => game._drill && game._drill.step === 4, 120000);
assert(game._brew.steep === 'ok' && playsOf(playLog, 'voice/steep_ok') === 1,
'D3 窗内双击 → steep_ok');
assert(playsOf(playLog, 'voice/res_good') === 1, '教学客首晚宽窗保送:res_good');
assert(playsOf(playLog, 'voice/tut_serve') === 1, 'D3 通过 → tut_serve');
assert(now - t0 <= 180000, `首正反馈 ≤3min(D4 前 ≈${((now - t0) / 1000).toFixed(1)}s)`);
assert(playsOf(playLog, 'voice/zhou_r1_good') === 0, 'D4 前不播反应句(等单击端茶)');
// D4:单击端茶 → pour_cup → 反应链
await g(game, 'tap');
assert(playsOf(playLog, 'sfx/pour_cup') === 2, 'D4 单击端茶:pour_cup(出汤+端盏各一)');
await advance(() => game._drill && game._drill.step === 5, 120000);
assert(chainOrderOk(playLog, ['serve_link', 'zhou_r1_good', 'bridge_good']),
'D4 反应链:serve_link → sig → zhou_r1_good → bridge_good');
assert(playsOf(playLog, 'sfx/coin_stack') === 1, '好茶:coin_stack(茶价+打赏)');
assert(playsOf(playLog, 'voice/tut_status') === 1, 'D4 通过 → tut_status');
// D5:上滑状态链 → tut_done
await g(game, 'up');
await advance(() => !audio.isVoicePlaying() && !game._chain);
assert(chainOrderOk(playLog.slice(-8), ['st_guest_1', 'st_rep_1', 'st_purse_1']),
'D5 状态链:st_guest_1 → st_rep_1 → st_purse_1');
await advance(() => game._drill === null, 60000);
assert(getProgress() && getProgress().meta.drillDone === true, 'D6 教学完成 drillDone 写入进度');
assert(playsOf(playLog, 'voice/tut_done') === 1, 'D6 播 tut_done');
// 教学客后账目照常:老周好茶 purse 0→8、rep 0→1
assert(game.run.incomeTonight === 8 && game.meta.purse === 0 && game.meta.rep === 1,
`教学客账目照常(当晚收入 8 入账 run、rep +1、purse 打烊才结转;实测 income=${game.run.incomeTonight} purse=${game.meta.purse} rep=${game.meta.rep})`);
// 第 2 位客人自由营业(无教学句)
await waitState(game, () => game.state === 'SERVE' && game._chain, 120000);
await settle(game, audio);
await waitState(game, () => game.state === 'BREW');
assert(game._drill === null && game.meta.drillDone, '教学后自由营业不再进 DRILL');
assert(playsOf(playLog, 'voice/brew_start') >= 2, '每位客人进 BREW 播 brew_start');
const g0 = gestureCount;
game.stop();
await flush();
realLog(` (教学关键路径手势数 ${g0})`);
assert(g0 <= 120, `教学关键路径手势预算 ≤120(实际 ${g0})`);
}
// ---- 教学跳过路径(首晚下滑跳过 → drillDone,二周目不再进) ----
realLog('== Block 0b · 教学跳过 + 二周目 ==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
serverBuckets.delete('teahouse');
const rig = makeRig(42);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await flush();
await g(game, 'tap');
await waitState(game, () => game.state === 'BREW' && game._drill && game._drill.step === 0);
await g(game, 'down'); // 跳过教学
await advance(() => !audio.isVoicePlaying() && !game._chain);
assert(playsOf(playLog, 'voice/tut_skip') === 1 && game.meta.drillDone === true,
'下滑跳过教学:tut_skip + drillDone 持久化');
assert(playsOf(playLog, 'voice/sys_help') === 1, 'R1-F2:跳过后补播 sys_help 最小帮助');
assert(playsOf(playLog, 'voice/tea_longjing') === 1, '跳过后恢复茶柜预览(tea_longjing)');
game.stop();
await flush();
// 二周目(drillDone=true):开场链无 intro/教学
const rig2 = makeRig(42);
const { audio: a2, playLog: p2, game: gm2 } = rig2;
await a2.unlock();
gm2.start();
await flush();
await g(gm2, 'tap');
await advance(() => !a2.isVoicePlaying() && !gm2._chain);
assert(playsOf(p2, 'voice/intro_1') === 0 && gm2._drill === null,
'二周目(drillDone)开场不再播 intro/教学');
await waitState(gm2, () => gm2.state === 'BREW');
assert(gm2._drill === null, '二周目直接自由营业');
gm2.stop();
await flush();
}
// ================================================================ Block 1 · 泡茶小局矩阵(§11-2)
realLog('== Block 1 · 判定三元组全组合 + 水声/滴答时间线 ==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
serverBuckets.delete('teahouse');
const rig = makeRig(42);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await flush();
storageData.delete(SAVE_KEY);
game.meta.drillDone = true;
await g(game, 'tap');
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA');
await settle(game, audio);
// --- TEA:循环 wrap + 茶名句 + 选定不可改后进入候汤 ---
const shelf = game._shelf();
assert(shelf.join(',') === 'longjing,moli,tieguanyin,dianhong', '初始茶柜 4 罐(茶柜未置办)');
const jar0 = playsOf(playLog, 'sfx/tea_jar');
await g(game, 'left'); // 从 0 左滑 → wrap 到最后
assert(game._brew.teaIdx === shelf.length - 1, 'TEA 左滑循环 wrap(0 → 末罐)');
assert(playsOf(playLog, 'sfx/tea_jar') === jar0 + 1 && playsOf(playLog, 'voice/tea_dianhong') === 1,
'换罐:tea_jar + 茶名句(滇红,口诀随身)');
await g(game, 'right');
assert(game._brew.teaIdx === 0, 'TEA 右滑循环 wrap(末罐 → 0)');
await settle(game, audio);
// --- WATER 时间线:blip 700/350ms 间隔、窗位=茶参数 ---
game._brew.tea = 'moli'; // 滚透茶
game._schedToken++;
const tickMark = game._tickLog.length;
const tMark = now;
game._startWater();
await advanceMs(4600);
const blips = game._tickLog.slice(tickMark).filter((e) => e.id === 'boil_blip');
assert(blips.length >= 7, `候汤 4.6s 内 blip ≥7(温水 3×700ms + 将沸 350ms 链,实测 ${blips.length})`);
const bt = blips.map((e) => e.t);
assert(Math.abs(bt[1] - bt[0] - 700) <= 30 && Math.abs(bt[2] - bt[1] - 700) <= 30,
'温水段 blip 间隔 700ms');
assert(bt.slice(3).every((v, i) => i === 0 || Math.abs(v - bt.slice(3)[i - 1] - 350) <= 30),
'将沸段(3.5s 起)blip 间隔 350ms');
assert(game._tickLog.slice(tickMark).some((e) => e.id === 'boil_simmer'), '将沸窗开:boil_simmer 低鸣');
await advanceMs(3200); // 到 ~6.8s
assert(game._tickLog.slice(tickMark).some((e) => e.id === 'boil_roar'), '滚透窗开(6.5s):boil_roar 连片');
// 时机窗内零语音(听觉分层 §3.3)
const voiceInWindow = playLog.filter((e) => e.id.startsWith('voice/') && e.t >= tMark && e.t <= now);
assert(voiceInWindow.length === 0, 'WATER 时机窗内 voice 播放事件 = 0');
game._clearBrewTimers(); // 中断本时间线(下面逐组合注入)
// --- 判定映射 9 组合(合成局面:客人要 longjing,窗 水[2000,5000]/闷[6000,8000]) ---
{
game._guest = { kind: 'arc', id: 'zhou', visit: 0, tea: 'longjing', sig: 'sig_zhou', orderId: 'zhou_o1' };
const cases = [
// [泡茶, 水ms, 闷ms, 期望结果, 期望归因]
['longjing', 3500, 7000, 'good', ['water_ok', 'steep_ok']],
['longjing', 1000, 7000, 'plain', ['water_early', 'steep_ok']],
['longjing', 8600, 7000, 'plain', ['water_late', 'steep_ok']],
['longjing', 3500, 2000, 'plain', ['water_ok', 'steep_early']],
['longjing', 3500, 12000, 'plain', ['water_ok', 'steep_late']],
['moli', 6500, 7000, 'wrong', ['water_ok', 'steep_ok']], // 茶错:水闷全 ok 仍 wrong
['longjing', 1000, 2000, 'plain', ['water_early', 'steep_early']],
['longjing', 8600, 12000, 'plain', ['water_late', 'steep_late']],
['tieguanyin', 6500, 11000, 'wrong', ['water_ok', 'steep_ok']],
];
for (const [tea, wm, sm, want, truth] of cases) {
const r = await judgeTriple(rig, { tea, waterMs: wm, steepMs: sm });
assert(r.result === want, `判定映射 {茶:${tea}, 水@${wm}, 闷@${sm}} → ${want}(实测 ${r.result})`);
assert(r.voiced(truth[0]) && r.voiced(truth[1]), `归因真话:${truth[0]} + ${truth[1]} 按真实时机播出`);
if (want === 'wrong') assert(r.voiced('res_wrong'), '茶错 ⇒ res_wrong');
if (want === 'good') assert(r.voiced('res_good') && r.voiced('zhou_r1_good'), '好茶 ⇒ res_good + r1_good 反应句');
if (want === 'plain') assert(r.voiced('res_plain'), '茶对任一偏 ⇒ res_plain');
}
}
// --- 超时自动:水 11s 无出手自动过老;闷窗后 +4s 自动出汤 ---
{
game._guest = { kind: 'arc', id: 'zhou', visit: 0, tea: 'longjing', sig: 'sig_zhou', orderId: 'zhou_o1' };
game._schedToken++;
game._brew = { sub: 'TEA', teaIdx: 0, tea: 'longjing', water: null, steep: null, judged: false };
const mark2 = playLog.length;
game._startWater();
await advanceMs(11500);
assert(game._brew.water === 'late' && playsOf(playLog.slice(mark2), 'voice/water_late') === 1
&& playsOf(playLog.slice(mark2), 'sfx/boil_over') === 1,
'水 11s 无出手:boil_over + 自动过老(water_late,真话归因)');
await waitState(game, () => game._brew && game._brew.sub === 'STEEP');
const [, hi] = game._steepWindow();
await advanceMs(hi + 4500);
assert(game._brew.steep === 'late' && playsOf(playLog.slice(mark2), 'voice/steep_late') === 1,
`闷泡窗后 +4s 自动出汤(steep_late,窗 hi=${hi})`);
await settle(game, audio);
game._chainSeq++;
game._chain = null;
}
// --- STEEP tick 只在目标前 2.5s 起、窗内加速 ---
{
game._schedToken++;
game._brew = { sub: 'STEEP', teaIdx: 0, tea: 'longjing', water: 'ok', steep: null, judged: false };
const mark3 = game._tickLog.length;
const tMark3 = now;
game._startSteep();
await advanceMs(4400); // 到 4.4s < 7000-2500=4500
assert(!game._tickLog.slice(mark3).some((e) => e.id.startsWith('tick_')),
'闷泡静默段零滴答(目标前 2.5s 不起)');
await advanceMs(700); // ~5.1s
const slow = game._tickLog.slice(mark3).filter((e) => e.id === 'tick_slow');
assert(slow.length >= 1, '目标前 2.5s 起 tick_slow');
await advanceMs(1800); // ~6.9s 入窗 [6000,8000]
const fast = game._tickLog.slice(mark3).filter((e) => e.id === 'tick_fast');
assert(fast.length >= 2, '入窗 tick_fast 加速(0.6s/拍)');
const voiceInSteep = playLog.filter((e) => e.id.startsWith('voice/') && e.t >= tMark3 && e.t <= now);
assert(voiceInSteep.length === 0, 'STEEP 时机窗内 voice 播放事件 = 0(滴答关键窗分层铁律)');
game._clearBrewTimers();
}
game.stop();
await flush();
}
// ---------------------------------------------------------------- 剧本驱动工具
/** 等 CLOSE 链完并按需置办,最后单击睡下进入下一晚。 */
async function closeNight(rig, { buy = null, mark = 0 } = {}) {
const { game, audio, playLog } = rig;
await waitState(game, () => game.state === 'CLOSE' && game._closeDone, 300000);
if (buy) {
await g(game, 'down'); // 菜单(CLOSE 含「打烊置办」)
await settle(game, audio);
assert(rig.game._menuItems().join(',') === 'sys_menu_resume,sys_menu_upg,sys_menu_help,sys_menu_save',
'CLOSE 菜单含「打烊置办」且位于第 2 项');
await g(game, 'tap'); // → sys_menu_upg
await settle(game, audio);
await g(game, 'doubletap'); // 进二级置办
await waitState(game, () => game._shopOpen);
await settle(game, audio);
const idx = { cabinet: 0, teaware: 1, sign: 2 }[buy];
for (let i = 0; i < idx; i++) { await g(game, 'right'); await settle(game, audio); }
await g(game, 'tap'); // 买定
await settle(game, audio);
await g(game, 'down'); // 退出置办回菜单
await settle(game, audio);
await g(game, 'down'); // 关菜单
await flush();
assert(game._menuOpen === false, '置办后菜单关闭');
}
const slice = playLog.slice(mark); // 含结算链 + 置办段
await g(game, 'tap'); // 睡下 → OPENING(night+1)
await waitState(game, () => game.state === 'OPENING', 30000);
await settle(game, audio);
return slice;
}
/** 完整泡一位客人(TEA 手势级 + 时机注入),等其反应链收尾。 */
async function serveGuest(rig, { tea, waterMs, steepMs }) {
await brewFor(rig, { tea, waterMs, steepMs });
// 反应链播完 → 下一位客人或打烊
await advance(() => !rig.audio.isVoicePlaying() && !rig.game._chain);
}
// ================================================================ Block 2 · 矩阵不变式 + seed 可复现(§11-3)
realLog('== Block 2 · 数据单源矩阵不变式 + seed 深相等 ==');
{
resetSettings('off');
const rig = makeRig(42);
const { DATA } = await import('../game.js');
const { game } = rig;
// 点单句 ⇒ 茶参数三元组与台词暗示一致(设计文档 §6.2 弧线表)
const ORDER_HINT = {
zhou: ['longjing', 'dianhong', 'longjing'],
su: ['dianhong', 'moli', 'dianhong'],
shi: ['moli', 'longjing', 'yinzhen'],
bai: ['tieguanyin', 'puer', 'puer'],
he: ['longjing', 'puer', 'yinzhen'],
};
let bad = 0;
for (const [id, gdef] of Object.entries(DATA.GUESTS)) {
gdef.teaByVisit.forEach((tea, v) => {
if (ORDER_HINT[id][v] !== tea) { bad++; realLog(` ✗ ${id} 晚${v + 1} teaByVisit=${tea} ≠ 点单暗示 ${ORDER_HINT[id][v]}`); }
const t = DATA.TEAS[tea];
if (!t || !t.name) { bad++; realLog(` ✗ ${id} 晚${v + 1} 茶 ${tea} 不在 TEAS 表`); }
});
assert(gdef.orders.length === 3 && gdef.teaByVisit.length === 3, `${id} 3 晚台词/茶序齐全`);
}
assert(bad === 0, '矩阵不变式:全部弧线客人 teaByVisit ↔ 点单句暗示 ↔ TEAS 参数一致');
// 茶参数 ↔ 口诀(知识单源)
assert(DATA.TEAS.longjing.water === 'simmer' && DATA.TEAS.longjing.steep === 'fast'
&& DATA.TEAS.puer.cabinet && DATA.TEAS.yinzhen.cabinet
&& !DATA.TEAS.moli.cabinet, '茶参数:龙井将沸/快、普洱银针茶柜锁');
// 氛围客
assert(DATA.AMBIENT.postman.tea === 'moli' && DATA.AMBIENT.drunk.tea === 'dianhong'
&& DATA.AMBIENT.embroider.tea === 'longjing', '氛围客 teaPool 与转述句暗示一致');
// seed 可复现
game.seed = 42;
const a1 = game._schedule(6), a2 = game._schedule(6);
assert(JSON.stringify(a1) === JSON.stringify(a2) && a1.length === 4,
'同 seed 两次构造 N6+ 轮换序深相等(4 席氛围客)');
game.seed = 43;
const b1 = game._schedule(6);
assert(JSON.stringify(a1) !== JSON.stringify(b1), '不同 seed 轮换序不同');
game.stop();
await flush();
}
// ================================================================ Block 3 · 弧线三方向(§11-4)
realLog('== Block 3 · 弧线三方向:结局 A/B/C 映射与反应句真话 ==');
{
resetSettings('off');
const { DATA } = await import('../game.js');
async function simVisit(rig, id, visit, result, run) {
const { game, audio, playLog } = rig;
const gdef = DATA.GUESTS[id];
const want = gdef.teaByVisit[visit];
game.run = run;
game.state = 'BREW';
game._guest = { kind: 'arc', id, visit, tea: want, sig: gdef.sig, orderId: gdef.orders[visit] };
game._brew = {
sub: 'STEEP', teaIdx: 0,
tea: result === 'wrong' ? (want === 'moli' ? 'longjing' : 'moli') : want,
water: 'ok', steep: result === 'good' ? 'ok' : 'early', judged: false,
};
const mark = playLog.length;
game._settleGuest();
await settle(game, audio);
game._chainSeq++;
game._chain = null;
game._schedToken++;
return (vid) => playLog.slice(mark).some((e) => e.id === 'voice/' + vid);
}
async function simArc(rig, id, results) {
const { game } = rig;
const voicedAny = [];
const run = { night: 1, guestIdx: 0, results: [], incomeTonight: 0, guestStory: {}, rentCharged: false, inBrew: true };
game.story = {};
for (let v = 0; v < 3; v++) {
const voiced = await simVisit(rig, id, v, results[v], run);
voicedAny.push(voiced);
}
return voicedAny;
}
// 老周:全好 → A(+3);好/普/错 → B(0);全错 → C(-3)
{
const rig = makeRig(7);
await rig.audio.unlock();
let v = await simArc(rig, 'zhou', ['good', 'good', 'good']);
assert(v[0]('zhou_r1_good') && v[1]('zhou_r2_good') && v[2]('zhou_e_a'),
'老周全好:r1_good/r2_good → 结局 A(sum=3)');
v = await simArc(rig, 'zhou', ['good', 'plain', 'wrong']);
assert(v[0]('zhou_r1_good') && v[1]('zhou_r2_plain') && v[2]('zhou_e_b'),
'老周好/普/错:→ 结局 B(sum=0)');
v = await simArc(rig, 'zhou', ['wrong', 'wrong', 'wrong']);
assert(v[0]('zhou_r1_wrong') && v[2]('zhou_e_c'), '老周全错:→ 结局 C(sum=-3)');
rig.game.stop();
}
// 反应句三方向与注入一致(假话即失败):苏晚 / 陈拾 / 白老 / 何雨眠 抽样
{
const rig = makeRig(8);
await rig.audio.unlock();
const v = await simArc(rig, 'su', ['plain', 'wrong', 'good']);
assert(v[0]('su_r1_plain') && v[1]('su_r2_wrong') && v[2]('su_e_b'),
'苏晚 普/错/好(sum=0+... 最终 +1+...)→ 结局句按累计 sum 映射');
const v2 = await simArc(rig, 'he', ['wrong', 'good', 'good']);
assert(v2[0]('he_r1_wrong') && v2[1]('he_r2_good') && v2[2]('he_e_b'),
'何雨眠 错/好/好(sum=1)→ 结局 B');
const v3 = await simArc(rig, 'bai', ['good', 'good', 'wrong']);
assert(v3[0]('bai_r1_good') && v3[1]('bai_r2_good') && v3[2]('bai_e_b'),
'白老先生 好/好/错(sum=1)→ 结局 B');
rig.game.stop();
}
// sum 边界:≥2 → A;-1..1 → B;≤-2 → C(-2 边界)
{
const rig = makeRig(9);
await rig.audio.unlock();
let v = await simArc(rig, 'shi', ['good', 'good', 'plain']); // sum=2
assert(v[2]('shi_e_a'), '陈拾 sum=2 边界 → A');
v = await simArc(rig, 'shi', ['good', 'wrong', 'wrong']); // sum=-1
assert(v[2]('shi_e_b'), '陈拾 sum=-1 边界 → B');
v = await simArc(rig, 'shi', ['plain', 'wrong', 'wrong']); // sum=-2
assert(v[2]('shi_e_c'), '陈拾 sum=-2 边界 → C');
rig.game.stop();
}
await flush();
}
// ================================================================ Block 4 · 经济剧本 A 五晚逐晚对账(§7.5/§11-5)
realLog('== Block 4 · 剧本 A「用心掌柜」五晚对账 ==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
serverBuckets.delete('teahouse');
const rig = makeRig(42);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await flush();
game.meta.drillDone = true;
await g(game, 'tap'); // 新店 → N1 OPENING
await settle(game, audio);
// [晚][客] = {tea, waterMs, steepMs};时机:simmer 3500 / rolling 6500;
// steep fast 7000 / mid 11000 / long 15000;早=1000-2000(偏)
const PLAN = [
[ // N1(无房租)
{ tea: 'longjing', waterMs: 3500, steepMs: 7000 }, // 老周 good 8
{ tea: 'moli', waterMs: 6500, steepMs: 7000 }, // 邮差 good 6
{ tea: 'dianhong', waterMs: 6500, steepMs: 15000 }, // 苏晚 good 7
{ tea: 'moli', waterMs: 6500, steepMs: 7000 }, // 陈拾 good 6
],
[ // N2
{ tea: 'dianhong', waterMs: 6500, steepMs: 1500 }, // 老周 plain 5
{ tea: 'tieguanyin', waterMs: 6500, steepMs: 11000 },// 白老 good 10
{ tea: 'moli', waterMs: 6500, steepMs: 7000 }, // 何雨眠 wrong 0(要普洱)
{ tea: 'longjing', waterMs: 3500, steepMs: 7000 }, // 陈拾 good 8
],
[ // N3 房租夜①
{ tea: 'longjing', waterMs: 3500, steepMs: 7000 }, // 老周③ good 8 → 结局 A
{ tea: 'moli', waterMs: 6500, steepMs: 7000 }, // 苏晚 good 6
{ tea: 'puer', waterMs: 6500, steepMs: 15000 }, // 白老② good 9
{ tea: 'dianhong', waterMs: 1000, steepMs: 15000 }, // 醉汉 plain 5(水偏早)
],
[ // N4
{ tea: 'puer', waterMs: 6500, steepMs: 15000 }, // 何② good 9
{ tea: 'yinzhen', waterMs: 3500, steepMs: 11000 }, // 陈拾③ good 12 → A
{ tea: 'longjing', waterMs: 6500, steepMs: 15000 }, // 苏③ wrong 0(要滇红)
{ tea: 'moli', waterMs: 6500, steepMs: 2000 }, // 邮差 plain 4(闷偏早)
],
[ // N5 房租夜② + chapter_end
{ tea: 'puer', waterMs: 6500, steepMs: 15000 }, // 白老③ good 9 → A
{ tea: 'yinzhen', waterMs: 3500, steepMs: 11000 }, // 何③ good 12 → B
{ tea: 'longjing', waterMs: 3500, steepMs: 2000 }, // 绣娘 plain 6(闷偏早)
{ tea: 'moli', waterMs: 6500, steepMs: 7000 }, // 邮差 good 6
],
];
// N1:27 文 / rep 0→4 / purse 0→27 / coin_3
for (const p of PLAN[0]) await serveGuest(rig, p);
let mark = playLog.length;
let slice = await closeNight(rig, { mark });
assert(slice.some((e) => e.id === 'voice/close_coin_3'), 'N1 进账 27 → close_coin_3(≥25)');
assert(game.meta.purse === 27 && game.meta.rep === 4, `N1 对账:purse 27 / rep 4(实测 ${game.meta.purse}/${game.meta.rep})`);
assert(game.run.night === 2, '睡下 → N2 OPENING');
// N2:23 文 / rep 4→5→4→5 双向跨档 / purse 50 → 置办茶柜 −15 = 35
mark = playLog.length;
for (const p of PLAN[1]) await serveGuest(rig, p);
slice = playLog.slice(mark);
assert(chainOrderOk2(slice, ['st_rep_2', 'st_rep_1', 'st_rep_2']),
'N2 rep 跨档双向播报:4→5(st_rep_2)→4(st_rep_1)→5(st_rep_2)');
assert(slice.filter((e) => e.id === 'sfx/rep_chime').length === 2
&& slice.filter((e) => e.id === 'sfx/rep_fall').length === 1,
'N2 口碑跨档瞬时标记:chime×2 + fall×1');
assert(slice.some((e) => e.id === 'voice/he_r1_wrong') && slice.some((e) => e.id === 'voice/res_wrong'),
'N2 何雨眠错茶:res_wrong + r1_wrong(免单静默无币声)');
const coinStackN2 = slice.filter((e) => e.id === 'sfx/coin_stack').length;
assert(coinStackN2 === 2, `N2 两盏好茶币声(白老/陈拾;实测 coin_stack×${coinStackN2})`);
mark = playLog.length;
slice = await closeNight(rig, { buy: 'cabinet', mark });
assert(slice.some((e) => e.id === 'voice/close_coin_2'), 'N2 进账 23 → close_coin_2(15-24)');
assert(game.meta.purse === 35, `N2 对账:27+23−15(茶柜)= 35(实测 ${game.meta.purse})`);
assert(game._shelf().includes('puer') && game._shelf().includes('yinzhen'), '茶柜置办:普洱/银针上柜');
assert(slice.some((e) => e.id === 'voice/upg_cabinet') && slice.some((e) => e.id === 'sfx/shop_buy'),
'置办链:upg_cabinet 报价句 + shop_buy');
// N3:28 文 / 房租 −20 / 置办茶具 −12 → 31;老周结局 A
mark = playLog.length;
for (const p of PLAN[2]) await serveGuest(rig, p);
slice = playLog.slice(mark);
assert(slice.some((e) => e.id === 'voice/zhou_e_a'), 'N3 老周③ sum=2 → 结局 A');
mark = playLog.length;
slice = await closeNight(rig, { buy: 'teaware', mark });
assert(slice.some((e) => e.id === 'voice/close_coin_3'), 'N3 进账 28 → close_coin_3');
assert(chainOrderOk2(slice, ['rent_due', 'rent_ok']), 'N3 房租链:rent_due → rent_ok');
assert(game.meta.purse === 31, `N3 对账:35+28−20(房租)−12(茶具)= 31(实测 ${game.meta.purse})`);
{
game._brew = { sub: 'STEEP', teaIdx: 0, tea: 'longjing', water: null, steep: null, judged: false };
const [lo, hi] = game._steepWindow();
assert(Math.abs(hi - 7000 - 2200) < 1 && Math.abs(lo - 6000) < 1,
`新茶具实测:闷泡窗 +0.8s(龙井 [${lo}, ${hi}])`);
game._brew = { sub: 'STEEP', teaIdx: 0, tea: 'moli', water: null, steep: null, judged: false };
const [wlo, whi] = game._waterWindow();
assert(Math.abs(whi - 6500 - 2300) < 1 && Math.abs(wlo - 6500 + 2300) < 1,
`新茶具实测:候汤窗 +0.8s(滚透 [${wlo}, ${whi}])`);
game._brew = null;
}
// N4:25 文;陈拾 A / 苏晚 B;purse 56
mark = playLog.length;
for (const p of PLAN[3]) await serveGuest(rig, p);
slice = playLog.slice(mark);
assert(slice.some((e) => e.id === 'voice/shi_e_a') && slice.some((e) => e.id === 'voice/su_e_b'),
'N4 陈拾③ → A、苏晚③ → B(sum 映射)');
mark = playLog.length;
slice = await closeNight(rig, { mark });
assert(slice.some((e) => e.id === 'voice/close_coin_3'), 'N4 进账 25 → close_coin_3');
assert(game.meta.purse === 56 && game.meta.rep === 9, `N4 对账:purse 56 / rep 9(实测 ${game.meta.purse}/${game.meta.rep})`);
// N5:33 文 / 房租 −20 / 置办招牌 −20 → 49;rep 12 恰达标;chapter_end
mark = playLog.length;
for (const p of PLAN[4]) await serveGuest(rig, p);
slice = playLog.slice(mark);
assert(slice.some((e) => e.id === 'voice/bai_e_a') && slice.some((e) => e.id === 'voice/he_e_b'),
'N5 白老③ → A、何雨眠③ → B');
mark = playLog.length;
slice = await closeNight(rig, { buy: 'sign', mark });
assert(slice.some((e) => e.id === 'voice/close_coin_3'), 'N5 进账 33 → close_coin_3');
assert(chainOrderOk2(slice, ['rent_due', 'rent_ok', 'chapter_end', 'settle_night']),
'N5 打烊链:rent_due → rent_ok → chapter_end → settle_night');
assert(game.meta.purse === 49, `N5 对账:56+33−20(房租)−20(招牌)= 49(实测 ${game.meta.purse})`);
assert(game.meta.rep === 12 && game.meta.upgrades.length === 3,
`N5 终局:rep 12(档3)/ 升级 3/3(实测 rep=${game.meta.rep})`);
// N6+ 无尽营业:night_more + 氛围客轮换
await waitState(game, () => game.state === 'SERVE' && game._chain, 60000);
await settle(game, audio);
assert(playsOf(playLog, 'voice/night_more') === 1, 'N6 开场播 night_more(无尽营业)');
assert(game._guest && game._guest.kind === 'amb', 'N6 客序=氛围客轮换(seed 可复现)');
// PUT summary 终局格式
const put = apiPuts.filter((p) => p.body && p.body.game === 'teahouse').pop();
assert(put && /第5晚打烊 · 口碑·小有名气 · 存款49文/.test(put.body.data.summary),
`PUT summary 命中生成规则(打烊后口径;实测:${put && put.body.data.summary})`);
game.stop();
await flush();
}
// ================================================================ Block 5 · 剧本 B「贴钱营业」(§7.6/§11-5)
realLog('== Block 5 · 剧本 B 全错 → 欠账流转 + debt_clear + rep clamp ==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
serverBuckets.delete('teahouse');
const rig = makeRig(42);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await flush();
game.meta.drillDone = true;
await g(game, 'tap');
await settle(game, audio);
const WRONG = { tea: 'moli', waterMs: 6500, steepMs: 7000 }; // 与每客需求相反 mostly
const wrongTeaFor = (want) => (want === 'moli' ? 'longjing' : 'moli');
// N1-N3 全错(每客免单,rep clamp 0)
for (let night = 1; night <= 3; night++) {
const plan = game._schedule(night);
for (let gi = 0; gi < plan.length; gi++) {
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA');
await serveGuest(rig, { tea: wrongTeaFor(game._guest.tea), waterMs: 6500, steepMs: 7000 });
}
realLog(` DBG B N${night} results=${JSON.stringify(game.run.results)} rep=${game.meta.rep}`);
let mark = playLog.length;
if (night < 3) {
await closeNight(rig, { mark });
assert(game.meta.rep === 0, `剧本 B N${night}:全错 rep clamp 0(实测 ${game.meta.rep})`);
assert(game.meta.purse === 0, `剧本 B N${night}:全免单 purse 0(实测 ${game.meta.purse})`);
}
}
// N3 房租:purse 0 < 20 → rent_short + debt 20 + danger
const mark3 = playLog.length;
await waitState(game, () => game.state === 'CLOSE' && game._closeDone, 300000);
const slice3 = playLog.slice(mark3);
assert(slice3.some((e) => e.id === 'voice/close_coin_1'), '剧本 B N3 进账 0 → close_coin_1');
assert(chainOrderOk2(slice3, ['rent_due', 'rent_short']), '剧本 B N3:rent_due → rent_short(欠账)');
assert(vibCount(WARN_PATTERN, mark3) >= 1, 'rent_short 当刻 danger/warning 震');
assert(game.meta.debt === 20 && game.meta.purse === 0, `剧本 B N3:debt 20(贴钱营业,无 game over)`);
assert(game.state === 'CLOSE', '房租不足不触发任何失败态(无 game over)');
// N4:何②/陈③ 无茶柜必然错茶(经营重量);苏好 + 邮普 = 11 → 还债 11 → debt 9
await g(game, 'tap'); // 睡下 → N4
await settle(game, audio);
const plan4 = game._schedule(4);
for (let gi = 0; gi < plan4.length; gi++) {
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA');
const want = game._guest.tea;
if (game._guest.kind === 'arc' && (game._guest.id === 'he' || game._guest.id === 'shi')) {
await serveGuest(rig, { tea: wrongTeaFor(want), waterMs: 6500, steepMs: 7000 }); // 必然错
} else if (game._guest.id === 'su') {
await serveGuest(rig, { tea: 'dianhong', waterMs: 6500, steepMs: 15000 }); // good 7
} else {
await serveGuest(rig, { tea: 'moli', waterMs: 6500, steepMs: 2000 }); // plain 4
}
}
realLog(` DBG B N4 results=${JSON.stringify(game.run.results)} rep=${game.meta.rep}`);
const mark4 = playLog.length;
await waitState(game, () => game.state === 'CLOSE' && game._closeDone, 300000);
const slice4 = playLog.slice(mark4);
assert(slice4.some((e) => e.id === 'voice/st_debt_1'), '剧本 B N4:还债后仍欠 → st_debt_1');
assert(game.meta.debt === 9 && game.meta.purse === 0, `剧本 B N4:income 11 还债 → debt 9(实测 ${game.meta.debt})`);
// N5:绣普 6 + 邮好 6 = 12 → 还债 9(debt_clear)→ purse 3 → 房租 → debt 17
await g(game, 'tap');
await settle(game, audio);
const plan5 = game._schedule(5);
for (let gi = 0; gi < plan5.length; gi++) {
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA');
if (game._guest.kind === 'arc') { // 白老③ 无茶柜 → 错茶
await serveGuest(rig, { tea: wrongTeaFor(game._guest.tea), waterMs: 6500, steepMs: 7000 });
} else if (game._guest.id === 'drunk' || (game._guest.id || '').includes('drunk')) {
await serveGuest(rig, { tea: 'dianhong', waterMs: 1000, steepMs: 15000 }); // plain 5
} else if (game._guest.orderId === 'amb_2') {
await serveGuest(rig, { tea: 'longjing', waterMs: 3500, steepMs: 2000 }); // plain 6
} else {
await serveGuest(rig, { tea: 'moli', waterMs: 6500, steepMs: 7000 }); // good 6
}
}
realLog(` DBG B N5 results=${JSON.stringify(game.run.results)} rep=${game.meta.rep}`);
const mark5 = playLog.length;
await waitState(game, () => game.state === 'CLOSE' && game._closeDone, 300000);
const slice5 = playLog.slice(mark5);
assert(slice5.some((e) => e.id === 'voice/debt_clear'), '剧本 B N5:旧账还清 → debt_clear');
assert(chainOrderOk2(slice5, ['debt_clear', 'rent_due', 'rent_short']), '剧本 B N5:debt_clear → rent_due → rent_short');
assert(game.meta.debt === 17 && game.meta.rep === 1,
`剧本 B 终局:debt 17 / rep 1(档1;§14-5 裁定:N5 白老/何雨眠错茶各 −1,rep 2 系 §7.6 算术笔误;实测 ${game.meta.debt}/${game.meta.rep})`);
game.stop();
await flush();
}
// ================================================================ Block 6 · 打烊死窗 + 链锁 + 睡下(§4.4/§11-7)
realLog('== Block 6 · CLOSE 800ms 死窗 + 结算链锁 + 单击睡下 ==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
serverBuckets.delete('teahouse');
const rig = makeRig(42);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await flush();
game.meta.drillDone = true;
await g(game, 'tap');
await settle(game, audio);
// 快速打完整晚(全好茶)
for (const p of [
{ tea: 'longjing', waterMs: 3500, steepMs: 7000 },
{ tea: 'moli', waterMs: 6500, steepMs: 7000 },
{ tea: 'dianhong', waterMs: 6500, steepMs: 15000 },
{ tea: 'moli', waterMs: 6500, steepMs: 7000 },
]) await serveGuest(rig, p);
await waitState(game, () => game.state === 'CLOSE', 300000);
const mark = playLog.length; // 覆盖完整结算链
assert(game._closeChainActive === true, '进 CLOSE 即结算链激活(链锁)');
// 死窗内 ghost 手势被吞(同拍 tap + 300ms 各一记)
await g(game, 'tap');
await advanceMs(300);
await g(game, 'doubletap');
await g(game, 'right');
assert(game.state === 'CLOSE' && game.run && game.run.night === 1, '死窗内 tap/doubletap/right 全被吞(不睡下/不重听/不重置)');
// 链锁:链播中手势一律吞掉
await advanceMs(600); // 过死窗,链仍在播
await g(game, 'tap');
await g(game, 'down');
assert(game.state === 'CLOSE' && game._menuOpen === false && game.run.night === 1,
'结算链播中(链锁):单击/下滑全吞(不睡下/不开菜单)');
await advance(() => playsOf(playLog, 'voice/settle_night') === 1, 300000);
assert(chainOrderOk(playLog, ['close_open', 'close_coin_3', 'settle_night']),
'结算链完整有序:close_open → close_coin → settle_night');
await settle(game, audio);
assert(playsOf(playLog.slice(mark), 'voice/settle_night') === 1, 'settle_night 落声(单击睡下提示)');
// 链完成后单击 = 睡下 → N2 OPENING
await g(game, 'tap');
await waitState(game, () => game.state === 'OPENING', 30000);
assert(game.run.night === 2 && playsOf(playLog, 'sfx/lamp_off') === 1, '单击睡下:lamp_off → OPENING(night=2)');
// N2 开场链:双击重听 / 上滑状态可用(定格自动推进专测 OPENING 手势)
await settle(game, audio);
game._schedToken++;
await g(game, 'doubletap');
assert(playsOf(playLog, 'voice/night_2') === 2, 'OPENING 双击重听开场链');
await settle(game, audio);
await g(game, 'up');
await advance(() => !audio.isVoicePlaying() && !game._chain);
assert(playsOf(playLog, 'voice/st_rep_1') >= 1, 'OPENING 上滑状态链可用');
game.stop();
await flush();
}
// ================================================================ Block 7 · 看门狗双轨(guide=full)
realLog('== Block 7 · 看门狗:无操作轨 + 无进展轨(乱按也被抢救) ==');
{
resetSettings('full');
storageData.delete(SAVE_KEY);
serverBuckets.delete('teahouse');
const rig = makeRig(42);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await flush();
game.meta.drillDone = true;
await g(game, 'tap');
await settle(game, audio);
// --- SERVE 无操作轨 [10s, 25s]:10s gd_serve,25s 静默(null),手势复位 ---
// (SERVE 链完 1.2s 自动进 BREW——用调度令牌定格空闲窗,专测 idle 轨 armed/firing)
await waitState(game, () => game.state === 'SERVE' && !game._chain, 120000);
game._schedToken++;
if (globalThis.__DBG) game.guide.setEventHook((e) => console.info(`[dbg] guide ${e.type} ${e.voiceId || e.name}${e.skipped ? ' SKIPPED' : ''} @${now}`));
const m0 = playLog.length;
realLog(` DBG arm wd=${!!game.guide._wd} at=${game.guide._wd && game.guide._wd.at} idx=${game.guide._wd && game.guide._wd.idx} voice=${audio.isVoicePlaying()}`);
realLog(` DBG timers: ` + globalThis.__timers().map((t) => `${t.id}:${t.in}ms:${t.cb.slice(0, 30)}`).join(' '));
await advanceMs(10500);
realLog(` DBG after10.5 voice=${audio.isVoicePlaying()} wd=${!!game.guide._wd} plays=${playsOf(playLog.slice(m0), 'voice/gd_serve')}`);
assert(playsOf(playLog.slice(m0), 'voice/gd_serve') === 1, 'SERVE 空闲 10s → gd_serve(full 档)');
await advanceMs(16000);
assert(playsOf(playLog.slice(m0), 'voice/gd_serve') === 1, 'SERVE 25s 第 2 级 null(零重复轰炸)');
await g(game, 'up'); // 任意手势复位看门狗
await advance(() => !audio.isVoicePlaying() && !game._chain);
await advanceMs(10500);
assert(playsOf(playLog.slice(m0), 'voice/gd_serve') === 2, '手势复位后 10s 重新提醒(第 2 次)');
// --- BREW-TEA 无操作轨 [12s, 30s] ---
await g(game, 'tap'); // SERVE 空闲单击 = 提前切下一段(解冻推进)
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA');
await settle(game, audio);
const m1 = playLog.length;
await advanceMs(12500);
assert(playsOf(playLog.slice(m1), 'voice/gd_brew') === 1, 'BREW-TEA 12s → gd_brew');
// --- 乱按 96s 零进展:45s earcon / 90s 口诀 / 135s 重播点单句 ---
game._progressTrack(); // 基线:进展事件后重新计时(随后的乱按零进展)
const m2 = game._tickLog.length;
const v2 = playLog.length;
const armBase = now;
for (let i = 0; i < 110 && now - armBase < 160000; i++) {
await g(game, i % 2 ? 'left' : 'right'); // 狂切茶罐(不听完=零进展)
await advanceMs(1500);
}
realLog(` DBG stuck stuckTimer=${game._stuckTimer !== null} orderReplay=${game._orderReplayCount} now=${now} m2=${m2} tickN=${game._tickLog.length}`);
realLog(' DBG ticks: ' + game._tickLog.slice(m2).map((e) => `${e.id}@${e.t}`).join(' '));
assert(game._tickLog.slice(m2).some((e) => e.id === 'turn_tick'), '乱按 45s+:L1 turn_tick earcon(不占台词通道)');
assert(playsOf(playLog.slice(v2), 'voice/hint_water') === 1, '乱按 90s+:L2 泡茶口诀 hint_water');
const replayN = playsOf(playLog.slice(v2), 'voice/zhou_o1');
assert(replayN >= 1 && game._tickLog.slice(m2).some((e) => e.id === 'sig_zhou'),
'乱按 135s+:L3 重播当前客人点单句(sig_sfx + zhou_o1 真话提示)');
assert(game._orderReplayCount >= 1 && game._orderReplayCount <= 2, 'L3 全局限 2 次(真话提示不替选茶)');
// --- 真实进展复位无进展轨:选定茶后 96s 零提示 ---
const m3 = game._tickLog.length;
const v3 = playLog.length;
await g(game, 'tap'); // 选定(进展事件)
for (let i = 0; i < 10; i++) { await advanceMs(9600); await flush(); }
await advanceMs(1000);
assert(playsOf(playLog.slice(v3), 'voice/hint_water') === 0
&& playsOf(playLog.slice(v3), 'voice/gd_serve') === 0,
'有真实进展后 96s 零语音提示(无进展轨被进展复位;earcon 不算提示)');
// --- BREW 时机窗内看门狗只 earcon 不发语音(§3.3) ---
await waitState(game, () => game._brew && game._brew.sub === 'WATER');
const v4 = playLog.length;
const m4 = game._tickLog.length;
await advanceMs(13000); // WATER 总长 11s;窗内 12s 看门狗触发点已过(earcon)
const voices = playLog.slice(v4).filter((e) => e.id.startsWith('voice/') && !e.id.includes('water_'));
assert(voices.length === 0, 'BREW 时机窗内看门狗零语音(分层铁律)');
game.stop();
await flush();
// --- guide=off:全静零定时器 ---
resetSettings('off');
const rig2 = makeRig(42);
const { audio: a2, game: gm2 } = rig2;
await a2.unlock();
gm2.start();
await flush();
gm2.meta.drillDone = true;
await g(gm2, 'tap');
await waitState(gm2, () => gm2.state === 'SERVE' && !gm2._chain, 120000);
await advanceMs(40000);
assert(gm2.guide._wd === null && gm2._stuckTimer === null,
"guide=off:全静、零看门狗定时器(SERVE 40s 空闲)");
gm2.stop();
await flush();
}
// ================================================================ Block 8 · 中断复述(§10/§11-9)
realLog('== Block 8 · 客人粒度存档恢复 + 复述链 ==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
serverBuckets.delete('teahouse');
const putCount0 = apiPuts.length;
const rig = makeRig(42);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await flush();
game.meta.drillDone = true;
await g(game, 'tap');
await settle(game, audio);
// 老周好茶入账
await serveGuest(rig, { tea: 'longjing', waterMs: 3500, steepMs: 7000 });
// 第 2 位客人(邮差)泡到一半(BREW-WATER)→ 菜单保存并回大厅
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA');
await g(game, 'tap'); // 选定 moli → WATER
await waitState(game, () => game._brew && game._brew.sub === 'WATER');
await g(game, 'down'); // 菜单
await settle(game, audio);
assert(game._menuOpen === true, 'BREW 内下滑开菜单(冻结计时)');
assert(game._brewEvents.every((e) => e.timer === null), '菜单冻结:水声时间线定时器全清');
const frozenElapsed = game._brewElapsed;
await advanceMs(5000); // 墙钟 5s(冻结中,时间线不走)
await g(game, 'tap'); await g(game, 'tap'); // → 保存并回大厅(第 3 项)
await g(game, 'doubletap'); // 双击确认执行
await advance(() => !audio.isVoicePlaying() && !game._chain);
await flush();
const savePut = apiPuts.slice(putCount0).filter((p) => p.body && p.body.game === 'teahouse').pop();
assert(savePut && savePut.body.data.run && savePut.body.data.run.inBrew === true,
'菜单「保存并回大厅」PUT /api/progress(BREW 中断存档带 inBrew 标记)');
assert(/第1晚 · 客人2\/4 · 口碑·还没立起来 · 存款0文/.test(savePut.body.data.summary),
`summary 命中生成规则(BREW 中断:收入未结转 purse 0、rep 1 档1;实测:${savePut.body.data.summary})`);
assert(game._navigatedHome === '../' && window.location.href === '../', '保存后回大厅跳转');
// 新实例读档:sys_continue_ask → 单击 → 复述链 → 客人 2 从进门链重放
window.location.href = '';
const rig2 = makeRig(42);
const { audio: a2, playLog: p2, game: gm2 } = rig2;
await a2.unlock();
gm2.start();
await flush();
assert(gm2.run && gm2.run.guestIdx === 1, '读档:run 恢复(第 2 位客人待接)');
assert(playsOf(p2, 'voice/sys_continue_ask') === 1, 'TITLE 有档播 sys_continue_ask');
const mark = p2.length; // 首句在 tap 回调内同步落声,标记须在 tap 之前
await g(gm2, 'tap'); // 继续
await waitState(gm2, () => gm2.state === 'SERVE' && gm2._chain, 60000);
const slice = p2.slice(mark);
const seq = slice.filter((e) => e.id.startsWith('voice/')).map((e) => e.id);
const iSig = seq.indexOf('voice/st_guest_2');
const iRep = seq.indexOf('voice/st_rep_1');
const iOrder = seq.indexOf('voice/amb_1');
const iGo = seq.indexOf('voice/recap_go');
assert(iSig >= 0 && iRep > iSig && iOrder > iRep && iGo > iOrder, '复述链序:st_guest_2 → st_rep → 点单句 → recap_go(BREW 存档含需求重播)');
await settle(gm2, a2);
assert(p2.slice(mark).some((e) => e.id === 'sfx/door_bell'), '恢复后当前客人从进门链重放(已结算客人不重复)');
assert(playsOf(p2, 'voice/zhou_enter') === 0 || playsOf(p2, 'voice/zhou_r1_good') === 0,
'已结算的老周不重复反应');
gm2.stop();
await flush();
}
// ================================================================ Block 9 · E1/E3 + 菜单/置办/键变体
realLog('== Block 9 · E1/E3 豁免 + 菜单关闭重播被停句 + 置办真话 ==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
serverBuckets.delete('teahouse');
const rig = makeRig(42);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await flush();
game.meta.drillDone = true;
await g(game, 'tap');
await settle(game, audio);
// E3:BREW 态长按快进关闭 + 长按=error
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA');
assert(audio._rateBoostEnabled === false, 'E3:BREW 态 setRateBoostEnabled(false)');
await g(game, 'longpress');
assert(vibCount(ERR_PATTERN) >= 1, 'E3:BREW 长按=error 震(无效输入)');
// E1-选茶步:双击=重听茶名句(宪法语义保留)
await settle(game, audio);
const t0 = playsOf(playLog, 'voice/tea_longjing');
await g(game, 'doubletap');
assert(playsOf(playLog, 'voice/tea_longjing') === t0 + 1, 'E1:TEA 步双击=重听茶名句');
// 上滑状态链第一段=sig+点单句重播
await g(game, 'up');
await advance(() => !audio.isVoicePlaying() && !game._chain);
assert(chainOrderOk2(playLog.slice(-10), ['zhou_o1', 'st_guest_1'])
&& playsOf(playLog, 'sfx/sig_zhou') >= 1,
'E1:BREW 上滑状态链第一段=sig_sfx+点单句重播(需求随时可查)');
// E3 恢复:回 SERVE 后快进恢复
await serveGuest(rig, { tea: 'longjing', waterMs: 3500, steepMs: 7000 });
assert(audio._rateBoostEnabled === true, 'E3:回 SERVE setRateBoostEnabled(true)');
// 菜单关闭重播被停句(SERVE 链播中开菜单)
await waitState(game, () => game.state === 'SERVE' && game._chain && game._chain.ids.includes('amb_1'));
const enterCount = playsOf(playLog, 'voice/amb_1');
await g(game, 'down'); // 开菜单(停链)
await settle(game, audio);
assert(game._menuOpen === true, 'SERVE 链播中下滑开菜单');
await g(game, 'down'); // 关菜单 → 重播被停句
await advance(() => !audio.isVoicePlaying() && !game._chain);
assert(playsOf(playLog, 'voice/amb_1') >= enterCount + 1, '菜单关闭重播被停句(节点不丢)');
// SERVE 链播中:单击跳句 / 双击重听当前句 / 左右滑静默忽略
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA');
await g(game, 'tap'); // 选定进 WATER
await waitState(game, () => game._brew && game._brew.sub === 'WATER');
const vibBefore = vibCount(ERR_PATTERN);
await g(game, 'left');
await g(game, 'right');
assert(game._brew.water === null && game._brew.sub === 'WATER', 'WATER 时机步滑动手势不判错步(流程照走)');
assert(vibCount(ERR_PATTERN) > vibBefore, 'WATER 滑动手势 error 震+纠错');
assert(playsOf(playLog, 'voice/gd_invalid') === 0, 'guide=off:纠错语音静默(只震)');
game._waterJudge(false);
await settle(game, audio);
await g(game, 'doubletap'); // STEEP 出汤(E1)
await settle(game, audio);
// 置办真话门(低存款/口碑/欠账):进 N1 打烊后测
for (let i = 0; i < 2; i++) { // 剩两位客人快速 good
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA');
await serveGuest(rig, { tea: 'longjing', waterMs: 3500, steepMs: 7000 });
}
await waitState(game, () => game.state === 'CLOSE' && game._closeDone, 300000);
await settle(game, audio);
game.meta.purse = 5; // 打烊结转后压低保真门
game.meta.rep = 3;
await g(game, 'down'); await settle(game, audio);
await g(game, 'tap'); await settle(game, audio); // → sys_menu_upg
await g(game, 'doubletap'); // 进置办
await waitState(game, () => game._shopOpen); await settle(game, audio);
await g(game, 'right'); await settle(game, audio); // → teaware(12 > 5)
await g(game, 'tap'); await settle(game, audio);
assert(playsOf(playLog, 'voice/upg_none') === 1, '置办真话:钱不够播 upg_none(不透支)');
await g(game, 'right'); await settle(game, audio); // → sign
await g(game, 'tap'); await settle(game, audio);
assert(playsOf(playLog, 'voice/upg_lock') === 1, '置办真话:口碑不足播 upg_lock(rep<12)');
game.meta.debt = 3; game.meta.rep = 13; // 欠账门(§14-1 修订记录)
await g(game, 'doubletap'); await settle(game, audio); // 重听当前项(sign)
await g(game, 'tap'); await settle(game, audio);
assert(playsOf(playLog, 'voice/upg_lock') === 2, '置办真话:有欠账招牌挂不得(debt=0 双门)');
game.meta.debt = 0;
await g(game, 'down'); await settle(game, audio); // 退出置办
await g(game, 'down'); await flush();
assert(game._menuOpen === false, '菜单关闭');
game.stop();
await flush();
}
// ================================================================ Block 10 · 键盘变体 + 设置边界(§11-12/全局)
realLog('== Block 10 · 键盘变体候选链 + 设置边界 ==');
{
resetSettings('off');
// 键盘:input=key 时候选链前置 voice/<gender>/key/(缺 key 资产回退主资产)
const audio = new WebAudioEngine('audio/');
await audio.unlock();
settings.set({ voice: 'female', input: 'key' });
let urlMark = requestedUrls.length;
audio.play('voice/sys_help');
await flush();
assert(requestedUrls.slice(urlMark).some((u) => u.includes('voice/female/key/sys_help')),
"input='key':sys_help 候选链前置 key 目录");
await advance(() => !audio.isVoicePlaying());
urlMark = requestedUrls.length;
audio.play('voice/tut_water');
await flush();
assert(requestedUrls.slice(urlMark).some((u) => u.includes('voice/female/key/tut_water')),
"input='key':tut_water 候选链前置 key 目录(V 组联动)");
await advance(() => !audio.isVoicePlaying());
settings.set({ input: 'touch' });
urlMark = requestedUrls.length;
audio.play('voice/sys_help');
await flush();
assert(requestedUrls.slice(urlMark).some((u) => u.includes('voice/female/sys_help'))
&& !requestedUrls.slice(urlMark).some((u) => u.includes('/key/')),
"input='touch':回基础句(零 key 目录请求)");
await advance(() => !audio.isVoicePlaying());
// voice 切换目录
settings.set({ voice: 'male' });
urlMark = requestedUrls.length;
audio.play('voice/night_1');
await flush();
assert(requestedUrls.slice(urlMark).some((u) => u.includes('voice/male/night_1')),
'settings.voice=male:下一句用 male 目录');
await advance(() => !audio.isVoicePlaying());
settings.set({ voice: 'female' });
// bgm=0:不加载
settings.set({ bgm: 0 });
urlMark = requestedUrls.length;
audio.play('bgm/menu');
await flush();
assert(!requestedUrls.slice(urlMark).some((u) => u.includes('/bgm/')), 'settings.bgm=0:无 bgm 层加载');
settings.set({ bgm: 1.0 });
resetSettings('off');
}
// ================================================================ Block 11 · 资产缺失兜底 + 静态审计 + 汇总
realLog('== Block 11 · 屏蔽 voice 资产:8s 兜底推进不卡死 ==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
blockedAssets.add('zhou_r1_good'); // 屏蔽反应句资产
const rig = makeRig(42);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await flush();
game.meta.drillDone = true;
await g(game, 'tap');
await settle(game, audio);
await serveGuest(rig, { tea: 'longjing', waterMs: 3500, steepMs: 7000 });
// 单句 8s 无 onComplete → 看门狗推进:流程继续到第 2 位客人
await waitState(game, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA', 200000);
assert(game.run.guestIdx === 1, '屏蔽反应句资产:8s 看门狗兜底推进到下一位客人(不卡死)');
blockedAssets.clear();
game.stop();
await flush();
}
realLog('== Block 12 · 静态审计:播放 id 全部在台词表/资产表内 ==');
{
const NARRATOR = new Set([
// G0(10)
'sys_menu', 'sys_menu_resume', 'sys_menu_upg', 'sys_menu_save', 'sys_menu_help',
'sys_saved', 'settings_hint', 'sys_help', 'sys_continue_ask', 'sys_reset_ask',
// G1(17)
'intro_1', 'intro_2', 'intro_3', 'tut_d0', 'tut_tea', 'tut_water', 'tut_steep',
'tut_serve', 'tut_status', 'tut_done', 'tut_skip',
'water_early', 'water_ok', 'water_late', 'steep_early', 'steep_ok', 'steep_late',
// G2(18)
'st_guest_1', 'st_guest_2', 'st_guest_3', 'st_guest_4', 'st_guest_5',
'st_rep_1', 'st_rep_2', 'st_rep_3', 'st_rep_4',
'st_purse_1', 'st_purse_2', 'st_purse_3',
'tea_longjing', 'tea_moli', 'tea_tieguanyin', 'tea_dianhong', 'tea_puer', 'tea_yinzhen',
// G3(5)
'brew_start', 'res_good', 'res_plain', 'res_wrong', 'serve_link',
// G4(9)
'bridge_good', 'bridge_plain', 'bridge_wrong',
'amb_1', 'amb_2', 'amb_3', 'amb_react_good', 'amb_react_plain', 'amb_react_wrong',
// G5(16)
'close_open', 'close_coin_1', 'close_coin_2', 'close_coin_3',
'rent_due', 'rent_ok', 'rent_short', 'debt_clear', 'st_debt_1', 'settle_night',
'upg_open', 'upg_cabinet', 'upg_teaware', 'upg_sign', 'upg_none', 'upg_lock',
// G6(6)
'gd_serve', 'gd_brew', 'gd_close', 'gd_invalid', 'hint_water', 'recap_go',
// G7(7)
'night_1', 'night_2', 'night_3', 'night_4', 'night_5', 'night_more', 'chapter_end',
]);
const NPC = new Set();
for (const id of ['zhou', 'su', 'shi', 'bai', 'he']) {
NPC.add(id + '_enter');
for (const o of [1, 2, 3]) NPC.add(`${id}_o${o}`);
for (const r of [1, 2]) for (const k of ['good', 'plain', 'wrong']) NPC.add(`${id}_r${r}_${k}`);
for (const e of ['a', 'b', 'c']) NPC.add(`${id}_e_${e}`);
}
const SFX = new Set([
'door_bell', 'steps_in', 'steps_out',
'sig_zhou', 'sig_su', 'sig_shi', 'sig_bai', 'sig_he',
'tea_jar', 'tea_scoop', 'water_pour', 'boil_blip', 'boil_simmer', 'boil_roar', 'boil_over',
'kettle_lift', 'steep_rustle', 'tick_slow', 'tick_fast', 'pour_cup',
'coin_soft', 'coin_stack', 'rep_chime', 'rep_fall',
'lamp_light', 'lamp_off', 'shop_buy', 'upg_install', 'turn_tick',
]);
const BGM = new Set(['menu', 'serve', 'brew']);
let bad = 0;
for (const id of allPlayedIds) {
const slash = id.indexOf('/');
const layer = id.slice(0, slash);
const name = id.slice(slash + 1);
if (layer === 'voice') { if (!NARRATOR.has(name) && !NPC.has(name)) { bad++; realLog(` ✗ 表外 voice id:${id}`); } }
else if (layer === 'sfx') { if (!SFX.has(name)) { bad++; realLog(` ✗ 表外 sfx id:${id}`); } }
else if (layer === 'bgm') { if (!BGM.has(name)) { bad++; realLog(` ✗ 表外 bgm id:${id}`); } }
}
assert(bad === 0, `全部播放 id 均在表内(审计 ${allPlayedIds.size} 个不同 id)`);
const unexpected = consoleErrs.filter((e) => !e.includes('资产缺失'));
assert(unexpected.length === 0, '全程无未捕获异常/非预期 console.error');
}
// ================================================================ 汇总
realLog('\n========================================');
realLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`);
if (failures.length) {
realLog('失败明细:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');