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 置完成。
1656 lines
79 KiB
JavaScript
1656 lines
79 KiB
JavaScript
/**
|
||
* 《深夜茶馆》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('全部断言通过。');
|