固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。 将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。 同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
204 lines
12 KiB
Python
204 lines
12 KiB
Python
"""生成 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()))
|