From 57c30909cd7aa6913d3502d0afc096efa21a0c5d Mon Sep 17 00:00:00 2001 From: lili Date: Tue, 23 Jun 2026 10:28:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(amodel-gen):=20Phase1=20=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=20agent(--mode=20design,=E8=AF=BB=E7=9C=9F=20sim=20skill)+=20A?= =?UTF-8?q?PI-=E5=AD=98=E5=9C=A8=E9=9D=99=E6=80=81=E9=97=A8=20+=20sim=20sk?= =?UTF-8?q?ill=20=E6=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承 Phase1 执行版(2026-06-23-design-agent-phase1-execution.md)U5/U1/U2/U4。 U5 sim skill 扩:补「进货→库存→售卖→补货」资源环范式(经营核心,治"没进货")+ 客流/出现节奏目标值 区间(治"顾客太快":清闲 ~2.4s/3/6s、rush 起点 ≥35s)+ §10 标注可机检 N=4(即时反馈/可见成长/解锁/音反馈 + 经营加经济扣减)。 U1+U2 设计 agent:gen.mjs 加 --mode design(tool-using 策划 agent:read_file/list_dir/done,M3,scaffold-free, 日志走 stderr / stdout 末行 JSON {ok,designText,gatespec})+ prompt.mjs buildDesignSystemPrompt(薄指令指 **read 真 sim skill 不手抄** + 可达性自检 + 好玩 N-of-8 + 结构化 gatespec)+ normGatespec 容错(M3 偶把 assertAfterPlay 包成 {item:[]}/expectLatch 写字符串 → 拍平)。 验:一句话经营 brief → 读 sim skill → 8.5KB 玩家视角设计稿(含进货环 + 节奏目标值表 rush≥5min + 好玩自检) + clean gatespec(expectLatch:true / score↑ / currency↓ 经济断言 / inventory changed)。 U4 API-存在静态门(tools.mjs check,玩法无关):游戏调的 plugins.<键>.()/ctx.() 必须在 api.d.ts 真存在 → 廉价根除"方法名幻觉"类(stopBgm/defineScenes/ctx.nowMs);键→dir 映射(juice→particles-juice 等); 只查已知插件键+ctx、非键跳过(over-permissive 少误报)。验:_template 零误报 + 抓 stopBgm/ctx.nowMs + 不误伤 loop/ctx.log。 待:U3(designNode amodel 路 shell-out,Java/mini)+ 本地 e2e(design→code 验更好玩)+ U6 蒸馏。 Co-Authored-By: Claude Opus 4.8 --- .agents/skills/sim-business-game-design.md | 10 +++ game-runtime/tools/amodel-gen/gen.mjs | 78 +++++++++++++++++++++- game-runtime/tools/amodel-gen/prompt.mjs | 65 ++++++++++++++++++ game-runtime/tools/amodel-gen/tools.mjs | 43 ++++++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) diff --git a/.agents/skills/sim-business-game-design.md b/.agents/skills/sim-business-game-design.md index fe10b11c..a7606aec 100644 --- a/.agents/skills/sim-business-game-design.md +++ b/.agents/skills/sim-business-game-design.md @@ -34,6 +34,7 @@ | **合成升级(merge)** | 两个同级合成更高级(更高产出),制造"再合一个"冲动 | core 逻辑 + particles-juice 合成"啪" + hud-ui | | **阶段解锁阶梯** | 攒够 → 解锁新商品/区域/角色,**永远露出下一个锁** | session-score 阈值 + scene-fsm/状态 + hud-ui 解锁庆祝弹窗 | | **轻经营决策** | 定价/摆放/升级取舍/雇员——**有选择但不烧脑** | core 逻辑 + hud-ui 面板/按钮 | +| **进货/补货循环**(经营核心资源环) | 花软币**进货**→**库存**→售卖收钱→**缺货补货**;制造"赚→进→卖→再赚"的资源管理张力 | core 库存状态(数量/上限)+ session-score 货币(进货扣、售卖加)+ hud-ui 库存条/补货按钮 + particles-juice 售卖爽感 | | **收集图鉴** | 凑齐商品/角色/装饰 | save-progress 存收集状态 + hud-ui 图鉴格 | | **数值成长曲线** | 产出/成本指数成长 + 卡点,制造"差一点" | balance 数值表 | @@ -56,6 +57,7 @@ - **看广告点位(自愿、不打断爽点)**:加速产出 · 双倍离线收益 · 免费解锁/抽奖 · 复活/续命 · 翻倍领取。**包装成"福利"**(金光按钮 + "免费 ×2"),不是惩罚。 - **卡点设计**:让玩家**"差一点就能升级/解锁"**(成本曲线略陡)→ 自然引向看广告或放置等待。**别卡死**(挫败 = 流失);卡点是"诱惑"不是"墙"。 - **成长曲线**:产出/成本指数(如 ×1.15/级),配合放置收益,制造"滚雪球"暴富快感。 +- **客流/出现节奏(点客·经营类必给目标值 —— 治"来太快/太慢")**:design 要给**具体区间锚**,别让 code agent 拍脑袋(实测拍出来偏激进 = 顾客太快)。参考:清闲期出现间隔 **~1.8–2.8s** / 同屏上限 **3** / 顾客耐心 **~5–7s**;高峰期间隔 **~1.0–1.5s** / 上限 **4**;**高峰别太早**——一局 60s 的话 rush 起点 **≥35s**(留足清闲铺垫,先松后紧)。这些目标值交 balance.js。 --- @@ -147,6 +149,14 @@ > 8 条命中越多越好玩;命中 ≤3 条 ≈ catch-fruit 那种"能玩但无趣"。**设计阶段就过这张表,别等做出来才发现无趣。** +> **可机检子集(N≥4 必过 —— 接好玩门 / API 门 / per-game 断言)**:这 8 条里 **4 条可机检**,design 产出必须覆盖、且 code 必须实现: +> - **①即时反馈** = 主操作处真调 `particles-juice`(飘字/粒子)+ `audioMusic`(音效)→ code-presence 可查; +> - **②可见成长** = `score`/营收随操作上升 → H 门 `assertAfterPlay {path:score,op:increased}`; +> - **③下一个解锁** = 有解锁逻辑 + 阈值常量 → code-presence 可查; +> - **⑧音反馈** = 真调 `audioMusic.playSfx`(或引擎 zzfx)→ code-presence 可查。 +> +> **经营/进货品类再加一条 per-game 运行时断言**:买/进货时货币真减 → gatespec 产 `{path:"currency",op:"decreased"}`(经现成 H 门 assertAfterPlay 校验;**注:通用九门不加品类语义,断言是每局喂的**)。其余(情感锚/放置回归/滚雪球)靠真玩判,不机检。 + --- ## 相关 diff --git a/game-runtime/tools/amodel-gen/gen.mjs b/game-runtime/tools/amodel-gen/gen.mjs index 5ce06901..527f9f07 100644 --- a/game-runtime/tools/amodel-gen/gen.mjs +++ b/game-runtime/tools/amodel-gen/gen.mjs @@ -18,7 +18,7 @@ import { writeFileSync, mkdirSync, cpSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { scaffold, scaffoldSaa, readFileTool, writeFileTool, listDirTool, check, build, buildSaa, stage, gameDir } from './tools.mjs'; import { chat, MODEL } from './m3.mjs'; -import { TOOLS, buildSystemPrompt } from './prompt.mjs'; +import { TOOLS, buildSystemPrompt, DESIGN_TOOLS, buildDesignSystemPrompt } from './prompt.mjs'; import { play, smokeBoot } from './play.mjs'; function arg(name, def) { @@ -51,8 +51,82 @@ function compactHistory(messages, keepLast = 8) { } } +/** 容错归一 gatespec(M3 偶把 assertAfterPlay 包成 {item:[…]} / expectLatch 写成字符串)→ 拍平成 {expectLatch:bool, assertAfterPlay:[]}。 */ +function normGatespec(g) { + if (!g || typeof g !== 'object') return null; + let aap = g.assertAfterPlay; + if (aap && !Array.isArray(aap) && Array.isArray(aap.item)) aap = aap.item; // 拆 {item:[…]} 这层 + if (!Array.isArray(aap)) aap = aap ? [aap] : []; + let latch = g.expectLatch; + if (typeof latch === 'string') latch = latch.trim().toLowerCase() === 'true'; + return { expectLatch: !!latch, assertAfterPlay: aap }; +} + +/** design 模式:tool-using 策划 agent(read_file/list_dir/done),产玩家视角设计稿+gatespec,不写文件/不 build。 + * 日志走 stderr;stdout **末行**单行 JSON {ok,designText,fail}(designNode shell-out 据此解析,同 saa 契约)。 */ +async function runDesign({ brief, maxSteps, maxWallMs }) { + const t0 = Date.now(); + const rec = (m) => console.error(`[+${((Date.now() - t0) / 1000).toFixed(1)}s] ${m}`); // ★ design 日志走 stderr,保 stdout 末行=JSON 契约 + rec(`design model=${MODEL} brief=「${brief}」maxSteps=${maxSteps}`); + const messages = [ + { role: 'system', content: buildDesignSystemPrompt() }, + { role: 'user', content: `请按这个 brief 产一份玩家视角玩法设计稿(含 gatespec):「${brief}」。先 read_file 读 sim skill 再设计。` }, + ]; + let designText = null, gatespec = null, steps = 0; + while (!designText && steps < maxSteps) { + if (Date.now() - t0 > maxWallMs) { rec('design wall-time cap 超时,截停'); break; } + steps++; + let resp; + try { resp = await chat(messages, DESIGN_TOOLS); } catch (e) { rec(`step${steps} M3 调用失败: ${e.message}`); break; } + const msg = resp.message; messages.push(msg); + const calls = msg.tool_calls || []; + if (calls.length === 0) { + rec(`step${steps}: 无 tool_calls(content="${(msg.content || '').slice(0, 80)}")`); + messages.push({ role: 'user', content: '请用 read_file/list_dir 读手册后,产出设计稿+gatespec 并调 done。' }); + continue; + } + for (const c of calls) { + const name = c.function.name; + let a = {}; try { a = JSON.parse(c.function.arguments || '{}'); } catch { /* 坏 JSON 当空参 */ } + let result; + if (name === 'read_file') { + const r = readFileTool(a.path); + result = r.ok ? (r.truncated ? '[内容已截断]\n' : '') + r.content : 'ERROR: ' + r.error; + rec(`read ${a.path} ${r.ok ? 'ok' : 'ERR ' + r.error}`); + } else if (name === 'list_dir') { + const r = listDirTool(a.path); + result = r.ok ? r.entries.join('\n') : 'ERROR: ' + r.error; + rec(`list ${a.path} ${r.ok ? 'ok' : 'ERR'}`); + } else if (name === 'done') { + designText = (a.designText || a.design || '').trim(); + gatespec = normGatespec(a.gatespec || null); + result = `DONE 收到设计稿 ${designText.length}B + gatespec ${gatespec ? 'OK' : '缺'}`; + rec(result); + messages.push({ role: 'tool', tool_call_id: c.id, content: result }); + break; + } else { + result = 'ERROR: 未知工具 ' + name; + } + messages.push({ role: 'tool', tool_call_id: c.id, content: String(result).slice(0, TOOL_RESULT_CAP) }); + } + } + const ok = !!(designText && designText.length > 80); + // stdout 末行单行 JSON(designNode shell-out 解析;日志全在 stderr,不污染)。 + console.log(JSON.stringify({ ok, designText: ok ? designText : null, gatespec: ok ? gatespec : null, fail: ok ? null : 'design agent 未产出设计稿' })); + return ok; +} + async function main() { - const mode = arg('mode', 'spike'); // 'spike'(默认,_template/__gameBooted 信封)| 'saa'(__GameBundle 信封 + stage 到 _wg1-gen) + const mode = arg('mode', 'spike'); // 'spike'(默认)| 'saa'(__GameBundle 信封)| 'design'(策划 agent,只产设计稿+gatespec) + // design 模式:tool-using 策划 agent,只产玩家视角设计稿+gatespec(供 designNode shell-out 喂 code agent),不 scaffold/build/play。 + if (mode === 'design') { + const ok = await runDesign({ + brief: arg('brief', '一个简单的经营小游戏'), + maxSteps: Number(arg('max-steps', '25')), + maxWallMs: Number(arg('max-wall-ms', '600000')), + }); + process.exit(ok ? 0 : 1); + } const isSaa = mode === 'saa'; const id = isSaa ? arg('game-id', arg('id', 'run1')) : arg('id', 'run1'); const brief = arg('brief', '一个简单的点击得分小游戏'); diff --git a/game-runtime/tools/amodel-gen/prompt.mjs b/game-runtime/tools/amodel-gen/prompt.mjs index e2fcf864..68329cc5 100644 --- a/game-runtime/tools/amodel-gen/prompt.mjs +++ b/game-runtime/tools/amodel-gen/prompt.mjs @@ -101,3 +101,68 @@ A 读手册(1)+ 读你的起点 game-logic.js(4)→ B 按 brief 定玩法(核心 现在开始:**先 read_file 读手册,别直接写码;核心玩法实现完、check+build 绿了就立即 done。**`; } + +/** design 模式工具集(策划 agent:只读 + done;不写文件、不 build、不 scaffold)。 */ +export const DESIGN_TOOLS = [ + { + type: 'function', + function: { + name: 'read_file', + description: '读 repo 内任意文件(只读)。用它读 sim/littlejs skill 与插件 api.d.ts。先读手册再设计。', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'repo 相对路径,如 .agents/skills/sim-business-game-design.md' } }, required: ['path'] }, + }, + }, + { + type: 'function', + function: { + name: 'list_dir', + description: '列目录(只读),发现文件。', + parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, + }, + }, + { + type: 'function', + function: { + name: 'done', + description: '提交设计稿 + 结构化 gatespec(两个参数)。设计稿写好、gatespec 想清楚即调。', + parameters: { type: 'object', properties: { + designText: { type: 'string', description: '玩家视角玩法设计稿正文(中文)' }, + gatespec: { + type: 'object', + description: '验收锚点;经营品类的 assertAfterPlay 必含 {path:"currency",op:"decreased"}', + properties: { + expectLatch: { type: 'boolean', description: '是否有明确终态(到点/失败结算)' }, + assertAfterPlay: { + type: 'array', + description: '玩后进展断言数组(至少一条),如 {path:"score",op:"increased"}', + items: { type: 'object', properties: { path: { type: 'string' }, op: { type: 'string', enum: ['increased', 'decreased', 'changed'] }, minDelta: { type: 'number' }, why: { type: 'string' } }, required: ['path', 'op'] }, + }, + }, + required: ['expectLatch', 'assertAfterPlay'], + }, + }, required: ['designText', 'gatespec'] }, + }, + }, +]; + +/** L4 薄 system prompt(策划 agent):指 read 真 skill(不手抄)→ 产玩家视角设计稿 + gatespec。 */ +export function buildDesignSystemPrompt() { + return `你是经营/休闲小游戏的【游戏策划 agent】。产物 = 一份**玩家视角、可直接实现**的玩法设计稿(中文),供下游 code agent 照着写成游戏。**只产设计稿,绝不写代码。** + +【先读手册(必须,别凭记忆猜)】 +1. read_file('.agents/skills/sim-business-game-design.md') —— 经营玩法设计手册(§1 范式含「进货/补货循环」· §3 数值经济 + 客流节奏目标值 · §8 输出配方 · §9 可达性红线 · §10 反无趣 + 可机检子集)。 +2. read_file('.agents/skills/littlejs-game-dev.md') —— code 层可达能力(12 插件)。你设计的机制**只能用这 12 插件 + boot.ctx 达成**。 +3. 某插件能力拿不准 → read_file 它的 api.d.ts。 + +【产出(按 §8 配方,紧凑中文条目,逐项可落运行时)】 +核心循环(+30 秒脚本)· 主机制 1–2 个(标用哪些插件)· **经营品类必含「进货→库存→售卖→补货」资源环** · 解锁阶梯(3–6 级 + 阈值)· 数值骨架(初始/成长率/卡点)· **客流/出现节奏目标值**(出现间隔·同屏上限·耐心·rush 起点 —— 按 §3 给具体区间,别拍激进=别让顾客太快)· 美术风格词(一组共用)· 音效清单(收益/升级/解锁,§10 红线「必有反馈音」)· UI 布局 · 首 3 分钟脚本。 + +【两道自检(必过,写进设计稿)】 +- **可达性自检(§9)**:逐条问「12 插件 + boot.ctx 能落吗」——落不到的(联网/3D/大世界/复杂经济)剔除或简化。 +- **好玩自检(§10)**:过 8 条,**至少命中 4 条可机检项**并写明命中哪几条:①即时反馈(飘字+音效+粒子)②可见成长(score 上升)③下一个解锁 ⑧音反馈;经营再加「买/进货扣币」。 + +【gatespec(钉死,下游据此验收)——作为 done 的结构化参数传,别塞进设计稿正文/别用围栏块】 +done 的 gatespec 参数 = 一个对象:expectLatch(布尔,是否有明确终态)+ assertAfterPlay(数组,至少一条进展断言)。例:营收/score 用 {"path":"score","op":"increased"};经营品类**必加经济断言** {"path":"currency","op":"decreased"}。op 可选 increased/decreased/changed。 + +【完成】设计稿写好 + gatespec 想清楚 → 调 done({ designText: 设计稿正文, gatespec: { expectLatch, assertAfterPlay } })。你**只有** read_file / list_dir / done 三个工具;不写文件、不写代码。`; +} diff --git a/game-runtime/tools/amodel-gen/tools.mjs b/game-runtime/tools/amodel-gen/tools.mjs index 42275f5b..f196a976 100644 --- a/game-runtime/tools/amodel-gen/tools.mjs +++ b/game-runtime/tools/amodel-gen/tools.mjs @@ -142,6 +142,28 @@ function stripCode(s) { .replace(/`(?:[^`\\]|\\.)*`/g, '``'); // 模板串 } +/** 插件键 → api.d.ts 目录(host-config 注入的 canonical 键名;特殊:juice/save/palettePost/physics 名≠dir)。 */ +const PLUGIN_KEY_DIR = { + sceneFsm: 'scene-fsm', sessionScore: 'session-score', hudUi: 'hud-ui', timerScheduler: 'timer-scheduler', + save: 'save-progress', gamefeel: 'gamefeel', juice: 'particles-juice', palettePost: 'palette-post', + audioMusic: 'audio-music', collision: 'collision', physics: 'physics-lite', +}; +/** PluginContext(boot.ctx)真方法集 + 运行时附加 log;time/random 是属性对象(ctx.time.nowMs()/ctx.random.next() 内层 time/random≠ctx,经"非键跳过"放行)。 */ +const CTX_METHODS = new Set(['getContext2d', 'onFrame', 'getInput', 'getAudioContext', 'getEngine', 'log']); +const _apiCache = new Map(); +/** 读某插件 api.d.ts 抽其声明的方法名集(宽松:取所有「名(」形态,over-permissive=少误报)。返回 Set|null。 */ +function apiMethodSet(dir) { + if (_apiCache.has(dir)) return _apiCache.get(dir); + let set = null; + try { + const txt = readFileSync(join(GAME_RUNTIME, 'src', 'plugins', dir, 'api.d.ts'), 'utf8'); + set = new Set(); + for (const m of txt.matchAll(/(?:^|\n)\s*(\w+)\??\s*\(/g)) set.add(m[1]); + } catch { set = null; } + _apiCache.set(dir, set); + return set; +} + /** * check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。 * 1) node --check 各 src/*.js + entry.js(语法); @@ -232,6 +254,27 @@ export function check(id) { } } + // 4) API-存在静态门(玩法无关,治"方法名幻觉" stopBgm/defineScenes/ctx.nowMs):游戏调的 plugins.<键>.() / ctx.() 必须在 api.d.ts 真存在。 + // 只查已知插件键(host-config 注入的 11)+ ctx 直接方法;非键(state/Math/g/bundle/time/random 内层…)跳过 → over-permissive 少误报。 + for (const f of ['game-logic.js', 'core.js', 'render.js']) { + const abs = join(gd, 'src', f); + if (!existsSync(abs)) continue; + const s = stripCode(readFileSync(abs, 'utf8')); + for (const m of s.matchAll(/\b(\w+)\.(\w+)\s*\(/g)) { + const key = m[1], method = m[2]; + if (key === 'ctx') { + if (!CTX_METHODS.has(method)) { + errors.push(`API 静态门 ${f}: ctx.${method}() 不存在——boot.ctx 真方法 = ${[...CTX_METHODS].join('/')};时间用 ctx.time.nowMs()、随机用 ctx.random.next()(time/random 是对象不是函数)`); + } + } else if (PLUGIN_KEY_DIR[key]) { + const ms = apiMethodSet(PLUGIN_KEY_DIR[key]); + if (ms && ms.size && !ms.has(method)) { + errors.push(`API 静态门 ${f}: ${key}.${method}() 不存在——见 src/plugins/${PLUGIN_KEY_DIR[key]}/api.d.ts 的真方法名(M3 方法名幻觉)`); + } + } + } + } + return { ok: errors.length === 0, errors }; }