"""生成 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×844,init 后首屏必须直接进入 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 使用同一非空 moveId,pair、runs、matchedCells 均为真实棋格且使用 1-based row/column;matchedCells 是 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()))