lili 8301f650fa
Some checks failed
docs-gate / docs-gate (push) Has been cancelled
Merge branch 'p11-trpg' into dev/2.0.0
# Conflicts:
#	cheap-worker/cheap_roles.py
#	cheap-worker/tests/test_golden_specs.py
#	contracts/prompts/04-config/cheap-system.md
#	contracts/prompts/registry.yaml
#	game-runtime/tools/amodel-gen/prompt.mjs
2026-07-02 23:09:27 -07:00

377 lines
31 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""
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 正文与 prompt.mjs 保持语义一致:入口契约 createGame({plugins,bundle,viewport}) 五法 + _forensicsView、
红线(零裸时间随机 DOM / bundle.tick / ctx.time.nowMs() / 扁平注入插件 / 方法名以 api.d.ts 为准)、
本周收口的 footgun(sceneFsm.define 逐场景调、真返 void 的绘制/音效 API 别接返回值;drawButton 已改返命中矩形、可直接接住判命中)、输入契约(handleTap/handleKey)。
「生成丰富度」设计步(U-A1):除指向 littlejs-game-dev.md(怎么写代码)外,再加一个「先设计后写码」步——
经营/养成/放置/点客类先 read sim-business-game-design.md 取「设计什么才好玩」的范式(核心循环 + 进货库存资源环
+ 3-4 级解锁阶梯 + 数值成长 + 音效清单),过 §10 八条好玩自检,并严守 §8 MVP-first 铁律(别一稿堆满,否则
code agent read-thrash 不收敛)。八条自检对所有品类通用、sim-business 范式只对经营类适用。**这是给生成 agent 的
设计创作指导(散文)、不是代码校验**——丰富度的「校验」走 cheap_verify.py 的纯 LLM judge(红线:丰富不丰富的
判断需大模型、绝不写成 code-presence/正则/断言)。prompt.mjs(Node 双源)必须同改、语义一致(仅标点全/半角差异)。
"""
# 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 装配:已替你注入**全 11 件能力插件** + viewport 固定 390×844 + 工厂 wiring;你无需装配任何插件)。
- **你只写 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()(+必须 _forensicsView)。**插件已扁平注入 plugins**,用到才解构:sceneFsm/sessionScore/hudUi/timerScheduler/save/gamefeel/juice/palettePost/audioMusic/collision/physics。
【先读手册(必须;别凭记忆猜 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 反无趣 8 条自检)。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 反无趣自检)。
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
能跑 ≠ 好玩。动手写码前,先据 brief 在心里(或 game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现:
- **核心循环**(一句话:玩家做什么 → 得什么即时反馈 → 怎么变强);
- **资源环**(经营/放置类必含):「进货 → 库存 → 售卖收钱 → 缺货补货」的软币循环,制造「赚→进→卖→再赚」的张力;
- **3–4 级解锁阶梯**:攒够阈值解锁新商品/区域/能力,任意时刻都露出「下一个锁」;
- **数值成长**:产出/成本随级上升、略带滚雪球感,别平淡线性;
- **音效清单**:收钱「叮」/ 升级欢呼 / 解锁号角(经 plugins.audioMusic;宁可程序化也别没有反馈音)。
定完过一遍 sim-business §10 的 **8 条好玩自检**(①即时反馈 ②可见成长 ③下一个解锁 ④30 秒内首次升级/解锁 ⑤数值滚雪球 ⑥情感锚〔萌角色/拥有物〕 ⑦放置回归惊喜 ⑧音反馈)——命中越多越好玩,命中 ≤3 ≈ 能玩但无趣。
**8 条自检对所有品类通用**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式;**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进、结局 latch 驻留,「选择有重量」——选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式。
【MVP-first 铁律(钉死·关乎你能不能收敛,别一稿堆满)】
设计太满 → 你实现负担过重 → read/write 反复跳、跑不收敛(实测:满配设计循环截停、精简设计 9 步收敛)。首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版。**先出能玩的核心,再谈丰富。**
【步骤】
A 读手册(1 + 按品类 5/6/7)+ 读你的起点 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)。**#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()(+必须 _forensicsView)+ **输入方法 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。
- **插件方法名一律以 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) 这种位置参形态。
- **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(不在 boot.assets)。无 manifest/无美术 → 全程序化绘制即可。
- **必须实现 _forensicsView()**(可测性红线 + 自动驱动契约):返回 { state(){…}, measures(){…} };state() 至少 { phase, score }。**若游戏靠点击推进(经营点客/打地鼠/点离散目标等):state().targets:[{x,y,occupied}] 列当前屏上目标,可点的(等待的客/冒头的鼠)置 occupied:true、不可点置 false** —— 自动验收据此点 occupied:true 的目标真驱动你的游戏。**score 用累计进展(营收/得分),随推进上升** —— 自动验收据 score 上升判"真有进展"。不写则验证读不到状态(check 会拦)。**钉死:phase/targets/score 必须在 state() 函数体内部实时读取/计算——绝不在 _forensicsView() 外层先算好再闭包返回**(host 只在 boot 调一次 _forensicsView,外层算的值会被定格成 boot 快照=永远 menu、targets 永远空 → 自动验收看不到顾客、永判死菜单)。
- **关键事件用 ctx.log 记日志**:在 init/场景切换/得分/出错处 调 ctx.log('tag', 信息)。**ctx 来自 init 的入参 boot,即「const ctx = boot.ctx」——绝不是 boot.boot.ctx**(host 传入的 boot 已是 {ctx, mainContext, canvas, seed, assets},多套一层 .boot=undefined → ctx=null → 无输入、游戏点不动;check 会拦 .boot.ctx)。插件调用已自动记,你只补游戏语义事件。
- **输入契约(钉死·根治"启动不了")**:输入订阅已由 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(本 spike **不跑** README 里的 node --test)。**一旦 check 与 build 都 PASS,立即调 finish**(summary 一句话)。
- **别做**:别写/改任何 test/ 文件、别写额外脚本、别追求完美、别加 brief/README 没要求的东西。这是 spike,**能跑能玩即可**。
- 你**只有** read_file / write_file / list_dir / check / build / finish 六个工具;**没有 edit_file** —— 改文件用 write_file 整体覆盖。
现在开始:**先 read_file 读手册,别直接写码;核心玩法实现完、check+build 绿了就立即 finish。**"""
# 默认脚手架描述(create 路 = 通用 _template「点圆得分」起点)。扩模板:per-genre 黄金骨架传各自描述覆盖,
# 让 AI 知道起点已是一款该品类的完整游戏、按 brief 换皮而非重写(与 scaffold_template 配对,见 run_studio)。
_DEFAULT_SCAFFOLD_DESC = "一个跑通的脚手架起点「点圆得分」:menu→play→over 骨架 + 五法齐全 + 编排插件示范"
# ── 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)