/** * 《深夜茶馆》无头试玩仿真测试(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//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('全部断言通过。');