Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

1656 lines
79 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《深夜茶馆》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('全部断言通过。');