Files
SoundGame/research/simon-polish/round1/driver.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

281 lines
11 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 玩家驱动(round1 取证)。
* 两种人设策略:
* node driver.mjs A —— 认真新手:听完整教学、听轮次播报、凭"听觉"复现(以事件流
* 重构的序列充当完美记忆),测试免费重听经济句,打到高轮次。
* node driver.mjs B —— 急性子乱按:连点跳旁白、播放期乱滑、乱答、长按、结算乱按。
* 决策只看事件流(vstart/vend/sfx start/vibrate),等价"听到什么做什么"。
*/
const BASE = 'http://localhost:8143';
const MODE = process.argv[2] || 'A';
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;
}
async function evq(since) {
const r = await fetch(`${BASE}/events?since=${since}`);
return r.json();
}
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 { events: evs, next } = await evq(w.since);
w.since = next;
for (const e of evs) w.all.push(e);
return evs;
}
async function until(cond, { timeout = 60000, step = 120 } = {}) {
const t0 = Date.now();
for (;;) {
const evs = await poll();
const hit = evs.filter(cond);
if (hit.length) return hit;
if (Date.now() - t0 > timeout) throw new Error(`until 超时(${Date.now() - t0}ms)`);
await sleep(step);
}
}
async function gesture(name, note) {
if (note) console.log(` [我] ${note} → ${name}`);
else console.log(` [我] ${name}`);
await post('/gesture', { type: name });
}
const voiceBusy = () => {
for (let i = w.all.length - 1; i >= 0; i--) {
const e = w.all[i];
if (e.ev === 'vend') return false;
if (e.ev === 'vstart') return true;
}
return false;
};
async function waitQuiet(extra = 300) {
for (;;) {
await poll();
if (!voiceBusy()) break;
await sleep(150);
}
await sleep(extra);
}
/**
* 收集一轮 playback:从调用时刻起,tone+紧随 beat 震 = 序列音;confirm 震 = 边界。
* 返回 { seq, confirmIdx }(confirmIdx = w.all 中边界震的下标)。
*/
async function collectPlayback() {
const mark = w.all.length;
await until((e) => e.ev === 'vibrate' && e.pattern === '[20]', { timeout: 40000 });
let confirmIdx = w.all.length - 1;
// 音/震次序不保证(play 异步时 beat 可能先 push):窗口内 tone 数 == beat 数即可,
// 按 tone 出现顺序取序列(input 回声在 confirm 之后,落不进窗口)。
const seq = [];
let beats = 0;
for (let i = mark; i <= confirmIdx; i++) {
const e = w.all[i];
if (e.ev === 'vibrate' && e.pattern === '[8]') beats++;
else if (e.ev === 'start' && e.layer !== 'voice' && isTone(vid(e.url))) seq.push(TONE2G[vid(e.url)]);
}
if (beats !== seq.length) console.log(` [!] 窗口内 beat=${beats} 与音数=${seq.length} 不齐`);
return { seq, confirmIdx };
}
/** 答一个音:回声 tone 必须在 1.5s 内出现,否则重发(吞手势检测,仅驱动自用)。 */
async function answerOne(g) {
for (let tries = 0; tries < 3; tries++) {
const mark = w.all.length;
await post('/gesture', { type: G2E[g] });
const t0 = Date.now();
for (;;) {
const evs = await poll();
if (evs.some((e) => e.ev === 'start' && isTone(vid(e.url)))) return;
if (Date.now() - t0 > 1500) break;
await sleep(120);
}
console.log(` [!] ${g} 被吞(无回声),重发`);
}
throw new Error(`手势 ${g} 连续被吞`);
}
async function answerAll(seq) {
for (const g of seq) await answerOne(g);
}
// ---------------------------------------------------------------- 局 A:认真新手
async function runA() {
console.log('== 局 A(认真新手,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', '解锁启动遮罩');
const teach = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
let ti = 0;
while (ti < teach.length) {
const idWant = `t${ti + 1}_${teach[ti] === 'doubletap' ? 'double' : teach[ti]}`;
const hit = await until((e) => e.ev === 'vstart' && vid(e.url) === idWant, { timeout: 40000 });
console.log(` [听] 教学语音:${vid(hit[0].url)}`);
await until((e) => e.ev === 'vstart' && vid(e.url) === 't_try', { timeout: 20000 });
await waitQuiet(200);
if (ti === 2) {
await gesture('swiperight', '故意把左滑做成右滑');
await until((e) => e.ev === 'vstart' && vid(e.url) === 't_wrong_left', { timeout: 10000 });
console.log(' [听] 纠错:t_wrong_left');
await waitQuiet(200);
}
await gesture(G2E[teach[ti]], `照做「${teach[ti]}」`);
ti++;
}
await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 30000 });
console.log(' [听] 教学收尾完毕,进第 1 轮');
let round = 1; // 第 1 轮播报已在教学收尾 until 听到
let announceId = 'round_01';
let relistens = 0;
const planRelisten = { 5: true, 7: true };
for (;;) {
const { seq: heard } = await collectPlayback();
console.log(`-- 第 ${round} 轮(${announceId}):序列 ${heard.length} 个音:${heard.join(' ')}`);
if (heard.length !== round + 1) console.log(` [!] 序列长度 ${heard.length} ≠ round+1=${round + 1}`);
let seq = heard;
if (planRelisten[round]) {
// 先把前缀的 up 答掉(期望恰为 up 时上滑=作答,不会触发重听)
while (seq.length && seq[0] === 'up') { await answerOne('up'); seq.shift(); }
}
if (planRelisten[round] && seq.length) {
relistens++;
await gesture('swipeup', `想不起来了,主动重听(第 ${relistens} 次)`);
await until((e) => e.ev === 'vstart' && vid(e.url) === 'relisten', { timeout: 10000 });
console.log(' [听] relisten + 余量句链播');
const rep = await collectPlayback(); // 重播同一序列(进度清零)
seq = rep.seq;
console.log(` [听] 重播 ${seq.length} 个音:${seq.join(' ')}`);
}
await waitQuiet(300); // 占用预告/轮到你了语音播完(第 1 轮 your_turn)
if (round >= 13) {
// 高轮后故意三连错耗尽复活 → 走完整结算(retry_use/chances_out/score_band/new_record)
for (let k = 0; k < 3; k++) {
const wrong = ['down', 'left', 'right', 'tap'].find((x) => x !== seq[0]);
await gesture(G2E[wrong], `故意答错(第 ${k + 1} 次)`);
if (k < 2) {
await until((e) => e.ev === 'vstart' && vid(e.url) === 'retry_use', { timeout: 10000 });
console.log(' [听] retry_use(自动消耗机会,重播同一序列)');
const rep = await collectPlayback();
seq = rep.seq;
await waitQuiet(300);
}
}
} else {
await answerAll(seq);
}
// 追终局或下一轮
const tail = await until(
(e) => e.ev === 'vstart' && (vid(e.url) === 'gameover' || vid(e.url).startsWith('score_band') || vid(e.url).startsWith('round_')),
{ timeout: 25000 },
);
const tid = vid(tail[0].url);
if (tid === 'gameover' || tid.startsWith('score_band')) {
console.log(` [听] 结算开播:${tid}`);
await waitQuiet(600);
await gesture('tap', '结算播报完,单击再来一局');
await until((e) => e.ev === 'vstart' && vid(e.url) === 'round_01', { timeout: 15000 });
console.log(' [听] 新局开始');
break;
}
round++;
announceId = tid;
}
// 新局打两轮,然后下滑进菜单体验菜单导航
for (let r = 1; r <= 2; r++) {
const { seq } = await collectPlayback();
console.log(`-- 新局第 ${r} 轮:${seq.join(' ')}`);
await waitQuiet(300);
await answerAll(seq);
await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 20000 }).catch(() => {});
}
await gesture('swipedown', '下滑开菜单');
await until((e) => e.ev === 'vstart' && vid(e.url) === 'menu_open', { timeout: 8000 });
await gesture('tap', '菜单里单击切到下一项');
await gesture('tap', '再单击一项');
await gesture('swipeup', '上滑听规则');
await waitQuiet(300);
await gesture('doubletap', '双击确认当前项');
await sleep(600);
console.log('== 局 A 结束');
}
// ---------------------------------------------------------------- 局 B:急性子乱按
async function runB() {
console.log('== 局 B(急性子乱按,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('swipeleft', '教学里乱滑');
await gesture('tap', '教学里连点');
await gesture('swipedown', '下滑,跳教学');
await until((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'), { timeout: 15000 });
console.log(' [听] 进第 1 轮(教学被跳)');
const rnd = () => ['up', 'down', 'left', 'right', 'tap', 'doubletap'][Math.floor(Math.random() * 6)];
let gameoverSeen = false;
for (let guard = 0; guard < 140 && !gameoverSeen; guard++) {
const evs = await poll();
for (const e of evs) {
if (e.ev === 'vstart' && vid(e.url) === 'gameover') { gameoverSeen = true; console.log(' [听] gameover'); }
}
if (gameoverSeen) break;
// 播放/重播中(最近事件有 beat 震):一半概率乱按
if (evs.some((e) => e.ev === 'vibrate' && e.pattern === '[8]') && Math.random() < 0.5) {
await gesture(G2E[rnd()], '播放中乱按一气');
continue;
}
// 轮次播报一响就跳
if (evs.some((e) => e.ev === 'vstart' && vid(e.url).startsWith('round_'))) {
await gesture('tap', '跳轮次播报');
}
await gesture(G2E[rnd()], '凭感觉乱答');
await sleep(200);
}
await waitQuiet(400);
await gesture('swipeup', '结算后上滑听规则');
await waitQuiet(200);
await gesture('tap', '结算里连点');
await gesture('tap', '再点');
await gesture('longpress', '长按试试');
await gesture('swipedown', '下滑开菜单');
await until((e) => e.ev === 'vstart' && vid(e.url) === 'menu_open', { timeout: 8000 }).catch(() => {});
await gesture('tap', '菜单乱点');
await gesture('swipeleft', '菜单乱滑');
await gesture('doubletap', '菜单双击');
await sleep(600);
console.log('== 局 B 结束');
}
const runner = MODE === 'B' ? runB : runA;
runner()
.then(async () => {
const stop = await post('/stop');
console.log('落盘:', stop.outdir, stop.files?.length, '件,', stop.segs, '语音段,', stop.rows, '判定行');
process.exit(0);
})
.catch(async (e) => {
console.error('驱动出错:', e);
try { await post('/stop'); } catch (_) { /* 忽略 */ }
process.exit(1);
});