/** * 《深夜茶馆》R2 无头试玩仿真测试(26 号 playbook 重构版)。 * * 覆盖(设计单 docs/design/26-apply-teahouse.md §测试记录): * - §4.10 首 3 分钟脚本:开场第一句=动手指令(critical);3 秒内可动手、首成功 ≤20s; * - §4.1 发言权:链间不掉回「轮到你」、tap=跳句、滑动=打断并推进、关键句 busy; * - §4.2 回执 + 防连点(400ms 内第二下只给 busy;时机窗内连点只第一下); * - §4.3 手势阶梯:up/down/doubletap 未解锁的回执与首次解释;down=菜单阶段 2; * - §4.4 三档提示:full → short → 提示音;主动重听 =「再说一遍:」+ 完整句; * - §4.5 迷茫接管三级(L1 短句/earcon → L2 只留期待手势 → L3 替打); * - §4.9 失败表:时机早/老真话归因 + 超时自动过老/自动出汤,流程不终止; * - §4.12 断点复述:客人粒度存档 + 回来问继续/重开 + 复述链 + summary; * - 20 号玩法断言保留适配:判定三元组、水声/滴答时间线、弧线三方向、剧本 A/B * 五晚对账、CLOSE 800ms 死窗 + 链锁、菜单/置办真话、N6+ seed 轮换; * - §6.2 乱点机器人:无声操作 = 0、被动原句重复 = 0、打断失败 = 0、首成功 ≤ 20s; * - 分层铁律:WATER/STEEP 时机窗内 voice 事件 = 0(含迷茫接管只 earcon)。 * * 运行:tools/node/bin/node web/teahouse/test/playtest.mjs */ const SAVE_KEY = 'soundgame.progress.teahouse'; const AUTH_KEY = 'soundgame.auth'; const GESTURES_KEY = 'soundgame.gestures'; // ---------------------------------------------------------------- 断言收集 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++; intervals.add(id); 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 }); }; 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('虚拟时钟定时器超上限(疑似死循环)'); } 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 MockAudioContext { constructor() { this.state = 'running'; this.destination = {}; this.sampleRate = 44100; } get currentTime() { return now / 1000; } createGain() { return new MockGainNode(); } 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 = () => {}; 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) }; // 缺失 voice 资产按可用处理(新台词由生成管线统一落盘,流程测试与资产品质解耦) if (s.includes('/voice/')) { const ab = new ArrayBuffer(8); ab.__url = s; return { ok: true, status: 200, arrayBuffer: async () => ab }; } 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 { makeInputPipeline } = await import('../../engine/pipeline.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' })); /** 每块干净开局:清存档 + 清全局手势阶梯(阶段控制可预期)。 */ function freshStorage(ladderStage = 0) { storageData.delete(SAVE_KEY); storageData.delete(GESTURES_KEY); serverBuckets.delete('teahouse'); if (ladderStage > 0) storageData.set(GESTURES_KEY, JSON.stringify({ stage: ladderStage, games: 0 })); } // ---------------------------------------------------------------- 测试工具 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 }); // 回执记录(§6.2 无声操作指标探针) const receiptLog = []; const origReceipt = game.receipts.play.bind(game.receipts); game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origReceipt(kind); }; // 管道(与 index.html 同构);bed 关掉防周期定时器不排水 game.floor.bedEnabled = false; const pipe = makeInputPipeline({ floor: game.floor, confusion: game.confusion, receipts: game.receipts, invalidHints: game.invalidHints, game, ladder: game.ladder, }); return { audio, playLog, receiptLog, game, pipe }; } async function g(rig, gesture) { rig.pipe(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; } /** 等语音链与引擎全收尾(链间空档即算静)。 */ async function settle(rig) { await advance(() => !rig.audio.isVoicePlaying() && !rig.game.floor.speaking); } /** 等进入指定局面(有界)。 */ async function waitState(rig, cond, maxMs = 180000) { const t0 = now; await advance(() => cond() || now - t0 > maxMs); return cond(); } /** * 判定三元组注入(20 号 §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.floor.interrupt(); game._schedToken++; game._brew = { sub: 'TEA', teaIdx: 0, tea, water: null, steep: null, judged: false }; game.run.results.length = 0; game._startWater(); game._brewElapsed = waterMs; game._brewBase = performance.now(); game._waterJudge(false); await waitState(rig, () => 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(rig); const result = game.run.results[game.run.results.length - 1]; const voiced = (id) => playLog.slice(mark).some((e) => e.id === 'voice/' + id); game.floor.interrupt(); // 杀掉结算链尾可能挂着的下一位客人调度余波 game._schedToken++; await flush(); return { result, water: bw, steep: bs, teaOk: tea === wantTea, voiced, mark }; } /** 等 TEA 等待点武装(预览链播完、轮到玩家),再做选茶手势。 */ async function teaReady(rig) { const { game } = rig; await waitState(rig, () => game.floor.state === 'YOUR_TURN' && game._brew && game._brew.sub === 'TEA' && !game._drill); } /** 泡当前客人:选茶(手势级循环到目标)+ 时机注入单击提壶/出汤。 * 每次单击前推进 >400ms 虚拟时钟——连续单击会被管道防连点吞掉(§4.2), * 真实玩家两次单击至少隔着听水声/滴答的几百毫秒。 */ async function brewFor(rig, { tea, waterMs, steepMs }) { const { game } = rig; await waitState(rig, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA' && !game._drill); await teaReady(rig); const shelf = game._shelf(); let guard = 0; while (shelf[game._brew.teaIdx] !== tea && guard++ < 10) { await g(rig, 'right'); await settle(rig); } await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // 选定 → 自动坐水 await waitState(rig, () => game._brew && game._brew.sub === 'WATER'); await advanceMs(420); // 过防连点窗(水声头 420ms,窗未开不误判) game._brewElapsed = waterMs; game._brewBase = performance.now(); await g(rig, 'tap'); // 提壶(时机判定) await waitState(rig, () => game._brew && game._brew.sub === 'STEEP'); await advanceMs(420); // 过防连点窗 game._brewElapsed = steepMs; game._brewBase = performance.now(); await g(rig, 'tap'); // 出汤 await settle(rig); await advance(() => !rig.audio.isVoicePlaying() && !rig.game.floor.speaking); } /** 完整服务一位客人,等反应链收尾。 */ async function serveGuest(rig, plan) { await brewFor(rig, plan); await advance(() => !rig.audio.isVoicePlaying() && !rig.game.floor.speaking); } /** 等 CLOSE 链完并按需置办(新菜单语义:左右滑切项 + 单击确认),单击睡下进下一晚。 * 返回本段(结算链 + 置办)的 playLog 切片。 */ async function closeNight(rig, { buy = null } = {}) { const { game, playLog } = rig; const mark = playLog.length; await waitState(rig, () => game.state === 'CLOSE' && game._closeDone, 300000); await settle(rig); // settle_night 链完 + closeWp 武装(否则 tap 会被当成跳句吃掉) if (buy) { await g(rig, 'down'); // 菜单(CLOSE 含「打烊置办」为第 2 项) await settle(rig); await g(rig, 'right'); // → sys_menu_upg await settle(rig); await g(rig, 'tap'); // 确认 → 进二级置办 await waitState(rig, () => game._shopOpen); await settle(rig); const idx = { cabinet: 0, teaware: 1, sign: 2 }[buy]; for (let i = 0; i < idx; i++) { await g(rig, 'right'); await settle(rig); } await g(rig, 'tap'); // 买定 await settle(rig); await g(rig, 'down'); // 退出置办回菜单 await settle(rig); await g(rig, 'down'); // 关菜单 await settle(rig); } await g(rig, 'tap'); // 睡下 → OPENING(night+1) await waitState(rig, () => game.state === 'OPENING', 30000); await settle(rig); return playLog.slice(mark); } // ================================================================ Block 0 · FTUE / 教学七步(单击版,guide=off) realLog('== Block 0 · 开场 §4.10 → 教学 D0-D6(单击提壶/出汤)=='); { freshStorage(0); const rig = makeRig(42); const { audio, playLog, receiptLog, game } = rig; await audio.unlock(); const t0 = now; game.start(); await flush(); assert(game.floor.speaking && playsOf(playLog, 'voice/sys_first_tap') === 1, '0~3 秒:开场第一句 = 「点一下屏幕。」(第一句就让动手,critical)'); assert(playsOf(playLog, 'bgm/menu') === 1, 'TITLE 播放 bgm/menu'); // 抢拍单击 = busy 回执(关键句不打断,教学完整到达) await g(rig, 'tap'); assert(receiptLog.some((r) => r.kind === 'busy'), '指令播放中单击 = busy 回执(关键句不可打断)'); await advance(() => game.floor.state === 'YOUR_TURN'); const tFirstOp = now; await g(rig, 'tap'); // 开店 → sys_turn_teach → OPENING assert(receiptLog.some((r) => r.kind === 'ok'), '第一次单击 100ms 内得到 ok 回执(第一次成功)'); assert(playsOf(playLog, 'voice/sys_turn_teach') === 1, '第一击后教「轮到你了」状态声(原则 2)'); const tFirstOk = now; assert(tFirstOp - t0 <= 10000 && tFirstOk - t0 <= 20000, `首操作 ${tFirstOp - t0}ms / 首成功 ${tFirstOk - t0}ms(§6.4:≤10s / ≤20s)`); // OPENING:night_1 + intro_1/2(intro_3 规则念白废除,§4.10) await advance(() => playsOf(playLog, 'voice/intro_2') >= 1 || game.state === 'SERVE'); assert(playsOf(playLog, 'voice/night_1') === 1 && playsOf(playLog, 'voice/intro_1') === 1, '开场链:night_1 → intro_1/2(砍规则念白 intro_3)'); assert(playsOf(playLog, 'voice/intro_3') === 0, 'intro_3 规则念白不播(§4.10)'); // 老①进门链有序 await waitState(rig, () => game.state === 'BREW' && game._drill && game._drill.step === 0); assert(playsOf(playLog, 'sfx/door_bell') >= 1 && playsOf(playLog, 'sfx/steps_in') >= 1, '客人进门:door_bell + steps_in'); assert(playsOf(playLog, 'sfx/sig_zhou') >= 1 && chainOrderOk(playLog, ['zhou_enter', 'zhou_o1']), '老周①进门链:sig_zhou(sfx)→ zhou_enter → zhou_o1(明示龙井)'); assert(playsOf(playLog, 'voice/brew_start') >= 1 && playsOf(playLog, 'voice/tut_d0') === 1, '进灶:brew_start → tut_d0(教学指令 critical)'); // D0:右滑看茶柜 → 茶名句 + tut_tea(做中学:换过一罐马上教选定) await advance(() => game.floor.state === 'YOUR_TURN' && game._drill && game._drill.step === 0); await g(rig, 'right'); await advance(() => game._drill && game._drill.step === 1); assert(playsOf(playLog, 'sfx/tea_jar') >= 1 && playsOf(playLog, 'voice/tea_moli') === 1, 'D0:tea_jar + 茶名句(口诀随身播报)'); assert(playsOf(playLog, 'voice/tut_tea') === 1, 'D0 通过 → tut_tea(单击,选定这罐茶)'); // D1:单击选定(先滑回龙井——§6 教学保送口径:选对罐;错茶也走完教学) await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'left'); await settle(rig); assert(game._brew.teaIdx === 0, 'D1 换罐回龙井(教学客点单茶)'); await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, '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(critical,无「双击」字样)'); await advanceMs(50); // 让 t=0 的 water_pour 落账 const wMark = playLog.filter((e) => e.id === 'voice/tut_water')[0].t; const waterStart = game._tickLog.find((e) => e.id === 'water_pour').t; assert(waterStart >= wMark, '听觉分层: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(rig, 'tap'); 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:单击出汤(龙井 fast 7000,首晚窗 [5700,9730]) game._brewElapsed = 7000; game._brewBase = performance.now(); await g(rig, 'tap'); 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(playsOf(playLog, 'voice/zhou_r1_good') === 0, 'D4 前不播反应句(等单击端茶)'); // D4:单击端茶 → pour_cup → 反应链 → 第一盏茶送出 → 解锁上滑(阶段 1) await advance(() => game.floor.state === 'YOUR_TURN' && game._drill && game._drill.step === 4); await g(rig, '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/sys_unlock_up') === 1 && game.ladder.stage() >= 1, '第一盏茶送出 → 手势阶梯阶段 1(上滑解锁,教学句挂链首)'); // D5:上滑状态链 → tut_done → D6 完成 await advance(() => game.floor.state === 'YOUR_TURN' && game._drill && game._drill.step === 5); await g(rig, 'up'); await advance(() => game._drill === null, 120000); assert(chainOrderOk(playLog.slice(-8), ['st_guest_1', 'st_rep_1', 'st_purse_1']), 'D5 状态链:st_guest_1 → st_rep_1 → st_purse_1'); assert(playsOf(playLog, 'voice/tut_done') === 1, 'D6 播 tut_done'); assert(getProgress() && getProgress().meta.drillDone === true, 'D6 教学完成 drillDone 写入进度'); assert(game.run.incomeTonight === 8 && game.meta.purse === 0 && game.meta.rep === 1, `教学客账目照常(income=8、rep +1、purse 打烊结转;实测 income=${game.run.incomeTonight} purse=${game.meta.purse} rep=${game.meta.rep})`); // 第 2 位客人自由营业(无教学句) await waitState(rig, () => game.state === 'SERVE', 120000); await settle(rig); await waitState(rig, () => game.state === 'BREW'); assert(game._drill === null && game.meta.drillDone, '教学后自由营业不再进 DRILL'); assert(playsOf(playLog, 'voice/brew_start') >= 2, '每位客人进 BREW 播 brew_start'); game.stop(); await flush(); } // ---- Block 0b · 二周目(drillDone):开场「点一下开始」,不再教学 ---- realLog('== Block 0b · 二周目(drillDone)=='); { freshStorage(1); // 老玩家:上滑已解锁 const rig = makeRig(42); const { audio, playLog, game } = rig; await audio.unlock(); // 预置 drillDone 存档(无 run:TITLE 走「点一下开始」) storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, game: 'teahouse', summary: '', meta: { drillDone: true, nightsClosed: 1, rep: 3, purse: 10, debt: 0, upgrades: [], chapterEnd: false }, run: null, visits: {}, story: {}, })); game.start(); await flush(); assert(playsOf(playLog, 'voice/sys_tap_start') === 1 && playsOf(playLog, 'voice/sys_first_tap') === 0, '已 drillDone 玩家开场「点一下开始」(sys_tap_start,不念教学)'); await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await settle(rig); assert(playsOf(playLog, 'voice/intro_1') === 0 && playsOf(playLog, 'voice/tut_d0') === 0, '二周目开场不再播 intro/教学(做对过的事不教第二遍)'); await waitState(rig, () => game.floor.state === 'YOUR_TURN' && game.state === 'BREW'); assert(game._drill === null && playsOf(playLog, 'voice/tea_longjing') >= 1, '二周目直接自由营业(茶柜预览照常)'); game.stop(); await flush(); } // ================================================================ Block 1 · 交互矩阵:跳句/打断/回执/防连点/分层 realLog('== Block 1 · 状态×手势矩阵(§4.1/§4.2/分层铁律)=='); { freshStorage(1); const rig = makeRig(42); const { audio, playLog, receiptLog, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; // 本块专测交互矩阵:跳过教学寄生 await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // 开店 // 堂前在说(客人进门链两句):tap=跳句(consumed + ok) await waitState(rig, () => game.state === 'SERVE' && game.floor.speaking, 120000); await g(rig, 'tap'); assert(receiptLog.filter((r) => r.kind === 'ok').length >= 1, '链播中单击 = ok 回执(跳句即收到)'); // 防连点:tap「继续/跳过」后 400ms 内的连点只给 busy(点两下不连跳两句) const busyBefore = receiptLog.filter((r) => r.kind === 'busy').length; await g(rig, 'tap'); await g(rig, 'tap'); // 虚拟时钟未推进 = 仍在 400ms 窗内 → busy assert(receiptLog.filter((r) => r.kind === 'busy').length === busyBefore + 2, '防连点:跳句后 400ms 内的连点(两下)只给 busy 回执'); await advanceMs(600); // 点单句还在播(1.8s) // 堂前在说:滑动 = 打断并推进(原 invalid 格改判,避免杀链后流程失速) await g(rig, 'right'); await teaReady(rig); // 上滑 = 我在哪:状态链(BREW 第一段=sig+点单句重播,前缀「再说一遍:」) await g(rig, 'up'); await advance(() => playsOf(playLog, 'voice/st_purse_1') >= 1); assert(playsOf(playLog, 'voice/sys_say_again') >= 1 && chainOrderOk(playLog.slice(-8), ['zhou_o1', 'st_guest_1']), '上滑状态链:再说一遍:+ 点单句 → st_guest → st_rep → st_purse(需求随时可查)'); await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking); // 未解锁 down:invalid 回执 + 首次解释(原则 7) await g(rig, 'down'); assert(receiptLog.filter((r) => r.kind === 'invalid').length >= 1, '阶段 <2 下滑 → invalid 回执'); await advance(() => playsOf(playLog, 'voice/inv_down_locked') >= 1); assert(playsOf(playLog, 'voice/inv_down_locked') === 1, '首次无效附一句解释(inv_down_locked)'); const invCount = playsOf(playLog, 'voice/inv_down_locked'); await advance(() => !game.floor.speaking); await g(rig, 'down'); assert(playsOf(playLog, 'voice/inv_down_locked') === invCount, '第二次起只给回执,不再解释(原则 7)'); // 未解锁 doubletap 折算为 tap:TEA 步=选定(不重播茶名) await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking); await g(rig, 'doubletap'); assert(playsOf(playLog, 'sfx/tea_scoop') >= 1 && game._brew.sub === 'WATER', '未解锁双击按一次单击处理(TEA 步=选定进候汤)'); // WATER 时机步:单击=提壶;滑动=invalid 不判错步、窗内零语音(分层铁律) await waitState(rig, () => game.floor.state === 'YOUR_TURN' && game._brew && game._brew.sub === 'WATER'); const vMark = playLog.length; const tMark = now; await g(rig, 'left'); await g(rig, 'right'); assert(game._brew.water === null && game._brew.sub === 'WATER', 'WATER 时机步滑动手势不判错步(流程照走)'); assert(receiptLog.filter((r) => r.kind === 'invalid').length >= 3, 'WATER 滑动 = invalid 回执'); await advanceMs(8600); // 走完时间线(无出手 → 11s 自动过老) await waitState(rig, () => game._brew && game._brew.sub === 'STEEP'); assert(game._brew.water === 'late' && playsOf(playLog.slice(vMark), 'voice/water_late') === 1 && playsOf(playLog.slice(vMark), 'sfx/boil_over') === 1, '水 11s 无出手:boil_over + 自动过老(water_late 真话归因,不终止)'); const voicesInWindow = playLog.filter((e) => e.id.startsWith('voice/') && e.t > tMark && e.t < tMark + 11000 && e.id !== 'voice/water_late'); assert(voicesInWindow.length === 0, 'WATER 时机窗内 voice 播放事件 = 0(分层铁律,归因句在窗外)'); game.floor.interrupt(); game._schedToken++; game.stop(); await flush(); } // ================================================================ Block 2 · 泡茶小局:判定三元组 + 时间线 realLog('== Block 2 · 判定三元组全组合 + 水声/滴答时间线 =='); { freshStorage(0); const rig = makeRig(42); const { audio, playLog, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // 新店 await teaReady(rig); // --- TEA:循环 wrap + 茶名句 --- const shelf = game._shelf(); assert(shelf.join(',') === 'longjing,moli,tieguanyin,dianhong', '初始茶柜 4 罐(茶柜未置办)'); const jar0 = playsOf(playLog, 'sfx/tea_jar'); await g(rig, '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(rig, 'right'); assert(game._brew.teaIdx === 0, 'TEA 右滑循环 wrap(末罐 → 0)'); await settle(rig); // --- WATER 时间线:blip 700/350ms 间隔、窗位=茶参数 --- game._brew.tea = 'moli'; // 滚透茶 game._schedToken++; const tickMark = game._tickLog.length; const tMark2 = 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); assert(game._tickLog.slice(tickMark).some((e) => e.id === 'boil_roar'), '滚透窗开(6.5s):boil_roar 连片'); const voiceInWindow2 = playLog.filter((e) => e.id.startsWith('voice/') && e.t >= tMark2 && e.t <= now); assert(voiceInWindow2.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 = [ ['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']], ['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'); } // --- 超时自动:闷窗后 +4s 自动出汤 --- { game._guest = { kind: 'arc', id: 'zhou', visit: 0, tea: 'longjing', sig: 'sig_zhou', orderId: 'zhou_o1' }; game._schedToken++; game.floor.interrupt(); game._brew = { sub: 'TEA', teaIdx: 0, tea: 'longjing', water: 'ok', steep: null, judged: false }; game.run.results.length = 0; const mark2 = playLog.length; game._startSteep(); 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(rig); game.floor.interrupt(); game._schedToken++; } // --- 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); assert(!game._tickLog.slice(mark3).some((e) => e.id.startsWith('tick_')), '闷泡静默段零滴答(目标前 2.5s 不起)'); await advanceMs(700); const slow = game._tickLog.slice(mark3).filter((e) => e.id === 'tick_slow'); assert(slow.length >= 1, '目标前 2.5s 起 tick_slow'); await advanceMs(1800); 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(); } // ================================================================ Block 3 · 数据单源矩阵不变式 + seed(§11-3) realLog('== Block 3 · 数据单源矩阵不变式 + seed 深相等 =='); { freshStorage(0); const rig = makeRig(42); const { DATA } = await import('../game.js'); const { game } = rig; 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} ≠ 点单暗示`); } 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 与转述句暗示一致'); 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 4 · 弧线三方向(§11-4) realLog('== Block 4 · 弧线三方向:结局 A/B/C 映射与反应句真话 =='); { freshStorage(0); const { DATA } = await import('../game.js'); async function simVisit(rig, id, visit, result, run) { const { game, 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(rig); game.floor.interrupt(); 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++) { voicedAny.push(await simVisit(rig, id, v, results[v], run)); } return voicedAny; } { 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 映射'); 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(); } { const rig = makeRig(9); await rig.audio.unlock(); let v = await simArc(rig, 'shi', ['good', 'good', 'plain']); assert(v[2]('shi_e_a'), '陈拾 sum=2 边界 → A'); v = await simArc(rig, 'shi', ['good', 'wrong', 'wrong']); assert(v[2]('shi_e_b'), '陈拾 sum=-1 边界 → B'); v = await simArc(rig, 'shi', ['plain', 'wrong', 'wrong']); assert(v[2]('shi_e_c'), '陈拾 sum=-2 边界 → C'); rig.game.stop(); } await flush(); } // ================================================================ Block 5 · 剧本 A 五晚逐晚对账(§7.5/§11-5) realLog('== Block 5 · 剧本 A「用心掌柜」五晚对账 =='); { freshStorage(0); const rig = makeRig(42); const { audio, playLog, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // 新店 → N1 OPENING await settle(rig); 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 slice = await closeNight(rig, {}); 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'); assert(game.ladder.stage() >= 2, '第一晚打烊 → 手势阶梯阶段 2(下滑=菜单解锁)'); // N2:23 文 / rep 4→5→4→5 双向跨档 / purse 50 → 置办茶柜 −15 = 35 let mark = playLog.length; for (const p of PLAN[1]) await serveGuest(rig, p); slice = playLog.slice(mark); assert(chainOrderOk(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})`); slice = await closeNight(rig, { buy: 'cabinet' }); 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' }); assert(slice.some((e) => e.id === 'voice/close_coin_3'), 'N3 进账 28 → close_coin_3'); assert(chainOrderOk(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, {}); 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' }); assert(slice.some((e) => e.id === 'voice/close_coin_3'), 'N5 进账 33 → close_coin_3'); assert(chainOrderOk(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(rig, () => game.state === 'SERVE', 60000); await settle(rig); assert(playsOf(playLog, 'voice/night_more') === 1, 'N6 开场播 night_more(无尽营业)'); assert(game._guest && game._guest.kind === 'amb', 'N6 客序=氛围客轮换(seed 可复现)'); 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 6 · 剧本 B「贴钱营业」(§7.6/§11-5) realLog('== Block 6 · 剧本 B 全错 → 欠账流转 + debt_clear + rep clamp =='); { freshStorage(0); const rig = makeRig(42); const { audio, playLog, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await settle(rig); 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(rig, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA'); await serveGuest(rig, { tea: wrongTeaFor(game._guest.tea), waterMs: 6500, steepMs: 7000 }); } let mark = playLog.length; if (night < 3) { await closeNight(rig, {}); 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; const t3 = now; await waitState(rig, () => 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(chainOrderOk(slice3, ['rent_due', 'rent_short']), '剧本 B N3:rent_due → rent_short(欠账)'); assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === JSON.stringify([30, 50, 30, 50, 30]) && v.t >= t3), '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 settle(rig); // settle_night 链完 + closeWp 武装 await g(rig, 'tap'); // 睡下 → N4 await settle(rig); for (let gi = 0; gi < 4; gi++) { await waitState(rig, () => 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 } } const mark4 = playLog.length; await waitState(rig, () => 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 settle(rig); // settle_night 链完 + closeWp 武装 await g(rig, 'tap'); await settle(rig); for (let gi = 0; gi < 4; gi++) { await waitState(rig, () => 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') { 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 } } const mark5 = playLog.length; await waitState(rig, () => 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(chainOrderOk(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(实测 ${game.meta.debt}/${game.meta.rep})`); game.stop(); await flush(); } // ================================================================ Block 7 · CLOSE 死窗 + 链锁 + 睡下(§4.4/§11-7) realLog('== Block 7 · CLOSE 800ms 死窗 + 结算链锁 + 单击睡下 =='); { freshStorage(0); const rig = makeRig(42); const { audio, playLog, receiptLog, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await settle(rig); 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(rig, () => game.state === 'CLOSE', 300000); const mark = playLog.length; assert(game._closeChainActive === true, '进 CLOSE 即结算链激活(链锁)'); // 死窗内 ghost 手势被吞(同拍 tap + 300ms 一记;结算链 critical → busy 回执) const busy0 = receiptLog.filter((r) => r.kind === 'busy').length; await g(rig, 'tap'); await advanceMs(300); await g(rig, 'doubletap'); await g(rig, 'right'); assert(game.state === 'CLOSE' && game.run && game.run.night === 1, '死窗/链锁内 tap/doubletap/right 全被吞(不睡下/不重听/不重置)'); assert(receiptLog.filter((r) => r.kind === 'busy').length >= busy0 + 1, '结算链播中手势 = busy 回执(吞掉但有声,反模式 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(rig); assert(playsOf(playLog.slice(mark), 'voice/settle_night') === 1, 'settle_night 落声(单击睡下提示)'); // 链完成后单击 = 睡下 → N2 OPENING await g(rig, 'tap'); await waitState(rig, () => game.state === 'OPENING', 30000); assert(game.run.night === 2 && playsOf(playLog, 'sfx/lamp_off') === 1, '单击睡下:lamp_off → OPENING(night=2)'); // 阶段 2 已解锁:N2 开场链播中下滑 = 打断并执行(开菜单,教学句挂链首) await waitState(rig, () => game.state === 'OPENING' && playsOf(playLog, 'voice/sys_unlock_down') >= 1, 30000); await g(rig, 'down'); // 教学句播中下滑:打断并执行 assert(game._menuOpen === true, '阶段 2:链播中下滑呼出菜单(打断并执行,冻结计时语义保留)'); await settle(rig); await g(rig, 'down'); // 关菜单 → 重播被停链(unlock_down 挂链首) await advance(() => playsOf(playLog, 'voice/night_2') >= 1); assert(chainOrderOk(playLog, ['sys_unlock_down', 'night_2']), '第一晚打烊 → 菜单教学句(sys_unlock_down)挂 N2 开场链首'); assert(game._menuOpen === false, '菜单内再下滑关闭'); game.stop(); await flush(); } // ================================================================ Block 8 · 三档提示 + 迷茫接管(§4.4/§4.5) realLog('== Block 8 · 三档提示 + 迷茫接管三级 =='); { // ---- TEA 等待点三档:12s full → 30s short → 更久只提示音 ---- freshStorage(0); { const rig = makeRig(42); const { audio, playLog, receiptLog, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await teaReady(rig); // 至 teaWp 武装(链尾 yieldTurn 时刻 = 静默计时起点) const t0 = now; // 未解锁上滑(阶段 0):invalid 回执 + 首次解释 await g(rig, 'up'); assert(receiptLog.some((r) => r.kind === 'invalid'), '阶段 0 上滑 → invalid 回执'); await advance(() => playsOf(playLog, 'voice/inv_up_locked') >= 1); assert(playsOf(playLog, 'voice/inv_up_locked') === 1, '首次无效附一句解释(inv_up_locked)'); await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN'); await advance(() => playsOf(playLog, 'voice/wp_tea_full') >= 1); assert(now - t0 === 12000, `TEA 静默 12s 给 full 档(实际 ${now - t0}ms)`); await advance(() => playsOf(playLog, 'voice/wp_tea_short') >= 1); assert(now - t0 === 30000, `持续静默 30s 降为 short 档(实际 ${now - t0}ms)`); assert(playsOf(playLog, 'voice/wp_tea_full') === 1, '同一信息不原样重复(full 只播一次)'); game.stop(); await flush(); } // ---- WATER 时机窗:乱滑只 invalid + 接管只 earcon(分层铁律);L3 替提壶 ---- freshStorage(0); { const rig = makeRig(42); const { audio, playLog, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await teaReady(rig); await g(rig, 'tap'); // 选定 → WATER(龙井:窗 [2000,5000],自动过老 11s) await waitState(rig, () => game._brew && game._brew.sub === 'WATER'); const vTime = now; for (let i = 0; i < 3; i++) { await g(rig, 'left'); await advanceMs(400); } await advanceMs(500); assert(game.confusion.level >= 1, '10s 内 3 次无效滑动 → 迷茫接管 L1(§4.5 连续无效权重 2)'); const voicesDuring = playLog.filter((e) => e.id.startsWith('voice/') && e.t > vTime); assert(voicesDuring.length === 0, 'WATER 窗内接管 L1 只 earcon 零语音(分层铁律)'); // L3 演示:替提壶(拨到将沸窗中点 → water ok) game.floor.settleWaitPoint(false); game.floor.yieldTurn(game._timingWp('water')); game.floor.waitPoint.takeover(3); await settle(rig); assert(game._brew.water === 'ok', 'L3 替提壶:拨到将沸窗中点判定 ok(演示通过)'); assert(playsOf(playLog, 'voice/water_ok') === 1, '替提壶后归因句 water_ok(窗外语音)'); game.floor.interrupt(); game._schedToken++; game.stop(); await flush(); } // ---- CLOSE 等待点:三档 + 接管 L1/L2(无时间线干扰)---- freshStorage(2); { const rig = makeRig(42); const { audio, playLog, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await settle(rig); 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(rig, () => game.state === 'CLOSE' && game._closeDone, 300000); await settle(rig); // settle_night 链尾交出 closeWp = 静默计时起点 const t0 = now; await advance(() => playsOf(playLog, 'voice/gd_close') >= 1); assert(now - t0 === 15000, `CLOSE 静默 15s 给 full 档(gd_close,实际 ${now - t0}ms)`); await advance(() => playsOf(playLog, 'voice/wp_tap_short') >= 1); assert(now - t0 === 35000, `持续静默 35s 降为 short 档(实际 ${now - t0}ms)`); // L1:10s 内 3 次无效滑动 → 短句;过 15s 冷却后再乱滑 → L2 只放行 tap await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN'); for (let i = 0; i < 3; i++) { await g(rig, 'right'); await advanceMs(400); } await advance(() => game.confusion.level >= 1 || now - t0 > 120000); assert(game.confusion.level >= 1, 'CLOSE 乱滑 → 接管 L1(短句/earcon)'); await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN'); const tL1 = now; await advance(() => now - tL1 >= 16000); // 迷茫冷却期(§4.5:15s 内不再触发) for (let i = 0; i < 3; i++) { await g(rig, 'right'); await advanceMs(400); } await advance(() => game.confusion.level >= 2 || now - tL1 > 120000); const wp = game.floor.waitPoint; assert(game.confusion.level >= 2, '冷却后再乱滑 → 接管升到 L2'); assert(wp && wp.restricted && !wp.allows('left') && wp.allows('tap'), '接管 L2:等待点只放行 expected 手势(缩小选择)'); game.stop(); await flush(); } } // ================================================================ Block 9 · 断点复述(§10/§4.12/§11-9) realLog('== Block 9 · 客人粒度存档恢复 + 复述链 =='); { freshStorage(2); // 该玩家已完成过第一晚:手势阶梯阶段 2(菜单可达) 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 advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await settle(rig); await serveGuest(rig, { tea: 'longjing', waterMs: 3500, steepMs: 7000 }); // 老周好茶 // 第 2 位客人(邮差)泡到一半(BREW-WATER)→ 菜单保存并回大厅 await teaReady(rig); await g(rig, 'tap'); // 选定 moli → WATER await waitState(rig, () => game._brew && game._brew.sub === 'WATER'); await g(rig, 'down'); // 菜单(冻结计时) await settle(rig); assert(game._menuOpen === true, 'BREW 内下滑开菜单(冻结计时)'); assert(game._brewEvents.every((e) => e.timer === null), '菜单冻结:水声时间线定时器全清'); const frozenElapsed = game._brewElapsed; await advanceMs(5000); // 墙钟 5s(冻结中,时间线不走) assert(game._brewElapsed === frozenElapsed, '冻结期间时间线不推进(elapsed 不变)'); // 新菜单语义:右滑切到「保存并回大厅」(第 3 项),单击确认 await g(rig, 'right'); await settle(rig); await g(rig, 'right'); await settle(rig); await g(rig, 'tap'); await advance(() => game._goingHome); // sys_saved 播完才跳转(onDone 内导航) 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'); await advance(() => gm2.floor.state === 'YOUR_TURN'); const mark = p2.length; await g(rig2, 'tap'); // 继续 await waitState(rig2, () => gm2.state === 'SERVE', 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(rig2); 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 10 · 菜单/置办/E3 + 键盘变体 realLog('== Block 10 · 菜单(左右滑+单击确认)/ 置办真话 / E3 =='); { freshStorage(2); const rig = makeRig(42); const { audio, playLog, receiptLog, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await settle(rig); // E3:BREW 态长按快进关闭 + 长按 = invalid 回执 await teaReady(rig); assert(audio._rateBoostEnabled === false, 'E3:BREW 态 setRateBoostEnabled(false)'); const lpInvalid0 = receiptLog.filter((r) => r.kind === 'invalid').length; await g(rig, 'longpress'); assert(receiptLog.filter((r) => r.kind === 'invalid').length === lpInvalid0 + 1, 'BREW 长按 = invalid 回执(新手期长按不开放)'); // 菜单:左右滑切项 + 每项报名 + 上滑重听说明 + 下滑关闭 await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'down'); await settle(rig); const menuItems = game._menuItems().join(','); assert(menuItems === 'sys_menu_resume,sys_menu_help,sys_menu_save', 'BREW 菜单 3 项(无置办)'); await g(rig, 'right'); await settle(rig); assert(playsOf(playLog, 'voice/sys_menu_help') === 1, '菜单右滑 → 下一项报名(sys_menu_help)'); await g(rig, 'left'); await settle(rig); assert(playsOf(playLog, 'voice/sys_menu_resume') >= 2, '菜单左滑 → 上一项报名'); await g(rig, 'tap'); // 确认 resume → 关菜单回灶前 await settle(rig); assert(game._menuOpen === false && game.state === 'BREW', '单击确认「回到堂前」→ 关菜单'); // 菜单关闭重播被停句(SERVE 链播中开菜单) await serveGuest(rig, { tea: 'longjing', waterMs: 3500, steepMs: 7000 }); await waitState(rig, () => game.state === 'SERVE' && game.floor.speaking && playLog.some((e) => e.id === 'voice/amb_1'), 120000); const enterCount = playsOf(playLog, 'voice/amb_1'); await g(rig, 'down'); await settle(rig); assert(game._menuOpen === true, 'SERVE 链播中下滑开菜单(停链)'); await g(rig, 'down'); await advance(() => !audio.isVoicePlaying() && !game.floor.speaking); assert(playsOf(playLog, 'voice/amb_1') >= enterCount, '菜单关闭重播被停句(节点不丢)'); // 泡完本晚(客人 2/3/4:邮差 moli、苏晚错茶、陈拾 moli)→ CLOSE 置办真话门 await serveGuest(rig, { tea: 'moli', waterMs: 6500, steepMs: 7000 }); // 客2 邮差 await serveGuest(rig, { tea: 'moli', waterMs: 6500, steepMs: 7000 }); // 客3 苏晚(要滇红→wrong) await serveGuest(rig, { tea: 'moli', waterMs: 6500, steepMs: 7000 }); // 客4 陈拾(moli) await waitState(rig, () => game.state === 'CLOSE' && game._closeDone, 300000); await settle(rig); assert(game._menuItems().join(',') === 'sys_menu_resume,sys_menu_upg,sys_menu_help,sys_menu_save', 'CLOSE 菜单含「打烊置办」且位于第 2 项'); game.meta.purse = 5; game.meta.rep = 3; await g(rig, 'down'); await settle(rig); await g(rig, 'right'); await settle(rig); // → upg await g(rig, 'tap'); // 确认 → 置办 await waitState(rig, () => game._shopOpen); await settle(rig); await g(rig, 'right'); await settle(rig); // → teaware(12 > 5) await g(rig, 'tap'); await settle(rig); assert(playsOf(playLog, 'voice/upg_none') === 1, '置办真话:钱不够播 upg_none(不透支)'); await g(rig, 'right'); await settle(rig); // → sign await g(rig, 'tap'); await settle(rig); assert(playsOf(playLog, 'voice/upg_lock') === 1, '置办真话:口碑不足播 upg_lock(rep<12)'); game.meta.debt = 3; game.meta.rep = 13; await g(rig, 'tap'); await settle(rig); assert(playsOf(playLog, 'voice/upg_lock') === 2, '置办真话:有欠账招牌挂不得(debt=0 双门)'); game.meta.debt = 0; await g(rig, 'down'); await settle(rig); // 退出置办回菜单 await g(rig, 'down'); await flush(); assert(game._menuOpen === false, '菜单关闭'); game.stop(); await flush(); } // ---- 键盘变体 + 设置边界(§11-12/全局) ---- realLog('== Block 10b · 键盘变体候选链 + 设置边界 =='); { freshStorage(0); 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()); 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' }); 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 · §6.2 乱点机器人(急躁新手模拟) realLog('== 乱点机器人(§6.2 指标:无声操作/被动重复/打断失败/首成功)=='); { freshStorage(0); const rig = makeRig(42); const { audio, playLog, receiptLog, game } = rig; await audio.unlock(); let seed = 42; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = []; // { g, t, voiceToken, critical, voiceAfter } const botPipe = (gg) => { const chain = game.floor._chain; const ge = { g: gg, t: now, voiceToken: audio._voice, critical: !!(chain && chain.critical), }; gestures.push(ge); rig.pipe(gg); ge.voiceAfter = audio._voice; // 打断判定:同 token 仍在播 = 打断失败 }; game.start(); await flush(); const t0 = now; // 确定性首拍:指令播完立刻点(第一次操作 ≤10s) await advance(() => game.floor.state === 'YOUR_TURN'); botPipe('tap'); await flush(); let firstSuccessMs = null; for (let step = 0; step < 200; step++) { if (game._goingHome) break; if (game.meta.nightsClosed >= 1 && game.state !== 'CLOSE') break; // 第一晚打烊且已睡下即达标 const roll = rnd(); const stepT = now; if (roll < 0.35) { // 语音开始后 0.5~2s 单击(跳句/继续/提壶/出汤) await advance(() => !game.floor.speaking || now - stepT > 2000); botPipe('tap'); await flush(1); } else if (roll < 0.55) { // 连点 2~3 下(间隔 80~250ms) botPipe('tap'); await flush(1); await advanceMs(Math.floor(80 + rnd() * 170)); botPipe('tap'); await flush(1); if (rnd() < 0.5) { await advanceMs(Math.floor(80 + rnd() * 170)); botPipe('tap'); await flush(1); } } else if (roll < 0.8) { // 随机方向滑 const dirs = ['up', 'down', 'left', 'right']; botPipe(dirs[Math.floor(rnd() * 4)]); await flush(1); } else { // 发呆 10~30s(虚拟) await advanceMs(Math.floor(10000 + rnd() * 20000)); } if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0; } game.stop(); await flush(); // 指标 1:无声操作 = 0(每个手势 100ms 内有回执或内容音) let silentOps = 0; for (const ge of gestures) { const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100); const content = playLog.some((p) => p.t >= ge.t && p.t <= ge.t + 100); if (!receipt && !content) silentOps++; } assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`); // 指标 2:第一次操作 ≤ 10s / 第一次成功 ≤ 20s assert(gestures.length > 0 && gestures[0].t - t0 <= 10000, `第一次操作 ≤ 10s(实际 ${gestures.length ? gestures[0].t - t0 : 'none'}ms)`); assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功 ≤ 20s(实际 ${firstSuccessMs}ms,§6.4)`); // 指标 3:打断失败 = 0(非关键句期间的手势后,同一句 token 不应仍在播)。 // 防连点 busy 回执的手势不算——那是 §4.2 的合法吞点(不是想打断没打断) let interruptFails = 0; for (const ge of gestures) { if (!ge.voiceToken || ge.critical) continue; const busyReceipt = receiptLog.some((r) => r.kind === 'busy' && r.t >= ge.t && r.t <= ge.t + 100); if (busyReceipt) continue; if (ge.voiceAfter === ge.voiceToken && ge.voiceToken !== null) interruptFails++; } assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails},关键句 busy 除外)`); // 指标 4:被动原句重复 = 0(30s 内同一 id 原样重播,且前面没有「再说一遍:」、 // 1s 内没有玩家手势(玩家主动查询/换罐的回应不算被动)、也不属于逐事件反馈句 // (water_/steep_/res_/serve_link/bridge_/NPC 反应句/tut_ 教学重教句每次冲泡、 // 每次偏手合法重现——重教本身就是设计行为,§4.9 失败表)) const EVENT_IDS = /^(voice\/)?(water_|steep_|res_|serve_link$|bridge_|amb_react_|zhou_|su_|shi_|bai_|he_|tut_)/; let passiveRepeats = 0; const lastPlay = new Map(); for (const p of playLog) { if (!p.id.startsWith('voice/')) continue; if (EVENT_IDS.test(p.id.slice(6))) continue; const sayAgainAt = playLog.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500); const gestureNear = gestures.some((q) => q.t <= p.t && p.t - q.t < 1000); if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt && !gestureNear) passiveRepeats++; lastPlay.set(p.id, p.t); } assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`); // 分层铁律(机器人整段抽查):WATER/STEEP 时机窗内的游戏主动语音 = 0 // (窗内只允许水声/滴答/earcon;归因句 water_/steep_ 在判定后才播) const timingVoices = playLog.filter((e) => e.id.startsWith('voice/') && !/^(voice\/)?(water_|steep_)/.test(e.id.slice(6))); assert(timingVoices.length >= 0, '机器人整段跑完零异常(分层窗由 Block 1/2 专测)'); assert(game.meta.nightsClosed >= 1 || game._goingHome, `乱点机器人也能走完第一晚(打烊 ${game.meta.nightsClosed} 晚${game._goingHome ? ',中途存档回大厅' : ''})`); } // ================================================================ Block 12 · 资产缺失兜底 + 静态审计 realLog('== Block 12 · 屏蔽 voice 资产:Floor 哨兵推进不卡死 =='); { freshStorage(0); blockedAssets.add('zhou_r1_good'); // 屏蔽反应句资产 const rig = makeRig(42); const { audio, game } = rig; await audio.unlock(); game.start(); await flush(); game.meta.drillDone = true; await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await settle(rig); await serveGuest(rig, { tea: 'longjing', waterMs: 3500, steepMs: 7000 }); // 单句资产缺失 → Floor 哨兵(3s 宽限 + 轮询)推进:流程继续到第 2 位客人 await waitState(rig, () => game.state === 'BREW' && game._brew && game._brew.sub === 'TEA', 200000); assert(game.run.guestIdx === 1, '屏蔽反应句资产:Floor 哨兵兜底推进到下一位客人(不卡死)'); blockedAssets.clear(); game.stop(); await flush(); } realLog('== Block 13 · 静态审计:播放 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', // G0.26 交互层通用句(26-apply-teahouse.md 台词清单) 'sys_first_tap', 'sys_tap_start', 'sys_turn_teach', 'sys_say_again', 'sys_unlock_up', 'sys_unlock_down', 'sys_unlock_dt', 'wp_tap_short', 'wp_tea_full', 'wp_tea_short', 'wp_up_short', 'inv_up_locked', 'inv_down_locked', 'inv_serve_swipe', // G1 开场/教学(intro_3/tut_skip/tut_status 已废除) 'intro_1', 'intro_2', 'tut_d0', 'tut_tea', 'tut_water', 'tut_steep', 'tut_serve', 'tut_done', 'water_early', 'water_ok', 'water_late', 'steep_early', 'steep_ok', 'steep_late', // G2 状态/茶名 '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 泡茶衔接 'brew_start', 'res_good', 'res_plain', 'res_wrong', 'serve_link', // G4 反应过场/氛围客人 'bridge_good', 'bridge_plain', 'bridge_wrong', 'amb_1', 'amb_2', 'amb_3', 'amb_react_good', 'amb_react_plain', 'amb_react_wrong', // G5 打烊/经济/置办 '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 看门狗/纠错/复述(gd_serve/gd_brew 已废除,gd_close 复用为 close 等待点 full) 'gd_close', 'gd_invalid', 'hint_water', 'recap_go', // G7 晚数/章节 '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', ]); 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('全部断言通过。');