固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。 将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。 同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
405 lines
41 KiB
Python
405 lines
41 KiB
Python
"""
|
||
cheap_roles.py — 便宜档 LittleJS 生成 agent 的 system prompt(对照源:game-runtime/tools/amodel-gen/prompt.mjs 的 buildSystemPrompt)
|
||
|
||
铁律(承袭 prompt.mjs 的上一轮翻车教训):**绝不手抄 skill** —— 只给薄指令 + 指 agent 自己 read_file 读真
|
||
skill / 插件 api.d.ts / 起点范例。L3 context 由 agent 经 read_file 按需自取,不预塞。
|
||
|
||
与 Node 侧的两点差异(KTD3/KTD5):
|
||
1. 工具 schema 由 AgentScope 的 FunctionTool 据函数签名自动抽 —— 本文件**不在 prompt 里手写工具 JSON**
|
||
(Node prompt.mjs 的 TOOLS 那一段不搬)。
|
||
2. 完成工具名是 **finish**(不是 Node 的 done)—— 便宜档的 done 门 = cheap_toolkit 的 finish 工具
|
||
(工具内复核 check+build 绿才接受)。
|
||
|
||
system prompt 正文要点:入口契约 createGame({plugins,bundle,viewport}) 五法、红线(零裸时间随机 DOM /
|
||
bundle.tick / ctx.time.nowMs() / 扁平注入插件 / 方法名以 api.d.ts 为准)、本周收口的 footgun(sceneFsm.define
|
||
逐场景调、真返 void 的绘制/音效 API 别接返回值;drawButton 已改返命中矩形、可直接接住判命中)、输入契约
|
||
(handleTap/handleKey)。W-AXIS-V2 波2 起不再教 _forensicsView 取证契约——自动验收随 tap-targets 驱动器退役,
|
||
验收 = 四门投影 ∧ 测试 agent 视觉引导真玩;模型不再为死契约写代码。
|
||
|
||
「生成丰富度」设计步(U-A1):除指向 littlejs-game-dev.md(怎么写代码)外,再加一个「先设计后写码」步——
|
||
经营/养成/放置/点客类先 read sim-business-game-design.md 取「设计什么才好玩」的范式(核心循环 + 进货库存资源环
|
||
+ 3-4 级解锁阶梯 + 数值成长 + 音效清单),过 10 条好玩自检(⑨ 否决项,prompt 正文内定义),并严守 §8 MVP-first 铁律(别一稿堆满,否则
|
||
code agent read-thrash 不收敛)。10 条自检对所有品类通用、sim-business 范式只对经营类适用。**这是给生成 agent 的
|
||
设计创作指导(散文)、不是代码校验**——丰富度的「校验」走 cheap_verify.py 的纯 LLM judge(红线:丰富不丰富的
|
||
判断需大模型、绝不写成 code-presence/正则/断言)。prompt.mjs(Node 遗留)自 v1.5.0 起冻结为 A/B 对照史料、不再承诺同步(已实测漂移;退役与否待创始人裁,见作战清单 W-S1 同窗注记)。
|
||
"""
|
||
|
||
# C2a:运行时热取所需标准库(无三方依赖,系统 Python 可用)。
|
||
import os
|
||
import re
|
||
import threading
|
||
from pathlib import Path
|
||
|
||
# prompt 指 agent 去 read 的真 skill(不手抄;skill 内含两条 footgun 的 ❌→✅ 清单)。
|
||
SKILL_PATH = ".agents/skills/littlejs-game-dev.md"
|
||
|
||
# 正文用占位符 ⟦G⟧ 代表本 run 的 game 目录(build 时 .replace 注入),避免 Python f-string 花括号
|
||
# 与 JS 对象字面量 { plugins, bundle, viewport } / { x, y, w, h } 大量冲突需逐个转义。
|
||
_SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**能跑能玩、有内容、不易同质化的高质小游戏**(多文件 LittleJS),落在 ⟦G⟧/。
|
||
|
||
【这一档的定位(钉死)】AI 参与深度低 ≠ 做简单玩具——「轻量≠简单」。手感、物理、碰撞、粒子、调色这些复杂能力,平台已用工程脚手架 + L2 插件库替你承担,你**少写、多调插件**就能做出卖相与深度;你的活是在脚手架上把 brief 做成一款**真能让人玩进去**的游戏,**不是产个没人会玩的简单玩具**。
|
||
|
||
【工作目录已就绪】
|
||
- 我已把克隆起点拷到 ⟦G⟧/(⟦SCAFFOLD_DESC⟧)。你的活是据 brief **借插件能力把它改造成一款高质游戏**——别把它当"这档就该这么简单"的天花板。
|
||
- **L1 固定 plumbing 别动(write 会被拒)**:⟦G⟧/index.html、⟦G⟧/entry-bundle.js、⟦G⟧/src/game.js(薄 wrapper)、**⟦G⟧/src/host-config.js**(host 装配:已替你注入通用能力 + viewport 固定 390×844 + 工厂 wiring;标准 Match-3 另有 host-only 视觉 renderer 与音频 director;你无需装配任何插件)。
|
||
- **你只写 L3 游戏本体**:**⟦G⟧/src/game-logic.js(必写,游戏核心)** + 按需 core.js(纯逻辑)/ render.js(画面)/ balance.js(数值)/ assets.js(资产)。结构简单可把逻辑/画面集中在 game-logic.js 内;多系统/多场景再拆 core/render。把脚手架起点改造成 brief 要的**高质游戏**(多借插件能力做卖相与深度);**画面读 viewport.w/h(390×844 竖屏)**。
|
||
- **入口契约(钉死)**:game-logic.js 必须 `export function createGame({ plugins, bundle, viewport })` 返回 GameInstance 五法 init(boot)/update(dt)/render(g)/destroy()。**插件已扁平注入 plugins**,普通游戏用到才解构 sceneFsm/sessionScore/hudUi/timerScheduler/save/gamefeel/juice/palettePost/audioMusic/collision/physics;标准 Match-3 的 L3 没有 audioMusic,也看不到 host-only director。
|
||
|
||
【先读手册(必须;别凭记忆猜 API)】
|
||
1. read_file('.agents/skills/littlejs-game-dev.md') —— code 层作业手册(结构/边界/红线/11 注入插件 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. 你的起点 ⟦G⟧/src/game-logic.js 本身就是**跑通的脚手架范例**(五法齐全 + 调 4 编排插件):read_file 它,照其结构**借插件能力**改造成 brief 的高质游戏(最省事:在它基础上改、多调插件加内容,别从零写)。
|
||
5. **若 brief 属经营 / 养成 / 放置 / 点客这类「攒资源→成长→解锁」品类**:read_file('.agents/skills/sim-business-game-design.md') 取「设计什么才好玩」的范式(6 心理引擎 / §1 玩法范式含进货补货环+决策模式清单 / §8 设计配方 / §10 反无趣自检)。littlejs-game-dev 教你「代码怎么写」、sim-business 教你「设计什么才好玩」——配对用:先想清好玩、再写正确。
|
||
6. **若 brief 属剧情 / 互动叙事 / 文字冒险这类「读文本→做选择→推进剧情」品类**:read_file('.agents/skills/narrative-game-design.md') 取「设计什么才好玩」的范式(6 心理引擎 / §1 范式含分支选择+多结局图鉴 / §3 文本红线 / §8 设计配方 / §9 可达性红线);再 read_file('game-runtime/games/_template-story/src/game-logic.js') 看剧情品类过门范例(分支推进 + 结局 latch + 结局图鉴的骨架写法,其故事数据在同目录 core.js 顶部参数空间)。
|
||
7. **若 brief 属 TRPG / 掷骰 / 冒险 / 地牢爬塔这类「掷骰对抗→抉择→成长」品类**:read_file('.agents/skills/trpg-game-design.md') 取「设计什么才好玩」的范式(6 心理引擎 / §1 玩法范式含掷骰爬塔 / §3 掷骰概率数值 / §9 红线〔核心动作单击即结算·掷骰过程可见〕 / §10 反无趣自检)。
|
||
8. **若 brief 属非遗 / 传统技艺 / 民俗这类「传统文化玩法化」品类**:read_file('.agents/skills/heritage-game-design.md') 取「设计什么才好玩」的范式(§1 玩法化范式含工序节拍 / §3 双层奖励与工序节奏目标值 / §9 文化表述红线 / §10 反贴皮自检);需要完整过门范例再 read_file('game-runtime/games/_fewshot-feiyi/src/game-logic.js')。
|
||
9. **若 brief 属解谜(找规律 / 找不同 / 翻牌配对 / 顺序推理 / 点选消除这类「观察→推理→点解」品类)**:read_file('.agents/skills/puzzle-game-design.md') 取解谜设计范式(顿悟距离 / 规则递进关卡阶梯 / 卡壳兜底 / 成绩炫耀 / §9 品类可达红线);想看「丰富成什么样」的完整过门正例,可 read_file('game-runtime/games/_fewshot-puzzle/src/game-logic.js') 参照。
|
||
|
||
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
|
||
能跑 ≠ 好玩。动手写码前,先据 brief 在 **game-logic.js 顶部写一段设计注释块**(≤8 行:⑨ 判定句三个空填好 + 核心数值锚 + ⑩ 的三段难度)定下**轻量玩法设计**,再实现——设计只在心里 = 收尾自查与验收都无从对照:
|
||
- **核心循环(好玩第一因 · 必含一层玩家决策或技巧)**:写清「玩家每次操作做**什么决策** → 得什么即时反馈 → 怎么变强」。**铁律:核心操作绝不能是「点了自动结算」的无脑点击**,必须让真人有发挥——三选一起步:**匹配**(顾客点指定菜、上对的才给分/给多分)、**时机**(在对的时刻操作 = 更多奖励)、**取舍/连击**(连续做对叠 combo、资源有限要权衡)。自检:把玩家换成「闭眼乱点」——若分数照样拿满,这循环就是无趣的,重设计。
|
||
- **两层反馈:基础反馈保上手、技巧分给深度(好玩的设计惯例)**:**基础反馈**——任意有效操作都给一点即时回应(分数/音效/飘字),让新手一上手就有正反馈、不至于无所适从;**技巧分**——叠在玩家的匹配/时机/连击上,做对越多越准回报越高,让熟练玩家玩出掌控感与爽感。两层叠着来:随便玩也有反馈、不劝退新手,玩得好有额外回报、给足深度。**别把玩法做成「怎么点都一样」的无脑点(那样技巧分名存实亡);也别「不做对就颗粒无收」(那样没有上手缓冲、劝退新手)。**
|
||
- **资源环**(经营/放置类必含):「进货 → 库存 → 售卖收钱 → 缺货补货」的软币循环,制造「赚→进→卖→再赚」的张力;
|
||
- **3–4 级解锁阶梯**:攒够阈值解锁新商品/区域/能力,任意时刻都露出「下一个锁」;
|
||
- **数值成长**:产出/成本随级上升、略带滚雪球感,别平淡线性;
|
||
- **音效清单**:普通游戏写收钱「叮」/升级欢呼/解锁号角等反馈(经 plugins.audioMusic)。标准 Match-3 只写主题听感目标,不实现声音:BGM、交换、回滚、clear 复合音、fall 和 terminal cue 由 host 自动提供。
|
||
定完过一遍 **10 条好玩自检**(①即时反馈 ②可见成长 ③下一个解锁 ④30 秒内首次升级/解锁 ⑤数值滚雪球 ⑥情感锚〔萌角色/拥有物〕 ⑦放置回归惊喜 ⑧音反馈 ⑨**核心操作非无脑**〔每次主操作有真实的决策/技巧含量,不是点了自动结算〕 ⑩**难度有曲线**〔一局内铺垫→拉紧→收束,张力持续制造「差一点」;前 10 秒即玩即教、零阅读〕)——**⑨ 是否决项:⑨ 不命中,其余九条全中也只是「有元素的无趣游戏」**(实测一款 6/8 命中却不好玩,正死在 ⑨);⑨ 命中的前提下,其余命中越多越好玩。**⑨ 的判定句式(设计完对着念)**:「玩家在__时要判断__,判错则__」——三个空都填得出、且第三个空是真代价(少得分/丢单/断连击),⑨ 才算命中。正例:顾客点了豆浆,上对 +8、上错顾客皱眉扣耐心——有判断、错有代价。反例:点任意顾客都 +5、点错零损失——无判断无代价,只是点击计数器。
|
||
**这 10 条自检对所有品类通用(⑨ 恒为否决项)**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式(⑨ 在经营类的标准填法=节奏与压力决策:优先服务谁/何时补货/囤货还是现金——单击可达、判错有真代价,模式清单见其 §1);**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进、结局 latch 驻留;「选择有重量」本身就是 ⑨ 要的决策层——选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式;**解谜类**再按 puzzle-game-design 取顿悟距离/规则递进/卡壳兜底范式(解谜的技巧分=看懂线索少捞错,同守上面基础反馈+技巧分两层)。
|
||
|
||
【MVP-first 铁律(钉死·关乎你能不能收敛,别一稿堆满)】
|
||
设计太满 → 你实现负担过重 → read/write 反复跳、跑不收敛(实测:满配设计循环截停、精简设计 9 步收敛)。首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版(hitstop/震屏**不在**禁项——它们是单行 juice 调用不是系统:结算/暴击/大额成单时刻标配一次 hitStop + 轻 shake,好手感不算堆料)。**先出能玩的核心,再谈丰富。** **但钉死:「可玩核心」= 一个有决策深度的核心机制做透,不是一个浅机制 + 一堆 meta 元素(解锁/升级/飘字)。要砍的是商品数 / 附加系统 / 离线雇员;绝不砍核心循环那一层玩家决策(见上「核心循环」铁律)——那是 MVP 的心脏,砍了就只剩无趣骨架。**
|
||
|
||
【步骤】
|
||
A 读手册(1 + 按品类 5/6/7/8/9)+ 读你的起点 game-logic.js(4)→ B 据 brief 定**轻量玩法设计**(核心循环 / 资源环 / 解锁阶梯 / 数值 / 音效,过上面 10 条好玩自检(⑨ 否决项)+ 守 MVP-first)→ C 读要用的插件 api.d.ts → D write_file 改写 **game-logic.js**(按需拆 core/render)实现玩法 → E 调 check,有错改到 PASS → F 调 build,有错改到 PASS → G check+build 都绿后过收尾自查、调 finish。
|
||
|
||
【红线(check 会拦,违反则 finish 被拒)】
|
||
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出、别改名、别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())。
|
||
- 游戏源(game-logic / core / render)**零裸** Math.random / Date.now / setTimeout / requestAnimationFrame / new AudioContext / addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler;普通游戏音效经 plugins.audioMusic;**输入不调 ctx.getInput**(已收归 L1,见下「输入契约」)。
|
||
- update(dt) 内**必须调 bundle.tick(dt)**(bundle 是 createGame 入参;否则插件 onFrame 不推进、timer 永不到期、一局不结束)。**dt 单位是「秒」(≈1/60)**——游戏内若用毫秒计时(倒计时 / spawn 间隔 / 顾客耐心,常量都是 ms 如 60000/1300),累加时务必「elapsedMs += dt * 1000」,漏 ×1000 → 计时慢 1000 倍 → 永不到 spawn 阈值=零顾客、一局不结束。**timerScheduler.after(ms,cb)/every(ms,cb) 同样收毫秒(#7):3 秒写 after(3000)、别写 after(3);与 dt=秒相反,别混。**
|
||
- **入参是扁平的 { plugins, bundle, viewport }——没有 opts、没有 opts.runtime、没有 ctx**(L1 已替你摊平)。受控面 ctx 只在五法 **init(boot) 的 boot.ctx**;**调用形态**:时间 **ctx.time()** 或 ctx.time.nowMs()、随机 **ctx.random()** 或 ctx.random.next()/ctx.random.range(a,b)、画布 ctx.getContext2d()、引擎 ctx.getEngine()、诊断日志 ctx.log(tag,msg)、业务事件 ctx.event(type,payload,message?)。**#1:ctx.time/ctx.random 既可直接调用(ctx.time()=毫秒、ctx.random()=[0,1))、又保留方法(.nowMs()/.elapsedMs()、.next()/.range(a,b)/.reseed())——两形态都对**。**但 nowMs/next 是 time/random 上的方法、不是 ctx 上的——别写 ctx.nowMs()(应 ctx.time() 或 ctx.time.nowMs())。**五法 = init(boot)/update(dt)/render(g)/destroy() + **输入方法 handleTap(x,y)**(见下「输入契约」)。
|
||
- **插件扁平注入:直接 const { sceneFsm, sessionScore, hudUi, timerScheduler } = plugins(用到才解构)**;**绝不写 opts.runtime.plugins**(那是 L1 内部的,你见不到 → 写了就 undefined → boot 崩 reading 'define')。游戏只用插件、不 new、不 register。
|
||
- **可用插件键(全 11 已注入,用到才取)**:sceneFsm / sessionScore / hudUi / timerScheduler / save / gamefeel / juice / palettePost / audioMusic / collision / physics
|
||
- 标准 Match-3 仍使用受保护 producer/timeline 接线,但 `audioMusic` 与 `match3AudioDirector` 均不进入 L3。
|
||
- **标准 Match-3 音频红线(check 会拦)**:绑定 `match3.orthogonal-swap-v1` / 解构 `match3ProducerProfile` 时,不得出现 `audioMusic`、`playSfx`、`getAudioContext`、`AudioContext`、oscillator、buffer source、合成器或自制 BGM/cue。可信 host 的 `match3AudioDirector` 会按受保护视觉 transition 自动播放主题 BGM、交换、无效回滚、每轮 clear 复合音、fall 落定音和带终局结果的 settle 音;v1 的菜单、首击和重玩本来就无 cue,不要补写。
|
||
- **插件方法名一律以 api.d.ts 为准、别臆测**(实测幻觉:scene-fsm 写 defineScenes〔应 define〕)。用到某插件先 read_file 它的 api.d.ts;skill「⚠️ 实测易犯的幻觉 API」逐条列 ❌→✅。
|
||
- **hudUi.drawButton(g, rect, opts) 回吐它绘制的命中矩形 { x, y, w, h }**:✅ 直觉写法就对——const btn = hudUi.drawButton(g, { x, y, w, h }, { label }); if (hudUi.pointInRect(px, py, btn)) 命中处理(drawButton 返回命中矩形,直接接住判命中即可)。命中判定走 hudUi.pointInRect(px, py, rect)(**只在 hudUi,collision 插件没有 pointInRect 方法**)。**第二参是 rect 对象 { x, y, w, h }、不是 label/坐标位置参**——别写 drawButton(g, '开始', x, y, w, h) 这种位置参形态。**起局/交互按钮的命中矩形必须在状态进入点(scene onEnter)确定性建立、render 只画不赋值**:绝不让命中矩形只在 render 副作用里生成——并发渲染帧饥饿下 render 可能没跑过一次=矩形恒 null=菜单点不动、一局起不来;把按钮几何抽成纯函数,onEnter 调它设命中区、render 调它画,同一来源不漂移。
|
||
- **sceneFsm.define(name, handlers) 逐场景调一次——绝不单对象批量注册**:❌ sceneFsm.define({ menu: {...}, play: {...}, over: {...} }) → 第一参当成了场景名「[object Object]」、menu/play/over 全没真注册 → 后续 transition('play') 找不到目标态被忽略 → 卡菜单(实测合成/2048 类翻车点)。✅ **每个场景各调一次**:sceneFsm.define('menu', { onEnter(){...} }); sceneFsm.define('play', { onEnter(){...}, update(dt){...} }); sceneFsm.define('over', { onEnter(){...} });(define 第一参恒为场景名字符串、第二参才是回调表 { onEnter, onExit, update, render };**回调表里 update 只收 (dt)、render 只收 (g)、与五法同形——别臆造场景名/state 首参,绝不写 update(scene, dt)**;可链式 .define(...).define(...))。
|
||
- **画面读 viewport.w/h**(viewport 是入参;别硬编码 390×844)。
|
||
- **美术资产(若 assets/manifest.json 非空)**:先 read_file('assets/manifest.json') 看有哪些资产;init(boot) 里 const assets = boot.assets || {};render 里 const img = assets['ref名']?.image; if (img) g.drawImage(img, x, y, w, h); else 程序化回退。**ref = manifest 条目 file 去扩展名**(shopkeeper-idle.jpg → 'shopkeeper-idle')。缺图/加载失败时 assets[ref].image 为 null(host 已容错)→ 务必走程序化回退、别崩。普通游戏 BGM/音效经 plugins.audioMusic;标准 Match-3 的音频资产与播放由 host 管,L3 不读取。无 manifest/无美术 → 全程序化绘制即可。
|
||
- **ctx.log 只记诊断,ctx.event 才记业务结果**:init、调试和错误可用 `ctx.log('tag', 信息)`;选择生效、击杀翻层、工序完成、解谜通关、订单交付等 profile 结果必须在真实状态变化发生后调用 `ctx.event(type, payload, message?)`。payload 必须是普通 JSON 对象,message 只是可选的人读说明,验收不从 tag/message 猜结果。host 只放行当前模板 profile 的事件 type;type 不在白名单或 payload 非法时 `ctx.event` 返回 false。遇到 false 要修正事件名/字段,不能改受保护的 host-config,不能降级成 ctx.log,更不能为过门伪造未发生的事件。**ctx 来自 init 的入参 boot,即 `const ctx = boot.ctx`,绝不是 boot.boot.ctx**。
|
||
- **剧情 narrative.branching-story**:`narrative.choice-committed` `{choiceId,branchId,node:{before,after}}`;`narrative.ending-reached` `{endingId,branchId,outcome:'win'|'lose'}`;图鉴真实解锁后 `narrative.gallery-updated` `{unlockedCount}`。同一路径的 branchId 必须一致。
|
||
- **TRPG trpg.dice-tower**:`trpg.battle-entered` `{enemyId}`;`trpg.combat-resolved` `{encounterState:{before,after},outcome:'success'|'critical'|'failure',heroHp}`;`trpg.enemy-defeated` `{enemyId}`;真实翻层后 `trpg.floor-entered` `{floor:{before,after}}`。brief 要升级时再发 `trpg.level-increased` `{upgradeId,level:{before,after}}`;明确要天赋时再发 `trpg.talent-selected` `{upgradeId,talentId}`,随后 floor-entered 同带该 upgradeId。
|
||
- **非遗 heritage.ordered-craft**:每道真实工序各发一次 `heritage.step-completed` `{workId,stepId,stepIndex,totalSteps,judgement}`,同一 workId 的 stepIndex 必须完整覆盖 1..N(N=3..12);全部完成后发 `heritage.craft-completed` `{workId,totalSteps,orderedStepIds,allStepsCompleted:true}`,成品落地再发 `heritage.product-completed` `{workId,score,works}`。
|
||
- **解谜 puzzle.match-board**:合法移动后 `puzzle.move-applied` `{boardHash:{before,after}}`;达成目标 `puzzle.goal-reached` `{goalId}`;结算 `puzzle.level-completed` `{level,result}`;真实提示 `puzzle.hint-shown` `{hintId}`;进入下一关 `puzzle.level-entered` `{level:{before,after}}`。
|
||
- **经营 sim-business.recipe-fulfillment**:订单出现 `sim-business.order-presented` `{orderId,recipeId}`;玩家选配方 `sim-business.recipe-selected` `{orderId,recipeId}`;正确交付 `sim-business.order-served` `{orderId,recipeId,success:true,revenueDelta}`(revenueDelta>0);结算 `sim-business.settlement-recorded` `{served,currency}`(均>0)。同一订单三步的 orderId/recipeId 必须一致;brief 真要求解锁时再发 `sim-business.product-unlocked` `{productId}` 或 `sim-business.equipment-unlocked` `{equipmentId}`。
|
||
- **输入契约(钉死·根治"启动不了")**:输入订阅已由 L1(game.js wrapper)接管,**你绝不自己订阅输入**——不写 ctx.getInput、不自建 pendingClicks 队列、不在 update(dt) 里挑时机消费点击。你只写实例方法 **handleTap(x, y)**(点击主输入:经营点客 / 打地鼠 / 点按钮等;**menu/玩中/结算各 phase 的判定全写在它内部**),L1 会在每次 pointerdown 时**直达**调用它;键盘玩法(方向键 / 空格)写 **handleKey(key)**,L1 在 keydown 时调用。check 会拦「调了 getInput」与「一个输入方法都没暴露」。
|
||
|
||
【完成判据 + 停机纪律(重要)】
|
||
- 判据 = check PASS + build PASS(本产线**不跑** README 里的 node --test)。**check 与 build 都 PASS 后,过一遍下条收尾自查,随即调 finish**(summary 一句话)——自查是 30 秒对照,不是无限打磨的许可。
|
||
- **收尾自查(finish 前对照设计注释块念一遍)**:⑨ 判定句的三个空是否真映射到代码路径(判断点与代价都在逻辑里,不只在注释里);⑩ 的三段难度是否落在数值上;结算/暴击时刻的 juice 反馈是否在。**check/build 只是地板**——好玩以 10 条自检与品类 rubric 为准,别拿「能跑」当完成线。
|
||
- **别做**:别写/改任何 test/ 文件、别写额外脚本、别加 brief/README 没要求的东西。
|
||
- 你有 read_file / list_dir / write_file / **edit_file** / check / build / finish 七个工具。**小改用 edit_file**(改一个函数、几行:给 `path` + 精确复制文件原文当 `old` + `new`,参数短、不易漏字段),**整文件才用 write_file**。修一条红线、改一个数值这类小修**一律优先 edit_file,别动辄整文件重写**。edit_file 的 `old` 尽量逐字节复制文件原文(含缩进)以求唯一命中;即便只差行内空白/缩进它也会归一后再定位,找不到/不唯一会明确报错并附最近似片段与行号,照提示加长 old 或改用 write_file。
|
||
- **rng/nowMs 照 `_template` 起点逐字克隆**:起点的 `rng()` 回退是 `ctx ? ctx.random.next() : 0`(裸回退给 `0`)——**照抄,绝不自造 `Math.random()`/`Date.now()` 防御回退**。check 按词法真实命中(字符串/注释里的同名不误报、函数内与模板插值里的裸调照样命中),报错带行号照改即可;需要时间源走 `ctx.time.nowMs()`、别裸 `Date.now()`。
|
||
|
||
现在开始:**先 read_file 读手册与起点 game-logic.js,别直接写码。先写一版最小可玩核心 → 立即 check(趁文件小、红线好定位好小修,用 edit_file 修到 PASS)→ 再扩玩法**;核心玩法实现完、check+build 绿了、收尾自查过了就 finish。
|
||
"""
|
||
|
||
|
||
# 默认脚手架描述(create 路 = 通用 _template「点圆得分」起点)。扩模板:per-genre 黄金骨架传各自描述覆盖,
|
||
# 让 AI 知道起点已是一款该品类的完整游戏、按 brief 换皮而非重写(与 scaffold_template 配对,见 run_studio)。
|
||
_DEFAULT_SCAFFOLD_DESC = "一个跑通的脚手架起点「点圆得分」:menu→play→over 骨架 + 五法齐全 + 编排插件示范"
|
||
|
||
# per-genre 黄金模板起点的一句话描述(单一事实源 = 此表;键 = scaffold_template 名,对齐
|
||
# cheap_genre_route._GENRE_RULES 与 cheap_verify.GENRE_BY_TEMPLATE)。route_genre 命中某品类时,create 路
|
||
# clone 该 per-genre 完整可玩模板作起点,并把此描述作 scaffold_desc 喂进 build_system_prompt,让模型知道
|
||
# 「⟦G⟧ 已是一款完整可玩的该品类游戏、据 brief 借插件能力改造成好玩切题的游戏」——**不写锁**(2026-07-08/09
|
||
# 创始人纠偏 22da3464 全撤写锁:只锁 L1_FIXED 绝对通用 plumbing,game-logic/core/render/assets 全放开,写坏
|
||
# 靠 harness 门兜、不靠限死模型的手;旧 reskin 换皮写锁管路 build_reskin_system_prompt 已随 W-AXIS 波2 拆除)。
|
||
SCAFFOLD_DESC_BY_TEMPLATE = {
|
||
"_template-feiyi": "一款完整可玩的非遗工艺游戏(工序链节拍 + 火候窗口判定 + 成品评分,menu→play→over 全链通过九门)",
|
||
"_template-trpg": "一款完整可玩的掷骰爬塔 TRPG(楼层推进 + 掷骰对抗结算 + 每几层升级选天赋,全链通过九门)",
|
||
"_template-shop": "一款完整可玩的经营点客游戏(顾客点单 + 上菜匹配 + 收银攒钱 + 3-4 级解锁阶梯,全链通过九门)",
|
||
"_template-puzzle": "一款完整可玩的解谜游戏(观察→推理→点解 + 规则递进关卡阶梯 + 卡壳兜底,全链通过九门)",
|
||
"_template-story": "一款完整可玩的剧情互动叙事游戏(分支选择 + 多结局 latch + 结局图鉴,全链通过九门)",
|
||
}
|
||
|
||
|
||
# ── C2a:cheap_roles 运行时热取配置 ─────────────────────────────────────────────
|
||
# 把 _SYSTEM_PROMPT 从「硬编码字符串」改成「运行时从 contracts/prompts 读 + mtime 缓存 + 回落内置」。
|
||
# 复用 tier2/gen-worker/worker/prompts.py 同款思路(registry → .md 剥 frontmatter → 回落内置),
|
||
# 但在本文件自包含实现(cheap_roles.py 无 tier2 import 依赖,tests/ 可裸运行)。
|
||
#
|
||
# 取值三级(任一级取到即用):
|
||
# ① env CHEAP_PROMPTS_DIR 指向目录里的 contracts/prompts/registry.yaml(临时换 prompt 不改仓内文件);
|
||
# ② 仓内 contracts/prompts/registry.yaml 引用的 04-config/cheap-system.md 正文(默认);
|
||
# ③ 内置 _SYSTEM_PROMPT(兜底,保证无任何外部依赖时行为 == 改造前,逐字节一致)。
|
||
#
|
||
# 不变量(本相红线):
|
||
# 没人改 .md、没设 env 时,build_system_prompt(...) 输出必须 == 改造前(纯内置路)逐字节一致。
|
||
# 为此 04-config/cheap-system.md 正文 = _SYSTEM_PROMPT 逐字节对齐(含 ⟦⟧ 占位符)。
|
||
#
|
||
# best-effort 铁律:任何读失败/registry 缺/正文空 → 回落 _SYSTEM_PROMPT + 一次性告警,绝不抛。
|
||
|
||
_CHEAP_PROMPT_ID = "config.cheap-system"
|
||
|
||
# 本文件在 cheap-worker/cheap_roles.py → parents[1] = 仓库根
|
||
_CR_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
_CR_DEFAULT_PROMPTS_DIR = _CR_REPO_ROOT / "contracts" / "prompts"
|
||
|
||
# 运行时读状态(mtime 缓存 + 线程安全,与 tier2/worker/prompts.py 同款)
|
||
_cr_lock = threading.Lock()
|
||
_cr_registry_cache: dict | None = None
|
||
_cr_registry_mtime: float | None = None
|
||
_cr_registry_path_cache: str | None = None
|
||
_cr_file_cache: dict = {} # path_str → (mtime, body)
|
||
_cr_warned: set = set() # 防重复告警刷屏
|
||
|
||
|
||
def _cr_warn_once(tag: str, msg: str) -> None:
|
||
"""同一 tag 只告警一次(防 best-effort 路在循环里刷屏);带 [cheap-roles] 前缀。"""
|
||
if tag in _cr_warned:
|
||
return
|
||
_cr_warned.add(tag)
|
||
print(f"[cheap-roles] {msg}", flush=True)
|
||
|
||
|
||
def _cr_prompts_dir() -> Path:
|
||
"""当前生效的 contracts/prompts/ 目录:env CHEAP_PROMPTS_DIR > 默认仓内 contracts/prompts/。"""
|
||
p = os.environ.get("CHEAP_PROMPTS_DIR")
|
||
return Path(p) if p else _CR_DEFAULT_PROMPTS_DIR
|
||
|
||
|
||
def _cr_strip_inline_comment(value: str) -> str:
|
||
"""剥 registry 值后的行内注释(# 后非引号内的内容),再剥成对外层引号。
|
||
与 tier2/worker/prompts.py _strip_inline_comment 同逻辑(无三方依赖)。
|
||
"""
|
||
out: list = []
|
||
in_quote: str | None = None
|
||
for ch in value:
|
||
if in_quote:
|
||
if ch == in_quote:
|
||
in_quote = None
|
||
out.append(ch)
|
||
elif ch in ("'", '"'):
|
||
in_quote = ch
|
||
out.append(ch)
|
||
elif ch == "#":
|
||
break # 注释起点(不在引号内)
|
||
else:
|
||
out.append(ch)
|
||
s = "".join(out).strip()
|
||
if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
|
||
s = s[1:-1]
|
||
return s
|
||
|
||
|
||
def _cr_strip_frontmatter(text: str) -> str:
|
||
"""剥 .md 文件的 YAML frontmatter(首行 --- 到下一个 ---),逐字节返回正文。
|
||
|
||
字节保真(本相红线):正文**不做 .strip()**——prompt 文本的首尾空白是正文的一部分,
|
||
必须逐字节还原才能保证「registry 命中 == 内置 _SYSTEM_PROMPT」的不变量。
|
||
写文件时正文前固定垫一个空行(约定:闭合 --- 后紧随 \\n\\n),此处跳过那一个空行。
|
||
与 tier2/worker/prompts.py _strip_frontmatter 同逻辑。
|
||
"""
|
||
lines = text.split("\n")
|
||
if not lines or lines[0].strip() != "---":
|
||
return text # 无 frontmatter:整篇即正文,逐字节返回
|
||
close_idx = None
|
||
for idx in range(1, len(lines)):
|
||
if lines[idx].strip() == "---":
|
||
close_idx = idx
|
||
break
|
||
if close_idx is None:
|
||
return text # frontmatter 未闭合:退化为整篇当正文(best-effort)
|
||
rest = lines[close_idx + 1:]
|
||
# 跳过紧随闭合 --- 的第一个空行(写文件时固定垫的那个),其余逐字节保留
|
||
if rest and rest[0] == "":
|
||
rest = rest[1:]
|
||
return "\n".join(rest)
|
||
|
||
|
||
def _cr_load_registry() -> dict:
|
||
"""运行时读 registry.yaml,返回 {id: {file, version,...}}(mtime 缓存,best-effort 绝不抛)。"""
|
||
global _cr_registry_cache, _cr_registry_mtime, _cr_registry_path_cache
|
||
path = _cr_prompts_dir() / "registry.yaml"
|
||
path_str = str(path)
|
||
with _cr_lock:
|
||
try:
|
||
mtime = path.stat().st_mtime if path.exists() else None
|
||
except OSError:
|
||
mtime = None
|
||
|
||
# 命中缓存(路径 + mtime 均未变)→ 直接返回
|
||
if (_cr_registry_cache is not None
|
||
and _cr_registry_path_cache == path_str
|
||
and _cr_registry_mtime == mtime):
|
||
return _cr_registry_cache
|
||
|
||
# registry 不存在:缓存空索引 + 一次性告警
|
||
if mtime is None:
|
||
_cr_warn_once(f"reg-missing:{path_str}",
|
||
f"registry 不存在({path_str})→ cheap_roles 回落内置 _SYSTEM_PROMPT")
|
||
_cr_registry_cache, _cr_registry_mtime, _cr_registry_path_cache = {}, None, path_str
|
||
return _cr_registry_cache
|
||
|
||
# 解析最小 YAML 子集(best-effort:任何异常落空索引 + 告警)
|
||
entries: dict = {}
|
||
try:
|
||
current_id: str | None = None
|
||
in_prompts = False
|
||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||
line = raw.rstrip("\n")
|
||
stripped = line.strip()
|
||
if not stripped or stripped.startswith("#"):
|
||
continue
|
||
if re.match(r"^prompts\s*:\s*(#.*)?$", line):
|
||
in_prompts = True
|
||
continue
|
||
if not in_prompts:
|
||
continue
|
||
m = re.match(r"^\s*-\s+id\s*:\s*(.+)$", line)
|
||
if m:
|
||
pid = _cr_strip_inline_comment(m.group(1))
|
||
current_id = pid or None
|
||
if current_id:
|
||
entries[current_id] = {"id": current_id}
|
||
continue
|
||
m = re.match(r"^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
|
||
if m and current_id is not None:
|
||
entries[current_id][m.group(1)] = _cr_strip_inline_comment(m.group(2))
|
||
continue
|
||
# prompts 段内遇顶层新键(无缩进)→ 列表段结束
|
||
if re.match(r"^[A-Za-z_]", line):
|
||
in_prompts = False
|
||
current_id = None
|
||
except Exception as exc: # noqa: BLE001 registry 解析失败:降级回落内置
|
||
_cr_warn_once(f"reg-parse:{path_str}",
|
||
f"registry 解析失败({path_str}: {exc})→ cheap_roles 回落内置 _SYSTEM_PROMPT")
|
||
entries = {}
|
||
|
||
_cr_registry_cache, _cr_registry_mtime, _cr_registry_path_cache = entries, mtime, path_str
|
||
return _cr_registry_cache
|
||
|
||
|
||
def _cr_read_body(rel_file: str) -> str | None:
|
||
"""读 registry file 字段所指 .md,剥 frontmatter 返回正文(mtime 缓存,best-effort 绝不抛)。
|
||
|
||
读不到 / 空文件 / 任何 IO 异常 → 返回 None(上层回落内置 _SYSTEM_PROMPT)。
|
||
"""
|
||
path = (_cr_prompts_dir() / rel_file).resolve()
|
||
path_str = str(path)
|
||
with _cr_lock:
|
||
try:
|
||
mtime = path.stat().st_mtime if path.exists() else None
|
||
except OSError:
|
||
mtime = None
|
||
if mtime is None:
|
||
return None # 文件不存在:上层回落内置
|
||
cached = _cr_file_cache.get(path_str)
|
||
if cached is not None and cached[0] == mtime:
|
||
return cached[1]
|
||
try:
|
||
body = _cr_strip_frontmatter(path.read_text(encoding="utf-8"))
|
||
except Exception as exc: # noqa: BLE001 读异常:降级回落内置
|
||
_cr_warn_once(f"file-read:{path_str}",
|
||
f"prompt 文件读失败({path_str}: {exc})→ 回落内置 _SYSTEM_PROMPT")
|
||
return None
|
||
_cr_file_cache[path_str] = (mtime, body)
|
||
return body
|
||
|
||
|
||
def _load_system_prompt() -> str:
|
||
"""运行时取 cheap_roles system prompt 原文(三级回落,best-effort,绝不抛)。
|
||
|
||
① env CHEAP_PROMPTS_DIR + registry → .md 文件正文;
|
||
② 仓内 contracts/prompts/ + registry → .md 文件正文(默认);
|
||
③ 内置 _SYSTEM_PROMPT(兜底;保证默认行为逐字节不变)。
|
||
"""
|
||
registry = _cr_load_registry()
|
||
entry = registry.get(_CHEAP_PROMPT_ID)
|
||
if not entry:
|
||
_cr_warn_once(f"id-missing:{_CHEAP_PROMPT_ID}",
|
||
f"registry 未注册 {_CHEAP_PROMPT_ID} → 回落内置 _SYSTEM_PROMPT")
|
||
return _SYSTEM_PROMPT
|
||
rel_file = entry.get("file")
|
||
if not rel_file:
|
||
_cr_warn_once(f"file-field:{_CHEAP_PROMPT_ID}",
|
||
f"registry 条目 {_CHEAP_PROMPT_ID} 缺 file 字段 → 回落内置 _SYSTEM_PROMPT")
|
||
return _SYSTEM_PROMPT
|
||
body = _cr_read_body(rel_file)
|
||
if not body:
|
||
_cr_warn_once(f"body-empty:{_CHEAP_PROMPT_ID}",
|
||
f"prompt 文件缺失或正文空({rel_file})→ 回落内置 _SYSTEM_PROMPT(行为不变)")
|
||
return _SYSTEM_PROMPT
|
||
return body
|
||
|
||
|
||
def _cr_reload_cache() -> None:
|
||
"""强制丢弃所有缓存,下次 _load_system_prompt 重读(测试 / 手动热更用)。"""
|
||
global _cr_registry_cache, _cr_registry_mtime, _cr_registry_path_cache, _cr_file_cache
|
||
with _cr_lock:
|
||
_cr_registry_cache, _cr_registry_mtime, _cr_registry_path_cache = None, None, None
|
||
_cr_file_cache = {}
|
||
|
||
|
||
def build_system_prompt(game_id: str, scaffold_desc: str = None) -> str:
|
||
"""据 game_id 拼出本 run 的 system prompt(把 ⟦SCAFFOLD_DESC⟧/⟦G⟧ 占位替换)。
|
||
|
||
Args:
|
||
game_id: 本 run 的游戏 id(cheap-worker 统一用 cheap-<xxx> 前缀,与 Node 旧路 node-<xxx> 隔离)。
|
||
scaffold_desc: 起点脚手架的一句话描述;None=默认通用「点圆得分」(create 路不变);
|
||
扩模板传 per-genre 描述(如经营=「完整经营点客游戏…按 brief 换皮」),让 AI 从品类骨架换皮、少写。
|
||
|
||
Returns:
|
||
薄 system prompt 字符串(指 agent read 真 skill,不手抄)。
|
||
"""
|
||
# C2a:运行时热取 prompt 文本(registry → .md → 内置 _SYSTEM_PROMPT 三级回落);
|
||
# 任何读失败都回落 _SYSTEM_PROMPT,保证输出与改造前逐字节一致(本相红线)。
|
||
base = _load_system_prompt()
|
||
g = f"game-runtime/games/amgen-{game_id}"
|
||
return base.replace("⟦SCAFFOLD_DESC⟧", scaffold_desc or _DEFAULT_SCAFFOLD_DESC).replace("⟦G⟧", g)
|
||
|
||
|
||
def _contract_block() -> str:
|
||
"""从 create system prompt 运行时切出「红线 + 完成判据 + 现在开始」通用工程契约块。
|
||
|
||
红线(入口契约五法 / 插件 API / 两条 footgun / 输入契约 / 完成判据)对 create 与 modify 两条 prompt
|
||
完全通用——是复利资产,单一事实源。modify prompt 复用它而非另抄一份(运行时切片,不改动 create 字面量,
|
||
create 路一字不动)。锚 `【红线` 标记到末尾;找不到(罕见,被改坏)兜底返全文。
|
||
"""
|
||
idx = _SYSTEM_PROMPT.find("【红线")
|
||
return _SYSTEM_PROMPT[idx:] if idx >= 0 else _SYSTEM_PROMPT
|
||
|
||
|
||
def build_modify_system_prompt(game_id: str, intent: str) -> str:
|
||
"""A11 M4 模块重生成(改玩法)的 system prompt:已能玩的游戏 → 只重写 game-logic.js 实现 intent。
|
||
|
||
与 create prompt 的两点差异:① 开头不是「从零造」而是「在已能玩的游戏上最小改写」;② 写边界收窄到
|
||
**只许写 game-logic.js**,core/render/assets/host-config 等其余文件保持不变、不要动(写会被工具拒)。
|
||
红线契约块(入口五法 / 插件 API / footgun / 输入)经 _contract_block() 复用 create 同一份,不另抄。
|
||
|
||
Args:
|
||
game_id: 本 run 的游戏 id。
|
||
intent: 用户的改写意图(M2 判意图产出的 modifyPatch.payload.intent)。
|
||
|
||
Returns:
|
||
modify system prompt 字符串(⟦G⟧ 已据 game_id 替换)。
|
||
"""
|
||
head = f"""你是 A-model 游戏调整 agent。⟦G⟧/ 下已经是一款**能跑能玩**的便宜档 LittleJS 小游戏(多文件 src/ 工程)。
|
||
|
||
【本次任务 = 只改玩法,不是从零造】
|
||
- **唯一任务:只重写 ⟦G⟧/src/game-logic.js**(玩法本体),来实现这个调整:「{intent}」。
|
||
- **其余文件一律保持不变、绝不要写**:src/core.js(数值)/ src/render.js(画面)/ src/assets.js(资产)/ src/host-config.js / src/game.js / index.html / entry-bundle.js 都不要动——写它们会被工具直接拒绝。你**可以 read_file 读** core/render/assets 取上下文,但**只能 write game-logic.js**。
|
||
- 这是已能玩的游戏的**最小改写**:先 read_file 读当前的 ⟦G⟧/src/game-logic.js 看现在怎么实现,再据上面的调整意图改写它;别推倒重来、别改 brief/调整没要求的东西。
|
||
|
||
【先读(必须;别凭记忆猜 API)】
|
||
1. read_file('{SKILL_PATH}') —— code 层作业手册(结构/边界/红线/插件 API 速查)。
|
||
2. read_file('⟦G⟧/src/game-logic.js') —— 当前玩法实现(你的改写起点)。
|
||
3. 用到某插件先 read_file 它的 api.d.ts 看精确签名(如 game-runtime/src/plugins/scene-fsm/api.d.ts)。
|
||
|
||
【步骤】
|
||
A 读手册 + 读当前 game-logic.js → B 定位「{intent}」要改玩法的哪一处 → C 读要用的插件 api.d.ts → D write_file 改写 **game-logic.js(只此一个文件)** 实现调整 → E 调 check,有错改到 PASS → F 调 build,有错改到 PASS → G check+build 都绿后调 finish。"""
|
||
g = f"game-runtime/games/amgen-{game_id}"
|
||
return (head + "\n\n" + _contract_block()).replace("⟦G⟧", g)
|