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