四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付: - cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档 - teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0 - keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4 - bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行 公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、 gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00) 验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿 遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
223 lines
8.8 KiB
JavaScript
223 lines
8.8 KiB
JavaScript
#!/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); });
|