lili cbfd4d871b
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(acceptance): 闭合 playtest v3 与 A+ 可信消费链
固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。

将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。

同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
2026-07-28 20:16:13 -07:00

405 lines
41 KiB
Python
Raw Permalink 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 正文要点:入口契约 createGame({plugins,bundle,viewport}) 五法、红线(零裸时间随机 DOM /
bundle.tick / ctx.time.nowMs() / 扁平注入插件 / 方法名以 api.d.ts 为准)、本周收口的 footgunsceneFsm.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.mjsNode 遗留)自 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/h390×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、资源有限要权衡。自检把玩家换成「闭眼乱点」——若分数照样拿满这循环就是无趣的重设计。
- **两层反馈:基础反馈保上手、技巧分给深度(好玩的设计惯例)****基础反馈**——任意有效操作都给一点即时回应(分数/音效/飘字),让新手一上手就有正反馈、不至于无所适从;**技巧分**——叠在玩家的匹配/时机/连击上,做对越多越准回报越高,让熟练玩家玩出掌控感与爽感。两层叠着来:随便玩也有反馈、不劝退新手,玩得好有额外回报、给足深度。**别把玩法做成「怎么点都一样」的无脑点(那样技巧分名存实亡);也别「不做对就颗粒无收」(那样没有上手缓冲、劝退新手)。**
- **资源环**(经营/放置类必含):「进货 → 库存 → 售卖收钱 → 缺货补货」的软币循环,制造「赚→进→卖→再赚」的张力;
- **34 级解锁阶梯**:攒够阈值解锁新商品/区域/能力,任意时刻都露出「下一个锁」;
- **数值成长**:产出/成本随级上升、略带滚雪球感,别平淡线性;
- **音效清单**:普通游戏写收钱「叮」/升级欢呼/解锁号角等反馈(经 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 个资源环(进货)+ 34 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版hitstop/震屏**不在**禁项——它们是单行 juice 调用不是系统:结算/暴击/大额成单时刻标配一次 hitStop + 轻 shake好手感不算堆料。**先出能玩的核心,再谈丰富。** **但钉死:「可玩核心」= 一个有决策深度的核心机制做透,不是一个浅机制 + 一堆 meta 元素(解锁/升级/飘字)。要砍的是商品数 / 附加系统 / 离线雇员;绝不砍核心循环那一层玩家决策(见上「核心循环」铁律)——那是 MVP 的心脏,砍了就只剩无趣骨架。**
【步骤】
A 读手册1 + 按品类 5/6/7/8/9+ 读你的起点 game-logic.js4→ 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) 同样收毫秒(#73 秒写 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?)。**#1ctx.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.tsskill「⚠ 实测易犯的幻觉 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)**只在 hudUicollision 插件没有 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 为 nullhost 已容错)→ 务必走程序化回退、别崩。普通游戏 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 的事件 typetype 不在白名单或 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..NN=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}`。
- **输入契约(钉死·根治"启动不了"**:输入订阅已由 L1game.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 绝对通用 plumbinggame-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 + 结局图鉴,全链通过九门)",
}
# ── C2acheap_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 的游戏 idcheap-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)