/** * simon live 玩家驱动(round1 取证)。 * 两种人设策略: * node driver.mjs A —— 认真新手:听完整教学、听轮次播报、凭"听觉"复现(以事件流 * 重构的序列充当完美记忆),测试免费重听经济句,打到高轮次。 * node driver.mjs B —— 急性子乱按:连点跳旁白、播放期乱滑、乱答、长按、结算乱按。 * 决策只看事件流(vstart/vend/sfx start/vibrate),等价"听到什么做什么"。 */ const BASE = 'http://localhost:8143'; const MODE = process.argv[2] || 'A'; 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; } async function evq(since) { const r = await fetch(`${BASE}/events?since=${since}`); return r.json(); } 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 { events: evs, next } = await evq(w.since); w.since = next; for (const e of evs) w.all.push(e); return evs; } async function until(cond, { timeout = 60000, step = 120 } = {}) { const t0 = Date.now(); for (;;) { const evs = await poll(); const hit = evs.filter(cond); if (hit.length) return hit; if (Date.now() - t0 > timeout) throw new Error(`until 超时(${Date.now() - t0}ms)`); await sleep(step); } } async function gesture(name, note) { if (note) console.log(` [我] ${note} → ${name}`); else console.log(` [我] ${name}`); await post('/gesture', { type: name }); } const voiceBusy = () => { for (let i = w.all.length - 1; i >= 0; i--) { const e = w.all[i]; if (e.ev === 'vend') return false; if (e.ev === 'vstart') return true; } return false; }; async function waitQuiet(extra = 300) { for (;;) { await poll(); if (!voiceBusy()) break; await sleep(150); } await sleep(extra); } /** * 收集一轮 playback:从调用时刻起,tone+紧随 beat 震 = 序列音;confirm 震 = 边界。 * 返回 { seq, confirmIdx }(confirmIdx = w.all 中边界震的下标)。 */ async function collectPlayback() { const mark = w.all.length; await until((e) => e.ev === 'vibrate' && e.pattern === '[20]', { timeout: 40000 }); let confirmIdx = w.all.length - 1; // 音/震次序不保证(play 异步时 beat 可能先 push):窗口内 tone 数 == beat 数即可, // 按 tone 出现顺序取序列(input 回声在 confirm 之后,落不进窗口)。 const seq = []; let beats = 0; for (let i = mark; i <= confirmIdx; i++) { const e = w.all[i]; if (e.ev === 'vibrate' && e.pattern === '[8]') beats++; else if (e.ev === 'start' && e.layer !== 'voice' && isTone(vid(e.url))) seq.push(TONE2G[vid(e.url)]); } if (beats !== seq.length) console.log(` [!] 窗口内 beat=${beats} 与音数=${seq.length} 不齐`); return { seq, confirmIdx }; } /** 答一个音:回声 tone 必须在 1.5s 内出现,否则重发(吞手势检测,仅驱动自用)。 */ async function answerOne(g) { for (let tries = 0; tries < 3; tries++) { const mark = w.all.length; await post('/gesture', { type: G2E[g] }); const t0 = Date.now(); for (;;) { const evs = await poll(); if (evs.some((e) => e.ev === 'start' && isTone(vid(e.url)))) return; if (Date.now() - t0 > 1500) break; await sleep(120); } console.log(` [!] ${g} 被吞(无回声),重发`); } throw new Error(`手势 ${g} 连续被吞`); } async function answerAll(seq) { for (const g of seq) await answerOne(g); } // ---------------------------------------------------------------- 局 A:认真新手 async function runA() { console.log('== 局 A(认真新手,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', '解锁启动遮罩'); const teach = ['up', 'down', 'left', 'right', 'tap', 'doubletap']; let ti = 0; while (ti < teach.length) { const idWant = `t${ti + 1}_${teach[ti] === 'doubletap' ? 'double' : teach[ti]}`; const hit = await until((e) => e.ev === 'vstart' && vid(e.url) === idWant, { timeout: 40000 }); console.log(` [听] 教学语音:${vid(hit[0].url)}`); await until((e) => e.ev === 'vstart' && vid(e.url) === 't_try', { timeout: 20000 }); await waitQuiet(200); if (ti === 2) { await gesture('swiperight', '故意把左滑做成右滑'); await until((e) => e.ev === 'vstart' && vid(e.url) === 't_wrong_left', { timeout: 10000 }); console.log(' [听] 纠错:t_wrong_left'); await waitQuiet(200); } await gesture(G2E[teach[ti]], `照做「${teach[ti]}」`); ti++; } await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 30000 }); console.log(' [听] 教学收尾完毕,进第 1 轮'); let round = 1; // 第 1 轮播报已在教学收尾 until 听到 let announceId = 'round_01'; let relistens = 0; const planRelisten = { 5: true, 7: true }; for (;;) { const { seq: heard } = await collectPlayback(); console.log(`-- 第 ${round} 轮(${announceId}):序列 ${heard.length} 个音:${heard.join(' ')}`); if (heard.length !== round + 1) console.log(` [!] 序列长度 ${heard.length} ≠ round+1=${round + 1}`); let seq = heard; if (planRelisten[round]) { // 先把前缀的 up 答掉(期望恰为 up 时上滑=作答,不会触发重听) while (seq.length && seq[0] === 'up') { await answerOne('up'); seq.shift(); } } if (planRelisten[round] && seq.length) { relistens++; await gesture('swipeup', `想不起来了,主动重听(第 ${relistens} 次)`); await until((e) => e.ev === 'vstart' && vid(e.url) === 'relisten', { timeout: 10000 }); console.log(' [听] relisten + 余量句链播'); const rep = await collectPlayback(); // 重播同一序列(进度清零) seq = rep.seq; console.log(` [听] 重播 ${seq.length} 个音:${seq.join(' ')}`); } await waitQuiet(300); // 占用预告/轮到你了语音播完(第 1 轮 your_turn) if (round >= 13) { // 高轮后故意三连错耗尽复活 → 走完整结算(retry_use/chances_out/score_band/new_record) for (let k = 0; k < 3; k++) { const wrong = ['down', 'left', 'right', 'tap'].find((x) => x !== seq[0]); await gesture(G2E[wrong], `故意答错(第 ${k + 1} 次)`); if (k < 2) { await until((e) => e.ev === 'vstart' && vid(e.url) === 'retry_use', { timeout: 10000 }); console.log(' [听] retry_use(自动消耗机会,重播同一序列)'); const rep = await collectPlayback(); seq = rep.seq; await waitQuiet(300); } } } else { await answerAll(seq); } // 追终局或下一轮 const tail = await until( (e) => e.ev === 'vstart' && (vid(e.url) === 'gameover' || vid(e.url).startsWith('score_band') || vid(e.url).startsWith('round_')), { timeout: 25000 }, ); const tid = vid(tail[0].url); if (tid === 'gameover' || tid.startsWith('score_band')) { console.log(` [听] 结算开播:${tid}`); await waitQuiet(600); await gesture('tap', '结算播报完,单击再来一局'); await until((e) => e.ev === 'vstart' && vid(e.url) === 'round_01', { timeout: 15000 }); console.log(' [听] 新局开始'); break; } round++; announceId = tid; } // 新局打两轮,然后下滑进菜单体验菜单导航 for (let r = 1; r <= 2; r++) { const { seq } = await collectPlayback(); console.log(`-- 新局第 ${r} 轮:${seq.join(' ')}`); await waitQuiet(300); await answerAll(seq); await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 20000 }).catch(() => {}); } await gesture('swipedown', '下滑开菜单'); await until((e) => e.ev === 'vstart' && vid(e.url) === 'menu_open', { timeout: 8000 }); await gesture('tap', '菜单里单击切到下一项'); await gesture('tap', '再单击一项'); await gesture('swipeup', '上滑听规则'); await waitQuiet(300); await gesture('doubletap', '双击确认当前项'); await sleep(600); console.log('== 局 A 结束'); } // ---------------------------------------------------------------- 局 B:急性子乱按 async function runB() { console.log('== 局 B(急性子乱按,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('swipeleft', '教学里乱滑'); await gesture('tap', '教学里连点'); await gesture('swipedown', '下滑,跳教学'); await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 15000 }); console.log(' [听] 进第 1 轮(教学被跳)'); const rnd = () => ['up', 'down', 'left', 'right', 'tap', 'doubletap'][Math.floor(Math.random() * 6)]; let gameoverSeen = false; for (let guard = 0; guard < 140 && !gameoverSeen; guard++) { const evs = await poll(); for (const e of evs) { if (e.ev === 'vstart' && vid(e.url) === 'gameover') { gameoverSeen = true; console.log(' [听] gameover'); } } if (gameoverSeen) break; // 播放/重播中(最近事件有 beat 震):一半概率乱按 if (evs.some((e) => e.ev === 'vibrate' && e.pattern === '[8]') && Math.random() < 0.5) { await gesture(G2E[rnd()], '播放中乱按一气'); continue; } // 轮次播报一响就跳 if (evs.some((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'))) { await gesture('tap', '跳轮次播报'); } await gesture(G2E[rnd()], '凭感觉乱答'); await sleep(200); } await waitQuiet(400); await gesture('swipeup', '结算后上滑听规则'); await waitQuiet(200); await gesture('tap', '结算里连点'); await gesture('tap', '再点'); await gesture('longpress', '长按试试'); await gesture('swipedown', '下滑开菜单'); await until((e) => e.ev === 'vstart' && vid(e.url) === 'menu_open', { timeout: 8000 }).catch(() => {}); await gesture('tap', '菜单乱点'); await gesture('swipeleft', '菜单乱滑'); await gesture('doubletap', '菜单双击'); await sleep(600); console.log('== 局 B 结束'); } const runner = MODE === 'B' ? runB : runA; runner() .then(async () => { const stop = await post('/stop'); console.log('落盘:', stop.outdir, stop.files?.length, '件,', stop.segs, '语音段,', stop.rows, '判定行'); process.exit(0); }) .catch(async (e) => { console.error('驱动出错:', e); try { await post('/stop'); } catch (_) { /* 忽略 */ } process.exit(1); });