diff --git a/.agents/README.md b/.agents/README.md index 15bd762f..24ea4bca 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -56,7 +56,8 @@ | `skills/staging-ops.md` | staging 运维配方:机器分工/代码同步/后端重部署/前端构建门/冒烟门 | | `skills/ui-walkthrough-cdp.md` | 真 UI 走查配方(CDP on mini-desktop):studio/admin 走查 + CDP 七坑 + 信道探针 | | `skills/game-e2e-cdp-harness.md` | Canvas 游戏 e2e 证据 harness:编排形制/驱动器六律/出厂红线/四件套口径(T1b-α 实证;§7 = W-G1 生成游戏真玩九门 = 假绿守卫 G 门 + H 机制/latch + I 控制手感 + 自产 gatespec) | -| `skills/cheap-model-game-generation.md` | 便宜模型(new-api DeepSeek/MiniMax)直出可真玩轻游戏 L1 worker:链路/模型与成本/L1 纪律/坑红线/latch 套壳/design agent 自产 gatespec/质量三层(W-G1 HJ-GEN-001 实证) | +| `skills/cheap-model-game-generation.md` | 便宜模型(new-api DeepSeek/MiniMax)直出可真玩轻游戏 L1 worker:链路/模型与成本/L1 纪律/坑红线/latch 套壳/design agent 自产 gatespec/质量三层(W-G1 HJ-GEN-001 实证)。**注**:A-model 路改用下条 agentic harness,本条 gamedef/worker-loop 部分已退役 | +| `skills/agentic-amodel-generation.md` | **agentic A-model 生成 harness(ReAct+M3 已实证·生产形态)**:scaffold→工具循环(read/write/list/check/build/done)→循环外 play;链路/5 文件/运行配方/8 坑(write 前缀·compaction·停机纪律·防假绿·bundle.tick·boot envelope·路径深度)/M3 3-玩法自闭环实证/主流定性;取代旧 gamedef 单次产线(HJ-AGI-003) | | `skills/saa-graph-orchestration.md` | SAA(Spring AI Alibaba)裸 StateGraph 生成编排:拓扑/加节点/接 new-api(剥 /v1 坑)/checkpoint(含 saved_at 无 tiebreaker 框架坑+显式 checkPointId 修法)/observation/最小依赖集/派发契约/验证门(HJ-AGI-002 实证) | | `skills/doc-organizer.md` | 文档整理助手(创始人手动触发):增量(上次清理→现在)+**两阶段审批门**——发起分析 Workflow→编清理计划→评审→批准后才执行;三轴=过期档清理(归档/压缩)/核心设计档措辞对齐现行真相/主任务总账回填;底层 `tools/doc-organizer.{sh,-analyze.mjs,-state.json}`(检测自动·判断留人·脚本不自删·最新日在飞档不碰) | | `skills/drive-remote-claude-tmux.md` | 远程驱动交互式 Claude Code(ssh + tmux):双向通道 send-keys 派活 + capture-pane 读屏,不走 ACP/headless;每设备配置块 + onboarding 侦察配方 + 专用会话(独立 worktree 防撞树) + bypass-perms + 安全红线;**完成判据=git push 非读屏**(创始人 2026-06-18 拍板·多设备复用) | diff --git a/.agents/skills/agentic-amodel-generation.md b/.agents/skills/agentic-amodel-generation.md new file mode 100644 index 00000000..c7ced73d --- /dev/null +++ b/.agents/skills/agentic-amodel-generation.md @@ -0,0 +1,82 @@ +# skill:agentic A-model 游戏生成 harness(ReAct + M3,已实证) + +> **一句话**:给模型 read/write/list/check/build/done 工具,让它**自己读 skill→读插件 api.d.ts→读范例→写多文件 LittleJS `src/`→自查 check/build→done**,harness 再跑循环外 play 出"能跑能玩"判定。这是 A-model 生成的**生产形态**(取代旧 gamedef/factory 单次产线)。 +> +> 配套:code 层手册 [`littlejs-game-dev.md`](littlejs-game-dev.md) · design 层 [`sim-business-game-design.md`](sim-business-game-design.md) · 设计权威 [`../../docs/agent-specs/2026-06-21-agentic-amodel-generation-design.md`](../../docs/agent-specs/2026-06-21-agentic-amodel-generation-design.md)。旧 [`cheap-model-game-generation.md`](cheap-model-game-generation.md) 的 gamedef/worker-loop 路对 A-model **已退役**,只看其通用纪律。 + +--- + +## 1. 何时用 + +- 要让一个模型(默认 **M3**,经 new-api)从一句 brief **自动生成一款可跑可玩的多文件 LittleJS 小游戏**; +- 验证某模型/某 brief 的生成可达性; +- 作为 SAA 生成图 `generate` 节点的进程外 spike(验通后端口进 SAA)。 + +## 2. 链路(agentic generate 内循环) + +``` +scaffold(clone _template → games/amgen-/) + → ReAct 循环(M3 @ new-api,openai 协议 + function-calling): + system+brief → M3 发 tool_calls → harness 执行 → 回喂 → 循环 + 工具:read_file/list_dir(repo 只读)· write_file(scoped)· check · build · done + done 门:check 与 build 都 PASS 才接受(harness 复核) + → play(循环外·权威门:serve + headless Chrome boot + 帧推进 + 截图 + 交互 = 能跑能玩) + → verdict.json + run-log/run-summary + 截图 +``` + +四层(自上而下,设计文档详):**L1 env**(mini-desktop 沙箱)→ **L2 harness**(本 ReAct)→ **L3 context**(agent 经 read_file 按需自取 skill/api/范例,不预塞)→ **L4 prompt**(薄,指 agent 读真 skill,**绝不手抄**)。 + +## 3. harness 位置 + 文件 + +`game-runtime/tools/amodel-gen/`(node,零三方依赖——Node22 全局 fetch/WebSocket): + +| 文件 | 职责 | +|---|---| +| `tools.mjs` | scaffold(clone `_template`,删 dist/test)+ 5 工具 read/write/list/check/build;check=`node --check`+五法/导出名/红线 lint(去注释串再扫) | +| `m3.mjs` | new-api M3 客户端(openai+tools,超时/重试;key:env 优先、`docs/内网凭据与端点.md` 兜底) | +| `prompt.mjs` | L4 薄 prompt + 工具 schema(指 agent 读真 skill) | +| `play.mjs` | 循环外权威门:CDP(全局 WebSocket)boot+帧推进+截图+`do(start)`/tap+读 `__gameHost.state()` | +| `gen.mjs` | orchestrator:scaffold→ReAct(compaction+loop-detection+done 门)→play→落证据 | + +## 4. 运行配方(mini-desktop) + +```bash +# 同步 harness(Mac→mini) +rsync -az -e "ssh -o ProxyCommand=none" game-runtime/tools/amodel-gen/ \ + root@100.64.0.7:/root/game-staging/repo/game-runtime/tools/amodel-gen/ +# 跑一款(端口两两错开,避开 live 48080) +ssh -o ProxyCommand=none root@100.64.0.7 'cd /root/game-staging/repo/game-runtime && \ + NEWAPI_KEY=sk-... node tools/amodel-gen/gen.mjs --id --brief "<玩法一句话>" \ + --max-steps 45 --port 4321 --cdp 9223' +# 证据:games/amgen-/evidence/{run-summary.json,run-log.txt,first-paint.png,after-play.png} +``` +> 控制组(无 agent,验管线):`node tools/amodel-gen/tools.mjs scaffold ctrl1 && ... build ctrl1 && node tools/amodel-gen/play.mjs ctrl1 4320 9222`。 + +## 5. 关键坑 / 红线(踩过的,务必守) + +1. **write 路径必须含 `game-runtime/` 前缀**(所有工具路径 = repo-根相对)。prompt 里 game 目录写 `game-runtime/games/amgen-/`,与 read/write 同源——**少前缀 → 写全被拦 → agent 原地打转**(mole2 实证)。boundary 报错要回显正确允许路径。 +2. **token compaction 必须做**:全历史每步重发会爆炸(2.3M token!)。`gen.compactHistory` 折叠旧的大 tool 结果 / 旧 write 内容为占位(保留最近 ~8 条)→ **降到 ~200K**。文件在磁盘,agent 需要可重新 read_file。 +3. **停机纪律**:① scaffold **删 `test/`**(去掉"写测试"诱因)② prompt 明示"check+build 绿就**立即 done**、**没有 edit_file**(用 write_file 整覆盖)、别写测试/额外文件" ③ 双绿后 harness **一次性催 done**。缺它 → agent 绿了还乱写测试耗尽步数(mole1 实证,45 步未 done)。 +4. **check 加"game.js≠`_template`"防假绿**:写路径错→没覆盖→check 会在**未改的模板**上 PASS,蒙混过关。 +5. **`update(dt)` 必调 `opts.runtime.bundle.tick(dt)`**:否则插件 `onFrame` 不推进、timer 永不到期、一局不结束(check 已硬拦)。 +6. **boot envelope(沿用 `_template`)**:全局名 `TemplateGame` + `buildTemplateHostConfig`(host-config 导出名固定,别改)+ 标记 `__gameBooted/__gameHost/__gameForensics`;play 用 `?engine=real`;build `--global-name=TemplateGame`。 +7. **路径深度**:游戏落 `games//`(直接在 games/ 下,与 catch-fruit 同深度)→ `src/*.js` import 插件用 `../../../src/`(3-up)。嵌更深一层会变 4-up,esbuild 解析失败。 +8. **固定 plumbing 不让 agent 碰**:`index.html`/`entry.js`/`src/main.js`(scaffold 自带);agent 只写 `src/{game,core,render,host-config,assets}.js`。 + +## 6. M3 实证(2026-06-21,HJ-AGI-003) + +| run | 玩法 | steps | tokens | check/build/done/play | +|---|---|---|---|---| +| mole1/mole3 | 打地鼠 | 14 | 200K | 全 ✓(mole3 自闭环) | +| dodge1 | 陨石躲避(移动+碰撞+提速) | 13 | 175K | 全 ✓ | +| react1 | 反应点击(寿命靶+save-progress) | 22 | 378K | 全 ✓ | + +**3/3 跨玩法干净自闭环**,截图实锤可玩。M3 tool-calling 经 new-api openai 协议快(~2s/步、不触慢思考)。bar=**能跑能玩**(暂丢九门;九门是旧管线的,A-model 验证另议)。 + +## 7. 主流定性(设计 §0.8) + +整体 = Anthropic taxonomy 的 **orchestrator workflow + 内嵌 agentic(ReAct)+ evaluator-optimizer(repair)**,属"受约束 agentic"——主流且被推荐用于可预测生产管线。已采纳"循环内便宜快反馈(check/build)+ 循环外昂贵权威验证(play)"分层。**按需补的缺口**:agent scratchpad、更强的长循环压缩、play 失败→repair 回环(spike 暂线性 done→play)。 + +## 8. 下一步(production) + +spike 过 → 把本 agentic generate 端口进 `SaaStudioGraph` 的 generate 节点(Java 侧重写,调用同形态 M3 tool-loop),按设计 §0.5 删 gamedef/factory,validate/play 换 A-model 版。**须过 §6.8 Codex+Opus 双评审后才执行删除(D1=A 无退路)。** diff --git a/AGENTS.md b/AGENTS.md index 21b29bf1..72c9b630 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,7 @@ games-development-ai/ | [`.agents/skills/add-game-template.md`](.agents/skills/add-game-template.md) | 新玩法模板上线配方(contract→prompt→runtime→backend→orchestrator→五级验收门) | | [`.agents/skills/ai-generation-pipeline.md`](.agents/skills/ai-generation-pipeline.md) | AI 生成流水线(Dify + OpenGame + aigc 外壳)开发手册 | | [`.agents/skills/cheap-model-game-generation.md`](.agents/skills/cheap-model-game-generation.md) | 便宜模型造游戏(W-G1):worker loop · 九门真玩 harness · design-agent 自产 gatespec · 成本 / 模型选择 · 5 个坑(HJ-GEN-001 已验证) | +| [`.agents/skills/agentic-amodel-generation.md`](.agents/skills/agentic-amodel-generation.md) | **agentic A-model 生成 harness(ReAct+M3 已实证·生产形态)**:scaffold→工具循环(read/write/list/check/build/done)→循环外 play;5 文件 + 运行配方 + 8 坑(write 前缀/compaction/停机纪律/防假绿/bundle.tick/boot envelope/路径深度)+ M3 3-玩法自闭环实证;取代旧 gamedef 单次产线(HJ-AGI-003) | | [`.agents/skills/saa-graph-orchestration.md`](.agents/skills/saa-graph-orchestration.md) | SAA 裸 StateGraph 生成编排:拓扑 / 加节点 / new-api(剥 /v1)/ checkpoint(含 saved_at 无 tiebreaker 的框架坑 + 显式 checkPointId 修法)/ 观测 / 最小依赖集 / dispatcher 契约 / 门(HJ-AGI-002 已验证) | | [`.agents/skills/prompt-governance.md`](.agents/skills/prompt-governance.md) | Prompt 作为第 8 契约:Registry / 加载-注入 / eval 门 / HITL 治理 | | [`.agents/skills/littlejs-game-dev.md`](.agents/skills/littlejs-game-dev.md) | **AI 直接写 LittleJS 游戏代码作业手册(code 层)**:12 插件 API 速查(8 基元 + 4 编排=session-score/hud-ui/scene-fsm/timer-scheduler)+ 可导航代码结构 + 写 WHAT 调 HOW 边界 + 资产/mmx 流 + 工厂五法契约 + 受控面铁律(终态产物=src/ 多文件工程,取代废弃的 gameDefinition JSON 投影线) | diff --git a/docs/agent-specs/2026-06-21-agentic-amodel-generation-design.md b/docs/agent-specs/2026-06-21-agentic-amodel-generation-design.md new file mode 100644 index 00000000..9fbbb86c --- /dev/null +++ b/docs/agent-specs/2026-06-21-agentic-amodel-generation-design.md @@ -0,0 +1,224 @@ +--- +topic: Agentic A-model 游戏生成 · 四层设计(自上而下) +type: design +status: draft(执行前过 §6.8 双评审) +date: 2026-06-21 +owner: Mac 开发执行线 +origin: + - docs/agent-specs/2026-06-21-saa-amodel-rewrite-review.md(SAA→A-model 改写评审版 v3,D1=A/D6=A) + - 审计结论(2026-06-21):上一轮 spike = 非测(harness path bug + skill 从未加载 + 单次塞 prompt)→ 改 agentic(方案 B) +--- + +# Agentic A-model 游戏生成 · 四层设计(自上而下) + +> **为什么自上而下、prompt 最后**:每层是下一层的地基——**env**(在哪跑/怎么隔离)→ **harness**(agent 有什么工具/怎么循环)→ **context 拼装**(agent 能读到/读什么)→ **prompt**(只是指挥 agent 用好上面三层的薄指令)。上一轮失败正是**跳过前三层、直接手写 6.6KB prompt 硬塞**(skill 全文 + api.d.ts + 范例 0 进 prompt + harness path bug)= 地基没打就盖楼。 +> +> **方案 = B(agentic)**:给模型 read/write-file 工具,让它**自己读 skill→读 api.d.ts→读范例→写多文件**(catch-fruit 已证有能力 agent 行)。**不**用 A(单次塞满 prompt)。 + +--- + +## §0 · 整条链路架构(current → new · 删 gamedef 留概念) — 2026-06-21 founder 定调 + +> **founder 三条定调(2026-06-21)**:① 可删旧 gamedef 生成代码;② 保留整体设计**概念**、重写整条链路;③ **模型只用 M3**。 +> **已验证前提(本日实测)**:M3 经 new-api(openai 协议)发 tool_calls **干净且快**(1.8s,reasoning=0,并行 2 调用,参数正确)→ 方案 B agentic 成立。M3-only 使旧的「模型档阶梯(stage1/stage2/fallback)」塌缩为单档。 + +### §0.1 现状 → 新架构 · 节点级映射(16 节点拓扑保留,generate 换芯) + +| 节点 | 现状 | 新(D1=A 单一 A-model + M3 + agentic) | 动作 | +|---|---|---|---| +| render | init role/tier/counters | 同;tier 简化(仅 M3) | 改·简化 | +| classify | 品类→archetype+三维profile | 同,M3 | 留 | +| design | 单次 M3→设计稿+自产 gatespec | M3(可 agentic 读 design-skill) | 留·可增强 | +| **generate** | **单次 completion → iife OR gamedef JSON** | **agentic ReAct:M3 + read/write/list → 多文件 `src/`** | **重写·核心** | +| **validate** | iife 静态契约 **OR** gamedef build-from-source.mjs | A-model 静态:`node --check` × src/*.js + 五法契约 + host-config 导出 + 插件键一致 | **重写** | +| scaffold | 落 generated-factory.js + 模板 | 装配固定 plumbing(entry-bundle/index/assets-stub)环绕 agent 的 `src/` | 改 | +| asset | 六类资产规格 | 同(spike 可 stub→标题底纹兜底) | 留 | +| build | esbuild build.mjs → bundle.iife.js | 同(`--global-name=__GameBundle`) | 留 | +| play | 九门 serve-and-play(A–E 客观门) | boot+截图+点击 = **能跑能玩**(spike 暂丢九门) | 改·简化 | +| player | VLM 视觉+文本顾问 | M3 文本顾问(视觉待 M3 能力确认) | 留·spike 暂缓 | +| nreview | 叙事质量门 | 同 | 留·spike 暂缓 | +| modify | 改源路 | 同(spike 只走 create) | 留·spike 外 | +| repair | count++,feedback→generate | 同 | 留 | +| **escalate** | 升档 stage2 强档**模型** | **M3-only 模型档塌缩** → 重定义为 M3+thinking预算/温度升档,或并入 repair | 改·塌缩 | +| emit | 读 bundle→engineBundle,成功 | 同(→ GamePackage 契约,**对下游不变的边界**) | 留 | +| giveup | 终态失败完整 dump | 同 | 留 | + +### §0.2 新链路 · 系统架构(分层 ASCII) + +``` +┌─ 后端 game-cloud · SAA StateGraph(进程内 dispatcher=saa,Java)──────────────────┐ +│ │ +│ START→render→classify→design→[generate]→validate→scaffold→asset→build→play │ +│ ▲ │ │ │ +│ repair 回环 ◄─────────┴── validate/build/play fail ──┘ │ +│ │ play pass │ +│ giveup(终态失败) {player | nreview} │ +│ │ ok │ +│ emit(终态成功) │ +│ │ generate:HTTP+工具循环 │ validate/build/play:ProcessBuilder│ emit │ +└─────────┼───────────────────────────┼───────────────────────────────────┼────────┘ + ▼ (M3 ReAct) ▼ → node 脚本 ▼ GamePackage + ┌──────────────┐ ┌─────────────────────────────────────────┐ ┌──────────┐ + │ new-api 网关 │ │ 运行时 game-runtime(node · mini-desktop)│ │ 契约边界 │ + │ 100.64.0.8 │ │ ┌───────────────────────────────────┐ │ │ game- │ + │ **M3 only** │◄────►│ │ games// ← 本次产物 │ │ │ package │ + │ tool-calls ✓ │ 工具 │ │ src/{core,render,balance,game, │ │ │ .schema │ + └──────────────┘ 读写 │ │ host-config}.js ← M3 写 │ │ │ →落库→ │ + │ │ entry-bundle.js(__GameBundle) │ │ │ studio/ │ + │ │ + index.html + assets stub │ │ │ feed │ + │ ├───────────────────────────────────┤ │ └──────────┘ + │ │ src/plugins/*(12) · core/*.d.ts │ │ + │ │ build.mjs(esbuild)· serve+play │ │ + │ └───────────────────────────────────┘ │ + └──────────────────────────────────────────┘ +``` + +### §0.3 generate 节点 · agentic 内循环(四层 + **循环内 check/build 自检 / 循环外 play 权威门** —— 详见 §0.8) + +> 下图 read/write/list 之外,工具集已加 **check/build**(循环内快反馈);**done 门 = check+build 必须已绿**;`play` 是 done 之后的**循环外权威门**(慢)。 + +``` +╔═ L1 ENV · games//(scoped 写)· repo 只读 · M3@new-api · 无 shell ════════════╗ +║ ║ +║ ╔═ L2 HARNESS · ReAct 控制器(node,确定性)═══════════════════════════════════╗ ║ +║ ║ ┌ L4 PROMPT(薄):角色+步骤+红线+完成判据;指 agent 读真 skill,不手抄 ┐ ║ ║ +║ ║ │ system+brief ─► M3 ─► tool_calls? ─yes─► harness 执行 │ ║ ║ +║ ║ │ ▲ │ read/write/list │ ║ ║ +║ ║ │ │ 回喂结果/纠错 no(done) └ L3 CONTEXT:按需读 ┐│ ║ ║ +║ ║ │ └───────────────────┼──── code-skill→api.d.ts→catch-fruit ──┘│ ║ ║ +║ ║ └────────────────────────── ▼ done/max-steps ───────────────────────┘ ║ ║ +║ ║ build.mjs ─► serve ─► play(boot+截图+点击)─► verdict(能跑能玩?) ║ ║ +║ ╚═══════════════════════════════════════════════════════════════════════════╝ ║ +╚═════════════════════════════════════════════════════════════════════════════════╝ +``` + +### §0.4 单次生成作业 · 流程图(repair/giveup 分支) + +``` +brief + └► render ─create─► classify(品类→archetype) ─► design(M3 读 design-skill → 玩法设计 + gatespec) + │ + ┌──────────────────────────────────────▼─────────────────────────────────┐ + │ generate(agentic M3):读 code-skill + 用到插件 api.d.ts + catch-fruit 范例 │ + │ → 多轮工具写 src/{core,render,balance,game,host-config}.js │ + └──────────────────────────────────────┬─────────────────────────────────┘ + ▼ + validate(A-model 静态:node --check + 五法契约 + host-config + 插件键) + │ ok │ fail + ▼ ▼ + scaffold(固定 plumbing) ──► asset ──► build repair(count++,feedback→generate) + │ ok │ fail ▲ repairCount/`);read = repo 只读;**不给 agent 任意 shell** | +| 命令边界 | build(esbuild)/ play(Chrome 九门/或 boot+截图)由 **harness 跑,非 agent**;端口错开 live :48080(4320/9222) | +| 凭据 | new-api key + base 经 env 注入(`docs/内网凭据与端点.md`) | +| 可观测 | 全程一个 run 日志(agent 每步工具调用 + build + play),founder 可 tail;SAA app 日志 = `/root/game-staging/app.log` | + +**依赖**:无(最底层)。**先建好它,后面才有地方跑。** + +**✅ L1 实测结论(2026-06-21,mini-desktop)**:用已知可玩的 **catch-fruit 当控制**——build(esbuild,220KB,引擎 inline)+ boot + play(真引擎、水果下落、HUD 活、截图确认)**全通** → build/play 基础设施对 A-model 游戏成立。recipe 已得。 + +**🔑 harness 形态决策(L1→L2 关键,基于 recipe)**: +- **目录**:run 落 `games/amgen-/`(**直接在 `games/` 下**,与 catch-fruit 同深度 → 插件 import 用 `../../../src/`,**与 catch-fruit 范例逐字一致,根除路径深度坑**);gitignore。 +- **形态 = catch-fruit shape(多文件 `src/`)**,**不用** `_wg1-gen` 旧的单文件 `generated-factory.js`(那是 factory 单文件路,与 A-model 多文件矛盾;且嵌进 `_wg1-gen//src` 会变 4-up 触发深度坑)。 +- **固定 plumbing(harness 写,不让 agent 碰)**:`index.html` + `entry.js` + `src/main.js`,**固定全局名 `__AModelGame` + 固定 boot 标记**(`__gameBooted`/`__gameHost`/`__gameForensics`);**agent 只产** `src/{host-config,game,core,render,balance,assets}.js`,约定 `host-config.js` 导出**固定名 `buildHostConfig`**、`game.js` 默认导出工厂、`update(dt)` 内调 `opts.runtime.bundle.tick(dt)`。 +- **play = 轻量 CDP**(serve + headless Chrome boot + 截图 + 一次 tap + 读 `__gameForensics` 状态 = **能跑能玩**),**不复用** `serve-and-play.sh` 九门(founder 已弃九门;且它硬编码 `_wg1-gen` 布局)。 +- **build**(locked esbuild,不改):`node scripts/build.mjs games/amgen-/entry.js games/amgen-/dist/bundle.js --global-name=__AModelGame`(cwd=`game-runtime`)。 + +--- + +## L2 · Harness(编排循环 + 工具框架) + +**目标**:确定性控制器,驱动「agentic 生成 → 装配 → build → play → verdict」,并定义 agent 的工具。**这就是项目延期的 ReAct 生成节点的 spike 版**;验通后端口进 SAA `generate` 节点。 + +| 关键决策 | 方案 | +|---|---| +| **工具集(给 agent)** | `read_file`(repo 只读)、`write_file`(scoped run 目录)、`list_dir`、**`check`**(`node --check` × src/*.js + 五法/host-config/插件键静态 lint)、**`build`**(esbuild,秒级);**无任意 shell**。经 new-api function-calling 暴露(**M3 已实测支持**) | +| ✅ 可行性(已验证) | **M3 经 new-api(openai 协议)发 tool_calls 干净且快**(1.8s,reasoning=0,并行 2 调用,参数正确)——agentic ReAct 成立,无需回退伪 agentic | +| agent 循环 | system+brief → M3 发 tool_calls → harness 执行(读/写/check/build)→ 回结果 → 循环至 agent done **或** max-steps;**done 门:check + build 必须已绿**;越界/读不到/check 失败 → 回喂纠错 + loop-detection 防卡死 | +| 装配(固定 plumbing) | run 目录预置:`entry-bundle.js`(暴露 `__GameBundle.bootGameHost`,**用 per-game host-config 而非 generic 壳**)+ `index.html`(play 模板)+ `src/assets.js` stub;**深度 = `games//`(治路径 bug:import 插件用 `../../../src/plugins`)**;agent 只产 `src/{core,render,balance,game,host-config}.js` | +| 收口(循环外权威门) | agent done(已自 build 绿)→ **play**:serve + 无头 Chrome boot+截图+点击(bar=**能跑能玩**,暂丢九门)→ 存 verdict/截图/日志;fail → repair 回 generate | +| 多 run | 循环 briefs(模型固定 M3)→ 汇总过率/失败模式 | + +**依赖**:L1(先有沙箱才能定工具边界)。 + +--- + +## L3 · Context 拼装(agent 读什么 / 怎么读) + +**目标**:让 agent 拿到**充足但不过量**的 context(治上一轮"skill 没加载")。**agentic 让 skill 的指针生效**(agent 按需读文件)。 + +| 关键决策 | 方案 | +|---|---| +| 两阶段 | **design 阶段**(读 `sim-business-game-design.md` → brief 变具体玩法设计:核心循环/机制/解锁/数值/插件清单/反无趣自检)→ **code 阶段**(读 `littlejs-game-dev.md` + 选用插件 api.d.ts + catch-fruit 范例 → 写多文件实现该设计) | +| 按需读、别全读 | skill 指引"用 X 插件就读 `src/plugins/X/api.d.ts`";一份完整范例(catch-fruit)即可,不读全 12;控 token | +| 必读锚点(prompt 点名) | ① 两份 skill ② `game-host.d.ts`(工厂五法契约)③ `games/catch-fruit/`(唯一完整范例)④ 用到插件的 `api.d.ts` | +| skill 生成可用度 | 已是指针式,**agentic 下够用**(agent 能读到指向的文件);若 agent 反复踩同一坑(如某 API),再把那条 inline 进 skill | + +**依赖**:L2(先有 read_file,agent 才能读)。 + +--- + +## L4 · Prompt(薄指令层)—— 最后写 + +**目标**:最薄的指令,指挥 agent 用好 L1-L3。**绝不手抄 skill**(上一轮的错)——指 agent 去读真 skill。 + +**system prompt 骨架**: +1. **角色**:你是 A-model 游戏生成 agent;产物 = 本 run 目录 `src/` 下多文件 LittleJS 游戏。 +2. **步骤**:读 skill(你的 manual)→ design(读 design skill)→ 读用到插件 api.d.ts + catch-fruit 范例 → 写 `src/` → 自检。 +3. **工具**:read_file / write_file(scoped)/ list_dir。 +4. **红线(精炼)**:工厂默认导出 + 零引擎 import;零裸 `Date/Math/setTimeout`;render 画在 g;timer 经 timer-scheduler + update 内 `runtime.bundle.tick(dt)`;host-config 导出 `buildHostConfig` 返 `{factory,plugins,registerOrder,viewport,buildFactoryOpts}`;插件键名与 game.js 解构一致。 +5. **完成判据**:harness build 过 + 能 boot + 能玩。 + +**依赖**:L1+L2+L3 全定好。 + +--- + +## 建设顺序(= 创始人定) + +**L1 环境 → L2 harness → L3 context 拼装 → L4 prompt**,每层验通再下一层。验证策略:先用**能力够的模型**把 agentic 端到端跑通(确认 harness/工具/context 对)→ 再逐级换便宜模型测经济性(bar=能跑能玩)。**过 = 才动 SAA 删 factory/gamedef(D1=A 无退路);不过 = 补 skill/工具,绝不先删退路。** diff --git a/game-runtime/tools/amodel-gen/gen.mjs b/game-runtime/tools/amodel-gen/gen.mjs new file mode 100644 index 00000000..cb37a6f6 --- /dev/null +++ b/game-runtime/tools/amodel-gen/gen.mjs @@ -0,0 +1,200 @@ +/** + * gen.mjs — agentic A-model 生成 orchestrator(L2 ReAct 循环 · spike) + * owner:amodel-gen spike + * + * 流程:scaffold(_template→amgen-) → ReAct(M3 + 5 工具,done 门=check+build 绿) + * → play(循环外·权威门) → 落 verdict / run-log / run-summary。 + * + * 用法(cwd=game-runtime,mini-desktop): + * node tools/amodel-gen/gen.mjs --id run1 --brief "挡板接球打砖块,清屏即胜" [--max-steps 40] [--port 4320] [--cdp 9222] + * + * 注:本文件是 harness 侧,允许 Date.now/setTimeout(红线只约束游戏 src)。 + */ + +'use strict'; + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { scaffold, readFileTool, writeFileTool, listDirTool, check, build, gameDir } from './tools.mjs'; +import { chat, MODEL } from './m3.mjs'; +import { TOOLS, buildSystemPrompt } from './prompt.mjs'; +import { play } from './play.mjs'; + +function arg(name, def) { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : def; +} + +const TOOL_RESULT_CAP = 30000; // 单次工具结果注入上限(够一份 skill/api.d.ts,控 context) + +/** 历史压缩:折叠旧的大 tool 结果 / write 内容,控 token(主流 context 工程:循环越长越要压)。占位 <400 字符 → 幂等。 */ +function compactHistory(messages, keepLast = 8) { + const head = 2; // system + 首个 user-brief 保全 + const tailStart = Math.max(head, messages.length - keepLast); + for (let i = head; i < tailStart; i++) { + const m = messages[i]; + if (!m) continue; + if (m.role === 'tool' && typeof m.content === 'string' && m.content.length > 400) { + m.content = '[旧工具结果已折叠省 token;需要可重新 read_file]'; + } + if (m.role === 'assistant' && Array.isArray(m.tool_calls)) { + for (const c of m.tool_calls) { + if (c.function && c.function.name === 'write_file' && c.function.arguments && c.function.arguments.length > 400) { + try { + const a = JSON.parse(c.function.arguments); + c.function.arguments = JSON.stringify({ path: a.path, content: '[旧 write 内容已折叠;文件在磁盘,需要可重新 read_file]' }); + } catch { /* 坏 JSON 略过 */ } + } + } + } + } +} + +async function main() { + const id = arg('id', 'run1'); + const brief = arg('brief', '一个简单的点击得分小游戏'); + const maxSteps = Number(arg('max-steps', '40')); + const port = Number(arg('port', '4320')); + const cdpPort = Number(arg('cdp', '9222')); + + const log = []; + const t0 = Date.now(); + const rec = (m) => { + const line = `[+${((Date.now() - t0) / 1000).toFixed(1)}s] ${m}`; + console.log(line); + log.push(line); + }; + + rec(`model=${MODEL} id=${id} brief=「${brief}」maxSteps=${maxSteps}`); + rec(`scaffold amgen-${id}(clone _template)`); + scaffold(id); + + const messages = [ + { role: 'system', content: buildSystemPrompt(id) }, + { role: 'user', content: `请按这个 brief 造一款游戏:「${brief}」。先读手册再动手。` }, + ]; + + let lastCheck = null; + let lastBuild = null; + let doneSummary = null; + let finished = false; + let nudgedDone = false; + let steps = 0; + let totalTokens = 0; + const callSig = new Map(); // loop-detection:同一 (name,args) 计数 + + while (!finished && steps < maxSteps) { + steps++; + compactHistory(messages); // 每轮先压旧大块,控 token + let resp; + try { + resp = await chat(messages, TOOLS); + } catch (e) { + rec(`step${steps} M3 调用失败: ${e.message}`); + break; + } + totalTokens += (resp.usage && resp.usage.total_tokens) || 0; + const msg = resp.message; + messages.push(msg); + const calls = msg.tool_calls || []; + + if (calls.length === 0) { + rec(`step${steps}: 无 tool_calls(finish=${resp.finishReason}; content="${(msg.content || '').slice(0, 80)}")`); + messages.push({ role: 'user', content: '请用工具继续(read_file/write_file/check/build);全绿后调 done。' }); + continue; + } + + for (const c of calls) { + const name = c.function.name; + let a = {}; + try { a = JSON.parse(c.function.arguments || '{}'); } catch { /* 容错:坏 JSON 当空参 */ } + const sig = name + ':' + (c.function.arguments || ''); + callSig.set(sig, (callSig.get(sig) || 0) + 1); + + 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 === 'write_file') { + const r = writeFileTool(id, a.path, a.content || ''); + result = r.ok ? `OK ${r.bytes}B 已写` : 'ERROR: ' + r.error; + rec(`write ${a.path} ${r.ok ? r.bytes + 'B' : 'ERR ' + r.error}`); + } else if (name === 'check') { + lastCheck = check(id); + result = lastCheck.ok ? 'PASS' : 'FAIL:\n' + lastCheck.errors.join('\n'); + rec(`check ${lastCheck.ok ? 'PASS' : 'FAIL(' + lastCheck.errors.length + ')'}`); + } else if (name === 'build') { + lastBuild = build(id); + result = lastBuild.ok ? 'PASS' : 'FAIL:\n' + lastBuild.error; + rec(`build ${lastBuild.ok ? 'PASS' : 'FAIL'}`); + } else if (name === 'done') { + // done 门:复核 check+build 必须都绿,否则拒绝并回喂 + const c2 = check(id); + const b2 = c2.ok ? build(id) : { ok: false, error: 'check 未过,先修 check' }; + lastCheck = c2; if (c2.ok) lastBuild = b2; + if (c2.ok && b2.ok) { + doneSummary = a.summary || ''; + result = 'DONE 已接受'; + rec(`done 接受: ${doneSummary}`); + messages.push({ role: 'tool', tool_call_id: c.id, content: result }); + finished = true; + break; + } + result = `DONE 被拒:done 前 check+build 必须都绿。check=${c2.ok ? 'PASS' : 'FAIL: ' + c2.errors.join('; ')} build=${b2.ok ? 'PASS' : 'FAIL: ' + (b2.error || '')}`; + rec('done 被拒(check/build 未全绿)'); + } else { + result = 'ERROR: 未知工具 ' + name; + } + messages.push({ role: 'tool', tool_call_id: c.id, content: String(result).slice(0, TOOL_RESULT_CAP) }); + } + + // check+build 都绿 → 一次性强提醒 done(治"绿了还乱写测试/额外文件") + if (!finished && !nudgedDone && lastCheck && lastCheck.ok && lastBuild && lastBuild.ok) { + messages.push({ role: 'user', content: 'check 与 build 都已 PASS。若已实现 brief 的核心玩法,请立即调 done(summary 一句话);不要再写测试或额外文件。' }); + nudgedDone = true; + rec('已提示 done(check+build 绿)'); + } + + // loop-detection:同一调用重复过多 → 截停(防卡死烧 token) + if ([...callSig.values()].some((n) => n > 6)) { + rec('loop-detection:同一工具调用重复过多,截停'); + break; + } + } + + // 循环外·权威门:play(仅在 agent 合格 done 后跑) + let verdict = null; + if (finished) { + rec('play(循环外·权威门:boot+截图+交互 = 能跑能玩)...'); + try { + verdict = await play(id, { port, cdpPort }); + rec(`play ${verdict.pass ? 'PASS(能跑)' : 'FAIL'} — ${verdict.notes.join(' | ')}`); + } catch (e) { + rec('play 异常: ' + (e.message || e)); + } + } else { + rec('未合格 done,跳过 play(本 run 未产出可玩游戏)'); + } + + const summary = { + id, brief, model: MODEL, steps, finished, doneSummary, + check: lastCheck ? { ok: lastCheck.ok, errors: lastCheck.errors } : null, + build: lastBuild ? { ok: lastBuild.ok } : null, + verdict: verdict ? { pass: verdict.pass, notes: verdict.notes, error: verdict.error || null } : null, + approxTokens: totalTokens, + wallSec: Number(((Date.now() - t0) / 1000).toFixed(1)), + }; + const evDir = join(gameDir(id), 'evidence'); + mkdirSync(evDir, { recursive: true }); + writeFileSync(join(evDir, 'run-log.txt'), log.join('\n')); + writeFileSync(join(evDir, 'run-summary.json'), JSON.stringify(summary, null, 2)); + console.log('\n=== RUN SUMMARY ==='); + console.log(JSON.stringify(summary, null, 2)); +} + +main().catch((e) => { console.error('gen.mjs 顶层异常:', e); process.exit(1); }); diff --git a/game-runtime/tools/amodel-gen/m3.mjs b/game-runtime/tools/amodel-gen/m3.mjs new file mode 100644 index 00000000..16ebb3c6 --- /dev/null +++ b/game-runtime/tools/amodel-gen/m3.mjs @@ -0,0 +1,75 @@ +/** + * m3.mjs — new-api M3 客户端(openai 协议 + function-calling) + * owner:amodel-gen spike | 消费方:gen.mjs(ReAct 循环每轮调 chat) + * + * 单一职责:发一轮 chat/completions(带 tools)→ 返 {message, usage, finishReason}。 + * 含超时 + 重试(429/5xx/IO 重试;4xx 不重试)。模型固定 MiniMax-M3(founder 定)。 + * 密钥(§9):优先 env NEWAPI_KEY,否则从 docs/内网凭据与端点.md(内网允许入仓)解析——doc 是单一事实源。 + */ + +'use strict'; + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROOT } from './tools.mjs'; + +const BASE = (process.env.NEWAPI_BASE_URL || 'http://100.64.0.8:3000').replace(/\/+$/, ''); +const MODEL = process.env.AMGEN_MODEL || 'MiniMax-M3'; // founder:只用 M3 +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** 取网关 token:env 优先,否则解析项目密钥文档(§9 单一事实源)。 */ +function getKey() { + if (process.env.NEWAPI_KEY) return process.env.NEWAPI_KEY; + try { + const doc = readFileSync(join(REPO_ROOT, 'docs/内网凭据与端点.md'), 'utf8'); + const m = doc.match(/NEWAPI_KEY[^\n]*?(sk-[A-Za-z0-9]+)/); + if (m) return m[1]; + } catch { /* fall through */ } + throw new Error('找不到 NEWAPI_KEY(请设 env 或确认 docs/内网凭据与端点.md 存在)'); +} +const KEY = getKey(); + +/** + * 发一轮带 tools 的 chat/completions。 + * @param {Array} messages openai 消息数组(含 system/user/assistant/tool) + * @param {Array} tools function-calling 工具定义 + * @param {{maxTokens?:number, temperature?:number, timeoutMs?:number}} [opt] + * @returns {Promise<{message:object, usage:object, finishReason:string}>} + */ +export async function chat(messages, tools, opt = {}) { + const { maxTokens = 16000, temperature = 0.3, timeoutMs = 180000 } = opt; + const body = { model: MODEL, messages, tools, tool_choice: 'auto', max_tokens: maxTokens, temperature }; + let lastErr; + for (let attempt = 0; attempt < 3; attempt++) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const r = await fetch(`${BASE}/v1/chat/completions`, { + method: 'POST', + headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + clearTimeout(timer); + if (!r.ok) { + const txt = await r.text().catch(() => ''); + // 4xx(除 429)= 请求本身错,不重试,直接抛 + if (r.status !== 429 && r.status < 500) throw new Error(`HTTP ${r.status}: ${txt.slice(0, 300)}`); + lastErr = new Error(`HTTP ${r.status}: ${txt.slice(0, 200)}`); + await sleep(1500 * (attempt + 1)); + continue; + } + const d = await r.json(); + const ch = d.choices && d.choices[0]; + if (!ch) throw new Error('响应无 choices: ' + JSON.stringify(d).slice(0, 300)); + return { message: ch.message, usage: d.usage || {}, finishReason: ch.finish_reason }; + } catch (e) { + clearTimeout(timer); + lastErr = e; + await sleep(1500 * (attempt + 1)); // 超时/IO 重试 + } + } + throw lastErr || new Error('M3 chat 失败(重试耗尽)'); +} + +export { MODEL, BASE }; diff --git a/game-runtime/tools/amodel-gen/play.mjs b/game-runtime/tools/amodel-gen/play.mjs new file mode 100644 index 00000000..c0553899 --- /dev/null +++ b/game-runtime/tools/amodel-gen/play.mjs @@ -0,0 +1,189 @@ +/** + * play.mjs — 循环外·权威门:serve + headless Chrome boot + 帧推进 + 截图 + 一次交互 = 能跑能玩 + * owner:amodel-gen spike | 消费方:gen.mjs(agent done 后跑) / CLI 独立验证 + * + * 【bar = 能跑能玩(非九门)】 + * 能跑 = booted(无 boot error)+ 真引擎掌帧推进(frameCount 前进); + * 能玩 = do(start)+tap 后状态有 phase(非 booting),且截图存证(供人眼最终确认); + * 截图落 games/amgen-/evidence/{first-paint,after-play}.png + verdict.json。 + * + * 【CDP】Node22 全局 WebSocket + fetch,无需 ws 包。Chrome --headless=new --remote-debugging-port。 + * 注:本文件是 harness 侧(非游戏代码),允许用 Date.now/setTimeout(红线只约束游戏 src)。 + */ + +'use strict'; + +import { spawn } from 'node:child_process'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GAME_RUNTIME, gameDir } from './tools.mjs'; + +const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** 起静态服务器(game-runtime 根)。 */ +function startServer(port) { + return spawn('node', ['test/harness/static-serve.cjs', '.', String(port)], { + cwd: GAME_RUNTIME, stdio: 'ignore', + }); +} + +/** 起 headless Chrome(端口避开 live;独立 user-data 防串)。 */ +function startChrome(cdpPort, userDataDir) { + return spawn(CHROME, [ + '--headless=new', '--no-sandbox', '--no-zygote', '--disable-gpu', + `--remote-debugging-port=${cdpPort}`, '--remote-allow-origins=*', + '--autoplay-policy=no-user-gesture-required', + `--user-data-dir=${userDataDir}`, 'about:blank', + ], { stdio: 'ignore' }); +} + +/** 轮询 CDP /json,拿一个 page target 的 ws 调试 url。 */ +async function getWsUrl(cdpPort, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const r = await fetch(`http://localhost:${cdpPort}/json`); + const list = await r.json(); + const page = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl); + if (page) return page.webSocketDebuggerUrl; + } catch { /* chrome 还没起好,继续轮询 */ } + await sleep(300); + } + throw new Error('CDP 未就绪(chrome 起失败?)'); +} + +/** 极简 CDP 客户端(全局 WebSocket)。 */ +class CDP { + constructor(ws) { + this.ws = ws; + this.id = 0; + this.pending = new Map(); + ws.addEventListener('message', (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + if (msg.id && this.pending.has(msg.id)) { + const { resolve: res, reject } = this.pending.get(msg.id); + this.pending.delete(msg.id); + msg.error ? reject(new Error(JSON.stringify(msg.error))) : res(msg.result); + } + }); + } + send(method, params) { + const id = ++this.id; + return new Promise((res, reject) => { + this.pending.set(id, { resolve: res, reject }); + this.ws.send(JSON.stringify({ id, method, params: params || {} })); + }); + } + /** 在页面里求值(returnByValue);异常透出。 */ + async evalJs(expression, awaitPromise = false) { + const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }); + if (r.exceptionDetails) { + throw new Error('页面 eval 异常: ' + (r.exceptionDetails.text || JSON.stringify(r.exceptionDetails))); + } + return r.result ? r.result.value : undefined; + } +} + +async function connectCDP(wsUrl) { + const ws = new WebSocket(wsUrl); + await new Promise((res, rej) => { + ws.addEventListener('open', res, { once: true }); + ws.addEventListener('error', () => rej(new Error('ws 连接失败')), { once: true }); + }); + return new CDP(ws); +} + +/** + * 真玩验证。返回 verdict;截图 + verdict.json 落 evidence/。 + * @param {string} id + * @param {{port?:number, cdpPort?:number}} [opts] + */ +export async function play(id, opts = {}) { + const port = opts.port || 4320; + const cdpPort = opts.cdpPort || 9222; + const url = `http://localhost:${port}/games/amgen-${id}/index.html?engine=real&seed=12345`; + const evDir = join(gameDir(id), 'evidence'); + mkdirSync(evDir, { recursive: true }); + const userDataDir = `/tmp/amgen-chrome-${id}`; + + const verdict = { id, url, pass: false, steps: {}, notes: [] }; + const server = startServer(port); + const chrome = startChrome(cdpPort, userDataDir); + let cdp; + try { + await sleep(900); // 等 server + chrome 起 + const wsUrl = await getWsUrl(cdpPort, 15000); + cdp = await connectCDP(wsUrl); + await cdp.send('Page.enable'); + await cdp.send('Runtime.enable'); + await cdp.send('Page.navigate', { url }); + + // 1) 等 boot(__gameBooted=true 或 __gameBootError) + let booted = false, bootErr = null; + const bootDeadline = Date.now() + 30000; + while (Date.now() < bootDeadline) { + const st = await cdp.evalJs( + '({b: window.__gameBooted, e: window.__gameBootError, ei: window.__gameHostEngineInitFired})', + ).catch(() => null); + if (st && st.b === true) { booted = true; verdict.steps.engineInit = !!st.ei; break; } + if (st && st.e) { bootErr = st.e; break; } + await sleep(400); + } + verdict.steps.boot = { booted, bootErr }; + if (!booted) { verdict.notes.push('boot 失败:' + (bootErr || '超时(30s)')); throw new Error('boot 失败'); } + + // 2) 帧推进(真引擎掌帧) + const f0 = await cdp.evalJs('window.__gameHost ? window.__gameHost.frameCount() : -1'); + await sleep(1000); + const f1 = await cdp.evalJs('window.__gameHost ? window.__gameHost.frameCount() : -1'); + verdict.steps.frames = { f0, f1, advanced: typeof f1 === 'number' && f1 > f0 }; + + // 3) 首屏截图 + const shot1 = await cdp.send('Page.captureScreenshot', { format: 'png' }); + writeFileSync(join(evDir, 'first-paint.png'), Buffer.from(shot1.data, 'base64')); + + // 4) do(start) 进 play + 一次 tap(do 形态两款游戏不一,两种都试) + const st0 = await cdp.evalJs('window.__gameHost ? window.__gameHost.state() : null'); + await cdp.evalJs(`(()=>{const h=window.__gameHost; if(!h||!h.do) return; try{h.do({type:'start'})}catch(e){} try{h.do('start')}catch(e){}})()`).catch(() => {}); + await sleep(300); + await cdp.evalJs('window.__gameHost && window.__gameHost.tap && window.__gameHost.tap(195, 500)').catch(() => {}); + await sleep(1200); + const st1 = await cdp.evalJs('window.__gameHost ? window.__gameHost.state() : null'); + verdict.steps.play = { st0, st1 }; + + // 5) 玩后截图 + const shot2 = await cdp.send('Page.captureScreenshot', { format: 'png' }); + writeFileSync(join(evDir, 'after-play.png'), Buffer.from(shot2.data, 'base64')); + + // 判定:能跑 = booted + 帧推进;phase 进展供"能玩"参考(人眼看截图最终拍板) + const phase1 = st1 && st1.phase; + verdict.pass = booted && verdict.steps.frames.advanced; + verdict.notes.push(`booted=${booted} engineInit=${verdict.steps.engineInit} framesAdvanced=${verdict.steps.frames.advanced}(${f0}→${f1}) phase=${phase1}`); + } catch (e) { + verdict.error = String((e && e.message) || e); + } finally { + try { cdp && cdp.ws.close(); } catch {} + try { chrome.kill('SIGKILL'); } catch {} + try { server.kill('SIGKILL'); } catch {} + } + writeFileSync(join(evDir, 'verdict.json'), JSON.stringify(verdict, null, 2)); + return verdict; +} + +// ───────────────────────── CLI ───────────────────────── +// 用法:node play.mjs [port] [cdpPort] +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [, , id, port, cdp] = process.argv; + if (!id) { console.error('用法:node play.mjs [port] [cdpPort]'); process.exit(2); } + play(id, { port: port ? Number(port) : undefined, cdpPort: cdp ? Number(cdp) : undefined }) + .then((v) => { + console.log('[play]', v.pass ? 'PASS(能跑)' : 'FAIL', '—', v.notes.join(' | ')); + if (v.error) console.log(' error:', v.error); + console.log(' evidence:', join(gameDir(id), 'evidence')); + process.exit(v.pass ? 0 : 1); + }); +} diff --git a/game-runtime/tools/amodel-gen/prompt.mjs b/game-runtime/tools/amodel-gen/prompt.mjs new file mode 100644 index 00000000..6053ff25 --- /dev/null +++ b/game-runtime/tools/amodel-gen/prompt.mjs @@ -0,0 +1,94 @@ +/** + * prompt.mjs — L4 薄 system prompt + 工具定义(openai function-calling schema) + * owner:amodel-gen spike | 消费方:gen.mjs + * + * 铁律(上一轮翻车教训):**绝不手抄 skill** —— 只给薄指令 + 指 agent 自己 read_file 读真 skill/范例。 + * L3 context = agent 经 read_file 按需自取(skill→插件 api.d.ts→catch-fruit 范例),不预塞。 + */ + +'use strict'; + +/** 给 agent 的工具(openai function-calling)。check/build 是循环内快反馈;done 有自检门。 */ +export const TOOLS = [ + { + type: 'function', + function: { + name: 'read_file', + description: '读 repo 内任意文件(只读)。用它读 skill、插件 api.d.ts、catch-fruit/_template 范例。先读手册再写码。', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'repo 相对路径,如 .agents/skills/littlejs-game-dev.md' } }, required: ['path'] }, + }, + }, + { + type: 'function', + function: { + name: 'list_dir', + description: '列目录(只读),用于发现文件。', + parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, + }, + }, + { + type: 'function', + function: { + name: 'write_file', + description: '写文件到本 run 的 game 目录(仅许写 games/amgen-/ 内)。整文件覆盖。', + parameters: { type: 'object', properties: { path: { type: 'string', description: '如 games/amgen-/src/game.js' }, content: { type: 'string', description: '完整文件内容' } }, required: ['path', 'content'] }, + }, + }, + { + type: 'function', + function: { + name: 'check', + description: '循环内快校验:node --check 各 src/*.js + 五法/导出名/红线 lint。返回 PASS 或错误清单。done 前必须 PASS。', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'build', + description: '循环内 esbuild 打包(秒级)。返回 PASS 或编译错误。done 前必须 PASS。', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'done', + description: '声明完成。仅当 check 与 build 都 PASS 才会被接受(harness 会复核)。', + parameters: { type: 'object', properties: { summary: { type: 'string', description: '一句话:你做了一款什么游戏、核心玩法' } }, required: ['summary'] }, + }, + }, +]; + +/** L4 薄 system prompt(指 agent 读真 skill,不手抄)。 */ +export function buildSystemPrompt(id) { + const G = `game-runtime/games/amgen-${id}`; // 与 read/write 的 repo-根相对路径一致(必须含 game-runtime/ 前缀) + return `你是 A-model 游戏生成 agent。目标产物 = 一款**能跑能玩**的多文件 LittleJS 小游戏,落在 ${G}/。 + +【工作目录已就绪】 +- 我已把克隆起点 _template 拷到 ${G}/(一个可玩的最小游戏「点圆得分」:menu→play→over,用了 4 件编排插件)。 +- **固定 plumbing 别动**:${G}/index.html、${G}/entry.js、${G}/src/main.js。 +- **你要改写游戏本体**:${G}/src/ 下的 game.js / core.js / render.js / host-config.js(按需也可改 balance.js / assets.js)。把「点圆得分」改造成 brief 要的游戏。 + +【先读手册(必须;别凭记忆猜 API)】 +1. read_file('.agents/skills/littlejs-game-dev.md') —— code 层作业手册(结构/边界/红线/12 插件 API 速查)。 +2. read_file('${G}/README.md') —— 这份克隆起点的文件职责 +「你写什么 vs 调什么」边界 + ⚠bundle.tick 坑。 +3. 用到某插件就 read_file 它的 api.d.ts(如 game-runtime/src/plugins/scene-fsm/api.d.ts、.../session-score/api.d.ts)看精确签名。 +4. 需要更完整范例时 read_file('game-runtime/games/catch-fruit/src/game.js')(catch-fruit 是真浏览器可玩的完整参照)。 + +【步骤】 +A 读手册(1-2) → B 按 brief 定玩法(核心循环 / 胜负 / 计分 / 数值)→ C 读要用的插件 api.d.ts → D write_file 改写 src/ 实现玩法 → E 调 check,有错改到 PASS → F 调 build,有错改到 PASS → G check+build 都绿后调 done。 + +【红线(check 会拦,违反则 done 被拒)】 +- game.js 默认导出工厂、**零引擎 import**;host-config.js **保留导出名 buildTemplateHostConfig**(只改插件集/wiring,别改函数名)。 +- 游戏源(game/core/render)**零裸** Math.random / Date.now / setTimeout / requestAnimationFrame / new AudioContext / addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler,输入经 ctx.getInput。 +- game.js 的 update(dt) 内**必须调 opts.runtime.bundle.tick(dt)**(否则插件 onFrame 不推进、timer 永不到期、一局不结束)。 +- 插件由 host 拥有、经 opts.runtime.plugins 注入;游戏只**用**、不 new、不 register;插件键名与 host-config 的 plugins 对象一致。 + +【完成判据 + 停机纪律(重要)】 +- 判据 = check PASS + build PASS(本 spike **不跑** README 里的 node --test)。**一旦 check 与 build 都 PASS,立即调 done**(summary 一句话)。 +- **别做**:别写/改任何 test/ 文件、别写额外脚本、别追求完美、别加 brief/README 没要求的东西。这是 spike,**能跑能玩即可**。 +- 你**只有** read_file / write_file / list_dir / check / build / done 六个工具;**没有 edit_file** —— 改文件用 write_file 整体覆盖。 + +现在开始:**先 read_file 读手册,别直接写码;核心玩法实现完、check+build 绿了就立即 done。**`; +} diff --git a/game-runtime/tools/amodel-gen/tools.mjs b/game-runtime/tools/amodel-gen/tools.mjs new file mode 100644 index 00000000..07d52b1c --- /dev/null +++ b/game-runtime/tools/amodel-gen/tools.mjs @@ -0,0 +1,244 @@ +/** + * tools.mjs — agentic A-model 生成 harness 的工具层 + * owner:amodel-gen spike | 消费方:gen.mjs(ReAct 循环) / play.mjs / CLI + * + * 【两类东西】 + * A) 给 agent 的 5 工具(L1 沙箱边界): + * - read_file : repo 根只读(agent 自读 skill / api.d.ts / catch-fruit 范例);拒绝逃出 repo 根;超大截断。 + * - write_file : 仅许写本 run 的 game 目录 games/amgen-/;拒绝越界。 + * - list_dir : repo 根内列目录。 + * - check : 循环内快反馈 = node --check 各 src/*.js + 五法/导出名/红线静态 lint。 + * - build : esbuild(scripts/build.mjs 锁参)→ bundle;循环内快反馈。 + * 不给 agent 任意 shell。 + * B) 确定性 scaffold(克隆 _template → 本 run 目录)。 + * + * 【路径基准】本文件在 game-runtime/tools/amodel-gen/ → GAME_RUNTIME=../.. → REPO_ROOT=../../.. + */ + +'use strict'; + +import { + readFileSync, writeFileSync, readdirSync, statSync, + mkdirSync, existsSync, rmSync, cpSync, +} from 'node:fs'; +import { resolve, dirname, relative, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// tools/amodel-gen → game-runtime → repo 根 +export const GAME_RUNTIME = resolve(__dirname, '../..'); +export const REPO_ROOT = resolve(__dirname, '../../..'); +const GAMES_DIR = join(GAME_RUNTIME, 'games'); +const TEMPLATE_DIR = join(GAMES_DIR, '_template'); + +const MAX_READ_BYTES = 200 * 1024; // 单文件读上限,防撑爆 agent context + +/** 把 repo 相对路径解析为绝对路径,并校验不逃出 repo 根。 */ +function resolveInRepo(p) { + const abs = resolve(REPO_ROOT, p); + if (abs !== REPO_ROOT && !abs.startsWith(REPO_ROOT + '/')) { + throw new Error(`路径越界(必须在 repo 根内):${p}`); + } + return abs; +} + +/** 本 run 的 game 目录(= 写边界)。 */ +export function gameDir(id) { + return join(GAMES_DIR, `amgen-${id}`); +} + +/** 校验绝对路径落在本 run 的 game 目录内(写边界)。 */ +function assertInGameDir(id, abs) { + const gd = gameDir(id); + if (abs !== gd && !abs.startsWith(gd + '/')) { + throw new Error(`write 越界:只许写 ${relative(REPO_ROOT, gd)}/ 内(你给的是 ${relative(REPO_ROOT, abs)})`); + } +} + +// ───────────────────────── 给 agent 的 5 工具 ───────────────────────── + +/** read_file:repo 根只读。返回 {ok, content?, truncated?, error?}。 */ +export function readFileTool(path) { + try { + const abs = resolveInRepo(path); + const st = statSync(abs); + if (st.isDirectory()) return { ok: false, error: `是目录,不是文件:${path}(请用 list_dir)` }; + let content = readFileSync(abs, 'utf8'); + let truncated = false; + if (Buffer.byteLength(content, 'utf8') > MAX_READ_BYTES) { + content = content.slice(0, MAX_READ_BYTES); + truncated = true; // 截断:超大文件只给前 200KB,提示 agent 它被截了 + } + return { ok: true, content, truncated }; + } catch (e) { + return { ok: false, error: String((e && e.message) || e) }; + } +} + +/** write_file:仅许写本 run game 目录。返回 {ok, bytes?, error?}。 */ +export function writeFileTool(id, path, content) { + try { + const abs = resolveInRepo(path); + assertInGameDir(id, abs); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content, 'utf8'); + return { ok: true, bytes: Buffer.byteLength(content, 'utf8') }; + } catch (e) { + return { ok: false, error: String((e && e.message) || e) }; + } +} + +/** list_dir:repo 根内列目录(目录名带尾 /)。返回 {ok, entries?, error?}。 */ +export function listDirTool(path) { + try { + const abs = resolveInRepo(path); + const entries = readdirSync(abs, { withFileTypes: true }) + .map((d) => (d.isDirectory() ? d.name + '/' : d.name)) + .sort(); + return { ok: true, entries }; + } catch (e) { + return { ok: false, error: String((e && e.message) || e) }; + } +} + +// ───────────────────────── scaffold / check / build ───────────────────────── + +/** scaffold:克隆 _template → games/amgen-/(去掉 dist;保留可玩骨架 + plumbing)。返回 game 目录。 */ +export function scaffold(id) { + const gd = gameDir(id); + if (existsSync(gd)) rmSync(gd, { recursive: true, force: true }); + cpSync(TEMPLATE_DIR, gd, { recursive: true }); + rmSync(join(gd, 'dist'), { recursive: true, force: true }); // 旧产物(若有)清掉 + rmSync(join(gd, 'test'), { recursive: true, force: true }); // 去 test/:spike 不让 agent 钻测试(gate=check+build+play),也去掉 README 的"跑测试"诱因 + return gd; +} + +// 游戏源红线:零裸时间/随机/DOM(时间随机经 boot.ctx,定时经 timer-scheduler)。只匹配"调用形态"。 +const BANNED = [ + /\bMath\.random\s*\(/, /\bDate\.now\s*\(/, /\bperformance\.now\s*\(/, + /\bsetTimeout\s*\(/, /\bsetInterval\s*\(/, /\brequestAnimationFrame\s*\(/, + /\bnew\s+AudioContext\b/, /\baddEventListener\s*\(/, +]; + +/** 去注释 + 字符串字面量,避免红线/结构正则误伤文档注释里的示例(如注释里写「禁用 Math.random()」)。 */ +function stripCode(s) { + return s + .replace(/\/\*[\s\S]*?\*\//g, ' ') // 块注释 + .replace(/\/\/[^\n]*/g, ' ') // 行注释 + .replace(/'(?:[^'\\]|\\.)*'/g, "''") // 单引号串 + .replace(/"(?:[^"\\]|\\.)*"/g, '""') // 双引号串 + .replace(/`(?:[^`\\]|\\.)*`/g, '``'); // 模板串 +} + +/** + * check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。 + * 1) node --check 各 src/*.js + entry.js(语法); + * 2) 结构契约:game.js 默认导出 + update 内调 bundle.tick;host-config.js 导出 buildTemplateHostConfig; + * 3) 红线:游戏源(game/core/render)零裸时间/随机/DOM。 + * 返回 {ok, errors[]}。 + */ +export function check(id) { + const gd = gameDir(id); + const errors = []; + const srcDir = join(gd, 'src'); + let srcFiles = []; + try { + srcFiles = readdirSync(srcDir).filter((f) => f.endsWith('.js')); + } catch { + return { ok: false, errors: ['src/ 目录不存在或不可读'] }; + } + + // 1) 语法 + for (const rel of [...srcFiles.map((f) => join('src', f)), 'entry.js']) { + const abs = join(gd, rel); + if (!existsSync(abs)) continue; + const r = spawnSync('node', ['--check', abs], { encoding: 'utf8' }); + if (r.status !== 0) { + errors.push(`语法错误 ${rel}: ${(r.stderr || '').trim().split('\n').slice(0, 3).join(' ')}`); + } + } + + // 2) 结构契约 + const gameAbs = join(gd, 'src', 'game.js'); + if (existsSync(gameAbs)) { + const g = stripCode(readFileSync(gameAbs, 'utf8')); + if (!/export\s+default/.test(g)) errors.push('game.js 必须 export default(工厂默认导出)'); + if (!/\bbundle\.tick\s*\(/.test(g)) { + errors.push('game.js 的 update(dt) 内必须调 bundle.tick(dt)(否则插件 onFrame 不推进、timer 永不到期、一局不结束)'); + } + } else { + errors.push('缺 src/game.js'); + } + const hcAbs = join(gd, 'src', 'host-config.js'); + if (existsSync(hcAbs)) { + const hc = stripCode(readFileSync(hcAbs, 'utf8')); + if (!/export\s+function\s+buildTemplateHostConfig\b/.test(hc)) { + errors.push('host-config.js 必须保留导出名 buildTemplateHostConfig(固定 plumbing main.js 据此 import,勿改名)'); + } + } else { + errors.push('缺 src/host-config.js'); + } + + // 2.5) game.js 必须真改写过(防 write 路径写错→没覆盖→拿 _template 原样蒙混过 check) + try { + const tmpl = readFileSync(join(TEMPLATE_DIR, 'src', 'game.js'), 'utf8'); + if (existsSync(gameAbs) && readFileSync(gameAbs, 'utf8') === tmpl) { + errors.push(`game.js 与 _template 完全相同:还没真正改写游戏(确认 write 路径写进了 ${relative(REPO_ROOT, gd)}/src/)`); + } + } catch { /* 模板缺失则跳过 */ } + + // 3) 红线(只查 agent 写的游戏文件) + for (const f of ['game.js', 'core.js', 'render.js']) { + const abs = join(gd, 'src', f); + if (!existsSync(abs)) continue; + const s = stripCode(readFileSync(abs, 'utf8')); + for (const re of BANNED) { + if (re.test(s)) errors.push(`红线 ${f}: 禁用 ${re.source}(时间/随机经 boot.ctx,定时经 timer-scheduler,输入经 ctx.getInput)`); + } + } + + return { ok: errors.length === 0, errors }; +} + +/** build:esbuild 锁参打包(cwd=game-runtime)。返回 {ok, error?}。 */ +export function build(id) { + const entry = `games/amgen-${id}/entry.js`; + const out = `games/amgen-${id}/dist/template-bundle.js`; + const r = spawnSync( + 'node', + ['scripts/build.mjs', entry, out, '--global-name=TemplateGame'], + { cwd: GAME_RUNTIME, encoding: 'utf8', timeout: 60000 }, + ); + if (r.status !== 0) { + const err = ((r.stderr || '') + (r.stdout || '')).trim().split('\n').slice(-8).join('\n'); + return { ok: false, error: err || `build 退出码 ${r.status}` }; + } + return { ok: true }; +} + +// ───────────────────────── CLI(独立验证用)───────────────────────── +// 用法:node tools.mjs scaffold | check | build +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [, , cmd, id] = process.argv; + if (!cmd || !id) { + console.error('用法:node tools.mjs '); + process.exit(2); + } + if (cmd === 'scaffold') { + const gd = scaffold(id); + console.log('[scaffold] →', relative(REPO_ROOT, gd)); + } else if (cmd === 'check') { + const r = check(id); + console.log('[check]', r.ok ? 'PASS' : 'FAIL'); + r.errors.forEach((e) => console.log(' -', e)); + process.exit(r.ok ? 0 : 1); + } else if (cmd === 'build') { + const r = build(id); + console.log('[build]', r.ok ? 'PASS' : 'FAIL'); + if (!r.ok) { console.log(r.error); process.exit(1); } + } else { + console.error('未知命令:', cmd); + process.exit(2); + } +}