#!/usr/bin/env node /** * cipher R1 live 驱动 —— 经 web/harness/live/control.mjs(端口 8131)打两局真机会话。 * * 局 A(主线):male / speed 1.0 / guide full —— 完整弧线图:四封全译对, * p2左·p4右·p6右·p8右·p9左 → e1「最后一电」。 * 局 B(压力):female / speed 1.7 / guide standard —— 同弧线图但带噪声 * (菜单乱逛、无效滑动、跳句连点),抉择全线右 → e2「值完这个班」。 * * 用法:node driver.mjs [A|B] (控制服务器须已在 8131、游戏服务器在 8001) */ const CTL = 'http://localhost:8131'; async function post(path, body) { const r = await fetch(CTL + path, { 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(`${path}: ${JSON.stringify(j)}`); return j; } async function get(path) { const r = await fetch(CTL + path, { headers: { connection: 'close' } }); const j = await r.json(); if (!r.ok) throw new Error(`${path}: ${JSON.stringify(j)}`); return j; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ---------------------------------------------------------------- 事件跟踪 let cursor = 0; const events = []; let lastPollCount = 0; let lastPollWall = Date.now(); async function poll() { const j = await get(`/events?since=${cursor}`); if (j.events.length) { console.log(`[poll +${j.events.length}] ${j.events.map((e) => `${e.ev}${e.url ? ':' + String(e.url).split('/').pop().replace('.ogg', '') : ''}@${Math.round((e.t || 0) / 1000)}`).join(' ').slice(0, 300)}`); } cursor = j.next; events.push(...j.events); if (events.length !== lastPollCount) { lastPollCount = events.length; lastPollWall = Date.now(); } return j.events; } const stalled = () => Date.now() - lastPollWall > 20000; const seenVend = (substr) => events.some((e) => e.ev === 'vend' && String(e.url || '').includes(substr)); const seenAny = (substr) => events.some((e) => String(e.url || e.msg || '').includes(substr)); async function waitVend(substr, timeoutMs = 30000) { const t0 = Date.now(); while (Date.now() - t0 < timeoutMs) { await poll(); if (seenVend(substr)) return true; await sleep(700); } return false; } async function voiceBusy() { await poll(); let busy = false; for (const e of events) { if (e.ev === 'vstart') busy = true; if (e.ev === 'vend') busy = false; } return busy; } // ---------------------------------------------------------------- 手势 async function tap() { await post('/gesture', { type: 'tap' }); } async function swipe(dir) { await post('/gesture', { type: 'swipe' + dir }); } // left/right/up/down → swipeleft 等 async function doubletap() { await post('/gesture', { type: 'doubletap' }); } async function longpress() { await post('/gesture', { type: 'longpress' }); } /** 链末单击推进,直到听到目标句播完(vend)。不在 DECODE 内使用。 */ async function tapUntil(vendSubstr, timeoutMs = 60000) { const t0 = Date.now(); let lastLog = 0; while (Date.now() - t0 < timeoutMs) { await poll(); if (seenVend(vendSubstr)) return true; if (!(await voiceBusy())) { await tap(); } if (Date.now() - lastLog > 15000) { lastLog = Date.now(); const lv = events.filter((e) => e.ev === 'vstart' || e.ev === 'vend'); console.log(`[tapUntil ${vendSubstr}] ev=${events.length} stalled=${stalled()} last=${lv.length ? lv[lv.length - 1].url.split('/').pop() + '@' + Math.round(lv[lv.length - 1].t / 1000) + 's' : '-'}`); } await sleep(900); } throw new Error(`tapUntil 超时:${vendSubstr}(已见事件 ${events.length})`); } /** 解码小局仪式:等预听落 → 双击原速 → 双击慢放 → 两段确认定下。 */ async function decodeRitual(tel, { useHint = false, sloppy = false } = {}) { const previewMs = { 1: 9500, 2: 9000, 3: 10000, 4: 10000 }[tel]; const txMs = { 1: 3600, 2: 3400, 3: 3600, 4: 3800 }[tel]; const slowMs = { 1: 7400, 2: 7000, 3: 7400, 4: 7800 }[tel]; if (useHint) { await sleep(1500); await longpress(); // 翻密码本(hint_tN) await sleep(6000); } else { await sleep(previewMs); } await doubletap(); // 重听原速 await sleep(txMs + 1200); if (!sloppy) { await doubletap(); // 慢放 0.5× await sleep(slowMs + 1200); } await tap(); // 第 1 击:cf_confirm + 电文重播 await sleep(2200 + txMs + 1500); await tap(); // 第 2 击:定下判定 await sleep(600); } /** 抉择:单击穿过链末等待段 → 等提示句播完(武装)→ 滑动。 */ async function choose(dir, promptVend) { await tapUntil(promptVend, 60000); await sleep(1600); // 武装 + 缓冲 await swipe(dir); await sleep(1200); } // ---------------------------------------------------------------- 局 async function playNight(which) { const main = which === 'A'; // 教学封 T1(BAY):dv1_ask 播完进浏览 await waitVend('dv1_ask', 90000); await decodeRitual(1, { sloppy: !main }); if (!await waitVend(main ? 'dv1_right' : 'dv1_right', 20000)) throw new Error('T1 判定无揭晓'); await choose(main ? 'left' : 'right', 'p2'); // A 报北风 / B 压日志 await tapUntil(main ? 'r1a_e' : 'r1b_e'); // T2(TOP) await tapUntil('dv2_ask'); await decodeRitual(2, { useHint: !main, sloppy: !main }); // 局 B 用一次密码本(live 覆盖 hint 流) if (!await waitVend(main ? 'rev_top' : 'rev_top', 20000)) throw new Error('T2 判定无揭晓'); await choose('right', main ? 'p4_known' : 'p4_known'); await tapUntil(main ? 'r2b_e' : 'r2b_e'); // T3(HAND) await tapUntil('dv3_ask'); if (!main) { // 局 B 噪声:浏览中开菜单乱逛再关 await sleep(6000); await swipe('down'); await sleep(3500); await tap(); await sleep(2500); await swipe('down'); await sleep(2500); } await decodeRitual(3, { sloppy: !main }); if (!await waitVend('rev_hand', 20000)) throw new Error('T3 判定无揭晓'); await choose('right', 'p6'); await tapUntil(main ? 'r3b_e_ok' : 'r3b_e_ok'); // T4(SPY · 高潮 · danger 拍) await tapUntil('dv4_ask'); if (!main) { // 局 B 噪声:无效滑动 + 连点 await sleep(4000); await swipe('left'); await sleep(700); await swipe('right'); await sleep(700); await tap(); await tap(); await sleep(1500); } await decodeRitual(4, { sloppy: !main }); if (!await waitVend('rev_spy', 25000)) throw new Error('T4 判定无揭晓'); await choose('right', 'p8_known'); await tapUntil('r4b_e'); // 签收 p9 await tapUntil(main ? 'p9_known' : 'p9_known'); await choose(main ? 'left' : 'right', main ? 'p9_known' : 'p9_known'); await tapUntil(main ? 'd9_send_e' : 'd9_sleepe', 60000); await tapUntil(main ? 'e1_a' : 'e2_a', 60000); await tapUntil(main ? 'ep_spy_ok' : 'ep_spy_ok', 60000); // 尾声逐封点名收束 } async function main() { const which = process.argv[2] || 'A'; const settings = which === 'A' ? { voice: 'male', speed: 'normal', bgm: 'normal', guide: 'full' } : { voice: 'female', speed: 'fast', bgm: 'normal', guide: 'standard' }; const sess = await post('/session', { game: 'cipher', settings }); console.log(`[live ${which}] outdir=${sess.outdir}`); cursor = 0; events.length = 0; const listenBad = []; const listenTimer = setInterval(async () => { try { const l = await get('/listen'); for (const r of l.rows) { if (r.verdict && r.verdict !== 'OK' && r.verdict !== 'SFX_BGM_ONLY') listenBad.push(r); } } catch (_) { /* 会话切换期静默 */ } }, 20000); await sleep(1500); // 等页面探针就绪再点遮罩(并行负载下页面加载可达数秒,早tap会丢) const t0 = Date.now(); while (!events.some((e) => e.ev === 'probe_ready') && Date.now() - t0 < 30000) { await poll(); await sleep(500); } await tap(); // 解锁启动遮罩 await sleep(2500); const u0 = Date.now(); while (!seenVend('sys_new_night') && Date.now() - u0 < 15000) { await poll(); await sleep(600); } if (!seenVend('sys_new_night')) throw new Error('解锁失败:sys_new_night 未播出'); await playNight(which); console.log(`[live ${which}] 结局链播完,单击再来一局后收档`); await sleep(2000); await tap(); // 结算单击 = 新夜班 await sleep(6000); try { await get('/listen'); } catch (_) { /* 末班判定尽力 */ } clearInterval(listenTimer); const stop = await post('/stop'); console.log(`[live ${which}] stopped: segs=${stop.segs} rows=${stop.rows}`, stop.outdir); console.log(`[live ${which}] 非 OK 判定 ${listenBad.length} 条`); for (const b of listenBad.slice(0, 20)) console.log(' ', JSON.stringify(b).slice(0, 220)); } main().catch((e) => { console.error('[live] FAILED:', e.message); process.exit(1); });