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无离群)
This commit is contained in:
xsl
2026-09-14 20:17:28 +08:00
parent ae850a4ce0
commit 258ffd5ba3
89 changed files with 7455 additions and 21 deletions
+354
View File
@@ -0,0 +1,354 @@
/**
* 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);
});