Files
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
2026-09-14 05:48:13 +08:00

223 lines
8.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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); });