- 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无离群)
355 lines
16 KiB
JavaScript
355 lines
16 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|