按已批 v2 plan(2026-07-10-001)附录 A 执行,生效语气统一「已裁定,随波 1/2 兑现」:
- 质量模型 SoT:裁定三四处(四门投影/测试 agent 证据链/同源边界修正——判定档切
MiniMax-M3 动因与兜底/tester_degraded 归因)+ 裁定一末句四门投影追补 + 裁定二
occupied/score 历史口径注记 + §2 表 L1 行/门类型消歧段/§3 总述/规范五/首局时间口径
- 验收门:§2.4 追加「便宜档消费口径 v2」段(F 归类坐实、首局门退役去向)+ 门分类段
与首局门段注记
- 运行时图说:护城河分层验收段 v2 终文 + 出题≠被考实质化修订 + §一概览 + C5/C6
两契约便宜档消费面退役注记 + driven 段 + §六 A11 modify 链切统一编排器注记
- spike 资产入 spikes/playtest-agent/(v3 脚本仓相对路径化 + 考卷终榜 jsonl +
10 局转写 + README 复跑方法/真相表/结论:真坏 3/3 全拦零假阳、2 假阴同源接地失手)
- 在飞板:07-09 三波 plan 收口移除(三波+n=5 基线全交),登 W-AXIS-V2 行(波0 ✅、
波1 待派)
验收:docs-gate 七检全绿;rg 复扫「五门/九门=验收」仅余两处已注记历史叙述,无活口径残留。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
204 lines
14 KiB
JavaScript
204 lines
14 KiB
JavaScript
/**
|
||
* playtest-agent-spike.cjs —— 「测试 agent 替 E/G/H」spike 原型(2026-07-09 创始人拍板:拆着杀,spike 先行)。
|
||
*
|
||
* 职责:一个多模态 agent 按 brief 真玩一款游戏——每步看截图(+运行日志尾巴,均为人类可见的诚实证据,
|
||
* 不读 _forensicsView 契约状态)→ 决定动作(tap/key/wait)→ CDP 派发 → 最终给结构化裁决与修复反馈。
|
||
* 考卷 = 今日基线 10 局已知真相游戏(5 过关 + 冤案 r2 + 真哑 puzzle-r3 + 真空壳 trpg-r3 等),
|
||
* 判对冤案与真哑 = 证明它比旧 E/G/H 强。
|
||
*
|
||
* 用法: node playtest-agent-spike.cjs <gameId> [--model=glm-5.2] [--steps=12] [--port=4998] [--cdp=9331]
|
||
* 输出: stdout JSON 一行(裁决) + 转写落 transcripts/playtest-<gid>.transcript.json(与考卷存档同目录)
|
||
* 依赖: 静态服务与 headless Chrome 已起(同 probe-tap 的起法);NEWAPI_KEY 从环境注入(runner 负责)。
|
||
*/
|
||
'use strict';
|
||
const fs = require('node:fs');
|
||
const http = require('node:http');
|
||
const path = require('node:path');
|
||
// 仓相对路径(本档位于 spikes/playtest-agent/,仓根 = ../../):复用 harness 的 CDP 会话件与 game-runtime 的 ws 依赖
|
||
const REPO = path.join(__dirname, '..', '..');
|
||
const H = require(path.join(REPO, 'game-runtime', 'test', 'harness', 'browser-evidence.cjs'));
|
||
const { CdpSession } = H;
|
||
const WebSocket = require(path.join(REPO, 'game-runtime', 'node_modules', 'ws'));
|
||
|
||
const args = process.argv.slice(2);
|
||
const GID = args.find((a) => !a.startsWith('--'));
|
||
const opt = (k, d) => { const m = args.find((a) => a.startsWith(`--${k}=`)); return m ? m.split('=')[1] : d; };
|
||
const MODEL = opt('model', 'glm-5.2');
|
||
const MAX_STEPS = parseInt(opt('steps', '12'), 10);
|
||
const PORT = opt('port', '4998');
|
||
const CDP = `http://localhost:${opt('cdp', '9331')}`;
|
||
const BASE_URL = process.env.NEWAPI_BASE_URL || 'http://100.64.0.8:3000';
|
||
const KEY = process.env.NEWAPI_KEY;
|
||
if (!KEY) { console.error('缺 NEWAPI_KEY(runner 应从凭据档注入)'); process.exit(2); }
|
||
// 转写落 transcripts/(入仓后的固定落点;复跑产物与考卷存档同目录)
|
||
const SCRATCH = path.join(__dirname, 'transcripts');
|
||
fs.mkdirSync(SCRATCH, { recursive: true });
|
||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
function httpJson(method, urlStr) {
|
||
return new Promise((resolve, reject) => {
|
||
const u = new URL(urlStr);
|
||
const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, timeout: 15000 },
|
||
(res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { try { resolve(b ? JSON.parse(b) : {}); } catch (e) { reject(e); } }); });
|
||
req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout'))); req.end();
|
||
});
|
||
}
|
||
|
||
/** 调 new-api /v1/chat/completions(OpenAI 兼容,image_url data-URI;trust_env 语义=Node 原生 fetch 不走系统代理需显式 no proxy——用 http 模块直连,天然绕代理)。 */
|
||
function chat(messages, maxTokens) {
|
||
const body = JSON.stringify({ model: MODEL, max_tokens: maxTokens || 4000, messages });
|
||
const u = new URL(BASE_URL + '/v1/chat/completions');
|
||
return new Promise((resolve, reject) => {
|
||
const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 120000 },
|
||
(res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => {
|
||
try {
|
||
const d = JSON.parse(b);
|
||
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}: ${b.slice(0, 200)}`));
|
||
const msg = (d.choices || [{}])[0].message || {};
|
||
resolve({ text: msg.content || '', usage: d.usage || {} });
|
||
} catch (e) { reject(new Error('响应非 JSON: ' + b.slice(0, 200))); }
|
||
}); });
|
||
req.on('error', reject); req.on('timeout', () => req.destroy(new Error('LLM 调用超时'))); req.write(body); req.end();
|
||
});
|
||
}
|
||
|
||
const SYSTEM = `你是小游戏【真人视角测试员】。你只依赖两样人类可见的证据:游戏截图 + 运行日志尾巴。绝不臆测截图里没有的东西。
|
||
任务:按 brief 把这款游戏当真人一样玩起来(点按钮/点目标/等待),验证三件事:①玩得通(能从菜单进游戏、操作有响应、能推进到有结果)②有决策层(点哪有区别、有输赢/取舍)③与 brief 是一款游戏。
|
||
【坐标必读】截图上烧了粉色坐标尺:细格 50px、粗格 100px,左缘 y100..y800、上缘 x100..x300 是刻度标签。
|
||
报 tap 坐标前,先对着刻度读出目标的位置(如:按钮中心在 y400 与 y500 粗线正中间 → y=450),绝不凭感觉估。
|
||
【落点回显】截图上的亮黄色圆环+十字 = 你上一步 tap 的真实落点。每一步先看它:落点不在你想点的目标上,
|
||
就按偏差校正坐标(如圆环在按钮下方 60px → 下一步 y 减 60);落点在目标上而画面没反应,才是游戏的问题。
|
||
每一步你只输出一个 JSON(不要 markdown 围栏):
|
||
{"act":"tap","x":195,"y":489,"why":"点开始按钮(按钮上沿贴 y500 线上方约半格)"} —— 坐标系 390×844,原点左上
|
||
{"act":"key","key":"ArrowLeft","why":"向左"}
|
||
{"act":"wait","ms":800,"why":"等动画/观察窗"}
|
||
{"act":"verdict","pass":true|false,"canSeeScreenshot":true|false,"problems":["…"],"feedback":"给写游戏的 agent 的修复指引(具体到现象与复现步骤)","summary":"一句话"}
|
||
规则:每次 tap 后对照上一步截图判断画面变没变;没变就【必须换坐标】(先沿 y 轴上下各挪 50 试)或 wait,同一坐标最多连点 2 次;若换过 ≥3 处坐标画面仍毫无响应,判 fail 并把「点了哪些坐标都没反应」写进 problems;≤${'${MAX_STEPS}'} 步内必须给 verdict;看不到截图时如实报 canSeeScreenshot=false 并判 fail(problems 注明「测试员看不到画面」)。`;
|
||
|
||
(async () => {
|
||
// 读 brief(诚实来源:生成时落盘的 brief.json)
|
||
const briefPath = `/Users/lili/Project/games-development-ai/game-runtime/games/amgen-${GID}/evidence/brief.json`;
|
||
const brief = JSON.parse(fs.readFileSync(briefPath, 'utf-8')).brief;
|
||
|
||
// 连页面(照 play.cdp.cjs:DPR=1 省 token,agent 看 390×844 原比例)
|
||
const created = await httpJson('PUT', `${CDP}/json/new?${encodeURI('about:blank')}`);
|
||
const ws = await new Promise((res, rej) => { const s = new WebSocket(created.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 }); s.on('open', () => res(s)); s.on('error', rej); });
|
||
const cdp = new CdpSession(ws, created.id);
|
||
await cdp.send('Page.enable'); await cdp.send('Runtime.enable');
|
||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
|
||
try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch (_) {}
|
||
await cdp.send('Page.navigate', { url: `http://localhost:${PORT}/games/_wg1-gen/${GID}/index.html` });
|
||
const deadline = Date.now() + 30000; await delay(800);
|
||
for (;;) {
|
||
const st = await cdp.evaluate('({ b: !!window.__genBooted, e: !!window.__gameHostEngineInitFired, err: (window.__genBootError||window.__gameHostBootError||null) })');
|
||
if (st && st.err) { console.log(JSON.stringify({ gid: GID, verdict: 'boot-dead', err: st.err })); process.exit(0); } // A 门职责,测试员不接
|
||
if (st && st.b && st.e) break;
|
||
if (Date.now() > deadline) throw new Error('装载超时');
|
||
await delay(300);
|
||
}
|
||
|
||
// 坐标尺覆盖层(spike v2:治 vision-LLM 坐标接地弱——M3 实测把 y=489 的按钮估到 650~720,连点六次全空)。
|
||
// 细网格 50px、粗线+刻度标签 100px;pointer-events:none 不挡输入;只进截图,游戏逻辑无感知。
|
||
await cdp.evaluate(`(function(){
|
||
if (document.getElementById('__testgrid')) return;
|
||
var d = document.createElement('div');
|
||
d.id = '__testgrid';
|
||
d.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:99999;' +
|
||
'background-image:linear-gradient(rgba(255,0,90,.28) 1px,transparent 1px),linear-gradient(90deg,rgba(255,0,90,.28) 1px,transparent 1px),' +
|
||
'linear-gradient(rgba(255,0,90,.14) 1px,transparent 1px),linear-gradient(90deg,rgba(255,0,90,.14) 1px,transparent 1px);' +
|
||
'background-size:100px 100px,100px 100px,50px 50px,50px 50px;';
|
||
for (var y = 100; y < 844; y += 100) {
|
||
var t = document.createElement('span');
|
||
t.textContent = 'y' + y;
|
||
t.style.cssText = 'position:absolute;left:2px;top:' + (y - 14) + 'px;font:12px monospace;color:#ff005a;background:rgba(255,255,255,.75);padding:0 2px;';
|
||
d.appendChild(t);
|
||
}
|
||
for (var x = 100; x < 390; x += 100) {
|
||
var s = document.createElement('span');
|
||
s.textContent = 'x' + x;
|
||
s.style.cssText = 'position:absolute;top:2px;left:' + (x - 14) + 'px;font:12px monospace;color:#ff005a;background:rgba(255,255,255,.75);padding:0 2px;';
|
||
d.appendChild(s);
|
||
}
|
||
document.body.appendChild(d);
|
||
})()`);
|
||
const shot = async () => {
|
||
const r = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 62 });
|
||
return 'data:image/jpeg;base64,' + r.data;
|
||
};
|
||
const logTail = async () => cdp.evaluate('(function(){ var lg=window.__gameLog||[]; return lg.slice(-6).map(function(e){ return (e.scope||"")+"/"+(e.tag||"")+":"+String(e.msg||"").slice(0,60); }); })()');
|
||
|
||
// 落点回显:tap 后在页面放亮黄圆环+十字(pointer-events:none),下一帧截图里 agent 能看到自己点在哪。
|
||
const placeTapMarker = (x, y) => cdp.evaluate(`(function(){
|
||
var m = document.getElementById('__tapmark');
|
||
if (!m) { m = document.createElement('div'); m.id='__tapmark'; document.body.appendChild(m); }
|
||
m.style.cssText = 'position:fixed;left:${x - 14}px;top:${y - 14}px;width:28px;height:28px;pointer-events:none;z-index:100000;' +
|
||
'border:3px solid #ffe000;border-radius:50%;box-shadow:0 0 0 1px #000;' +
|
||
'background:radial-gradient(circle,rgba(255,224,0,.9) 2px,transparent 3px);';
|
||
})()`);
|
||
|
||
const transcript = [];
|
||
const history = []; // 文本化历史(旧截图不重发,只留动作与观察摘要)
|
||
let usageIn = 0, usageOut = 0, verdict = null;
|
||
let lastTap = null, sameTapStreak = 0, pushedBack = false;
|
||
const tapPoints = new Set();
|
||
|
||
for (let step = 0; step < MAX_STEPS && !verdict; step++) {
|
||
const img = await shot();
|
||
const lg = await logTail();
|
||
// 防误导硬提示(runner 侧确定性注入,不靠模型自觉)
|
||
let hint = '';
|
||
if (sameTapStreak >= 2) hint = `\n⚠ 你已连续 ${sameTapStreak} 次点同一坐标(${lastTap});规则:立即换点(先 y±50)或 wait,不许再点原处。`;
|
||
const userParts = [
|
||
{ type: 'text', text: `brief:${brief}\n已执行历史:${history.length ? history.join(' → ') : '(第一步)'}\n运行日志尾:${JSON.stringify(lg)}${hint}\n当前截图(第 ${step + 1} 步,共 ${MAX_STEPS} 步预算;黄圆环=你上一步落点):` },
|
||
{ type: 'image_url', image_url: { url: img } },
|
||
{ type: 'text', text: '输出下一个动作 JSON(或 verdict):' },
|
||
];
|
||
const resp = await chat([{ role: 'system', content: SYSTEM.replace('${MAX_STEPS}', String(MAX_STEPS)) }, { role: 'user', content: userParts }], 3000);
|
||
usageIn += resp.usage.prompt_tokens || 0; usageOut += resp.usage.completion_tokens || 0;
|
||
let act;
|
||
try { act = JSON.parse(resp.text.replace(/^```json?\s*|```\s*$/g, '').trim()); }
|
||
catch (_) { history.push(`step${step}:输出非JSON(截断记录)`); transcript.push({ step, raw: resp.text.slice(0, 300) }); continue; }
|
||
transcript.push({ step, act, log: lg });
|
||
if (act.act === 'verdict') {
|
||
// 反早退:fail 裁决若探索不足(独立落点 <3),驳回一次逼它继续测(narrative-r1 两拍即弃案的确定性补丁)
|
||
if (act.pass === false && act.canSeeScreenshot !== false && tapPoints.size < 3 && !pushedBack) {
|
||
pushedBack = true;
|
||
history.push(`裁决被驳回(仅试过 ${tapPoints.size} 处落点,规则要求 ≥3 处;继续测试)`);
|
||
continue;
|
||
}
|
||
verdict = act; break;
|
||
}
|
||
if (act.act === 'tap' && typeof act.x === 'number') {
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: act.x, y: act.y }] });
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||
await placeTapMarker(act.x, act.y);
|
||
const key = `${Math.round(act.x)},${Math.round(act.y)}`;
|
||
sameTapStreak = (lastTap === key) ? sameTapStreak + 1 : 1;
|
||
lastTap = key;
|
||
tapPoints.add(key);
|
||
history.push(`tap(${key})[${act.why || ''}]`);
|
||
await delay(450);
|
||
} else if (act.act === 'key' && act.key) {
|
||
const code = act.key.startsWith('Arrow') ? act.key : act.key;
|
||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', code, key: act.key });
|
||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', code, key: act.key });
|
||
history.push(`key(${act.key})`);
|
||
sameTapStreak = 0;
|
||
await delay(300);
|
||
} else if (act.act === 'wait') {
|
||
history.push(`wait(${act.ms || 500})`);
|
||
sameTapStreak = 0;
|
||
await delay(Math.min(act.ms || 500, 3000));
|
||
} else {
|
||
history.push(`step${step}:未知动作`);
|
||
}
|
||
}
|
||
if (!verdict) verdict = { act: 'verdict', pass: false, problems: ['步数预算内未给出裁决(测试员超时)'], feedback: '', summary: 'no-verdict', degraded: true };
|
||
|
||
const out = { gid: GID, model: MODEL, pass: verdict.pass, canSee: verdict.canSeeScreenshot, problems: verdict.problems || [], feedback: verdict.feedback || '', summary: verdict.summary || '', steps: transcript.length, tokens: { in: usageIn, out: usageOut } };
|
||
fs.writeFileSync(path.join(SCRATCH, `playtest-${GID}.transcript.json`), JSON.stringify({ out, transcript }, null, 1));
|
||
console.log(JSON.stringify(out));
|
||
process.exit(0);
|
||
})().catch((e) => { console.error('SPIKE-ERR:', e.message); process.exit(2); });
|