Files
SoundGame/research/simon-polish/round2/driver2.mjs
T
xsl 258ffd5ba3 fix(simon): 音序回响状态透明打磨(玩家代理取证迭代,玩法零改动)
- S2:新增input_open双音柔铃标记「能答了」边界(playback/input边界此前吞手势无标记)
- S1/S3/S4:input阶段长按=状态查询(13句status_*分档整句:长度4档×进度4档×账目5档,两本账口径一致,不耗账不闭窗)
- S5:announce/roundend非单击手势补error震(00 §5.4三秒反馈律)
- S6 P0:gameover下滑进菜单→结算链死锁8.4s修复(结算看守+挂起补播)
- S7:your_turn每局一次(原每轮重念)
- 取证:live两会话+4人设评审,S清单见research/simon-polish/round1/findings.md
- 验证:playtest 156/156 + guide 122 + 终检绿 + 14游戏回归零回退 + live复跑核对局14/14压力局8/8;状态感知2/5→4/5(四席同升),愿意玩改口为是
- 02号文档追加§9修订记录;新增input_open+13句status_*走完整TTS管线(flock串行QC无离群)
2026-09-14 20:17:28 +08:00

355 lines
16 KiB
JavaScript
Raw 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.
/**
* 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);
});