feat(cheap-worker): 便宜档生成丰富度 + M1 达标门富化重验(切片一 A/B)

接 sim-business 设计指导到生成 agent(cheap_roles+prompt.mjs 双源,先设计后写码+MVP-first);新建纯 LLM 丰富度验证 agent cheap_verify(非阻塞·不进 verdict·零 code-presence 断言,红线落地);bake_off 加 richness 列;cheap_roles 三级回落配置热取(C2a 加载器);cheap_studio trace 接线(C1b)。富游戏 M1 三品类各 5/5=100% 过九门达标。测试 test_roles 12 / test_cheap_verify 11 / test_bake_off 17 / test_cheap_trace 4 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-30 01:01:57 -07:00
parent ba0197a1a3
commit e6525aaa3d
11 changed files with 1148 additions and 6 deletions

View File

@ -56,6 +56,32 @@ def judge_genre(runs: list) -> dict:
return {**base, "passRate": rate, "status": "meets" if meets else "below", "meets": meets}
def richness_dist(runs: list) -> dict:
"""单品类丰富度分布(U-B1 · 纯报告 · **与达标判定完全正交**)。
richness LLM judge(cheap_verify)读产物源码后给的非阻塞质量信号(命中数 0..8),
与九门 verdict.pass 的达标判定**互不影响** 本函数只统计分布绝不参与 meets
非降级款收集 score 算均分/区间;降级款(LLM 评分失败/超时/ src)单列计数;
richness 字段的款(对照路 run_gates=False / 老产物)直接不计
"""
scored = []
degraded = 0
for r in runs:
rv = r.get("richness")
if not isinstance(rv, dict):
continue # 没跑 richness → 不计入分布
if rv.get("degraded") or rv.get("score") is None:
degraded += 1
continue
try:
scored.append(int(rv["score"]))
except (TypeError, ValueError):
degraded += 1 # 脏评分值兜底为降级,绝不抛
mean = round(sum(scored) / len(scored), 2) if scored else None
return {"max": 8, "scoredCount": len(scored), "degradedCount": degraded,
"meanScore": mean, "scores": sorted(scored)}
def aggregate(genre_runs: dict, required_genres: list) -> dict:
"""按品类聚合 + 整体达标判定(质量口径)。
@ -72,6 +98,8 @@ def aggregate(genre_runs: dict, required_genres: list) -> dict:
overall = (not missing) and (not below)
unconverged_total = sum(per_genre[k]["unconverged"] for k in required_genres)
sample_total = sum(per_genre[k]["total"] for k in required_genres)
# U-B1:LLM 丰富度分布(非阻塞报告 · 与达标判定正交)。只观察「游戏变丰富没」,绝不改 overallMeets。
richness_by_genre = {k: richness_dist(genre_runs.get(k, [])) for k in required_genres}
return {
"perGenre": per_genre,
"requiredGenres": required_genres,
@ -81,6 +109,7 @@ def aggregate(genre_runs: dict, required_genres: list) -> dict:
"threshold": _PASS_THRESHOLD,
"unconvergedTotal": unconverged_total, # 编排未收敛款数(M2 follow-up,不计入质量达标率)
"sampleTotal": sample_total,
"richnessByGenre": richness_by_genre, # U-B1:逐品类丰富度分布(LLM judge 非阻塞信号,不入达标判据)
"note": "质量口径(分母=收敛款,编排未收敛剔出、单列 unconvergedTotal 归 M2);"
"本机/实验室口径,生产真实分布达标在 M3 复验;判定零 LLM(纯确定性九门 verdict.pass)",
}
@ -117,7 +146,8 @@ def run_one_sync(genre_key: str, brief: str, k: int, port: int, cdp: int) -> dic
return {"gid": gid, "passed": bool(v and v.get("pass")), "finished": summary.get("finished"),
"failedGates": (v or {}).get("failedGates"),
"breakerKind": (breaker or {}).get("kind"), # 未收敛原因(归 M2 编排稳定性诊断)
"costRmb": summary.get("costRmb")}
"costRmb": summary.get("costRmb"),
"richness": summary.get("richness")} # U-B1:additive 丰富度评分(run_studio 已写 summary,非阻塞)
async def run_bakeoff(genre_keys: list, n: int, conc: int = 1, offset: int = 0,
@ -180,6 +210,11 @@ def print_report(report: dict) -> None:
unc = f" +{j['unconverged']} 未收敛(剔出)" if j.get("unconverged") else ""
print(f"[{k}] 质量 {j['passed']}/{j['converged']} = {j['passRate']} {j['status']}"
f" (原始 {j['rawPassRate']}/{j['total']} 款){unc}", file=sys.stderr)
rd = report.get("richnessByGenre", {}).get(k, {}) # U-B1:丰富度只读旁注(非阻塞·不入达标)
if rd.get("scoredCount") or rd.get("degradedCount"):
deg = f" +{rd['degradedCount']} 降级" if rd.get("degradedCount") else ""
print(f" 丰富度(LLM·非阻塞)均分 {rd.get('meanScore')}/8 "
f"评分 {rd.get('scoredCount')}{rd.get('scores')}{deg}", file=sys.stderr)
flag = "达标 ✅" if report["overallMeets"] else "未达标 ❌"
print(f">>> 整体:{flag}(逐品类阈值 {report['threshold']}) 缺样本:{report['missingGenres']} "
f"不达标:{report['belowGenres']}", file=sys.stderr)

View File

@ -13,8 +13,21 @@ skill / 插件 api.d.ts / 起点范例。L3 context 由 agent 经 read_file 按
system prompt 正文与 prompt.mjs 保持语义一致入口契约 createGame({plugins,bundle,viewport}) 五法 + _forensicsView
红线零裸时间随机 DOM / bundle.tick / ctx.time.nowMs() / 扁平注入插件 / 方法名以 api.d.ts 为准
本周收口的 footgunsceneFsm.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.mjsNode 双源必须同改语义一致仅标点全/半角差异
"""
# 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"
@ -35,9 +48,23 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**
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 教你设计什么才好玩配对用先想清好玩再写正确
先设计后写码决定这游戏好不好玩关键别跳过
能跑 好玩动手写码前先据 brief 在心里 game-logic.js 顶部一小段注释里定一份**轻量玩法设计**再实现
- **核心循环**一句话玩家做什么 得什么即时反馈 怎么变强
- **资源环**经营/放置类必含进货 库存 售卖收钱 缺货补货的软币循环制造再赚的张力
- **34 级解锁阶梯**攒够阈值解锁新商品/区域/能力任意时刻都露出下一个锁
- **数值成长**产出/成本随级上升略带滚雪球感别平淡线性
- **音效清单**收钱/ 升级欢呼 / 解锁号角 plugins.audioMusic宁可程序化也别没有反馈音
定完过一遍 sim-business §10 **8 条好玩自检**即时反馈 可见成长 下一个解锁 30 秒内首次升级/解锁 数值滚雪球 情感锚萌角色/拥有物 放置回归惊喜 音反馈命中越多越好玩命中 3 能玩但无趣
**8 条自检对所有品类通用**动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式
MVP-first 铁律钉死·关乎你能不能收敛别一稿堆满
设计太满 你实现负担过重 read/write 反复跳跑不收敛实测满配设计循环截停精简设计 9 步收敛首版**只做可玩核心**核心循环 + 1 个主机制 + 1 个资源环进货+ 34 级解锁 + 基础数值/音效**商品 3 种起步****离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标后续·MVP 不做**别塞进首版**先出能玩的核心再谈丰富**
步骤
A 读手册1+ 读你的起点 game-logic.js4 B brief 定玩法核心循环 / 胜负 / 计分 / 数值 C 读要用的插件 api.d.ts D write_file 改写 **game-logic.js**按需拆 core/render实现玩法 E check有错改到 PASS F build有错改到 PASS G check+build 都绿后调 finish
A 读手册1+5+ 读你的起点 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()
@ -68,6 +95,224 @@ A 读手册1+ 读你的起点 game-logic.js4→ B 按 brief 定玩
_DEFAULT_SCAFFOLD_DESC = "一个跑通的脚手架起点「点圆得分」menu→play→over 骨架 + 五法齐全 + 编排插件示范"
# ── 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⟧ 占位替换)。
@ -79,8 +324,11 @@ def build_system_prompt(game_id: str, scaffold_desc: str = None) -> str:
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 _SYSTEM_PROMPT.replace("⟦SCAFFOLD_DESC⟧", scaffold_desc or _DEFAULT_SCAFFOLD_DESC).replace("⟦G⟧", g)
return base.replace("⟦SCAFFOLD_DESC⟧", scaffold_desc or _DEFAULT_SCAFFOLD_DESC).replace("⟦G⟧", g)
def _contract_block() -> str:

