/** * simon live 修后验证驱动(round2)。 * node driver2.mjs V —— 核对局:S2 input_open 后才作答 / S1·S3·S4 长按查询 / * S7 your_turn 一次 / S6 结算链阶段看守+补播 / S5 误触 error 震 * node driver2.mjs P —— 压力局:乱按路径复跑,核对 S5/S6/S2 有落点 * 断言全在事件流上(等价玩家听+摸得到的反馈);末尾 /listen 取 ASR 判定行。 */ const BASE = 'http://localhost:8143'; const MODE = process.argv[2] || 'V'; const fs = await import('node:fs'); async function post(p, body) { const r = await fetch(BASE + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}), }); const j = await r.json(); if (!r.ok || j.ok === false) throw new Error(`${p}: ${JSON.stringify(j)}`); return j; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const G2E = { up: 'swipeup', down: 'swipedown', left: 'swipeleft', right: 'swiperight', tap: 'tap', doubletap: 'doubletap' }; const TONE2G = { tone_up: 'up', tone_down: 'down', tone_left: 'left', tone_right: 'right', tone_tap: 'tap', tone_doubletap: 'doubletap', }; const vid = (url) => String(url || '').split('/').pop().replace(/\.\w+$/, ''); const isTone = (id) => id.startsWith('tone_'); const w = { since: 0, all: [] }; async function poll() { const r = await fetch(`${BASE}/events?since=${w.since}`); const { events: evs, next } = await r.json(); w.since = next; for (const e of evs) w.all.push(e); return evs; } async function until(cond, { timeout = 60000, step = 120, tag = '' } = {}) { const t0 = Date.now(); for (;;) { const evs = await poll(); const hit = evs.filter(cond); if (hit.length) return hit; if (Date.now() - t0 > timeout) { const tail = w.all.slice(-6).map((e) => `${e.ev}:${vid(e.url) || e.pattern || ''}`).join(' | '); throw new Error(`until 超时(${Date.now() - t0}ms)${tag}:since=${w.since} all=${w.all.length} tail=${tail}`); } await sleep(step); } } let PASS = 0, FAIL = 0; function check(cond, msg) { if (cond) { PASS++; console.log(` ✓ ${msg}`); } else { FAIL++; console.log(` ✗ ${msg}`); } } async function gesture(name, note) { if (note) console.log(` [我] ${note} → ${name}`); await post('/gesture', { type: name }); } const mark = () => w.all.length; const since = (m) => w.all.slice(m); const evVibrate = (m, pat) => since(m).filter((e) => e.ev === 'vibrate' && e.pattern === pat); const evVstart = (m, id) => since(m).filter((e) => e.ev === 'vstart' && vid(e.url) === id); /** * 听 playback 直到 input_open 标记(_toInput,答题窗口真开放),从该标记倒取 * expectedLen 个 tone-start 为序列。input_open 唯一且不可与 tap 确认震混淆 * (跳过播报的 tap 也发 [20],曾让 confirm 边界误匹配、序列倒穿进上一轮历史)。 */ async function collectPlayback(expectedLen) { await until((e) => e.ev === 'start' && vid(e.url) === 'input_open', { timeout: 40000, tag: 'input_open' }); let idx = -1; for (let i = w.all.length - 1; i >= 0; i--) { if (w.all[i].ev === 'start' && vid(w.all[i].url) === 'input_open') { idx = i; break; } } const seq = []; for (let i = idx - 1; i >= 0 && seq.length < expectedLen; i--) { const e = w.all[i]; if (e.ev === 'start' && e.layer !== 'voice' && isTone(vid(e.url))) seq.push(TONE2G[vid(e.url)]); } seq.reverse(); if (seq.length !== expectedLen) console.log(` [!] 序列 ${seq.length} 音 ≠ 期望 ${expectedLen}`); console.log(` [debug-collect] input_open ta=${w.all[idx]?.ta} seq=${seq.join(' ')}`); return seq; } // input_open 游标:跨批次的"下一个未消费标记"(drain 批可能把 confirm 与 // input_open 同窗送达,按 mark 快照等新事件会漏看——游标扫全量历史,源驱动免疫)。 let ioCursor = 0; async function waitInputOpen(timeout = 15000) { const t0 = Date.now(); for (;;) { for (; ioCursor < w.all.length; ioCursor++) { const e = w.all[ioCursor]; if (e.ev === 'start' && vid(e.url) === 'input_open') { ioCursor++; return true; } } if (Date.now() - t0 > timeout) return false; await poll(); } } /** 答完整序列(调用方负责先 waitInputOpen)。 */ async function answerAll(seq) { for (const g of seq) { await post('/gesture', { type: G2E[g] }); await sleep(250); } } async function listenAll() { const rows = []; let cur = null; for (let i = 0; i < 40; i++) { const r = await fetch(`${BASE}/listen`); const j = await r.json(); rows.push(...j.rows); if (j.rows.length === 0) break; cur = j; await sleep(1500); } return rows; } // ---------------------------------------------------------------- 局 V:核对局 async function runV() { console.log('== 局 V(修后核对局,female/normal/guide full)'); await post('/session', { game: 'simon', settings: { voice: 'female', speed: 'normal', bgm: 'normal', guide: 'full' } }); await until((e) => e.ev === 'probe_ready', { timeout: 30000 }); await sleep(800); await gesture('tap', '解锁'); // 教学讲解期维持静默忽略(设计裁定:S5 只管 announce/roundend,教学不动)——锁定负例 const m0 = mark(); await gesture('swipeleft', '教学讲解期乱滑'); await sleep(300); check(evVibrate(m0, '[60,40,60]').length === 0, '教学讲解期维持静默忽略(S5 不扩面,负例锁定)'); await gesture('swipedown', '跳教学'); await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 15000 }); // 第 1 轮:S7——首边界 your_turn 一遍;答错触发复活重播,验证不重念 let seq = await collectPlayback(2); console.log(`-- 第 1 轮:${seq.join(' ')}`); { check(await waitInputOpen(), 'S2:第 1 轮边界 input_open 到达'); await sleep(700); await poll(); const ytBase = w.all.filter((e) => e.ev === 'vstart' && vid(e.url) === 'your_turn').length; check(ytBase === 1, 'S7:首次开放 your_turn 一遍'); await post('/gesture', { type: G2E[seq[0]] }); // 答首音 await sleep(300); const wrong = ['down', 'left', 'right', 'tap'].find((x) => x !== seq[1]); await post('/gesture', { type: G2E[wrong] }); // 答错 → 复活 await until((e) => e.ev === 'vstart' && vid(e.url) === 'retry_use', { timeout: 8000 }); seq = await collectPlayback(2); // 复活重播 check(await waitInputOpen(), 'S2:复活重播回 input 有 input_open 标记'); await sleep(700); await poll(); const ytNow = w.all.filter((e) => e.ev === 'vstart' && vid(e.url) === 'your_turn').length; check(ytNow === ytBase, 'S7:复活重播不重念 your_turn'); await answerAll(seq); } await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 20000, tag: 'round' }); // 第 2 轮:S2——input_open 与占用预告的次序(本轮占用多半已被第 1 轮封顶抑制,双路径都验) const mB = mark(); seq = await collectPlayback(3); console.log(`-- 第 2 轮:${seq.join(' ')}`); { const hasOcc = since(mB).some((e) => e.ev === 'vstart' && vid(e.url).startsWith('gd_occ')); check(await waitInputOpen(), `S2:input_open 标记到达(${hasOcc ? '占用预告链尾' : '无链轮(占用被第 1 轮封顶抑制)'})`); if (hasOcc) { const occVends = w.all.map((e, i) => (e.ev === 'vend' && vid(e.url).startsWith('gd_occ') ? i : -1)).filter((i) => i >= 0 && i >= mB); const openIdx = w.all.findIndex((e, i) => i >= mB && e.ev === 'start' && vid(e.url) === 'input_open'); check(openIdx > occVends[occVends.length - 1], 'S2:input_open 在占用预告结束之后'); } await answerAll(seq); } await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 20000, tag: 'round' }); // 第 3 轮:S5——announce 播报期乱滑给 error 震(round_03 播报已由上行 until 消费) { const mA = mark(); await gesture('swipeleft', '轮次播报期乱滑'); await gesture('swiperight', '再滑一下'); // 断言等对 drain 宽容:轮询至 error 震出现(或 2s 上限——drain 批延迟可达 1s+) let s5ok = false; for (let i = 0; i < 17; i++) { await poll(); if (evVibrate(mA, '[60,40,60]').length >= 1) { s5ok = true; break; } await sleep(120); } check(s5ok, 'S5:announce 播报期误触有 error 震'); await gesture('tap', '跳播报'); seq = await collectPlayback(4); console.log(`-- 第 3 轮:${seq.join(' ')}`); await waitInputOpen(); await answerAll(seq); } await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 20000, tag: 'round' }); // 第 4 轮:S1/S3/S4——input 阶段长按查询 seq = await collectPlayback(5); console.log(`-- 第 4 轮(${seq.length} 音):${seq.join(' ')}`); { await waitInputOpen(); const mQ = mark(); await gesture('longpress', '长按问状态'); // 等到三档 vstart 全部开播再断言(真实语音每句 1-2s,链式开播) for (let i = 0; i < 50; i++) { await poll(); if (since(mQ).filter((e) => e.ev === 'vstart' && vid(e.url).startsWith('status_')).length >= 3) break; await sleep(200); } const heard = since(mQ).filter((e) => e.ev === 'vstart').map((e) => vid(e.url)); check(heard.filter((x) => x.startsWith('status_len')).length === 1 && heard.filter((x) => x.startsWith('status_prog')).length === 1 && heard.filter((x) => x.startsWith('status_free') || x.startsWith('status_chance') || x === 'status_out').length === 1, `S1/S3/S4:查询三档整句(${heard.filter((x) => x.startsWith('status_')).join(', ')})`); const lenId = seq.length <= 4 ? 'status_len_short' : seq.length <= 8 ? 'status_len_mid' : 'status_len_long'; check(heard.includes(lenId), `S1:长度档与真实长度一致(${lenId})`); check(heard.includes('status_prog_start'), 'S3:进度档 start(尚未作答)'); check(heard.includes('status_free2'), 'S4:账目档 free2(免费两次未用)'); await answerAll(seq); } await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 20000, tag: 'round' }); // 第 5 轮:S6——input 开放后故意三连错(机会 = 初始 1 + 第 4 轮完成 +1 = 2)→ gameover seq = await collectPlayback(5); console.log(`-- 第 5 轮(${seq.length} 音):${seq.join(' ')}`); await waitInputOpen(); { // 机会账:初始 1 - 第 1 轮故意答错 1 + 第 4 轮完成 +1 = 1 → 两连错耗尽 for (let k = 0; k < 2; k++) { const wrong = ['down', 'left', 'right', 'tap'].find((x) => x !== seq[0]); await gesture(G2E[wrong], `故意答错(第 ${k + 1} 次)`); if (k < 1) { await until((e) => e.ev === 'vstart' && vid(e.url) === 'retry_use', { timeout: 8000 }); seq = await collectPlayback(5); await waitInputOpen(); } } } // S6:结算链 +1400ms 点火之前下滑进菜单(死亡瞬间手型,live-B 行 141) await sleep(600); // 判死本身即时(error/hit 震 + sfx/gameover +700ms) { const mG = mark(); await gesture('swipedown', '死亡瞬间下滑进菜单'); await until((e) => e.ev === 'vstart' && vid(e.url) === 'menu_open', { timeout: 8000 }); check(since(mG).filter((e) => e.ev === 'vstart' && /^(gameover|score_band)/.test(vid(e.url))).length === 0, 'S6 前提:进菜单前结算链未开播'); const mM = mark(); await sleep(2500); // 越过原 +1400ms 点火点 const leaked = since(mM).filter((e) => e.ev === 'vstart' && /^(gameover|score_band)/.test(vid(e.url))); check(leaked.length === 0, 'S6:菜单内不交付结算链'); await gesture('swipedown', '关菜单回结算页'); let redelivered = []; for (let i = 0; i < 42; i++) { await poll(); redelivered = since(mM).filter((e) => e.ev === 'vstart' && /^(gameover|score_band)/.test(vid(e.url))); if (redelivered.length >= 2) break; await sleep(150); } check(redelivered.length >= 2, `S6:回结算页补播结算链(${redelivered.map((e) => vid(e.url)).join(', ')})`); } console.log('== 局 V 结束'); } // ---------------------------------------------------------------- 局 P:压力局 async function runP() { console.log('== 局 P(修后压力局,male/1.7/guide full)'); await post('/session', { game: 'simon', settings: { voice: 'male', speed: '1.7', bgm: 'normal', guide: 'full' } }); await until((e) => e.ev === 'probe_ready', { timeout: 30000 }); await sleep(800); await gesture('tap', '解锁'); for (let i = 0; i < 3; i++) await gesture('tap', '连点跳旁白'); await gesture('swipedown', '跳教学'); await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 15000 }); const mA = mark(); await gesture('swipeleft', '播报期乱滑'); let s5ok = false; for (let i = 0; i < 17; i++) { await poll(); if (evVibrate(mA, '[60,40,60]').length >= 1) { s5ok = true; break; } await sleep(120); } check(s5ok, 'S5:跳播报路径上误触有 error 震'); const rnd = () => ['up', 'down', 'left', 'right', 'tap', 'doubletap'][Math.floor(Math.random() * 6)]; let dead = false; for (let guard = 0; guard < 140 && !dead; guard++) { const evs = await poll(); for (const e of evs) { // 判死检测用 hit 震(+300ms,gameover 专属震型)——赶在结算链 +1400ms 点火前 // 完成下滑,给 S6 留确定性的操作窗 if (e.ev === 'vibrate' && e.pattern === '[80]') { dead = true; } } if (dead) break; if (evs.some((e) => e.ev === 'vibrate' && e.pattern === '[8]') && Math.random() < 0.5) { const mPl = mark(); await gesture(G2E[rnd()], '播放中乱按'); let errSeen = false; for (let i = 0; i < 13; i++) { await poll(); if (evVibrate(mPl, '[60,40,60]').length > 0) { errSeen = true; break; } await sleep(120); } check(!errSeen, 'S5 负例:playback 维持静默豁免'); continue; } if (evs.some((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'))) { await gesture('tap', '跳轮次播报'); } await gesture(G2E[rnd()], '凭感觉乱答'); await sleep(200); } check(dead, '压力局到达 gameover'); // S6 压力路径:死亡瞬间下滑 → 菜单 → 回结算页补播 const mG = mark(); await gesture('swipedown', '死亡瞬间下滑进菜单'); await until((e) => e.ev === 'vstart' && vid(e.url) === 'menu_open', { timeout: 8000 }).catch(() => []); const mM = mark(); await sleep(2500); const leaked = since(mM).filter((e) => e.ev === 'vstart' && /^(gameover|score_band)/.test(vid(e.url))); check(leaked.length === 0, 'S6:菜单内不交付结算链(压力路径)'); await gesture('swipedown', '关菜单'); let redelivered = []; for (let i = 0; i < 42; i++) { await poll(); redelivered = since(mM).filter((e) => e.ev === 'vstart' && /^(gameover|score_band)/.test(vid(e.url))); if (redelivered.length >= 2) break; await sleep(150); } check(redelivered.length >= 2, `S6:回结算页补播(${redelivered.map((e) => vid(e.url)).join(', ')})`); const ioCount = w.all.filter((e) => e.ev === 'start' && vid(e.url) === 'input_open').length; const roundEnters = w.all.filter((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_')).length; check(ioCount >= 2 && ioCount >= roundEnters, `S2:input_open 每轮边界到达(${ioCount} 次 ≥ 进入轮次 ${roundEnters};压力局死在第 1 轮亦不少于初始+复活边界)`); console.log('== 局 P 结束'); } const runner = MODE === 'P' ? runP : runV; runner() .then(async () => { const rows = await listenAll(); const bad = rows.filter((r) => r.verdict !== 'OK'); console.log(`ASR 判定行 ${rows.length},非 OK ${bad.length}`); for (const r of bad.slice(0, 10)) console.log(` [${r.verdict}] ${r.voiceId} sim=${r.similarity} heard=${r.heard}`); const stop = await post('/stop'); console.log(`落盘: ${stop.outdir}(${stop.segs} 语音段 / ${stop.rows} 判定行)`); console.log(`\n验证断言:通过 ${PASS},失败 ${FAIL}`); process.exit(FAIL > 0 ? 1 : 0); }) .catch(async (e) => { console.error('驱动出错:', e); try { await post('/stop'); } catch (_) { /* 忽略 */ } process.exit(1); });