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