games-development-ai/spikes/playtest-agent/playtest-agent-spike.cjs
lili 8edb0ffa39
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
docs(cheap-gen): W-AXIS-V2 波0——SoT 修订 A1–A5 逐处落 + spike 资产入仓 + 在飞板登记
按已批 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>
2026-07-10 04:40:41 -07:00

204 lines
14 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.

/**
* 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); });