games-development-ai/cheap-worker/match3_gold_batch.py
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

204 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

"""生成 fresh Match-3 金标候选;只跑现行生成主链,不预写人工结论。
每款使用独立 gameId串行执行 AgentScope Writer → check → build → stage → smoke。
本批不调用通用 puzzle 验收,后续统一交给 playtest/3 Match-3 专项门和真人浏览器定标。
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import cheap_studio # noqa: E402
from cheap_roles import SCAFFOLD_DESC_BY_TEMPLATE # noqa: E402
from worker import genconfig # noqa: E402
RESULTS_DIR = Path(__file__).resolve().parent / "results"
RESULTS_FILE = RESULTS_DIR / "match3-gold-generation-r3.jsonl"
MANIFEST_FILE = RESULTS_DIR / "match3-gold-batch-manifest-r3.json"
GAME_ROOT = Path(__file__).resolve().parents[1] / "game-runtime" / "games"
TEMPLATE = "_template-puzzle"
MODEL = "MiniMax-M3"
INTERACTION_PROFILE_ID = "match3.orthogonal-swap-v1"
MAX_TOKENS = genconfig.get("model", "max_tokens", 32000)
HARD_STOP_RMB = 75.0
COMMON_REQUIREMENTS = """
玩法必须是经典相邻交换三消解谜,不得改成点选找目标、华容道或点击消除。使用竖屏 390×844init 后首屏必须直接进入 8×8 棋盘,禁止创建或启动 menu。玩家先点一个棋子再点上下左右相邻棋子有效交换形成至少三个同类连续棋子随后消除、下落、补新棋子并允许连锁无效交换必须换回并显示换回结果盘面不得保留无效交换。每次交换消耗一步共 20 步。本关目标是达到 120 分:首次达到目标后进入并驻留 gameover 成功页20 步耗尽仍未达到目标则进入并驻留 gameover 失败页。两种终态都显示分数、已用步数和重玩入口。
平台集成必须完整ctx.log 只能写诊断,所有业务事实必须通过 ctx.event 写原生 game 事件。先 read_file contracts/play-loop/game-event.schema.json再按 schema 原样实现禁止自创字段。每次有效交换依次写puzzle.move-applied 的 payload 为 { moveId, boardHash:{ before, after } }puzzle.swap-committed 的 payload 只能是 { moveId, pair:[{row,column},{row,column}], boardRevisionBefore, boardRevisionAfter };紧接着 puzzle.match-formed 的 payload 只能是 { moveId, runs:[{axis,cells}], matchedCells, matchedCount }。swap 与 match 使用同一非空 moveIdpair、runs、matchedCells 均为真实棋格且使用 1-based row/columnmatchedCells 是 runs 的去重并集matchedCount 与其长度一致。达到 120 分时写 puzzle.goal-reached { goalId:'score-120', score },再写 puzzle.level-completed { level:1, result:{ score, movesUsed, movesLeft } }。不得伪造事件或在未形成新三连时记录成功ctx.event 返回 false 必须作为错误记录,不能用 ctx.log 冒充成功。
_forensicsView().state() 必须只读返回 phase、result、score、movesLeft、8×8 board 快照和全部棋格中心 targets棋格中心使用逻辑像素坐标。棋盘图标必须清晰可分格心、行列间距和棋盘几何在点击前后保持稳定。使用 runtime 注入能力实现反馈不引入外部网络资源。check 与 build 全绿后立即 finish。
本任务绑定 match3.orthogonal-swap-v1。先 read_file game-runtime/src/plugins/match3-producer-profile/api.d.ts 和 game-runtime/src/plugins/match3-visual-timeline/api.d.ts再从 plugins 解构受保护的 match3ProducerProfile 与 match3VisualTimeline。棋盘必须逐格调用 drawCellShell逐格调用 themeSafeBounds(cell),格心与命中只用其 centerX/centerY主题图标只能画在返回的安全内区选中态只能调用 drawSelectionOverlay。不得自行计算另一套棋盘坐标不得自行绘制高亮、填充、缩放或环形描边。第一次点选只更新 selected除 canonical 白环外整帧不得产生其他像素变化,也不得写任何业务事件。
第二次合法相邻点击必须在同一次 handleTap 同步调用栈内完成盘面提交、初始三连、全部确定性连锁、重力补新、计分和目标/通关判定,并按实际发生顺序写 puzzle.move-applied / puzzle.swap-committed / 每轮 puzzle.match-formed / puzzle.goal-reached / puzzle.level-completed这些业务事实不得延迟。同步结算同时收集标准 0-based 8×8 beforeKinds、swappedKinds、每轮 {runs,matchedCells,afterFallKinds}、finalKinds 与 pendingTerminal并立即调用 match3VisualTimeline.begin(...) 封存视觉事务。handleTap 最前面必须 `if (match3VisualTimeline.isLocked()) return;`,锁定期间不得改 selected、盘面、分数、步数、revision、事件或终局。update(dt) 必须调用 match3VisualTimeline.update(dt)render(g) 必须读取 match3VisualTimeline.view().tiles 绘制交换、匹配停留、消失、下落补位和逐轮连锁,禁止读取已经完成全部补位的业务终盘伪装动画。重玩必须调用 reset()。业务终态可以同步 latch但成功/失败场景只能在 timeline settle 后显示。无效交换必须同步换回业务盘面,同时用 valid:false 事务播放 swap_forward→rollback→settle若耗尽最后一步失败结果仍同步封存但延迟显示。若不遵守生成静态门、真实 Chromium profile preflight 或后续视觉事务门会拒绝。
""".strip()
CANDIDATES = (
{
"gameId": "gold-m3-gem-r3",
"label": "高对比宝石基准",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:深色背景上的六类高对比几何宝石。主题图标全部限制在 capability 返回的安全内区;消除有粒子、音效和分数反馈。",
},
{
"gameId": "gold-m3-candy-r3",
"label": "糖果形状基准",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:六类颜色和轮廓都不同的糖果。糖果主体不得接触 capability 的 selection support背景与棋格保持高对比。",
},
{
"gameId": "gold-m3-porcelain-r3",
"label": "中式器物图标",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:六类中式瓷器、玉佩、铜钱等程序化图标,不使用纯色方块冒充。每类保持稳定轮廓和配色,同类跨棋格外观一致,图标不得越出 capability 的主题安全区。",
},
{
"gameId": "gold-m3-fruit-r3",
"label": "水果轮廓基准",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:六类轮廓明显不同的水果切片,使用稳定程序化形状和高对比配色。同类跨棋格外观一致,不加入障碍、万能块或特殊棋子。",
},
{
"gameId": "gold-m3-rune-r3",
"label": "符文图形基准",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:六类形状和内部刻纹都不同的发光符文,保持静态稳定,不做呼吸、闪烁或漂移动效。同类跨棋格外观逐像素一致,不加入障碍、万能块或特殊棋子。",
},
)
def _batch_manifest() -> dict:
"""完整封存本批合成题面;不受通用用户 brief 的 1000 字符隐私限长影响。"""
return {
"schemaVersion": "Match3GoldGenerationBatch/3",
"template": TEMPLATE,
"model": MODEL,
"candidates": [
{
"gameId": candidate["gameId"],
"label": candidate["label"],
"brief": candidate["brief"],
"briefSha256": hashlib.sha256(candidate["brief"].encode("utf-8")).hexdigest(),
}
for candidate in CANDIDATES
],
}
def _write_batch_manifest() -> None:
"""生成前先写完整 manifest硬失败或中断也能还原模型收到的全部题面。"""
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
MANIFEST_FILE.write_text(
json.dumps(_batch_manifest(), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _load_completed_ids() -> set[str]:
"""只认已落 JSONL 的 gameId失败同样属于金标候选不用同 ID 洗结果。"""
if not RESULTS_FILE.is_file():
return set()
completed = set()
for line in RESULTS_FILE.read_text(encoding="utf-8").splitlines():
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(row.get("gameId"), str):
completed.add(row["gameId"])
return completed
def _prior_cost() -> float:
"""跨次运行累计成本必须计入硬停线,避免断点续跑绕过批次预算。"""
if not RESULTS_FILE.is_file():
return 0.0
total = 0.0
for line in RESULTS_FILE.read_text(encoding="utf-8").splitlines():
try:
total += float(json.loads(line).get("costRmb") or 0.0)
except (json.JSONDecodeError, TypeError, ValueError):
continue
return total
async def _run_candidate(candidate: dict) -> dict:
"""执行单款 generation-only异常也原样入账供后续负金标归因。"""
started = time.time()
game_id = candidate["gameId"]
try:
summary = await cheap_studio.run_studio(
game_id,
candidate["brief"],
run_gates=False,
interaction_profile_id=INTERACTION_PROFILE_ID,
scaffold_template=TEMPLATE,
scaffold_desc=SCAFFOLD_DESC_BY_TEMPLATE[TEMPLATE],
genre="puzzle",
verify_richness_enabled=False,
max_tokens=MAX_TOKENS,
)
except Exception as exc: # noqa: BLE001 - 生成异常本身就是需要保留的样本事实。
return {
"gameId": game_id,
"label": candidate["label"],
"generated": False,
"finished": False,
"staged": False,
"smokeOk": False,
"costRmb": 0.0,
"wallSec": round(time.time() - started, 1),
"error": f"{type(exc).__name__}: {exc}",
}
return {
"gameId": game_id,
"label": candidate["label"],
"generated": True,
"finished": bool(summary.get("finished")),
"checkOk": bool((summary.get("check") or {}).get("ok")),
"buildOk": bool((summary.get("build") or {}).get("ok")),
"staged": bool(summary.get("staged")),
"smokeOk": bool((summary.get("smoke") or {}).get("ok")),
"attempts": summary.get("attempts"),
"costRmb": float(summary.get("costRmb") or 0.0),
"tokens": summary.get("tokens"),
"wallSec": round(time.time() - started, 1),
"sourceDir": f"game-runtime/games/amgen-{game_id}",
"stagedDir": f"game-runtime/games/_wg1-gen/{game_id}",
"runSummary": f"game-runtime/games/amgen-{game_id}/evidence/run-summary.json",
}
async def main() -> int:
"""按声明顺序串行生成前 N 款默认五款CLI 可先用 1 做冒烟。"""
requested = int(sys.argv[1]) if len(sys.argv) > 1 else len(CANDIDATES)
if requested < 1 or requested > len(CANDIDATES):
raise ValueError(f"候选数量必须是 1..{len(CANDIDATES)}")
_write_batch_manifest()
completed = _load_completed_ids()
cumulative = _prior_cost()
print(f">>> fresh Match-3 生成批:目标 {requested} 款,已花 ¥{cumulative:.4f},硬停 ¥{HARD_STOP_RMB:.0f}")
for candidate in CANDIDATES[:requested]:
if candidate["gameId"] in completed:
print(f">>> 跳过已落盘 {candidate['gameId']},同 ID 不重跑")
continue
row = await _run_candidate(candidate)
with RESULTS_FILE.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
cumulative += float(row.get("costRmb") or 0.0)
print(f">>> {candidate['gameId']} finished={row.get('finished')} staged={row.get('staged')} "
f"smoke={row.get('smokeOk')} cost=¥{row.get('costRmb')} wall={row.get('wallSec')}s")
if cumulative > HARD_STOP_RMB:
print(f">>> 累计成本 ¥{cumulative:.4f} 超过硬停线,停止剩余候选")
return 2
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))