View File

@ -23,12 +23,22 @@ import _bootstrap # noqa: E402,F401
from cheap_roles import build_system_prompt # noqa: E402
from cheap_toolkit import CheapSession, build_toolkit # noqa: E402
import cheap_run # noqa: E402
import cheap_verify # noqa: E402 U-A2便宜档「丰富度」LLM 验证 agent非阻塞·只报告
# tier2 框架层import config 触发代理旁路 + 加载 agentscope必须在裸 import agentscope/openai 之前)。
from worker import config # noqa: E402
from worker.middleware import ( # noqa: E402
CircuitBreakerMiddleware, Tier2TraceMiddleware, Tier2CircuitBreak,
)
# C1b trace 落库:复用 tier2 observability 的 JSONL sinkcheap-worker 不另造 sink
# 非阻塞铁律:导入失败只告警,回落到无 sink 模式,绝不阻断生成主链。
try:
from observability.trace import make_jsonl_sink as _make_jsonl_sink # noqa: E402
_TRACE_SINK_AVAILABLE = True
except Exception as _import_err:
_TRACE_SINK_AVAILABLE = False
print(f"[cheap_studio] trace sink import 失败(回落无 sink 模式,不影响生成):{_import_err}",
flush=True)
# AgentScope此时 agentscope 已由 config import 链加载、代理旁路已装)。
from agentscope.agent import Agent, ReActConfig # noqa: E402
from agentscope.message import UserMsg # noqa: E402
@ -111,7 +121,7 @@ def build_trace_source(verdict, driver_type, attempts, stage, model) -> dict:
async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=16000,
port=4320, cdp_port=9222, run_gates=True,
system_prompt=None, initial_kick=None, write_whitelist=None, prepare=None,
scaffold_template=None, scaffold_desc=None):
scaffold_template=None, scaffold_desc=None, verify_richness_enabled=True):
"""跑便宜档一局生成scaffold → ReAct 写 src/ → done 门 → 收口 stage+smoke+九门)。返回 run-summary dict。
run_gates=True默认spike 单局收口跑全套 stagesmokeensure_play_spec九门 play
@ -144,7 +154,19 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
# on_model_call 在每次裸模型调用前按「已花 + 本次预估」判、越线即 fail-closed 抛 Tier2CircuitBreak(budget)
# 取价不可达时 _ensure_pricing 降级为次数闸(既有行为,不静默超支、不静默阻断)。步数/超时仍用 tier2 默认。
breaker = CircuitBreakerMiddleware(enable_rmb_gate=True, rmb_hard_limit=10.0)
tracer = Tier2TraceMiddleware(trace_id=game_id)
# C1b trace 落库把生成轨迹按五字段traceId/step/cost/verdict/timestamp + ext
# 追加写到产物目录 games/amgen-<id>/trace.jsonl供成本对账 / replay 用。
# 非阻塞铁律:接线异常只告警 + 回落无 sink 模式,绝不阻断生成主链。
# 为什么落盘路径选产物目录:与 run-summary.json / evidence/ 同目录,随产物一起留存,运维按 gameId 定位直观。
_trace_sink = None
if _TRACE_SINK_AVAILABLE:
try:
_trace_path = cheap_run.game_dir(game_id) / "trace.jsonl"
_trace_sink = _make_jsonl_sink(_trace_path)
_rec(f"trace sink 接线 → {_trace_path}")
except Exception as _sink_err:
_rec(f"trace sink 构造异常(只告警,回落无 sink不阻断生成{_sink_err}")
tracer = Tier2TraceMiddleware(trace_id=game_id, sink=_trace_sink)
writer = Agent(
name="cheap-writer",
@ -244,6 +266,8 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
"tokens": {"in": tok_in, "out": tok_out, "total": tok_in + tok_out},
"doneSummary": session.finished.get("summary") if session.finished else None,
"wallSec": round(time.time() - t0, 1),
# C1b trace 收口摘要:{traceId, steps, dropped}供编排器对账trace.jsonl 落盘路径已在接线处 _rec
"trace": tracer.summary(),
}
# ── M3b U1additive 富化 9d trace 源维度verdictFull/driverType/models/stage──
# 收口段已算出但没存的维度结构化进 summary供 result_out 组 payload["trace"]、后端 D11 算分;
@ -251,6 +275,24 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
stage = _furthest_stage(finished=finished, staged=staged,
smoke_ran=(smoke_ok is not None), played=(verdict is not None))
summary.update(build_trace_source(verdict, driver_type, attempts, stage, _bootstrap.SPIKE_MODEL))
# ── U-A2便宜档「丰富度」LLM 评分(非阻塞·只报告·不进 verdict / 不改 ok 达标)──
# 架构红线:玩法「丰富不丰富」的校验需大模型能力,绝不写成 code-presence/正则/断言(违反创始人红线)。
# 故纯走 LLM judgecheap_verify.verify_richness结果 additive 写 summary["richness"]
# · 不进 verdict、不改 summary["ok"]/达标判定、不动任何既有键result_out._build_trace 只读 named 字段,不读 richness
# · judge 用独立 model 实例token 不污染生成成本台账costRmb 仍只算生成);
# · 非阻塞双保险verify_richness 内部已 try/except 兜底 degraded这里再包一层verify 失败绝不影响 run。
# 只在 run_gates完整 spike 收口)且 finished有真产物时跑对照路run_gates=False零改动、不触发 LLM。
if verify_richness_enabled and run_gates and finished:
try:
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief)
rv = summary["richness"]
_rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} degraded={rv.get('degraded')}")
except Exception as e: # noqa: BLE001 非阻塞铁律richness 绝不影响生成主链
summary["richness"] = {"score": None, "degraded": True,
"reason": f"richness 接线异常:{type(e).__name__}: {e}"}
_rec(f"丰富度评分接线异常(已降级,不影响 run{type(e).__name__}: {e}")
ev_dir = cheap_run.game_dir(game_id) / "evidence"
ev_dir.mkdir(parents=True, exist_ok=True)
(ev_dir / "run-summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")

View File

@ -0,0 +1,241 @@
"""
cheap_verify.py 便宜档丰富度LLM 验证 agent非阻塞·只报告
架构红线创始人多轮强调绝不可破**项目代码只做机械确定的事**玩法/美术/音乐的丰富
无论生成还是校验都需大模型能力**绝不写成代码校验**code-presence/静态扫描/正则/断言一律禁
故本模块只做两件 100% 机械确定的事丰富不丰富的判断本身完全交 LLM judge
读产物 L3 源码确定性文件 I/O+ judge prompt确定性字符串
解析 LLM 返回的 JSON 成结构化评分确定性解析 + degraded 兜底
本模块**不含任何丰富度的代码断言**8 条好玩清单只是喂给 LLM 的评分尺命中与否全由 judge 读码后裁
sim-business-game-design.md §10 写了4 条可机检 code-presence子集那与本红线冲突本模块刻意不照它写代码校验
非阻塞铁律LLM 调失败/超时/解析失败 返回 {score:None, degraded:True, reason:...}
**绝不抛绝不阻断生成绝不进 verdict / 不改达标判定**接线方cheap_studio再包一层 try/except 双保险
cheap_studio.run_studio 收口段调用九门 play 之后 run-summary 之前结果 additive summary["richness"]
"""
import asyncio
import cheap_run # 仅 stdlib 依赖(读产物 src 文件);不 import agentscope/_bootstrap故纯函数可在无 agentscope 环境单测。
# ── sim-business §10 反「无趣」8 条好玩清单judge 的评分 rubric──
# 这是【喂给 LLM judge 的打分尺】,不是代码校验:丰富不丰富全由 judge 读源码后逐条裁。
# 与 .agents/skills/sim-business-game-design.md §10 同源(那边是 design 自检,这里是产物 judge 评分)。
# 每项 = (标准名, 含义)judge 按标准名逐条回命中与否 + 理由。
RICHNESS_CHECKLIST = [
("即时反馈", "每个主要操作有「飘字 +N + 音效 + 粒子」即时回报"),
("可见成长", "有数字/规模肉眼可见地变大(分数飙升/店铺扩张/等级上涨)"),
("下一个解锁", "任意时刻玩家眼前都有「再攒一点就解锁 X」的钩子"),
("30秒爽点", "开局 30 秒内有第一次升级/解锁"),
("数值滚雪球", "成长有「越来越快」的暴富段,而非平淡线性"),
("情感锚", "有萌角色/拥有物让玩家「想养大它」"),
("放置回归", "离线回来有惊喜(离线收益弹窗等)"),
("音反馈", "收益/升级/解锁有声音(哪怕程序化)"),
]
_MAX = len(RICHNESS_CHECKLIST) # = 8
# 只读 L3 游戏本体(玩法/画面/数值/资产);跳过 L1 plumbinghost-config/game.js/index.html——
# 固定脚手架、与「游戏丰富不丰富」无关,喂给 judge 只会稀释信号)。
_L3_SOURCE_FILES = ("game-logic.js", "core.js", "render.js", "balance.js", "assets.js")
_MAX_SRC_BYTES = 24000 # 喂 judge 的源码总量上限(控 token / 成本;超限按字节截断)。
_JUDGE_MAX_TOKENS = 3000 # judge 输出小8 条短理由 + 一句点评3000 足够、M3 无 thinking。
# judge 的 system / user 框定——只评分、不改码、要看源码真实现、输出严格 JSON。
_JUDGE_SYSTEM = (
"你是轻量小游戏的【丰富度评审 agent】。任务 = 读一款便宜档 LittleJS 小游戏的源码,"
"只评判它「作为一款游戏够不够丰富、好玩」,逐条给出 8 条好玩清单的命中与否 + 一句中文理由,"
"最后输出严格 JSON。你只评分、不修改代码、不阻断发布——这是非阻塞的质量信号。"
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈与音效,别被空壳或注释骗。"
)
def _degraded(reason: str) -> dict:
"""降级结果非阻塞铁律score=None + degraded=Truescore 为 None 表示「这次没评出来」,绝不参与达标。"""
return {"score": None, "max": _MAX, "hits": [], "notes": None, "degraded": True, "reason": reason}
def _coerce_bool(v) -> bool:
"""把 judge 可能给的多形态命中值归一为 booltrue/1/""/"命中" 等 → True其余 → False"""
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v > 0
if isinstance(v, str):
return v.strip().lower() in ("true", "1", "yes", "y", "", "命中", "hit", "", "", "")
return False
def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST) -> dict:
"""纯函数:把 judge 的 LLM 文本输出解析成结构化丰富度评分(含 degraded 兜底)。可单测、零网络、零 agentscope。
成功 {score:int(命中数 0..8), max:8, hits:[{name,hit,why}*8], notes:str, degraded:False}
解析失败/ JSON 对象/ checks 数组 _degraded(...)score=None, degraded=True
对齐策略优先按 name 对齐 judge checks 8 条标准项name 对不上的按位置兜底容忍模型改名/英文名
本函数**不含任何丰富度的代码判断**只搬运 judge 的逐条裁决并计命中数
"""
if not isinstance(text, str) or not text.strip():
return _degraded("judge 输出为空")
try:
# json_repair 容忍 markdown 围栏 / 前后赘语 / 尾逗号等 LLM 常见脏输出。
import json_repair
data = json_repair.loads(text)
except Exception as e: # noqa: BLE001 解析层任何异常都降级,绝不抛
return _degraded(f"judge 输出解析失败:{type(e).__name__}")
if not isinstance(data, dict):
return _degraded("judge 输出不是 JSON 对象")
checks_raw = data.get("checks")
if not isinstance(checks_raw, list) or not checks_raw:
return _degraded("judge 输出缺 checks 数组")
by_name = {}
for c in checks_raw:
if isinstance(c, dict):
nm = str(c.get("name", "")).strip()
if nm:
by_name[nm] = c
hits = []
score = 0
for i, (name, _meaning) in enumerate(checklist):
c = by_name.get(name)
if c is None and i < len(checks_raw) and isinstance(checks_raw[i], dict):
c = checks_raw[i] # name 对不上 → 位置兜底
hit = _coerce_bool(c.get("hit")) if isinstance(c, dict) else False
why = (str(c.get("why", "")).strip()[:200]) if isinstance(c, dict) else ""
hits.append({"name": name, "hit": hit, "why": why})
if hit:
score += 1
notes = str(data.get("notes", "")).strip()[:500]
return {"score": score, "max": len(checklist), "hits": hits, "notes": notes, "degraded": False}
def _collect_sources(game_id: str, *, max_bytes: int = _MAX_SRC_BYTES) -> str:
"""读产物 L3 源码game-logic/core/render/balance/assets拼成带文件头的一段文本总量截断到 max_bytes。
只读 L3 游戏本体读不到的文件跳过全空 返回空串上层据此降级跳过 LLM不白烧钱
"""
src_dir = cheap_run.game_dir(game_id) / "src"
parts = []
total = 0
for name in _L3_SOURCE_FILES:
p = src_dir / name
try:
if not p.is_file():
continue
txt = p.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue # 单文件读失败不致命,跳过即可
chunk = f"\n// ===== {name} =====\n{txt}\n"
parts.append(chunk)
total += len(chunk.encode("utf-8"))
if total >= max_bytes:
break
joined = "".join(parts)
enc = joined.encode("utf-8")
if len(enc) > max_bytes: # 字节级截断兜底(防单个大文件超限撑爆 token
joined = enc[:max_bytes].decode("utf-8", "ignore") + "\n// [源码已截断]"
return joined
def _build_judge_user(src_text: str, brief: str = "") -> str:
"""拼 judge 的 user 消息brief可选+ 8 条清单 + 产物源码 + 严格 JSON 输出契约(纯确定性字符串拼接)。"""
checklist_lines = "\n".join(
f"{i + 1}. {name}{meaning}" for i, (name, meaning) in enumerate(RICHNESS_CHECKLIST)
)
brief_block = f"这款游戏的 brief玩家想要的{brief}\n\n" if brief else ""
return (
f"{brief_block}"
"下面是这款便宜档小游戏的 L3 源码。请逐条评判它是否命中这 8 条好玩清单,"
"据**源码里真实实现了的玩法/数值/音效/解锁**判断(别被注释或空壳骗:比如只 import 了 audioMusic "
"但收益处没真调 playSfx则「音反馈」不算命中\n\n"
f"{checklist_lines}\n\n"
"=== 源码开始 ===\n"
f"{src_text}\n"
"=== 源码结束 ===\n\n"
"严格只输出以下 JSON不要任何额外文字、不要 markdown 围栏):\n"
'{"checks":[{"name":"即时反馈","hit":true,"why":"一句中文理由"}, … 共 8 条,'
'name 用上面 8 条的中文名、hit 为 true/false], "notes":"整体一句话点评"}'
)
def _extract_text(resp) -> str:
"""从 ChatResponse 抽纯文本content 是 TextBlock/ToolCall... 序列,只取 text 块(兼容 pydantic 块与 dict 块)。"""
content = getattr(resp, "content", None)
if content is None and isinstance(resp, dict):
content = resp.get("content")
if isinstance(content, str):
return content
parts = []
for b in (content or []):
if isinstance(b, dict):
if b.get("type") == "text" and isinstance(b.get("text"), str):
parts.append(b["text"])
elif getattr(b, "type", None) == "text":
t = getattr(b, "text", None)
if isinstance(t, str):
parts.append(t)
return "".join(parts)
async def verify_richness(game_id: str, *, brief: str = "", model=None, sources=None,
timeout: float = 90.0, max_src_bytes: int = _MAX_SRC_BYTES) -> dict:
"""生成完成后跑一次 LLM 丰富度评分(非阻塞·只报告)。**绝不抛、绝不阻断、绝不进 verdict / 不改达标**。
Args:
game_id: run 游戏 id games/amgen-<id>/src/ L3 源码
brief: 本局 brief judge 做上下文提升情感锚/解锁等判断质量缺省空串
model: 可选注入的 LLM 客户端单测用 fake model 注入零网络None _bootstrap.build_cheap_model
新建**独立** M3 实例judge token 不污染生成成本台账 costRmbrichness 是非阻塞 add-on
sources: 可选直接注入的源码文本单测用绕过文件 I/ONone game_id 收集
timeout: judge LLM 调用超时秒数超时 degraded绝不卡死收口
max_src_bytes: judge 的源码上限
Returns:
成功 parse_judge_output 的结构化评分 judgeTokens 观测best-effort
任何失败src /LLM 异常/超时/解析失败 _degraded(...)score=None, degraded=True, reason
"""
try:
src_text = sources if sources is not None else _collect_sources(game_id, max_bytes=max_src_bytes)
if not src_text or not src_text.strip():
return _degraded("产物 src 为空/读不到,跳过 LLM 评分")
# 惰性 importagentscope / _bootstrap 较重,且让上面的 parse_judge_output 等纯函数能在无 agentscope 环境单测。
from agentscope.message import SystemMsg, UserMsg
m = model
if m is None:
import _bootstrap # 代理旁路 + key 注入由 _bootstrap.build_cheap_model 内部处理
m = _bootstrap.build_cheap_model(max_tokens=_JUDGE_MAX_TOKENS)
sys_msg = SystemMsg(name="system", content=_JUDGE_SYSTEM)
user_msg = UserMsg(name="user", content=_build_judge_user(src_text, brief))
# 硬超时包裹真模型调用judge 卡住绝不能拖死收口(非阻塞铁律)。
resp = await asyncio.wait_for(m([sys_msg, user_msg]), timeout=timeout)
result = parse_judge_output(_extract_text(resp))
# best-effort 附 judge token外部 LLM 调用可观测与生成成本台账隔离——judge 用独立 model 实例)。
try:
ti, to = m.usage_sum()
result["judgeTokens"] = {"in": ti, "out": to, "total": ti + to}
except Exception: # noqa: BLE001 观测字段失败不影响评分主体
pass
return result
except (asyncio.TimeoutError, TimeoutError):
return _degraded(f"judge LLM 超时(>{timeout}s")
except Exception as e: # noqa: BLE001 非阻塞铁律:任何异常(网络/import/解析)都降级,绝不抛断生成主链
return _degraded(f"judge 异常:{type(e).__name__}: {e}")
if __name__ == "__main__":
# 便捷自跑python cheap-worker/cheap_verify.py <gameId> ["brief"] —— 对已有产物真跑一次评分。
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
gid = sys.argv[1] if len(sys.argv) > 1 else "cheap-smoke1"
bf = sys.argv[2] if len(sys.argv) > 2 else ""
out = asyncio.run(verify_richness(gid, brief=bf))
print(json.dumps(out, ensure_ascii=False, indent=2))

View File

@ -110,6 +110,48 @@ def test_aggregate_unconverged_total_surfaced():
assert r["overallMeets"] is True and r["unconvergedTotal"] == 3 and r["sampleTotal"] == 18
# ───────────────────────── 丰富度分布(U-B1 · additive · 与达标正交)─────────────────────────
def _pr(n, score):
"""n 款过九门 + richness 评分 score(非降级)。"""
return [{"passed": True, "finished": True,
"richness": {"score": score, "max": 8, "degraded": False}} for _ in range(n)]
def _pd(n):
"""n 款过九门 + richness 降级(LLM 评分失败/超时,score=None)。"""
return [{"passed": True, "finished": True,
"richness": {"score": None, "max": 8, "degraded": True}} for _ in range(n)]
def test_richness_dist_basic():
"""丰富度分布:非降级款收集 score 算均分/分布,降级款单列计数(纯报告)。"""
d = B.richness_dist(_pr(2, 6) + _pr(1, 8) + _pd(1))
assert d["scoredCount"] == 3 and d["degradedCount"] == 1
assert d["meanScore"] == round((6 + 6 + 8) / 3, 2) and d["scores"] == [6, 6, 8] and d["max"] == 8
def test_richness_dist_all_degraded_or_missing():
"""全降级 / 无 richness 字段(对照路/老产物)→ meanScore=None、scoredCount=0,不报错。"""
d = B.richness_dist(_pd(2) + _p(1)) # _p 无 richness 字段
assert d["scoredCount"] == 0 and d["meanScore"] is None and d["degradedCount"] == 2
def test_aggregate_richness_additive_does_not_change_meets():
"""红线:richness 是 additive 报告 —— 加 richness 后 overallMeets / perGenre 达标判定与无 richness 时完全一致。"""
bare = {"a": _p(4) + _f(1)} # 无 richness
rich = {"a": _pr(4, 7) + _f(1)} # 4 过门款带 richness=7、1 质量挂
rb = B.aggregate(bare, ["a"])
rr = B.aggregate(rich, ["a"])
# 达标判定完全不受 richness 影响(判据只看 verdict.pass,与 richness 正交)
assert rr["overallMeets"] == rb["overallMeets"] is True
assert rr["perGenre"]["a"]["passRate"] == rb["perGenre"]["a"]["passRate"] == 0.8
assert rr["perGenre"]["a"]["meets"] == rb["perGenre"]["a"]["meets"] is True
# richness 分布 additive 出现,不混进达标判据
assert rr["richnessByGenre"]["a"]["meanScore"] == 7.0
assert rb["richnessByGenre"]["a"]["scoredCount"] == 0 # 无 richness 款 → 空分布,不报错
# ───────────────────────── 并发上限 / 零 LLM ─────────────────────────
def test_clamp_conc_upper_cap():

View File

@ -0,0 +1,172 @@
"""test_cheap_trace.py — C1b cheap-worker trace 落盘单测TDD先红后绿
验证 cheap-worker 生成轨迹落盘
1. make_jsonl_sink + TraceAdapter emit 事件 trace.jsonl 落盘
2. 每行是合法 JSON
3. 五字段traceId / step / cost / verdict / timestamp齐全
4. sink 写失败 只告警不抛非阻塞铁律
5. cheap_studio.run_studio 通过 Tier2TraceMiddleware(sink=...) 接线后
产物目录 games/amgen-<id>/trace.jsonl 存在可选 e2e此处为集成桩
复用 tier2 observability/trace.py JsonlFileSink / make_jsonl_sink / TraceAdapter
不另造 sink最小改动原则cheap-worker 已通过 _bootstrap tier2/gen-worker 加进 sys.path
跑法 agentscope 依赖系统 Python 即可
cheap-worker/.venv/bin/python cheap-worker/tests/test_cheap_trace.py
"""
import sys
import os
import json
import tempfile
from pathlib import Path
# sys.path把 cheap-worker/ 和 tier2/gen-worker/ 加进去
# tier2/gen-worker 含 observability 包cheap-worker 含 cheap_studio 等)
_HERE = os.path.dirname(os.path.abspath(__file__))
_CW = os.path.dirname(_HERE) # cheap-worker/
_REPO_ROOT = os.path.dirname(_CW) # repo root
_GEN_WORKER = os.path.join(_REPO_ROOT, "tier2", "gen-worker")
for _p in (_CW, _GEN_WORKER):
if _p not in sys.path:
sys.path.insert(0, _p)
# C1b 复用 tier2 trace 基础件cheap-worker 不另造 sink
from observability.trace import ( # noqa: E402
JsonlFileSink, make_jsonl_sink, TraceAdapter,
)
# ── 测试一:核心落盘 + 五字段完整性happy path────────────────────────────────
def test_cheap_trace_creates_file_and_five_fields():
"""emit 三条桩事件 → trace.jsonl 落盘;每行合法 JSON五字段齐内容正确。"""
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir) / "trace.jsonl"
adapter = TraceAdapter(
trace_id="c1btrace-test",
sink=make_jsonl_sink(out),
)
# 便宜档生成中会产生的典型事件形态dict 形式,无需真 AgentScope 环境)
fake_events = [
# ReplyStartEvent一轮 ReAct 回复开始
{"type": "ReplyStartEvent", "created_at": "2026-06-30T10:00:00",
"reply_id": "r1", "name": "cheap-writer"},
# ModelCallEndEvent模型调用结束带 token 用量
{"type": "ModelCallEndEvent", "created_at": "2026-06-30T10:00:01",
"input_tokens": 200, "output_tokens": 80},
# ToolResultEndEvent工具调用结果带 verdict
{"type": "ToolResultEndEvent", "created_at": "2026-06-30T10:00:02",
"state": "success"},
]
for e in fake_events:
adapter.ingest(e)
# ① 文件已落盘
assert out.exists(), f"trace.jsonl 应已落盘,但不存在:{out}"
lines = out.read_text(encoding="utf-8").strip().split("\n")
# ② 三条事件对应三行
assert len(lines) == 3, f"应有 3 行,实际 {len(lines)} 行:\n" + "\n".join(lines)
# ③ 每行合法 JSON + 五字段齐全
CORE_FIELDS = {"traceId", "step", "cost", "verdict", "timestamp"}
for i, line in enumerate(lines):
obj = json.loads(line) # 若非合法 JSON 此处抛 JSONDecodeError
missing = CORE_FIELDS - set(obj.keys())
assert not missing, f"{i} 行缺五字段 {missing}{line}"
assert obj["traceId"] == "c1btrace-test", (
f"{i} 行 traceId 错(期望 c1btrace-test实际 {obj['traceId']}")
assert obj["step"] == i, (
f"{i} 行 step 错(期望 {i},实际 {obj['step']}")
# ④ ModelCallEnd 步 cost.tokens 正确
second = json.loads(lines[1])
assert second["cost"] is not None, "ModelCallEnd 步 cost 应非空"
assert second["cost"]["tokens"]["in"] == 200, "input_tokens 应为 200"
assert second["cost"]["tokens"]["out"] == 80, "output_tokens 应为 80"
# ⑤ ToolResultEnd 步 verdict=success
third = json.loads(lines[2])
assert third["verdict"] == "success", (
f"ToolResultEnd 步 verdict 应为 success实际 {third['verdict']}")
print(f" [ok] traceId={json.loads(lines[0])['traceId']}, {len(lines)} 行, 五字段齐")
# ── 测试二:嵌套目录自动创建 ─────────────────────────────────────────────────────
def test_cheap_trace_auto_creates_dir():
"""游戏目录若不存在sink 应自动创建并写入amgen-<id>/ 由 scaffold 建,但 best-effort 时序容错)。"""
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir) / "subdir" / "amgen-c1b" / "trace.jsonl"
sink = make_jsonl_sink(out)
step = {
"traceId": "c1b-auto", "step": 0, "cost": None,
"verdict": None, "timestamp": "2026-06-30T00:00:00",
"ext": {"raw": {"event": "ReplyStartEvent"}},
}
sink(step)
assert out.exists(), f"嵌套目录下 trace.jsonl 应自动创建:{out}"
obj = json.loads(out.read_text(encoding="utf-8").strip())
assert obj["traceId"] == "c1b-auto"
print(f" [ok] auto-mkdir: {out}")
# ── 测试三:写失败只告警不抛(非阻塞铁律)────────────────────────────────────────
def test_cheap_trace_write_fail_no_raise():
"""sink 写失败(不可写路径)→ 只 print 告警,绝不抛,不阻断调用方(非阻塞铁律)。"""
bad = Path("/proc/nonexistent_cheap_trace_test/trace.jsonl")
sink = JsonlFileSink(bad)
try:
sink({"traceId": "t-fail", "step": 0, "cost": None, "verdict": None,
"timestamp": "", "ext": {"raw": {"event": "X"}}})
print(" [ok] 写失败只告警不抛")
except Exception as e:
raise AssertionError(f"sink 写失败时不应抛异常,但抛了:{e}") from e
# ── 测试四sink 内部抛 → adapter.ingest best-effort 不传播(非阻塞)──────────────
def test_cheap_trace_adapter_sink_fail_no_raise():
"""adapter 内层 sink 崩溃 → adapter.ingest 只记 dropped不向外抛non-blocking 铁律)。"""
def _bad_sink(step):
raise IOError("模拟磁盘满写失败")
adapter = TraceAdapter(trace_id="t-sink-fail", sink=_bad_sink)
# 正常 emit 一条sink 内部崩溃)
result = adapter.ingest(
{"type": "ReplyStartEvent", "created_at": "2026-06-30T10:00:00", "reply_id": "r1"}
)
# ingest 应返回 TraceStep映射成功只是 sink 写失败记 dropped
assert result is not None or adapter.dropped > 0, (
"sink 崩溃时要么返回 stepsink 失败只计 dropped要么 dropped>0")
print(f" [ok] sink 崩溃 → dropped={adapter.dropped}adapter.ingest 不抛")
# ── 入口 ────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
tests = [
("test_cheap_trace_creates_file_and_five_fields",
test_cheap_trace_creates_file_and_five_fields),
("test_cheap_trace_auto_creates_dir",
test_cheap_trace_auto_creates_dir),
("test_cheap_trace_write_fail_no_raise",
test_cheap_trace_write_fail_no_raise),
("test_cheap_trace_adapter_sink_fail_no_raise",
test_cheap_trace_adapter_sink_fail_no_raise),
]
passed = failed = 0
for name, fn in tests:
try:
print(f"[RUN] {name}")
fn()
print(f"[PASS] {name}")
passed += 1
except Exception as exc:
import traceback
print(f"[FAIL] {name}: {exc}")
traceback.print_exc()
failed += 1
print(f"\n结果:{passed} 通过 / {failed} 失败")
import sys as _sys
_sys.exit(0 if failed == 0 else 1)

View File

@ -0,0 +1,165 @@
"""
test_cheap_verify.py U-A2 便宜档丰富度LLM 验证 agent 单测
两层
parse_judge_output 纯函数解析 LLM 输出 结构化评分 + degraded 兜底 agentscope零网络
plain python 即可跑cheap-worker/.venv/bin/python cheap-worker/tests/test_cheap_verify.py
verify_richness 非阻塞契约fake model 注入零真网络验证LLM 失败/超时/解析失败/ src degraded
绝不抛绝不阻断 agentscope.messagevenv 缺则自动跳过
cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_verify.py -q
"""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_verify # noqa: E402
try:
import agentscope.message # noqa: E402,F401
_HAS_AS = True
except Exception: # noqa: BLE001 无 agentscope 时 ② 类测试跳过(纯函数 ① 不受影响)
_HAS_AS = False
def _full_checks(hits):
"""据布尔列表造 8 条 checksname 用 cheap_verify 的标准名)。"""
return [{"name": n, "hit": h, "why": "理由"} for (n, _m), h in zip(cheap_verify.RICHNESS_CHECKLIST, hits)]
# ───────────────────────── ① parse_judge_output 纯函数(无 agentscope/无网络)─────────────────────────
def test_parse_valid_full():
"""合法 8 条 JSON → 命中数正确、非 degraded、hits/notes 落位。"""
text = json.dumps({"checks": _full_checks([True, True, False, True, False, False, False, True]),
"notes": "还行"}, ensure_ascii=False)
r = cheap_verify.parse_judge_output(text)
assert r["degraded"] is False
assert r["score"] == 4 and r["max"] == 8
assert len(r["hits"]) == 8 and r["hits"][0]["name"] == "即时反馈"
assert r["notes"] == "还行"
def test_parse_fenced_json():
"""markdown 围栏 + 前后赘语 → json_repair 兜得住、正确解析。"""
body = json.dumps({"checks": _full_checks([True] * 8), "notes": ""}, ensure_ascii=False)
text = "这是我的评分:\n```json\n" + body + "\n```\n以上。"
r = cheap_verify.parse_judge_output(text)
assert r["degraded"] is False and r["score"] == 8
def test_parse_garbage_degraded():
"""完全非 JSON → degradedscore=None不抛。"""
r = cheap_verify.parse_judge_output("完全不是 JSON 的一段话")
assert r["degraded"] is True and r["score"] is None and r["max"] == 8
def test_parse_none_and_empty_degraded():
"""None / 空串 → degraded。"""
assert cheap_verify.parse_judge_output(None)["degraded"] is True
assert cheap_verify.parse_judge_output("")["degraded"] is True
assert cheap_verify.parse_judge_output(" ")["degraded"] is True
def test_parse_non_dict_degraded():
"""JSON 数组(非对象)→ degraded。"""
assert cheap_verify.parse_judge_output("[1,2,3]")["degraded"] is True
def test_parse_missing_checks_degraded():
"""有 JSON 对象但缺 checks 数组 → degraded不静默判 0 分)。"""
assert cheap_verify.parse_judge_output(json.dumps({"notes": "x"}))["degraded"] is True
assert cheap_verify.parse_judge_output(json.dumps({"checks": []}))["degraded"] is True
def test_parse_coerce_and_positional():
"""hit 用字符串/数字 + name 对不上时按位置兜底(容忍模型改名)。"""
checks = [{"hit": "true", "why": "a"}, {"hit": 1}, {"hit": ""}] + [{"hit": False}] * 5
r = cheap_verify.parse_judge_output(json.dumps({"checks": checks}))
assert r["degraded"] is False
assert r["score"] == 2 # 前两条 "true"/1 命中,第三条「否」不命中,其余 False
# ───────────────────────── ② verify_richness 非阻塞契约fake model零真网络─────────────────────────
class _FakeResp:
"""mock ChatResponsecontent 用 dict 文本块_extract_text 兼容 dict 块)。"""
def __init__(self, text):
self.content = [{"type": "text", "text": text}]
class _FakeModel:
"""async 可调用 mock LLM返回固定文本 / 抛异常 / 睡眠测超时。usage_sum 仿 RecordingModel。"""
def __init__(self, text="", raise_exc=None, sleep=0.0):
self._text, self._raise, self._sleep = text, raise_exc, sleep
async def __call__(self, messages, **kw):
if self._sleep:
await asyncio.sleep(self._sleep)
if self._raise:
raise self._raise
return _FakeResp(self._text)
def usage_sum(self):
return (11, 22)
_SRC = "// game-logic.js\nfunction createGame(){ /* 进货 库存 解锁 playSfx 飘字 */ }"
def _skip_no_as(name):
print(f" SKIP {name}(无 agentscope")
def test_verify_happy_path():
"""fake model 返回合法 JSON → 结构化评分 + judgeTokens非 degraded。"""
if not _HAS_AS:
return _skip_no_as("test_verify_happy_path")
body = json.dumps({"checks": _full_checks([True] * 5 + [False] * 3), "notes": "不错"}, ensure_ascii=False)
r = asyncio.run(cheap_verify.verify_richness("x", sources=_SRC, model=_FakeModel(text=body)))
assert r["degraded"] is False and r["score"] == 5
assert r["judgeTokens"]["total"] == 33
def test_verify_model_raises_degraded():
"""LLM 抛异常 → degraded绝不抛、绝不阻断"""
if not _HAS_AS:
return _skip_no_as("test_verify_model_raises_degraded")
r = asyncio.run(cheap_verify.verify_richness("x", sources=_SRC, model=_FakeModel(raise_exc=RuntimeError("boom"))))
assert r["degraded"] is True and r["score"] is None
def test_verify_timeout_degraded():
"""LLM 超时 → degraded不卡死收口"""
if not _HAS_AS:
return _skip_no_as("test_verify_timeout_degraded")
r = asyncio.run(cheap_verify.verify_richness("x", sources=_SRC,
model=_FakeModel(text="{}", sleep=0.3), timeout=0.05))
assert r["degraded"] is True
def test_verify_empty_sources_skips_llm():
"""空 src → 不调 LLM 直接 degradedmodel 给个会抛的,证明它没被调用)。"""
if not _HAS_AS:
return _skip_no_as("test_verify_empty_sources_skips_llm")
r = asyncio.run(cheap_verify.verify_richness("x", sources=" ",
model=_FakeModel(raise_exc=RuntimeError("不该被调用"))))
assert r["degraded"] is True
assert "跳过" in r.get("reason", "") # 走的是「空 src 跳过」路径,而非 LLM 抛异常路径
if __name__ == "__main__":
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
_failed = 0
for _fn in _fns:
try:
_fn()
print(f" PASS {_fn.__name__}")
except AssertionError as e:
_failed += 1
print(f" FAIL {_fn.__name__}: {e}")
print(f"\n{len(_fns) - _failed}/{len(_fns)} passed")
sys.exit(1 if _failed else 0)

View File

@ -73,6 +73,118 @@ def test_game_id_injected():
assert "⟦G⟧" not in p
import os
import tempfile
# ── C2a热取加载器不变量 + 回落测试 ──────────────────────────────────────────────
def test_hotload_invariant_equals_inline():
"""不变量:热取路输出 == 内置回落路输出(同 game_id/scaffold_desc逐字节一致。
没人改 .md没设 CHEAP_PROMPTS_DIR build_system_prompt 输出必须逐字节等于
改造前直接用 _SYSTEM_PROMPT.replace的结果本相红线
"""
import cheap_roles as _r
game_id = "inv-test-abc"
scaffold_desc = "测试脚手架描述"
# 热取路(默认:读 contracts/prompts/04-config/cheap-system.md
_r._cr_reload_cache()
hot = build_system_prompt(game_id, scaffold_desc)
# 内置回落路:直接用 _SYSTEM_PROMPT
g = f"game-runtime/games/amgen-{game_id}"
inline = (
_r._SYSTEM_PROMPT
.replace("⟦SCAFFOLD_DESC⟧", scaffold_desc)
.replace("⟦G⟧", g)
)
assert hot == inline, (
f"热取路与内置路输出不一致(前 100 字符 diff\n"
f" hot : {hot[:100]!r}\n"
f" inline: {inline[:100]!r}"
)
def test_hotload_default_scaffold_desc():
"""scaffold_desc=None 时,热取路与内置路同样使用 _DEFAULT_SCAFFOLD_DESC。"""
import cheap_roles as _r
_r._cr_reload_cache()
hot = build_system_prompt("inv-default")
g = "game-runtime/games/amgen-inv-default"
inline = (
_r._SYSTEM_PROMPT
.replace("⟦SCAFFOLD_DESC⟧", _r._DEFAULT_SCAFFOLD_DESC)
.replace("⟦G⟧", g)
)
assert hot == inline, "scaffold_desc=None 时热取路与内置路输出不一致"
def test_fallback_on_empty_prompts_dir():
"""CHEAP_PROMPTS_DIR 指向空目录(无 registry→ 回落内置 _SYSTEM_PROMPT不抛。"""
import cheap_roles as _r
old_env = os.environ.get("CHEAP_PROMPTS_DIR")
with tempfile.TemporaryDirectory() as d:
os.environ["CHEAP_PROMPTS_DIR"] = d
_r._cr_reload_cache()
try:
result = build_system_prompt("fallback-empty")
# 应与内置替换结果一致
g = "game-runtime/games/amgen-fallback-empty"
expected = (
_r._SYSTEM_PROMPT
.replace("⟦SCAFFOLD_DESC⟧", _r._DEFAULT_SCAFFOLD_DESC)
.replace("⟦G⟧", g)
)
assert result == expected, "空目录回落后输出与内置不一致"
finally:
if old_env is None:
os.environ.pop("CHEAP_PROMPTS_DIR", None)
else:
os.environ["CHEAP_PROMPTS_DIR"] = old_env
_r._cr_reload_cache() # 恢复默认路径缓存
def test_fallback_on_missing_md_file():
"""registry 有该 id 但 .md 文件不存在 → 回落内置 _SYSTEM_PROMPT不抛。"""
import cheap_roles as _r
old_env = os.environ.get("CHEAP_PROMPTS_DIR")
with tempfile.TemporaryDirectory() as d:
# 在临时目录里建一个 registry.yaml指向不存在的 .md
import pathlib
reg = pathlib.Path(d) / "registry.yaml"
reg.write_text(
"version: 1\nprompts:\n"
" - id: config.cheap-system\n"
" version: 1.0.0\n"
" file: 04-config/cheap-system.md\n",
encoding="utf-8",
)
# .md 文件不创建 → _cr_read_body 返回 None → 回落内置
os.environ["CHEAP_PROMPTS_DIR"] = d
_r._cr_reload_cache()
try:
result = build_system_prompt("fallback-no-md")
g = "game-runtime/games/amgen-fallback-no-md"
expected = (
_r._SYSTEM_PROMPT
.replace("⟦SCAFFOLD_DESC⟧", _r._DEFAULT_SCAFFOLD_DESC)
.replace("⟦G⟧", g)
)
assert result == expected, ".md 不存在时回落输出与内置不一致"
finally:
if old_env is None:
os.environ.pop("CHEAP_PROMPTS_DIR", None)
else:
os.environ["CHEAP_PROMPTS_DIR"] = old_env
_r._cr_reload_cache()
if __name__ == "__main__":
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
_failed = 0

View File

@ -0,0 +1,60 @@
---
id: config.cheap-system
version: 1.0.0
---
你是 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/h390×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 教你「设计什么才好玩」——配对用:先想清好玩、再写正确。
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
能跑 ≠ 好玩。动手写码前,先据 brief 在心里(或 game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现:
- **核心循环**(一句话:玩家做什么 → 得什么即时反馈 → 怎么变强);
- **资源环**(经营/放置类必含):「进货 → 库存 → 售卖收钱 → 缺货补货」的软币循环,制造「赚→进→卖→再赚」的张力;
- **34 级解锁阶梯**:攒够阈值解锁新商品/区域/能力,任意时刻都露出「下一个锁」;
- **数值成长**:产出/成本随级上升、略带滚雪球感,别平淡线性;
- **音效清单**:收钱「叮」/ 升级欢呼 / 解锁号角(经 plugins.audioMusic宁可程序化也别没有反馈音
定完过一遍 sim-business §10 的 **8 条好玩自检**(①即时反馈 ②可见成长 ③下一个解锁 ④30 秒内首次升级/解锁 ⑤数值滚雪球 ⑥情感锚〔萌角色/拥有物〕 ⑦放置回归惊喜 ⑧音反馈)——命中越多越好玩,命中 ≤3 ≈ 能玩但无趣。
**8 条自检对所有品类通用**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式。
【MVP-first 铁律(钉死·关乎你能不能收敛,别一稿堆满)】
设计太满 → 你实现负担过重 → read/write 反复跳、跑不收敛(实测:满配设计循环截停、精简设计 9 步收敛)。首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 34 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版。**先出能玩的核心,再谈丰富。**
【步骤】
A 读手册1+5+ 读你的起点 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)。**#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()+必须 _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.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) 这种位置参形态。
- **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不在 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。插件调用已自动记你只补游戏语义事件。
- **输入契约(钉死·根治"启动不了"**:输入订阅已由 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本 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。**

View File

@ -115,4 +115,15 @@ prompts:
owner: WS2
file: 09-tier2-richgame/player-system.md
desc: L3 视觉软检玩家 agent看截图+语义 state 判富游戏体验;只评分绝不当门;带参 input.persona
# ── cheap-worker 便宜档 A-model 生成线 prompt04-configrange=cheap-worker/cheap_roles.py──────────
# 消费方 = cheap_roles.py 经 _load_system_prompt() 运行时读本段 file 指向的正文;
# 读不到/脏/缺文件 → 回落 cheap_roles._SYSTEM_PROMPTbest-effort绝不中断生成
# 首版正文 = _SYSTEM_PROMPT 内置原文逐字节对齐(含 ⟦G⟧/⟦SCAFFOLD_DESC⟧ 占位符),默认行为字节不变;
# 调一条 prompt = 改 04-config/cheap-system.md 正文 + 升 version下次生成自动生效。
- id: config.cheap-system
version: 1.0.0
stage: "04-config"
owner: WS2
file: 04-config/cheap-system.md
desc: 便宜档 LittleJS A-model 生成 agent system prompt轻量 AI 参与档,入口契约+插件+红线+输入契约+完成判据;运行时热取,回落 cheap_roles._SYSTEM_PROMPT
# 其余 prompt 待各工位 Day-0 抽取迁入Dify 节点/OpenGame 内嵌 prompt → 本 Registry

View File

@ -78,9 +78,23 @@ export function buildSystemPrompt(id) {
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 教你设计什么才好玩配对用:先想清好玩再写正确
先设计后写码:决定这游戏好不好玩(关键,别跳过)
能跑 好玩动手写码前,先据 brief 在心里( game-logic.js 顶部一小段注释里)定一份**轻量玩法设计**,再实现:
- **核心循环**(一句话:玩家做什么 得什么即时反馈 怎么变强);
- **资源环**(经营/放置类必含):进货 库存 售卖收钱 缺货补货的软币循环,制造再赚的张力;
- **34 级解锁阶梯**:攒够阈值解锁新商品/区域/能力,任意时刻都露出下一个锁;
- **数值成长**:产出/成本随级上升略带滚雪球感,别平淡线性;
- **音效清单**:收钱/ 升级欢呼 / 解锁号角( plugins.audioMusic;宁可程序化也别没有反馈音)
定完过一遍 sim-business §10 **8 条好玩自检**(①即时反馈 ②可见成长 ③下一个解锁 ④30 秒内首次升级/解锁 ⑤数值滚雪球 ⑥情感锚萌角色/拥有物 ⑦放置回归惊喜 ⑧音反馈)命中越多越好玩,命中 3 能玩但无趣
**8 条自检对所有品类通用**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式
MVP-first 铁律(钉死·关乎你能不能收敛,别一稿堆满)
设计太满 你实现负担过重 read/write 反复跳跑不收敛(实测:满配设计循环截停精简设计 9 步收敛)首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 34 级解锁 + 基础数值/音效,**商品 3 种起步****离线收益 / 看广告位 / 雇员 / 多档 BGM / hitstop / 6+ 商品 一律标后续·MVP 不做**别塞进首版**先出能玩的核心,再谈丰富**
步骤
A 读手册(1)+ 读你的起点 game-logic.js(4) B brief 定玩法(核心循环 / 胜负 / 计分 / 数值) C 读要用的插件 api.d.ts D write_file 改写 **game-logic.js**(按需拆 core/render)实现玩法 E check,有错改到 PASS F build,有错改到 PASS G check+build 都绿后调 done
A 读手册(1+5)+ 读你的起点 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 都绿后调 done
红线(check 会拦,违反则 done 被拒)
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出别改名别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())