From 5bcaf6ac1e7491b6ce6260eaeb558b317786af1f Mon Sep 17 00:00:00 2001 From: lili Date: Fri, 10 Jul 2026 07:07:08 -0700 Subject: [PATCH] =?UTF-8?q?feat(cheap-gen):=20W-AXIS-V2=20=E6=B3=A21?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=B5=8B=E8=AF=95=20agent=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E5=8C=96+=E4=B8=89=E8=B7=AF=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E7=BC=96=E6=8E=92=E5=99=A8+=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opus 执行位交付、主会话四步验收过(测试亲跑/红线 diff 亲读/硬证亲眼): - playtest.cdp.cjs 生产版(§3 八件:DPR=1 playtest/1 坐标协议/坐标尺/落点回显/ 同点硬提示/反早退/预算 14→24 随进展扩/fail 二掷显式 seed+清存档/图像自检 fail-closed exit=3)+ serve-and-playtest.sh;证据落 evidence/playtest/roll-N/ - cheap_verify:project_floor 四门投影(预筛权威唯一产地,绝不复用 harness 原生 verdict.pass)+ run_playtest(子进程/超时/记账/playtest.json 真相层/端口派生避 Chrome unsafe 5060/5061)+ run_acceptance 编排器(v2=floor∧测试员阻断/ shadow=旧口径+shadowV2Accepted 对照/v1=旧判定器;修复反馈只引现象段) - 三路接线:cheap_studio/cheap_service_driver/cheap_modify 两档全改调编排器, create 与 modify 单一验收标准 - §4 字段迁移:run-summary acceptanceVersion+playtest+floor 段、trace.playtest additive(firstPlay 三字段镜像 ReadinessScorer 口径)、批账 floorPass+accepted+ acceptanceVersion(verdictPass 仅对照);result_out 三级取值(floor→verdictFull 投影→verdict.pass 回落)、gate_judge 续修触发器切四门、xtheme 同批;判定语义单轨 - §5 Java 读侧:ReadinessScorer 可玩性/firstPlay 优先读 trace.playtest, GenMetrics 归因加 playtest/tester_degraded(向后兼容,未编译验证——本机无后端 构建环境,待 mini-desktop 构建窗口) - genconfig/generation.yaml:acceptance.mode 三态(波1 灰度默认 shadow)+ playtest 旋钮(steps/二掷/超时/成本上限);judge 段未动 - 单测 32 项新增(二掷/图像 fail-closed/mode 三态/投影/现象分离/退出码/unsafe 端口) 验收:pytest 512 绿 + node 44/44(主会话亲跑);10 局考卷生产件重跑判对 9/9 (真坏 3/3 零放行、假阴 0/6,heritage-r4/puzzle-r1 二掷翻案、sb2 假超时根因= Chrome unsafe 端口已修);Service E2E(w1v2-e2e1 shadow→v2 一发收敛)与 modify 回归(w1v2-mod1 ROUND_MS 60000→45000,src 与局内截图双证)真浏览器硬证; 总成本 ¥2.3。已知余项:真续修回喂环仅单测覆盖(波2 n=3 冒烟自然覆盖)。 Co-Authored-By: Claude Fable 5 --- cheap-worker/cheap_modify.py | 35 +- cheap-worker/cheap_service_driver.py | 20 +- cheap-worker/cheap_studio.py | 25 +- cheap-worker/cheap_verify.py | 376 ++++++++++++++++++ cheap-worker/hard_genre_batch.py | 50 ++- cheap-worker/hard_genre_xtheme.py | 22 +- cheap-worker/result_out.py | 42 +- .../tests/test_a11_m3_deterministic.py | 9 +- cheap-worker/tests/test_a11_m4_regenerate.py | 3 + cheap-worker/tests/test_acceptance_v2.py | 334 ++++++++++++++++ .../tests/test_cheap_service_driver.py | 23 +- .../aigc/framework/metrics/GenMetrics.java | 28 +- .../aigc/service/task/ReadinessScorer.java | 25 +- .../games/_wg1-gen/_shared/playtest.cdp.cjs | 374 +++++++++++++++++ .../_wg1-gen/_shared/serve-and-playtest.sh | 70 ++++ tier2/config/generation.yaml | 17 + tier2/gen-worker/worker/gate_judge.py | 22 +- tier2/gen-worker/worker/genconfig.py | 17 + 18 files changed, 1411 insertions(+), 81 deletions(-) create mode 100644 cheap-worker/tests/test_acceptance_v2.py create mode 100644 game-runtime/games/_wg1-gen/_shared/playtest.cdp.cjs create mode 100755 game-runtime/games/_wg1-gen/_shared/serve-and-playtest.sh diff --git a/cheap-worker/cheap_modify.py b/cheap-worker/cheap_modify.py index 6a443ae8..d24355d1 100644 --- a/cheap-worker/cheap_modify.py +++ b/cheap-worker/cheap_modify.py @@ -29,6 +29,19 @@ from pathlib import Path import cheap_assert import cheap_run +import cheap_verify # W-AXIS-V2 波1:modify 两档改调统一验收编排器(create/modify 同标准) + + +def _read_game_brief(game_id: str) -> str: + """读原游戏 brief(evidence/brief.json;modify 验收给测试员上下文——改后仍应是同一款可玩游戏)。缺/坏 → 空串。""" + try: + p = cheap_run.game_dir(game_id) / "evidence" / "brief.json" + if p.is_file(): + d = json.loads(p.read_text(encoding="utf-8")) + return (d.get("brief") if isinstance(d, dict) else "") or "" + except Exception: # noqa: BLE001 brief 读失败不致命,测试员用空 brief 仍可判 broken/hollow + pass + return "" # A11 M4 模块重生成:behavior 在便宜档映射到玩法文件(KTD4)——只重写它,其余文件保持不变。 _BEHAVIOR_TARGET = "game-logic.js" @@ -326,7 +339,13 @@ def execute_deterministic_modify(game_id: str, source_project, modify_patch, cheap_run.ensure_play_spec(game_id, sm.get("state")) # 已存在不覆盖;缺则据 state 形态补 driver(防裸跑假绿) pr = cheap_run.play(game_id, port=port, cdp_port=cdp_port) verdict = pr.get("verdict") - gates_pass = isinstance(verdict, dict) and verdict.get("pass") is True + # W-AXIS-V2 波1:create 与 modify 不允许两套验收标准——确定性改也过统一编排器(四门投影 ∧ 测试员真玩)。 + # modify 同步执行(worker_service 懒绑),用 asyncio.run 跑 async 编排器(与 _default_rewrite 同款); + # brief 取原游戏 brief(改数值行为不变,验证「改后仍是同一款可玩游戏」);测试员端口自动派生(与九门 4320 分段)。 + import asyncio # noqa: PLC0415 + _acc = asyncio.run(cheap_verify.run_acceptance( + {"verdict": verdict}, game_id=game_id, brief=_read_game_brief(game_id), verdict=verdict)) + gates_pass = bool(_acc.get("accepted")) # M5 三结构断言:base→改后 真比对(断言①改动真生效 / 断言②非目标稳)+ 血缘(③ baseVersionId)。 # 断言②对 deterministic 是兜 apply bug(理应只改目标文件,真比对抓"是否顺手碰了非目标")。 @@ -337,11 +356,14 @@ def execute_deterministic_modify(game_id: str, source_project, modify_patch, status = "succeeded" if (gates_pass and a12_pass) else "failed" err = None if status != "succeeded": - err = "九门未通过" if not gates_pass else "三断言未通过:" + json.dumps( + err = "验收未通过(v2:四门∧测试员)" if not gates_pass else "三断言未通过:" + json.dumps( {k: v for k, v in assertions.items() if isinstance(v, dict) and not v.get("pass")}, ensure_ascii=False) - log(f"确定性改重建+九门+三断言完成 game_id={game_id} status={status} gates={gates_pass} a12={a12_pass}") + log(f"确定性改重建+v2验收+三断言完成 game_id={game_id} status={status} gates={gates_pass} a12={a12_pass} " + f"mode={_acc.get('acceptanceVersion')}") return {"status": status, "manifest": manifest, "verdict": verdict, "assertions": assertions, - "stage": "play", "error": err, "smokeOk": sm.get("ok"), "playOk": pr.get("ok")} + "stage": "play", "error": err, "smokeOk": sm.get("ok"), "playOk": pr.get("ok"), + # v2 验收摘要 additive(供 worker_service 组 run-summary / trace): + "acceptance": {k: _acc.get(k) for k in ("acceptanceVersion", "floor", "playtest", "judge", "accepted")}} # ───────────────────────── ④ execute_regenerate_modify(模块重生成 · 改玩法)───────────────────────── @@ -504,7 +526,10 @@ def execute_regenerate_modify(game_id: str, source_project, modify_patch, # (误伤非目标=边界泄漏、目标没变=no-op,即便九门过也判失败)。 verdict_full = run_summary.get("verdictFull") if isinstance(run_summary.get("verdictFull"), dict) else None vb = run_summary.get("verdict") if isinstance(run_summary.get("verdict"), dict) else None - gates_pass = bool(vb and vb.get("pass") is True) + # W-AXIS-V2 波1:_default_rewrite 走 run_studio(run_gates=True),收口已跑统一验收编排器,验收权威 = accepted + # (create/modify 同标准);run_summary 缺 accepted(注入桩/老路)→ 回落四门投影,不回落九门 verdict.pass(退役口径)。 + _acc = run_summary.get("accepted") + gates_pass = bool(_acc) if _acc is not None else cheap_verify.project_floor(verdict_full)["pass"] # 三断言 verdict(统一形态,与 deterministic 路一致):①changed ②非目标稳(含误伤清单)③血缘。 nontarget_changed = sorted(k for k in set(nontarget_before) | set(nontarget_after) if nontarget_before.get(k) != nontarget_after.get(k)) diff --git a/cheap-worker/cheap_service_driver.py b/cheap-worker/cheap_service_driver.py index 5f33ea1d..198a9678 100644 --- a/cheap-worker/cheap_service_driver.py +++ b/cheap-worker/cheap_service_driver.py @@ -412,16 +412,18 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user except Exception as e: # noqa: BLE001 —— richness 非阻塞铁律:绝不影响回调 summary["richness"] = {"score": None, "degraded": True, "reason": f"richness 接线异常:{type(e).__name__}: {e}"} - # ⑪ 玩法地板判定 + ok 语义切换(W-AXIS 波2 · 质量模型 SoT 裁定三):机械九门(_build_summary 的 j.passed) - # 只作「未见明显死」预筛,过筛者交独立多模态档看真玩证据(首帧/局中连拍/局末截图 + game-log + 取证时间线) - # 裁 broken/hollow/off-brief,阻断放行(ok = 预筛 ∧ 判定)。与丰富度(非阻塞观测)是两物:fail-closed, - # 评不出/无证据 → 不放行、标 degraded。apply_gameplay_judge 内建 fail-closed 与顶层兜底、绝不抛, - # additive 写 summary['accepted']/['judge'] 并更新 ['ok']——result_out 据 accepted 落 status。 - summary = await cheap_verify.apply_gameplay_judge(summary, game_id=game_id, brief=brief) + # ⑪ 统一验收编排器(W-AXIS-V2 波1 · 拆着杀):四门投影(A/B/C/D)作预筛权威,过筛者交测试 agent 视觉引导 + # 真玩裁 broken/hollow/off-brief,阻断放行(accepted = floor ∧ 测试员;acceptance.mode 三态 v2/shadow/v1)。 + # verdict 传入做 floor 投影;端口缺省按 game_id 派生(driver 收口可能并发,测试员起服避撞)。 + # run_acceptance 内建 fail-closed 与顶层兜底、绝不抛,additive 写 floor/playtest/judge/acceptanceVersion, + # 更新 ok/accepted——result_out 据 accepted 落 status。 + summary = await cheap_verify.run_acceptance(summary, game_id=game_id, brief=brief, verdict=verdict) _js = summary.get("judge") or {} - print(f"[cheap-driver] game={game_id} 玩法地板判定 ran={_js.get('ran')} blocking={_js.get('blocking')} " - f"verdict={_js.get('verdict')} rejectClasses={_js.get('rejectClasses')} degraded={_js.get('degraded')} " - f"costRmb={_js.get('costRmb')} → accepted={summary.get('accepted')}", flush=True) + _pt = summary.get("playtest") or {} + print(f"[cheap-driver] game={game_id} 验收 v2 mode={summary.get('acceptanceVersion')} " + f"floor={(summary.get('floor') or {}).get('pass')} playtest.accepted={_pt.get('accepted')} " + f"rolls={_pt.get('rollCount')} degraded={_pt.get('degraded')} costRmb={_pt.get('costRmb')} " + f"→ accepted={summary.get('accepted')} ok={summary.get('ok')}", flush=True) print(f"[cheap-driver] game={game_id} 单 POST 结束: ok={summary['ok']} attempts={summary['attempts']} " f"costRmb={summary['costRmb']} wallSec={summary['wallSec']}", flush=True) diff --git a/cheap-worker/cheap_studio.py b/cheap-worker/cheap_studio.py index 03abdf57..379e1c6c 100644 --- a/cheap-worker/cheap_studio.py +++ b/cheap-worker/cheap_studio.py @@ -463,22 +463,19 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens= "reason": f"richness 接线异常:{type(e).__name__}: {e}"} _rec(f"丰富度评分接线异常(已降级,不影响 run):{type(e).__name__}: {e}") - # ── W-AXIS 波2:独立模型玩法地板判定 + ok 语义切换(ok = 预筛 ∧ 判定;质量模型 SoT 裁定三)── - # 机械九门只作「未见明显死」预筛,过筛者交独立多模态档看真玩证据裁 broken/hollow/off-brief,阻断放行。 - # 与丰富度(非阻塞观测)是两物:这里 fail-closed,评不出/无证据 → 不放行、标 degraded。 - # 只在 run_gates(有真 verdict + 首帧/局中/局末截图证据)时接入;对照路(run_gates=False)零改动、不触发判定。 - # apply_gameplay_judge 内建 fail-closed 与顶层兜底、绝不抛,additive 写 summary['accepted']/['judge'] 并更新 ['ok']。 + # ── W-AXIS-V2 波1:统一验收编排器(floor 四门投影 ∧ 测试 agent 真玩;acceptance.mode 三态)── + # 拆着杀:契约无关四门(A/B/C/D 投影)= 预筛权威(取代旧 verdict.pass 九门口径,那随契约退役会坍缩), + # 过筛者交测试 agent 视觉引导真玩裁 broken/hollow/off-brief;mode=v2 阻断、shadow 灰度对照、v1 旧口径。 + # run_acceptance 内建 fail-closed 与顶层兜底、绝不抛,additive 写 summary['floor']/['playtest']/['judge'] + # /['acceptanceVersion'] 并更新 ['ok']/['accepted']。只在 run_gates(有真 verdict)时接入;对照路零改动。 if run_gates and finished: - # 预筛必须显式喂九门口径(裁定一/三):本路 summary.ok=finished 是「模型自称收敛+check/build 绿」, - # 不是机械九门信号——2026-07-09 W3 真跑(w3-verify)暴露:E/G/H 三门挂的局按 finished 当预筛, - # 被判定放行成 accepted=True,违 SoT『ok=预筛∧判定』,已修。verdict 缺/坏 → 预筛按未过(fail-closed)。 - _prefilter = bool((verdict or {}).get("pass")) - summary = await cheap_verify.apply_gameplay_judge(summary, game_id=game_id, brief=brief, - prefilter=_prefilter) + summary = await cheap_verify.run_acceptance(summary, game_id=game_id, brief=brief, verdict=verdict) _js = summary.get("judge") or {} - _rec(f"玩法地板判定 ran={_js.get('ran')} blocking={_js.get('blocking')} verdict={_js.get('verdict')} " - f"rejectClasses={_js.get('rejectClasses')} degraded={_js.get('degraded')} " - f"costRmb={_js.get('costRmb')} → accepted={summary.get('accepted')} ok={summary.get('ok')}") + _pt = summary.get("playtest") or {} + _rec(f"验收 v2 mode={summary.get('acceptanceVersion')} floor={(summary.get('floor') or {}).get('pass')} " + f"playtest.accepted={_pt.get('accepted')} rolls={_pt.get('rollCount')} degraded={_pt.get('degraded')} " + f"costRmb={_pt.get('costRmb')} judge.verdict={_js.get('verdict')} " + f"→ accepted={summary.get('accepted')} ok={summary.get('ok')}") ev_dir = cheap_run.game_dir(game_id) / "evidence" ev_dir.mkdir(parents=True, exist_ok=True) diff --git a/cheap-worker/cheap_verify.py b/cheap-worker/cheap_verify.py index f493f189..c4aae2db 100644 --- a/cheap-worker/cheap_verify.py +++ b/cheap-worker/cheap_verify.py @@ -19,6 +19,7 @@ cheap_verify.py — 便宜档「丰富度」LLM 验证 agent(非阻塞·只报 import asyncio import base64 import json +import subprocess import time from pathlib import Path @@ -956,6 +957,381 @@ async def apply_gameplay_judge(summary: dict, *, game_id: str, brief: str = "", return summary +# ══════════════════════════════════════════════════════════════════════════════ +# 验收 v2:四门投影 + 测试 agent 真玩 + 统一编排器(W-AXIS-V2 波1 · 「拆着杀」) +# ────────────────────────────────────────────────────────────────────────────── +# W-AXIS 三波把验收权威交给独立模型判定,但证据仍由「一份固定代码玩几十种游戏」的取证契约(play-spec / +# tap-targets 驱动器 / _forensicsView)生产——n=5 基线逐局破案坐实这条证据生产线本身就是最大失败源。 +# 本段兑现创始人 2026-07-09「拆着杀」:契约无关的四门(A/B/C/D)降为地板保留,依赖契约的 E/G/H/I/F 与 +# 驱动器退役,验收证据的生产与裁决交测试 agent(playtest.cdp.cjs:视觉引导真玩 + 玩法地板判定合一)。 +# 三路(CLI/Service/modify)共用一个编排器 run_acceptance;acceptance.mode 三态 v2/shadow/v1 控阻断与灰度。 + +# 四门 = 契约无关:读装载标志 / 未捕获异常 / 引擎帧号 / canvas 像素,不需游戏配合任何自报接口, +# 零成本、确定性、抓真死(基线里 boot 空指针正是 A 门拦的)。F_wiring 的期望前缀集源自 play-spec、 +# 随契约退役降观测,不入地板(裁定三 2026-07-10 修订)。 +_FLOOR_GATES = ("A_boot", "B_uncaught", "C_frame", "D_render") + + +def project_floor(verdict) -> dict: + """四门投影(floor 段的**唯一产地**):从 verdict.guards 显式抽取 A_boot/B_uncaught/C_frame/D_render 求 AND。 + + 这是消费层函数,**绝不复用 harness 原生 verdict.pass**——那是九门(driven)/七门(undriven)AND、随驱动器语义, + 拆契约后会静默坍缩;四门投影独立于它,是便宜档预筛的权威取值(裁定一 2026-07-10 修订)。 + 任一门缺失(guards 无该门 dict)或 pass 非 True → floor.pass=False(fail-closed:无四门证据不放行)。 + 返回 {pass: bool, gates: {A,B,C,D: bool}}(短键 A/B/C/D = plan §4 字段协议)。 + """ + guards = (verdict or {}).get("guards") or {} + gates = {} + all_ok = True + for full in _FLOOR_GATES: + short = full.split("_")[0] # A_boot → A + node = guards.get(full) + ok = isinstance(node, dict) and node.get("pass") is True + gates[short] = ok + all_ok = all_ok and ok + return {"pass": all_ok, "gates": gates} + + +def _acceptance_cfg() -> dict: + """读验收 v2 配置(genconfig acceptance.* + playtest.* + judge.*;worker 不可达回落编译默认,保单测)。""" + try: + from worker import genconfig # noqa: PLC0415 + g = genconfig.get + except Exception: # noqa: BLE001 无 worker(纯单测)→ 编译默认 + def g(_area, _key, default): + return default + return { + "mode": str(g("acceptance", "mode", "shadow")), # v2|shadow|v1(波1 灰度窗口默认 shadow) + "model": g("judge", "model", _JUDGE_DEFAULT_MODEL), # 测试员 = 判定档同一配置(§2;现 MiniMax-M3) + "steps_base": int(g("playtest", "steps_base", 14)), # 基础步数预算(§3 第6件) + "steps_max": int(g("playtest", "steps_max", 24)), # 有进展扩展上限 + "second_roll": bool(g("playtest", "second_roll", True)),# fail 二掷确认(§3 第7件) + "timeout_s": float(g("playtest", "timeout_s", 600.0)), # 单掷子进程墙钟超时 + "cost_cap_rmb": float(g("playtest", "cost_cap_rmb", 1.5)), # 单局验收成本上限(含二掷) + "blocking": bool(g("judge", "blocking", True)), # 测试员裁决是否阻断放行 + } + + +def _playtest_script() -> Path: + """serve-and-playtest.sh 路径(与 serve-and-play.sh 同套起服编排,末步换调 playtest.cdp.cjs)。""" + return cheap_run._WG1_DIR / "_shared" / "serve-and-playtest.sh" + + +def _playtest_env() -> dict: + """subprocess env:复用 cheap_run 起服 env(CHROME_BIN + NO_PROXY)+ 注入 NEWAPI_KEY/BASE(测试员连 new-api)。 + + playtest.cdp.cjs 用 Node http 模块直连 new-api、天然绕系统代理;key/base 由本函数从凭据档解析后注入 env。 + """ + env = dict(cheap_run._shell_env()) + try: + import _bootstrap # noqa: PLC0415 代理旁路时序与 key 注入 + _bootstrap.ensure_api_key_env() + from worker import client # noqa: PLC0415 + env["NEWAPI_KEY"] = client.get_api_key() + env["NEWAPI_BASE_URL"] = client.resolve_base_url() + except Exception: # noqa: BLE001 凭据解析失败 → 交 cjs 自身的 env 兜底(仍可能从进程环境拿到) + pass + return env + + +# Chrome unsafe ports(net::ERR_UNSAFE_PORT):派生范围 4998..5097 内被 Chrome 封锁的页面加载端口。 +# 2026-07-10 考卷真跑实锤:hard-sim-business-r2 派生到 5060(SIP),curl 就绪检查 200 而 Chrome navigate +# 直接拒(NAT slipstreaming 防护),__genBooted 永不置位 → 假 boot-timeout。派生必须跳过。 +_CHROME_UNSAFE_PORTS = {5060, 5061} + + +def _derive_playtest_ports(game_id) -> tuple: + """按 game_id 确定性派生测试员起服端口(避并发多局撞:Service 异步队列 / modify / 批跑可能同时收口)。 + + 基址 4998/9331(spike 同段,与九门 play 的 4320/9222 分离)+ game_id 稳定 hash % 100(md5,禁 Python + 随机化 hash)。同一 game_id 恒得同端口(可复现);不同 game_id 大概率不撞(并发 ≤15 远低于 100 槽)。 + 静态服务端口撞 Chrome unsafe 清单(5060/5061=SIP,页面加载被 ERR_UNSAFE_PORT 拒)→ +2 避开 + (CDP 端口无此限制——unsafe 清单只管页面加载,不管 Chrome 自身监听)。 + """ + import hashlib # noqa: PLC0415 + h = int(hashlib.md5(str(game_id).encode("utf-8")).hexdigest()[:4], 16) % 100 + port = 4998 + h + if port in _CHROME_UNSAFE_PORTS: + port += 2 # 5060→5062 / 5061→5063(5062/5063 不在清单;仍确定性可复现) + return port, 9331 + h + + +def _run_playtest_roll_sync(game_id, brief_file, *, model_name, roll, seed, steps_base, steps_max, + evidence_dir, port, cdp_port, timeout) -> dict: + """同步跑一掷(subprocess serve-and-playtest.sh → playtest.cdp.cjs);解析 stdout 末行 JSON 裁决。 + + 绝不抛——超时/异常/未产裁决一律归 degraded 裁决(fail-closed)。退出码语义(playtest/1 协议): + 0=正常裁决、2=运行错误/装载失败(degraded)、3=图像通道故障(degraded,归因测试员图像通道非游戏缺陷)。 + """ + argv = ["bash", str(_playtest_script()), str(game_id), str(port), str(cdp_port), "--", + f"--model={model_name}", f"--roll={roll}", f"--seed={seed}", + f"--steps-base={steps_base}", f"--steps-max={steps_max}", + f"--brief-file={brief_file}", f"--evidence-dir={evidence_dir}"] + try: + r = subprocess.run(argv, cwd=str(cheap_run._GAME_RUNTIME), capture_output=True, text=True, + timeout=timeout, env=_playtest_env(), check=False) + except subprocess.TimeoutExpired: + return {"degraded": True, "reason": f"测试员子进程超时(>{timeout}s),fail-closed", "exitCode": -1, "roll": roll} + except Exception as e: # noqa: BLE001 subprocess 层异常一律 fail-closed + return {"degraded": True, "reason": f"测试员子进程异常:{type(e).__name__}: {e}", "exitCode": -1, "roll": roll} + # 解析 stdout 末行单行 JSON(playtest.cdp.cjs 末行 = 裁决)。 + out = None + for line in reversed((r.stdout or "").splitlines()): + line = line.strip() + if line.startswith("{") and line.endswith("}"): + try: + out = json.loads(line) + break + except json.JSONDecodeError: + continue + if out is None: + return {"degraded": True, "reason": "测试员未产出裁决 JSON(见 raw)", "exitCode": r.returncode, + "raw": (r.stdout + r.stderr)[-1000:], "roll": roll} + out["exitCode"] = r.returncode + # 退出码/自报标志 → degraded(fail-closed):运行错误、装载失败、图像通道故障都不放行。 + if r.returncode == 2 or out.get("runnerError") or out.get("bootDead"): + out["degraded"] = True + out.setdefault("reason", "测试员运行错误/装载失败,fail-closed") + if r.returncode == 3 or out.get("imageBlind"): + out["degraded"] = True + out["imageBlind"] = True + out.setdefault("reason", "测试员图像通道故障(自报看不到截图),fail-closed 归因测试员非游戏") + return out + + +def _roll_brief(roll) -> dict: + """把单掷裁决压成轻量摘要(落 playtest.json 的 rolls 列表 / 批次账,不塞完整转写)。""" + return {"roll": roll.get("roll"), "seed": roll.get("seed"), "pass": roll.get("pass"), + "degraded": bool(roll.get("degraded")), "canSee": roll.get("canSee"), + "steps": roll.get("steps"), "tapPoints": roll.get("tapPoints"), + "summary": roll.get("summary"), "reason": roll.get("reason"), + "tokens": roll.get("tokens")} + + +def _playtest_roll_cost(model_name, roll) -> float: + """据 new-api 权威倍率把一掷 token 折成 ¥(与判定成本同口径);取价失败 → 0.0(不伪造成本、不误停)。""" + tok = roll.get("tokens") or {} + ti, to = int(tok.get("in") or 0), int(tok.get("out") or 0) + c = _estimate_judge_cost(model_name, ti, to, 0) + return float(c) if isinstance(c, (int, float)) else 0.0 + + +def _persist_playtest_json(evidence_dir, result: dict) -> None: + """测试员验收真相层落盘(best-effort,绝不抛):evidence/playtest/playtest.json = 汇总裁决 + 二掷账。 + + 与 judge.json 同一动机(W-AXIS 波2):验收结论必须在盘上留痕,§6.13 亲眼验收看的就是它 + roll-N 截图序列。 + """ + try: + ev = Path(evidence_dir) if evidence_dir else None + if ev is None: + return + pdir = ev / "playtest" + pdir.mkdir(parents=True, exist_ok=True) + payload = dict(result) + payload["ts"] = int(time.time() * 1000) + (pdir / "playtest.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: # noqa: BLE001 落盘失败绝不连累验收主链 + pass + + +async def run_playtest(game_id, *, brief="", model_name=None, evidence_dir=None, + port=None, cdp_port=None, steps_base=None, steps_max=None, + second_roll=None, timeout=None, cost_cap_rmb=None, seed=None) -> dict: + """测试 agent 真玩验收(阻断权威;裁定三 v2)。**fail-closed**:超时/图像故障/未裁决 → degraded=拒绝。 + + 二掷(§3 第7件):roll-1 判 fail 且非 degraded、second_roll 开、成本未撞上限 → roll-2(不同 seed、清 + localStorage 独立);两掷同 fail 才落 fail,roll-2 pass 即翻案(治 puzzle 类坐标失手假阴)。pass 一掷即过。 + + Returns: + playtest 段 dict:{accepted, verdict, pass, degraded, reason, problems, feedback, summary, + firstPlay, rolls, rollCount, costRmb, model, imageBlind}。落 evidence/playtest/playtest.json。 + """ + cfg = _acceptance_cfg() + mn = model_name or cfg["model"] + sb = steps_base if steps_base is not None else cfg["steps_base"] + smax = steps_max if steps_max is not None else cfg["steps_max"] + sr = cfg["second_roll"] if second_roll is None else bool(second_roll) + to = timeout if timeout is not None else cfg["timeout_s"] + cap = cost_cap_rmb if cost_cap_rmb is not None else cfg["cost_cap_rmb"] + ev = Path(evidence_dir) if evidence_dir is not None else _wg1_evidence_dir(game_id) + base_seed = seed if seed is not None else int(time.time() * 1000) % 1_000_000 + if port is None or cdp_port is None: # 缺省 → 按 game_id 派生 per-game 端口(并发避撞) + dp, dc = _derive_playtest_ports(game_id) + port = dp if port is None else port + cdp_port = dc if cdp_port is None else cdp_port + _NULL_FIRSTPLAY = {"playableAtMs": None, "firstFeedbackMs": None, "loopClosed": False} + + result = {"model": mn, "rolls": [], "degraded": False} + brief_file = None + try: + import tempfile # noqa: PLC0415 brief 写临时文件(诚实来源,避免命令行转义/长度) + with tempfile.NamedTemporaryFile("w", suffix=".brieftxt", delete=False, encoding="utf-8") as bf: + bf.write(brief or "") + brief_file = bf.name + + async def _one(roll, s): + # subprocess 阻塞 → to_thread 让出事件循环(超时由 subprocess timeout 兜)。 + return await asyncio.to_thread( + _run_playtest_roll_sync, game_id, brief_file, + model_name=mn, roll=roll, seed=s, steps_base=sb, steps_max=smax, + evidence_dir=str(ev), port=port, cdp_port=cdp_port, timeout=to) + + # roll-1 + r1 = await _one(1, base_seed) + result["rolls"].append(_roll_brief(r1)) + cost = _playtest_roll_cost(mn, r1) + final = r1 + # 二掷:仅当 roll-1 是【确定的 fail】(非 degraded)、开关开、成本未撞线时;degraded 不二掷(图像/运行故障重掷无益)。 + if (not r1.get("degraded")) and (r1.get("pass") is False) and sr and cost < cap: + r2 = await _one(2, base_seed + 7919) # 素数偏移换 seed(禁隐式时钟种子) + result["rolls"].append(_roll_brief(r2)) + cost += _playtest_roll_cost(mn, r2) + if r2.get("pass") is True: + final = r2 # roll-2 翻案 → pass + elif r2.get("degraded"): + final = r2 # roll-2 degraded → fail-closed + else: + final = r1 # 两掷同 fail → 维持 fail + except Exception as e: # noqa: BLE001 编排层任何异常 → fail-closed degraded + result.update({"accepted": False, "verdict": "reject", "pass": False, "degraded": True, + "reason": f"测试员编排异常:{type(e).__name__}: {e}", "problems": [], "feedback": "", + "summary": "orchestration-error", "firstPlay": dict(_NULL_FIRSTPLAY), + "rollCount": len(result["rolls"]), "costRmb": 0.0, "imageBlind": False}) + _persist_playtest_json(ev, result) + return result + finally: + if brief_file: + try: + import os as _os # noqa: PLC0415 + _os.unlink(brief_file) + except Exception: # noqa: BLE001 + pass + + final_pass = final.get("pass") is True + final_degraded = bool(final.get("degraded")) + result.update({ + "accepted": final_pass and not final_degraded, + "verdict": "accept" if (final_pass and not final_degraded) else "reject", + "pass": final_pass, + "degraded": final_degraded, + "reason": final.get("reason"), + "problems": final.get("problems") or [], # 现象+推测(修复反馈拼装只引现象,见 _build_repair_feedback) + "feedback": final.get("feedback") or "", + "summary": final.get("summary") or "", + "firstPlay": final.get("firstPlay") or dict(_NULL_FIRSTPLAY), + "rollCount": len(result["rolls"]), + "costRmb": round(cost, 5), + "imageBlind": bool(final.get("imageBlind")), + }) + _persist_playtest_json(ev, result) + return result + + +def _build_repair_feedback(floor: dict, playtest: dict) -> str: + """修复反馈拼装(现象/推测强制分离,续修**只引现象段**)——给 writer resume 的续修来源。 + + 治 spike 三次「测试员失手后错误归因」失效模式:测试员 problems 里凡含「推测」的分句剔除,只留带步号+落点的 + 客观现象进反馈;degraded 则不派续修(fail-closed 待人工)。附四门地板逐门结果作接线上下文。 + """ + import re # noqa: PLC0415 + if playtest.get("degraded"): + return f"测试员未能给出有效裁决(degraded:{playtest.get('reason')}),fail-closed 待人工复核,不派续修。" + g = (floor or {}).get("gates") or {} + gate_str = " ".join(f"{k}={'✓' if g.get(k) else '✗'}" for k in ("A", "B", "C", "D")) + lines = [] + for p in (playtest.get("problems") or []): + # 现象/推测分离:按分句剔除含「推测」的段,只留客观现象。 + phenom = ";".join(seg.strip() for seg in re.split(r"[;;]", str(p)) if seg.strip() and "推测" not in seg) + if phenom: + lines.append(phenom) + head = (f"[测试员真玩现象·续修依据] 四门地板 {gate_str};测试员判不通过。以下仅列客观现象(带步号与落点坐标," + f"推测已剔除),据此定位修复:\n") + if not lines: + lines = [playtest.get("summary") or "测试员判不通过但未列具体现象"] + return head + "\n".join(f"- {ln}" for ln in lines) + + +async def run_acceptance(summary: dict, *, game_id: str, brief: str = "", verdict=None, + evidence_dir=None, mode=None, model=None, + port=None, cdp_port=None) -> dict: + """统一验收编排器(CLI / Service / modify 三路共用)。accepted = floor ∧ 测试员(mode=v2)/旧口径(shadow/v1)。 + + · floor = project_floor(verdict):四门投影(预筛权威,唯一产地),写 summary['floor']。 + · mode=v2(目标态):测试员真玩阻断,accepted = floor.pass ∧ playtest.accepted。 + · mode=shadow(波1 灰度):测试员照跑照落证据(对照),accepted 仍取旧口径(判定器 apply_gameplay_judge); + 并写 summary['shadowV2Accepted'] 供批次账新旧口径对照表。 + · mode=v1(回退态):W-AXIS 旧口径(apply_gameplay_judge:driven 九门预筛→四门投影 + 判定器读驱动器证据)。 + + additive 写 summary:floor 段、acceptanceVersion、playtest 段、judge 段(§4 双写窗口)、ok/accepted。 + 绝不抛(顶层兜底 fail-closed:异常 → accepted=False)。verdict 缺 → 取 summary.verdict/verdictFull。 + """ + try: + cfg = _acceptance_cfg() + mode = mode or cfg["mode"] + ev = evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id) + v = verdict if verdict is not None else (summary.get("verdict") or summary.get("verdictFull") or {}) + floor = project_floor(v) + summary["floor"] = floor + summary["acceptanceVersion"] = mode + + if mode == "v1": + # 回退态:预筛权威统一为四门投影(floor.pass),判定仍走驱动器证据(apply_gameplay_judge→judge_gameplay_floor)。 + # 时序约束:v1 仅在 play-spec/驱动器仍挂载的波1~波2 前有效(波2 拆除后不可达)。 + return await apply_gameplay_judge(summary, game_id=game_id, brief=brief, + evidence_dir=ev, prefilter=floor["pass"]) + + # v2 / shadow:跑测试员——仅预筛(四门)过时跑,省 ¥(预筛没过已被机械门拒,不花测试员钱)。 + if floor["pass"]: + playtest = await run_playtest(game_id, brief=brief, evidence_dir=ev, + model_name=model or cfg["model"], port=port, cdp_port=cdp_port) + else: + playtest = {"accepted": False, "verdict": "reject", "pass": False, "degraded": False, + "reason": "四门地板未过,不跑测试员(省 ¥)", "problems": [], "feedback": "", + "summary": "floor-fail", "rolls": [], "rollCount": 0, "costRmb": 0.0, "model": cfg["model"], + "firstPlay": {"playableAtMs": None, "firstFeedbackMs": None, "loopClosed": False}} + summary["playtest"] = playtest + v2_accepted = bool(floor["pass"]) and bool(playtest.get("accepted")) and not bool(playtest.get("degraded")) + + if mode == "shadow": + # 灰度:accepted 取旧口径(判定器 apply_gameplay_judge 写 judge 段);测试员只观测、并列落对照。 + summary = await apply_gameplay_judge(summary, game_id=game_id, brief=brief, + evidence_dir=ev, prefilter=floor["pass"]) + summary["shadowV2Accepted"] = v2_accepted # 对照:v2 生效时会是什么(批次账新旧口径对照表) + return summary + + # mode == v2:测试员阻断放行。 + blk = cfg["blocking"] + # rejectClasses:v2 测试员不产 broken/hollow/off_brief 细类,归一为 playtest_reject / tester_degraded(供 gate 桶归因)。 + if playtest.get("degraded"): + reject_classes = ["tester_degraded"] + elif not v2_accepted: + reject_classes = ["playtest_reject"] + else: + reject_classes = [] + summary["judge"] = { # §4 双写窗口:v2 下 judge 段镜像 playtest 裁决,喂 trace.gameplayJudge 与旧消费面 + "ran": True, "blocking": blk, "verdict": playtest.get("verdict"), + "accepted": bool(playtest.get("accepted")), "rejectClasses": reject_classes, + "degraded": bool(playtest.get("degraded")), "reason": playtest.get("reason"), + "notes": playtest.get("summary"), "model": playtest.get("model"), + "costRmb": playtest.get("costRmb"), "acceptanceVersion": "v2", + } + summary["accepted"] = v2_accepted + summary["ok"] = v2_accepted if blk else floor["pass"] + if not v2_accepted: # 续修回喂来源(只引现象段);accepted 时无失败现象、不拼装(E2E 实测曾误拼自相矛盾文案) + summary["repairFeedback"] = _build_repair_feedback(floor, playtest) + if blk and not v2_accepted and playtest.get("degraded"): + summary.setdefault("failureReason", "tester_degraded") # §4 新增归因层 + _writeback_verdict_json(ev, v2_accepted, summary["judge"]) + return summary + except Exception as e: # noqa: BLE001 编排接线级异常:fail-closed(v2/shadow 不放行)。 + summary["acceptanceVersion"] = summary.get("acceptanceVersion") or (mode or "v2") + summary["judge"] = {"ran": False, "degraded": True, + "reason": f"验收编排异常(fail-closed):{type(e).__name__}: {e}"} + summary["accepted"] = False + summary["ok"] = False + return summary + + if __name__ == "__main__": # 便捷自跑:python cheap-worker/cheap_verify.py ["brief"] —— 对已有产物真跑一次评分。 import json diff --git a/cheap-worker/hard_genre_batch.py b/cheap-worker/hard_genre_batch.py index dea03bcd..89aa89dc 100644 --- a/cheap-worker/hard_genre_batch.py +++ b/cheap-worker/hard_genre_batch.py @@ -94,18 +94,23 @@ async def _run_one(genre, brief, port, cdp, round_no): write_whitelist=write_whitelist, genre=routed_genre, max_tokens=_MAXTOK) except Exception as e: # noqa: BLE001 harness 级异常也原样计入基线(失败也是基线的一部分) return {"genre": genre, "gid": gid, "round": round_no, - "accepted": False, "verdictPass": None, "finished": False, "ok": False, + "accepted": False, "floorPass": None, "acceptanceVersion": None, + "verdictPass": None, "finished": False, "ok": False, "judge": None, "fail": f"{type(e).__name__}: {e}", "wallSec": round(time.time() - t0, 1)} v = summary.get("verdict") or {} + floor = summary.get("floor") or {} # 四门投影(run_acceptance 落;缺则空 → floorPass=None) rich = summary.get("richness") or {} layer = summary.get("failureLayer") or {} # classify_cheap_failure_layer 返回 {layer,reason,failedGates} - j = summary.get("judge") or {} # apply_gameplay_judge 写入(仅 run_gates∧finished 时有) + j = summary.get("judge") or {} # run_acceptance 写入(仅 run_gates∧finished 时有) return { "genre": genre, "gid": gid, "round": round_no, "template": scaffold_template or "_template(通用)", - # ── 三个验收信号分开记(2026-07-09 新语义,别混): - "accepted": bool(summary.get("accepted")), # ★最终权威 = 预筛 ∧ 独立模型玩法判定 - "verdictPass": v.get("pass"), # 预筛(机械九门),只是地板不是验收 + # ── 验收信号分开记(W-AXIS-V2 波1 语义,别混): + "accepted": bool(summary.get("accepted")), # ★最终权威 = 四门投影 ∧ 测试员真玩(v2)/ 旧口径(shadow/v1) + "floorPass": floor.get("pass"), # ★预筛权威 = 四门投影(A/B/C/D),取代 verdictPass 作预筛口径 + "acceptanceVersion": summary.get("acceptanceVersion"), # v2/shadow/v1(shadow 与 v2 行靠它区分,防波3对账串数) + "shadowV2Accepted": summary.get("shadowV2Accepted"), # shadow 模式下 v2 若生效的影子裁决(新旧口径对照) + "verdictPass": v.get("pass"), # harness 原生九门/七门 AND,窗口内仅对照打印、【不作判定输入】 "finished": bool(summary.get("finished")), # 模型自称收敛(check+build 绿),不是验收 "ok": bool(summary.get("ok")), # run_studio 收口 ok(blocking 下 = accepted) # ── 判定段(verdict/rejectClasses/degraded/costRmb + 诊断): @@ -129,16 +134,16 @@ async def _run_one(genre, brief, port, cdp, round_no): def _mark(r: dict) -> str: - """一局的达标标记(accepted 口径): + """一局的达标标记(accepted 口径,W-AXIS-V2 波1): ✅ accepted=True(最终权威过) - ⚠️判定拒 预筛过(verdictPass=True)但判定拒(accepted=False)——机械门放行、独立判定逮住的坏游戏 - finished-only 模型自称收敛(finished=True)但预筛未过(旧 FALSE-PASS 改名,新语义) + ⚠️测试员拒 四门过(floorPass=True)但测试员拒(accepted=False)——机械门放行、测试员逮住的坏游戏 + finished-only 模型自称收敛(finished=True)但四门未过 ❌ 未收敛/harness 挂 """ if r.get("accepted"): return "✅" - if r.get("verdictPass") is True: - return "⚠️判定拒" + if r.get("floorPass") is True: + return "⚠️测试员拒" if r.get("finished"): return "finished-only" return "❌" @@ -166,7 +171,8 @@ async def main(): cumulative += _row_total_cost(r) j = r.get("judge") or {} print(f"{_mark(r):>13s} r{round_no} {genre:14s} tpl={r.get('template','?')} " - f"accepted={r.get('accepted')} verdictPass={r.get('verdictPass')} finished={r.get('finished')} " + f"accepted={r.get('accepted')} floorPass={r.get('floorPass')} ver={r.get('acceptanceVersion')} " + f"verdictPass(对照)={r.get('verdictPass')} finished={r.get('finished')} " f"judge={j.get('verdict')}/{j.get('rejectClasses')} degraded={j.get('degraded')} " f"layer={r.get('failureLayer')} attempts={r.get('attempts')} " f"genCost=¥{r.get('costRmb')} judgeCost=¥{j.get('costRmb')} wall={r.get('wallSec')}s " @@ -177,17 +183,27 @@ async def main(): stopped = True break - # ── 本次汇总(accepted 口径为主,verdictPass/finished 并列对照)── + # ── 本次汇总(accepted 口径为主,floorPass/finished 并列;verdictPass 仅对照、不作判定输入)── n = len(session_rows) acc = sum(1 for r in session_rows if r.get("accepted")) - vp = sum(1 for r in session_rows if r.get("verdictPass") is True) + fp = sum(1 for r in session_rows if r.get("floorPass") is True) # 预筛权威 = 四门投影 + vp = sum(1 for r in session_rows if r.get("verdictPass") is True) # harness 原生九门/七门,仅对照 fin = sum(1 for r in session_rows if r.get("finished")) - deg = sum(1 for r in session_rows if (r.get("judge") or {}).get("degraded")) - print("\n========== 本次汇总(新验收语义)==========") + deg = sum(1 for r in session_rows if (r.get("judge") or {}).get("degraded") + or (r.get("playtest") or {}).get("degraded")) + print("\n========== 本次汇总(W-AXIS-V2 验收语义)==========") print(f">>> 真实率(accepted 最终权威):{acc}/{n} = {acc / n:.0%}" if n else ">>> 无完成局") if n: - print(f">>> 对照:预筛过(verdictPass) {vp}/{n}={vp / n:.0%} | 收敛(finished) {fin}/{n}={fin / n:.0%} | 判定 degraded {deg}/{n}") - print(f">>> 差额 = 预筛过但判定拒 {vp - acc} 局(机械门放行、独立判定逮住);全 finished-only={fin - vp} 局(收敛但预筛未过)") + print(f">>> 预筛(floorPass 四门投影):{fp}/{n}={fp / n:.0%} | 收敛(finished){fin}/{n}={fin / n:.0%} | degraded {deg}/{n}") + print(f">>> 对照(harness 原生 verdictPass,不作判定输入):{vp}/{n}={vp / n:.0%}") + print(f">>> 差额 = 四门过但测试员拒 {fp - acc} 局(机械门放行、测试员逮住)") + # shadow 灰度对照表(§4/波1 验收):shadow 行的旧口径 accepted vs v2 影子裁决(逐局对账,防波3串数)。 + shadow_rows = [r for r in session_rows if r.get("acceptanceVersion") == "shadow" + and r.get("shadowV2Accepted") is not None] + if shadow_rows: + old_acc = sum(1 for r in shadow_rows if r.get("accepted")) + v2_acc = sum(1 for r in shadow_rows if r.get("shadowV2Accepted")) + print(f">>> shadow 对照表:{len(shadow_rows)} 局灰度,旧口径 accepted={old_acc} vs v2 影子 accepted={v2_acc}") print(f">>> 累计成本(本次+历史)= ¥{cumulative:.2f}") # 本次聚合另存一份(便于取用);逐局真相仍以 JSONL 为准。 out = _JSONL.parent / f"wax-baseline-r{start_round}-{start_round + n_rounds - 1}.json" diff --git a/cheap-worker/hard_genre_xtheme.py b/cheap-worker/hard_genre_xtheme.py index 965ce969..a95fae4c 100644 --- a/cheap-worker/hard_genre_xtheme.py +++ b/cheap-worker/hard_genre_xtheme.py @@ -48,13 +48,19 @@ async def _run_one(sem, tag, brief, port, cdp): write_whitelist=write_whitelist, genre=routed_genre) except Exception as e: # noqa: BLE001 return {"tag": tag, "gid": gid, "template": scaffold_template, "locked": write_whitelist is not None, - "ok": False, "fail": f"{type(e).__name__}: {e}", "wallSec": round(time.time() - t0, 1)} + "ok": False, "accepted": False, "floorPass": None, "acceptanceVersion": None, "verdictPass": None, + "fail": f"{type(e).__name__}: {e}", "wallSec": round(time.time() - t0, 1)} v = summary.get("verdict") or {} + floor = summary.get("floor") or {} rich = summary.get("richness") or {} return { "tag": tag, "gid": gid, "template": scaffold_template or "_template(通用)", "locked": write_whitelist is not None, - "ok": bool(summary.get("ok")), "verdictPass": v.get("pass"), + "ok": bool(summary.get("ok")), + "accepted": bool(summary.get("accepted")), # W-AXIS-V2:最终验收(四门 ∧ 测试员) + "floorPass": floor.get("pass"), # 预筛权威 = 四门投影(取代 verdictPass) + "acceptanceVersion": summary.get("acceptanceVersion"), + "verdictPass": v.get("pass"), # harness 原生九门/七门,仅对照、不作判定输入 "failedGates": v.get("failedGates"), "attempts": summary.get("attempts"), "costRmb": summary.get("costRmb"), "richness": rich.get("score"), "wallSec": round(time.time() - t0, 1), @@ -69,14 +75,16 @@ async def main(): print("\n\n========== 轻A 跨主题换皮验证 ==========") passed = 0 for r in results: - vp = r.get("verdictPass") is True - passed += 1 if vp else 0 - mark = "✅" if vp else ("⚠️FALSE-PASS" if r.get("ok") else "❌") + acc = bool(r.get("accepted")) + fp = r.get("floorPass") is True + passed += 1 if acc else 0 + mark = "✅" if acc else ("⚠️测试员拒" if fp else ("⚠️FALSE-PASS" if r.get("ok") else "❌")) print(f"{mark} {r['tag']:16s} tpl={r.get('template')} lock={r.get('locked')} " - f"verdictPass={r.get('verdictPass')} failedGates={r.get('failedGates')} " + f"accepted={r.get('accepted')} floorPass={r.get('floorPass')} ver={r.get('acceptanceVersion')} " + f"verdictPass(对照)={r.get('verdictPass')} failedGates={r.get('failedGates')} " f"attempts={r.get('attempts')} rich={r.get('richness')}/12 " f"cost={r.get('costRmb')} wall={r.get('wallSec')}s {('FAIL='+r['fail']) if r.get('fail') else ''}") - print(f">>> 跨主题换皮达标(九门 verdict.pass):{passed}/{len(results)} 过门率={passed/len(results):.0%}") + print(f">>> 跨主题换皮达标(accepted 最终验收 = 四门 ∧ 测试员):{passed}/{len(results)} 达标率={passed/len(results):.0%}") out = Path(__file__).resolve().parent / "results" / "hard-genre-xtheme.json" out.parent.mkdir(exist_ok=True) out.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/cheap-worker/result_out.py b/cheap-worker/result_out.py index 595d1924..0e927f4d 100644 --- a/cheap-worker/result_out.py +++ b/cheap-worker/result_out.py @@ -17,6 +17,8 @@ import hashlib import json from pathlib import Path +import cheap_verify # W-AXIS-V2 波1:四门投影(project_floor)= 便宜档预筛权威的唯一产地 + # §6.1 result-out / 契约#6 output.failureReason 枚举(对齐后端 FailureReasonEnum,真验改正: # 原稿用了 content_violation/budget_exceeded/generation_failed 三个不在枚举内的值 → 真 e2e 被后端 # DifyCallbackTxService L165 拒「failureReason 不在枚举内」、回调事务回滚。真枚举见 FailureReasonEnum)。 @@ -215,6 +217,27 @@ def _build_trace(summary: dict) -> dict | None: "blocking": judge.get("blocking"), "ran": judge.get("ran"), } + # playtest(W-AXIS-V2 波1):additive 透传测试员真玩裁决 + firstPlay 三字段(§5 首局门语义并入,后端 + # ReadinessScorer.scoreFirstPlay 改读 trace.playtest.firstPlay)。trace.gameplayJudge 双写窗口保留(旧消费面)。 + playtest = summary.get("playtest") + if isinstance(playtest, dict): + fp = playtest.get("firstPlay") if isinstance(playtest.get("firstPlay"), dict) else {} + trace["playtest"] = { + "accepted": playtest.get("accepted"), + "verdict": playtest.get("verdict"), + "degraded": playtest.get("degraded"), + "rollCount": playtest.get("rollCount"), + # firstPlay 键名逐字镜像 ReadinessScorer 读取口径(playableAtMs/firstFeedbackMs/loopClosed)。 + "firstPlay": {"playableAtMs": fp.get("playableAtMs"), "firstFeedbackMs": fp.get("firstFeedbackMs"), + "loopClosed": fp.get("loopClosed")}, + } + # floor 四门投影(预筛权威)与 acceptanceVersion:additive,后端可据它区分预筛与最终验收、对账 shadow/v2。 + floor = summary.get("floor") + if isinstance(floor, dict): + trace["floor"] = {"pass": floor.get("pass"), "gates": floor.get("gates")} + av = summary.get("acceptanceVersion") + if av is not None: + trace["acceptanceVersion"] = av return trace @@ -239,10 +262,21 @@ def build_result_out(job: dict, summary: dict, game_dir, *, bundle_text = _read_bundle(game_dir) verdict = summary.get("verdict") or {} - prefilter_pass = verdict.get("pass") is True - # W-AXIS 波2:验收权威 = accepted(= 预筛 ∧ 独立模型玩法判定,见 cheap_verify.apply_gameplay_judge)。 - # summary 无 accepted 键(旧产物 / 对照路 / 判定未跑)→ 回落预筛通过(向后兼容,不误拒); - # 判定拒 / degraded 时 accepted=False → status=failed,判卷合约与既有 succeeded/failed 二分不变。 + vf = summary.get("verdictFull") if isinstance(summary.get("verdictFull"), dict) else verdict + # W-AXIS-V2 波1:预筛权威 = 四门投影(floor)。三级取值(注意 summary.verdict 是 brief 版、无 guards): + # ① summary.floor(run_acceptance 落,生产路)→ 直接用; + # ② verdictFull 带 guards(老产物 / W-AXIS 波2 路)→ 现算 project_floor(四门投影,**不复用**九门 verdict.pass); + # ③ 完全无 guards(极老产物 / 契约桩只给 verdict.pass)→ 回落 verdict.pass 作预筛(向后兼容、不误拒;无 guards + # 的假绿在上游 gate_judge/run_acceptance 已拦,build_result_out 是最终契约组装、非验收门)。 + if isinstance(summary.get("floor"), dict): + floor = summary["floor"] + elif vf.get("guards"): + floor = cheap_verify.project_floor(vf) + else: + floor = {"pass": bool(verdict.get("pass")), "gates": {}} + prefilter_pass = bool(floor.get("pass")) + # W-AXIS 波2:验收权威 = accepted(= 预筛 ∧ 测试 agent 真玩裁决)。summary 无 accepted 键(旧产物 / 对照路 / + # 验收未跑)→ 回落四门投影(向后兼容,不误拒);测试员拒 / degraded 时 accepted=False → status=failed。 accepted = summary.get("accepted") gates_pass = (accepted is True) if accepted is not None else prefilter_pass engine_ok = bool(bundle_text) and "__GameBundle" in bundle_text diff --git a/cheap-worker/tests/test_a11_m3_deterministic.py b/cheap-worker/tests/test_a11_m3_deterministic.py index bf52b886..076cfd9f 100644 --- a/cheap-worker/tests/test_a11_m3_deterministic.py +++ b/cheap-worker/tests/test_a11_m3_deterministic.py @@ -249,7 +249,10 @@ def test_worker_deterministic_route_success_carries_bundle_and_source(): fake = {"status": "succeeded", "manifest": [{"file": "src/core.js", "kind": "config", "key": "ROUND_MS", "old": "30000", "new": "20000", "found": True}], - "verdict": {"pass": True, "guards": {}}, "error": None, "stage": "play"} + # 真实 verdict.guards 带完整四门(W-AXIS-V2 预筛权威 = 四门投影;build_result_out 据它算 status)。 + "verdict": {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True}, + "C_frame": {"pass": True}, "D_render": {"pass": True}}}, + "error": None, "stage": "play"} profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"} state = W.WorkerState(modify_fn=lambda *a, **kw: fake, send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""), @@ -292,7 +295,9 @@ def test_create_path_unaffected_by_modify_wiring(): (gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8") called = {} state = W.WorkerState( - run_fn=lambda job: (called.update(ran=True) or {"verdict": {"pass": True}}, gd), + run_fn=lambda job: (called.update(ran=True) or {"verdict": {"pass": True, "guards": { + "A_boot": {"pass": True}, "B_uncaught": {"pass": True}, + "C_frame": {"pass": True}, "D_render": {"pass": True}}}}, gd), send_fn=lambda u, p, s: called.update(payload=p) or (200, ""), profile_fn=lambda j, g: None, modify_fn=_boom_modify, diff --git a/cheap-worker/tests/test_a11_m4_regenerate.py b/cheap-worker/tests/test_a11_m4_regenerate.py index 16dbab32..47e143a0 100644 --- a/cheap-worker/tests/test_a11_m4_regenerate.py +++ b/cheap-worker/tests/test_a11_m4_regenerate.py @@ -79,6 +79,9 @@ def _fake_rewrite_ok(game_id, intent, **kw): encoding="utf-8") return {"verdict": {"pass": True, "failedGates": []}, "verdictFull": {"pass": True, "guards": {}}, + # W-AXIS-V2 波1:run_studio 经 run_acceptance 收口带 accepted(v2 验收结论); + # execute_regenerate_modify 据它判 gates_pass(create/modify 同标准),不再读九门 verdict.pass。 + "accepted": True, "attempts": 3, "stage": "play", "costRmb": 0.5, "models": {"code": "MiniMax-M3"}, "gameId": game_id} diff --git a/cheap-worker/tests/test_acceptance_v2.py b/cheap-worker/tests/test_acceptance_v2.py new file mode 100644 index 00000000..692a52d8 --- /dev/null +++ b/cheap-worker/tests/test_acceptance_v2.py @@ -0,0 +1,334 @@ +""" +test_acceptance_v2.py — W-AXIS-V2 波1 验收编排单测(四门投影 / run_playtest 二掷 / run_acceptance mode 三态 / + 修复反馈现象分离 / fail-closed 各路径)。全部零真网络零起服(fake roll 注入 + tmp 证据目录 + monkeypatch)。 + +§3 八件的覆盖分工:第 1-5 件(坐标尺 / 落点回显 / 同点硬提示 / 反早退 / 坐标协议)是 playtest.cdp.cjs 的浏览器 + DOM 行为,由 10 局考卷真跑覆盖(见 spikes/playtest-agent/README 真相表);本单测覆盖可 Python 单测的部分—— + 第 6 件(firstPlay/预算字段流转)、第 7 件(二掷编排)、第 8 件(图像通道 fail-closed),及编排器 mode 三态、 + 投影函数、fail-closed 各路径(超时 / 运行错误 / 未裁决)。 + +跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_acceptance_v2.py -q +""" + +import asyncio +import json +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ +import cheap_verify # noqa: E402 + + +# ══════════════════════════════════════════════════════════════════════════════ +# ① project_floor 四门投影(floor 唯一产地;不复用 harness verdict.pass) +# ══════════════════════════════════════════════════════════════════════════════ +def _guards(a=True, b=True, c=True, d=True, extra=None): + g = {"A_boot": {"pass": a}, "B_uncaught": {"pass": b}, "C_frame": {"pass": c}, "D_render": {"pass": d}} + if extra: + g.update(extra) + return {"guards": g} + + +def test_project_floor_all_green(): + f = cheap_verify.project_floor(_guards()) + assert f["pass"] is True + assert f["gates"] == {"A": True, "B": True, "C": True, "D": True} + + +def test_project_floor_one_gate_fail(): + assert cheap_verify.project_floor(_guards(c=False))["pass"] is False + assert cheap_verify.project_floor(_guards(a=False))["gates"]["A"] is False + + +def test_project_floor_missing_gate_fail_closed(): + # 只有 A_boot,B/C/D 缺 → fail-closed(缺门即 False) + f = cheap_verify.project_floor({"guards": {"A_boot": {"pass": True}}}) + assert f["pass"] is False + assert f["gates"]["A"] is True and f["gates"]["B"] is False + + +def test_project_floor_empty(): + assert cheap_verify.project_floor(None)["pass"] is False + assert cheap_verify.project_floor({})["pass"] is False + assert cheap_verify.project_floor({"guards": {}})["pass"] is False + + +def test_project_floor_not_reuse_verdict_pass(): + # 即使 harness verdict.pass=True,只要四门不全,floor 就不放行(绝不复用 verdict.pass)。 + v = {"pass": True, "guards": {"A_boot": {"pass": True}, "E_live": {"pass": True}}} + assert cheap_verify.project_floor(v)["pass"] is False + + +def test_project_floor_ignores_eghif_gates(): + # E/G/H/I/F 挂但四门全绿 → floor 放行(契约门降观测,不入地板)。 + v = _guards(extra={"E_live": {"pass": False}, "G_input": {"pass": False}, "H_progress": {"pass": False}}) + assert cheap_verify.project_floor(v)["pass"] is True + + +# ══════════════════════════════════════════════════════════════════════════════ +# ② run_playtest 二掷编排(§3 第7件)+ 图像 fail-closed(§3 第8件)——fake roll 注入 +# ══════════════════════════════════════════════════════════════════════════════ +def _roll(pass_=None, degraded=False, image_blind=False, reason=None): + r = {"gid": "t", "roll": None, "pass": pass_, "canSee": not image_blind, "problems": [], + "feedback": "", "summary": "s", "steps": 5, "tapPoints": 3, "tokens": {"in": 1000, "out": 100}, + "firstPlay": {"playableAtMs": 100, "firstFeedbackMs": 100, "loopClosed": bool(pass_)}} + if degraded: + r["degraded"] = True + r["reason"] = reason or "degraded" + if image_blind: + r["degraded"] = True + r["imageBlind"] = True + r["reason"] = "图像故障" + return r + + +def _patch_rolls(monkeypatch, rolls, cost=0.1): + """按 roll 序号返回预设结果(rolls=[roll1, roll2, ...]);成本固定,绕网关取价。""" + seq = list(rolls) + + def fake(game_id, brief_file, *, roll, **kw): + out = dict(seq[roll - 1] if roll - 1 < len(seq) else seq[-1]) + out["roll"] = roll + return out + + monkeypatch.setattr(cheap_verify, "_run_playtest_roll_sync", fake) + monkeypatch.setattr(cheap_verify, "_playtest_roll_cost", lambda mn, r: cost) + + +def _run_pt(monkeypatch, rolls, cost=0.1, **kw): + _patch_rolls(monkeypatch, rolls, cost=cost) + ev = Path(tempfile.mkdtemp()) + return asyncio.run(cheap_verify.run_playtest("t", brief="b", evidence_dir=ev, **kw)), ev + + +def test_playtest_pass_single_roll(monkeypatch): + r, _ = _run_pt(monkeypatch, [_roll(pass_=True)], second_roll=True) + assert r["accepted"] is True and r["rollCount"] == 1 # pass 一掷即过,不二掷 + + +def test_playtest_fail_then_pass_second_roll(monkeypatch): + r, _ = _run_pt(monkeypatch, [_roll(pass_=False), _roll(pass_=True)], second_roll=True) + assert r["accepted"] is True and r["rollCount"] == 2 # 二掷翻案(治坐标失手假阴) + + +def test_playtest_fail_both_rolls(monkeypatch): + r, _ = _run_pt(monkeypatch, [_roll(pass_=False), _roll(pass_=False)], second_roll=True) + assert r["accepted"] is False and r["rollCount"] == 2 and r["verdict"] == "reject" # 两掷同 fail + + +def test_playtest_degraded_no_second_roll(monkeypatch): + r, _ = _run_pt(monkeypatch, [_roll(degraded=True), _roll(pass_=True)], second_roll=True) + assert r["degraded"] is True and r["accepted"] is False and r["rollCount"] == 1 # degraded 不二掷 + + +def test_playtest_image_blind_fail_closed(monkeypatch): + r, _ = _run_pt(monkeypatch, [_roll(image_blind=True)], second_roll=True) + assert r["degraded"] is True and r["accepted"] is False and r["imageBlind"] is True # §3 第8件 + + +def test_playtest_second_roll_disabled(monkeypatch): + r, _ = _run_pt(monkeypatch, [_roll(pass_=False), _roll(pass_=True)], second_roll=False) + assert r["accepted"] is False and r["rollCount"] == 1 # 关二掷 → 单掷 fail + + +def test_playtest_cost_cap_no_second_roll(monkeypatch): + r, _ = _run_pt(monkeypatch, [_roll(pass_=False), _roll(pass_=True)], cost=2.0, + second_roll=True, cost_cap_rmb=1.5) + assert r["rollCount"] == 1 and r["accepted"] is False # roll-1 成本已撞上限,不二掷 + + +def test_playtest_cost_accumulates(monkeypatch): + r, _ = _run_pt(monkeypatch, [_roll(pass_=False), _roll(pass_=False)], cost=0.3, second_roll=True) + assert abs(r["costRmb"] - 0.6) < 1e-6 # 两掷成本累加 + + +def test_playtest_firstplay_passthrough(monkeypatch): + r, _ = _run_pt(monkeypatch, [_roll(pass_=True)], second_roll=True) + assert r["firstPlay"]["loopClosed"] is True and r["firstPlay"]["playableAtMs"] == 100 # §5 三字段透传 + + +def test_playtest_persist_json(monkeypatch): + _, ev = _run_pt(monkeypatch, [_roll(pass_=True)], second_roll=True) + assert (ev / "playtest" / "playtest.json").is_file() # 真相层落盘(§6.13 亲眼验收看它) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ③ run_acceptance mode 三态(v2 / shadow / v1) +# ══════════════════════════════════════════════════════════════════════════════ +async def _fake_pt_accept(game_id, **kw): + return {"accepted": True, "verdict": "accept", "pass": True, "degraded": False, "problems": [], + "feedback": "", "summary": "ok", "rollCount": 1, "costRmb": 0.1, "model": "M", + "firstPlay": {"playableAtMs": 100, "firstFeedbackMs": 100, "loopClosed": True}} + + +async def _fake_pt_reject(game_id, **kw): + return {"accepted": False, "verdict": "reject", "pass": False, "degraded": False, + "problems": ["第2步点(100,200)后画面未变;推测:事件未绑定"], "feedback": "修", "summary": "no", + "rollCount": 2, "costRmb": 0.2, "model": "M", + "firstPlay": {"playableAtMs": None, "firstFeedbackMs": None, "loopClosed": False}} + + +async def _fake_apply_judge(summary, **kw): + summary["accepted"] = True + summary["ok"] = True + summary["judge"] = {"ran": True, "verdict": "accept", "blocking": True} + return summary + + +def _acc(monkeypatch, verdict, mode, pt=_fake_pt_accept, judge=None): + monkeypatch.setattr(cheap_verify, "run_playtest", pt) + if judge is not None: + monkeypatch.setattr(cheap_verify, "apply_gameplay_judge", judge) + monkeypatch.setattr(cheap_verify, "_writeback_verdict_json", lambda *a, **k: None) + ev = str(Path(tempfile.mkdtemp())) + return asyncio.run(cheap_verify.run_acceptance({}, game_id="t", brief="b", verdict=verdict, + mode=mode, evidence_dir=ev)) + + +def test_run_acceptance_v2_accept(monkeypatch): + s = _acc(monkeypatch, _guards(), "v2", pt=_fake_pt_accept) + assert s["accepted"] is True and s["ok"] is True and s["acceptanceVersion"] == "v2" + assert s["floor"]["pass"] is True and s["playtest"]["accepted"] is True + assert "repairFeedback" not in s # accepted 无失败现象,不拼装续修反馈(E2E 曾误拼自相矛盾文案) + + +def test_run_acceptance_v2_reject_by_playtest(monkeypatch): + s = _acc(monkeypatch, _guards(), "v2", pt=_fake_pt_reject) + assert s["accepted"] is False # 四门过但测试员拒 + assert s["judge"]["rejectClasses"] == ["playtest_reject"] + assert "repairFeedback" in s and "第2步点(100,200)后画面未变" in s["repairFeedback"] + + +def test_run_acceptance_v2_floor_fail_skips_playtest(monkeypatch): + called = {"n": 0} + + async def spy(game_id, **kw): + called["n"] += 1 + return {"accepted": True} + + s = _acc(monkeypatch, _guards(c=False), "v2", pt=spy) + assert called["n"] == 0 and s["accepted"] is False # 四门不过不跑测试员(省 ¥) + assert s["playtest"]["reason"].startswith("四门") + + +def test_run_acceptance_shadow_takes_old_verdict(monkeypatch): + # shadow:accepted 取旧口径(apply_gameplay_judge accept),测试员照跑照落 + shadowV2Accepted 对照。 + s = _acc(monkeypatch, _guards(), "shadow", pt=_fake_pt_reject, judge=_fake_apply_judge) + assert s["accepted"] is True # 旧口径判 accept + assert s["shadowV2Accepted"] is False # v2 影子(测试员拒)——新旧口径对照 + assert s["playtest"]["accepted"] is False # 测试员照跑照落证据 + + +def test_run_acceptance_v1_uses_apply_judge(monkeypatch): + called = {"n": 0} + + async def spy(*a, **k): + called["n"] += 1 + return {} + + s = _acc(monkeypatch, _guards(), "v1", pt=spy, judge=_fake_apply_judge) + assert s["accepted"] is True and called["n"] == 0 # v1 不跑测试员,走旧判定器 + assert s["acceptanceVersion"] == "v1" and s["floor"]["pass"] is True + + +# ══════════════════════════════════════════════════════════════════════════════ +# ④ 修复反馈现象/推测强制分离(续修只引现象段) +# ══════════════════════════════════════════════════════════════════════════════ +def test_repair_feedback_strips_speculation(): + floor = {"pass": True, "gates": {"A": True, "B": True, "C": True, "D": True}} + pt = {"degraded": False, "summary": "no", + "problems": ["第2步点(100,200)后画面未变;推测:事件未绑定", "推测:可能是渲染 bug"]} + fb = cheap_verify._build_repair_feedback(floor, pt) + assert "第2步点(100,200)后画面未变" in fb # 现象保留 + assert "事件未绑定" not in fb # 分句里的推测剔除 + assert "渲染 bug" not in fb # 整条推测剔除 + + +def test_repair_feedback_degraded_no_repair(): + fb = cheap_verify._build_repair_feedback({"gates": {}}, {"degraded": True, "reason": "图像故障"}) + assert "不派续修" in fb and "图像故障" in fb + + +# ══════════════════════════════════════════════════════════════════════════════ +# ⑤ _run_playtest_roll_sync 退出码语义(fail-closed 各路径)+ 配置/辅助 +# ══════════════════════════════════════════════════════════════════════════════ +class _FakeProc: + def __init__(self, stdout, returncode): + self.stdout, self.stderr, self.returncode = stdout, "", returncode + + +def _roll_sync(monkeypatch, proc_or_exc): + monkeypatch.setattr(cheap_verify, "_playtest_env", lambda: {}) + if isinstance(proc_or_exc, Exception): + def r(*a, **k): + raise proc_or_exc + else: + def r(*a, **k): + return proc_or_exc + monkeypatch.setattr(cheap_verify.subprocess, "run", r) + return cheap_verify._run_playtest_roll_sync( + "t", "/tmp/b", model_name="M", roll=1, seed=1, steps_base=14, steps_max=24, + evidence_dir="/tmp", port=1, cdp_port=2, timeout=10) + + +def test_roll_sync_image_blind_exit3(monkeypatch): + v = json.dumps({"gid": "t", "pass": False, "canSee": False, "imageBlind": True}) + r = _roll_sync(monkeypatch, _FakeProc(v, 3)) + assert r["degraded"] is True and r["imageBlind"] is True # 退出码 3 → 图像故障 fail-closed + + +def test_roll_sync_runner_error_exit2(monkeypatch): + r = _roll_sync(monkeypatch, _FakeProc("", 2)) + assert r["degraded"] is True # 退出码 2 / 未产裁决 → degraded + + +def test_roll_sync_normal_pass(monkeypatch): + v = json.dumps({"gid": "t", "pass": True, "canSee": True}) + r = _roll_sync(monkeypatch, _FakeProc(v, 0)) + assert r["pass"] is True and not r.get("degraded") # 退出码 0 正常裁决 + + +def test_roll_sync_timeout(monkeypatch): + r = _roll_sync(monkeypatch, cheap_verify.subprocess.TimeoutExpired("cmd", 10)) + assert r["degraded"] is True and "超时" in r["reason"] # 子进程超时 → fail-closed + + +def test_roll_sync_no_json(monkeypatch): + r = _roll_sync(monkeypatch, _FakeProc("some non-json noise", 0)) + assert r["degraded"] is True and "未产出裁决" in r["reason"] # 无裁决 JSON → fail-closed + + +def test_acceptance_cfg_defaults(): + cfg = cheap_verify._acceptance_cfg() + assert cfg["mode"] in ("v2", "shadow", "v1") + assert isinstance(cfg["steps_base"], int) and isinstance(cfg["steps_max"], int) + assert cfg["steps_max"] >= cfg["steps_base"] and cfg["cost_cap_rmb"] > 0 + + +def test_derive_playtest_ports_stable(): + p1 = cheap_verify._derive_playtest_ports("hard-puzzle-r1") + p2 = cheap_verify._derive_playtest_ports("hard-puzzle-r1") + assert p1 == p2 and 4998 <= p1[0] < 5100 and 9331 <= p1[1] < 9431 # 同 id 恒同端口(可复现) + + +def test_derive_playtest_ports_skips_chrome_unsafe(): + # 2026-07-10 考卷实锤:sb2 曾派生到 5060(SIP,Chrome ERR_UNSAFE_PORT 拒加载)→ 假 boot-timeout。 + # 派生必须跳过 unsafe 清单;全 hash 空间扫一遍保证无一落入。 + assert cheap_verify._derive_playtest_ports("hard-sim-business-r2")[0] not in cheap_verify._CHROME_UNSAFE_PORTS + for h in range(100): + port = 4998 + h + if port in cheap_verify._CHROME_UNSAFE_PORTS: + port += 2 + assert port not in cheap_verify._CHROME_UNSAFE_PORTS + + +def test_roll_brief_shape(): + b = cheap_verify._roll_brief({"roll": 1, "seed": 42, "pass": True, "steps": 5, + "summary": "s", "tokens": {"in": 1, "out": 2}}) + assert b["roll"] == 1 and b["pass"] is True and b["tokens"] == {"in": 1, "out": 2} + + +if __name__ == "__main__": + import pytest + sys.exit(pytest.main([__file__, "-q"])) diff --git a/cheap-worker/tests/test_cheap_service_driver.py b/cheap-worker/tests/test_cheap_service_driver.py index 41bc19ef..ab12cbfe 100644 --- a/cheap-worker/tests/test_cheap_service_driver.py +++ b/cheap-worker/tests/test_cheap_service_driver.py @@ -44,7 +44,10 @@ def test_build_summary_shape_feeds_result_out(tmp_path, monkeypatch): gd.mkdir(parents=True) (gd / "bundle.iife.js").write_text("var x=1; window.__GameBundle={};", encoding="utf-8") monkeypatch.setattr(D, "_read_driver_type", lambda gid: "tap-targets") - verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "H_progress": {"pass": True}}} + # 真实 verdict.guards 带完整四门(A/B/C/D)——W-AXIS-V2 预筛权威 = 四门投影;H_progress 保留(降观测门,trace 仍带)。 + verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True}, + "C_frame": {"pass": True}, "D_render": {"pass": True}, + "H_progress": {"pass": True}}} svc = {"costRmb": 0.42, "rmbGate": "active", "repairs": 2, "budgetSoftTripped": False} turn = {"ended": True, "reason": "REPLY_END"} summary = D._build_summary("g9", "点击得分小游戏", verdict, svc, turn, t0=0.0) @@ -102,7 +105,10 @@ def test_build_summary_trace_contract_end_to_end(tmp_path, monkeypatch): gd.mkdir(parents=True) (gd / "bundle.iife.js").write_text("var x=1; window.__GameBundle={};", encoding="utf-8") monkeypatch.setattr(D, "_read_driver_type", lambda gid: "tap-targets") - verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "H_progress": {"pass": True}}} + # 真实 verdict.guards 带完整四门(A/B/C/D)——W-AXIS-V2 预筛权威 = 四门投影;H_progress 保留(降观测门,trace 仍带)。 + verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True}, + "C_frame": {"pass": True}, "D_render": {"pass": True}, + "H_progress": {"pass": True}}} svc = {"costRmb": 0.42, "rmbGate": "active", "repairs": 2, "budgetSoftTripped": False} summary = D._build_summary("70012", "点击得分小游戏", verdict, svc, {"ended": True, "reason": "REPLY_END"}, t0=0.0) @@ -189,16 +195,19 @@ def test_drive_cheap_generation_fake_sse(tmp_path, monkeypatch): # ① 回合真结束(REPLY_END)→ fake turn 写本次 verdict/sidecar → driver 回合后读它组 succeeded 形。 async def _ended(*a, **k): vev = tmp_path / "_wg1-gen" / "70012" / "evidence"; vev.mkdir(parents=True, exist_ok=True) - (vev / "verdict.json").write_text(json.dumps({"pass": True, "guards": {"A_boot": {"pass": True}}}), encoding="utf-8") + (vev / "verdict.json").write_text(json.dumps({"pass": True, "guards": { + "A_boot": {"pass": True}, "B_uncaught": {"pass": True}, + "C_frame": {"pass": True}, "D_render": {"pass": True}}}), encoding="utf-8") sev = tmp_path / "amgen-70012" / "evidence"; sev.mkdir(parents=True, exist_ok=True) (sev / "service-run-summary.json").write_text( json.dumps({"costRmb": 0.5, "rmbGate": "active", "repairs": 1}), encoding="utf-8") return {"ended": True, "reason": "REPLY_END", "endEvent": {}} monkeypatch.setattr(CP, "_wait_for_turn_end", _ended) - # W-AXIS 波2:本用例验的是单 POST 驱动器管路(scaffold/session/verdict/sidecar/costRmb → ok 映射)=机械预筛这一半; - # 玩法地板判定是新增的独立阻断层(无真截图+无判定模型的桩环境跑不了它),故这里关 blocking,让 ok=预筛, - # 隔离出管路本身。判定阻断路由 test_gameplay_judge.py 与真跑 w2a-verify 覆盖。判定仍会 observe-only 跑一遍 - # (无截图 → fail-closed degraded、无网络无成本),下面顺带断言 judge 段已 additive 落盘、accepted 如实记 False。 + # W-AXIS-V2 波1:本用例验的是单 POST 驱动器管路(scaffold/session/verdict/sidecar/costRmb → ok 映射)=四门预筛 + # 这一半;测试 agent 真玩是独立阻断层、桩环境(无 Chrome/无判定模型)起不了服,故设 acceptance.mode=v1 隔离出 + # 管路本身(v1 走 apply_gameplay_judge 不起服测试员)+ 关 blocking 让 ok=预筛(四门投影)。测试员阻断/mode 三态 + # 由 test_acceptance_v2.py 隔离覆盖、真玩由考卷真跑覆盖。判定仍 observe-only 跑一遍(无截图 → fail-closed degraded)。 + monkeypatch.setenv("TIER2_GEN__ACCEPTANCE__MODE", "v1") monkeypatch.setenv("TIER2_GEN__JUDGE__BLOCKING", "false") summary, gdir = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "t9", "brief": "点球"})) assert scaffolded["gid"] == "70012", "C2:scaffold 用后端 gameId(str),非 session_id" diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/metrics/GenMetrics.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/metrics/GenMetrics.java index f91bb3ec..088a669e 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/metrics/GenMetrics.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/metrics/GenMetrics.java @@ -198,16 +198,26 @@ public class GenMetrics { @SuppressWarnings("unchecked") private void recordGateFails(Map trace) { Object verdict = trace.get("sevenGateVerdict"); - if (!(verdict instanceof Map)) { - return; + if (verdict instanceof Map) { + Object guards = ((Map) verdict).get("guards"); + if (guards instanceof Map) { + for (Map.Entry gate : ((Map) guards).entrySet()) { + if (!guardPass(gate.getValue())) { + registry.counter(M_GATE_FAIL, "gate", gate.getKey()).increment(); + } + } + } } - Object guards = ((Map) verdict).get("guards"); - if (!(guards instanceof Map)) { - return; - } - for (Map.Entry gate : ((Map) guards).entrySet()) { - if (!guardPass(gate.getValue())) { - registry.counter(M_GATE_FAIL, "gate", gate.getKey()).increment(); + // W-AXIS-V2 波1(§4):测试 agent 真玩验收失败也计入 gen_gate_fail 归因——让观测线区分「机械门失败」与 + // 「玩法验收失败」。gate=playtest(测试员判 broken/hollow/off_brief 拒)/ gate=tester_degraded(评不出、 + // fail-closed 待人工)。源 = trace.playtest(result_out 落);playtest 段缺失(v1/老产物)→ 不记,向后兼容。 + Object playtest = trace.get("playtest"); + if (playtest instanceof Map) { + Map pt = (Map) playtest; + Object accepted = pt.get("accepted"); + if (accepted != null && !asBool(accepted)) { + String gate = asBool(pt.get("degraded")) ? "tester_degraded" : "playtest"; + registry.counter(M_GATE_FAIL, "gate", gate).increment(); } } } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/ReadinessScorer.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/ReadinessScorer.java index 7461a8eb..f1448842 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/ReadinessScorer.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/ReadinessScorer.java @@ -107,12 +107,22 @@ public class ReadinessScorer { } } - /** 可玩性子项:trace.pass==true → 1.0;明确 false → 0;缺失 → 中性 0.5。 */ + /** 可玩性子项:优先测试员真玩裁决 trace.playtest.accepted(W-AXIS-V2),回落 trace.pass;缺失 → 中性 0.5。 */ private double scorePlayability(Map trace) { return scorePlayability(trace, view()); } + @SuppressWarnings("unchecked") private double scorePlayability(Map trace, ReadinessView v) { + // W-AXIS-V2 波1(§5):可玩性优先读测试 agent 真玩裁决(playtest.accepted = 真人玩得通、有决策层、切题); + // 契约退役后 trace.pass 仍作预筛/老产物回落,playtest 段存在时以它为准。 + Object playtest = trace.get("playtest"); + if (playtest instanceof Map) { + Object accepted = ((Map) playtest).get("accepted"); + if (accepted != null) { + return asBool(accepted) ? 1.0 : 0.0; + } + } Object pass = trace.get("pass"); if (pass == null) { return v.neutralScore(NEUTRAL); @@ -136,6 +146,19 @@ public class ReadinessScorer { @SuppressWarnings("unchecked") private double scoreFirstPlay(Map trace, ReadinessView v) { + // W-AXIS-V2 波1(§5):首局门三断言随驱动器/H_progress 契约退役,由测试员转写接管——优先读 + // trace.playtest.firstPlay:测试员真玩到闭环(loopClosed)且首次交互有效(playableAtMs 非空)→ 1.0。 + Object playtest = trace.get("playtest"); + if (playtest instanceof Map) { + Object firstPlay = ((Map) playtest).get("firstPlay"); + if (firstPlay instanceof Map) { + Map fp = (Map) firstPlay; + boolean loopClosed = asBool(fp.get("loopClosed")); + boolean playable = fp.get("playableAtMs") != null; // 首次有效交互时刻存在 = 玩得起来 + return (loopClosed && playable) ? 1.0 : v.neutralScore(NEUTRAL); + } + } + // 回落旧口径(v1 / 老产物:driver + H_progress,契约仍挂载的窗口内有效)——向后兼容。 Object gatespec = trace.get("gatespec"); boolean hasDriver = gatespec instanceof Map && ((Map) gatespec).get("driver") != null; Object verdict = trace.get("sevenGateVerdict"); diff --git a/game-runtime/games/_wg1-gen/_shared/playtest.cdp.cjs b/game-runtime/games/_wg1-gen/_shared/playtest.cdp.cjs new file mode 100644 index 00000000..d49aad9f --- /dev/null +++ b/game-runtime/games/_wg1-gen/_shared/playtest.cdp.cjs @@ -0,0 +1,374 @@ +/** + * playtest.cdp.cjs —— 便宜档验收 v2「测试 agent 真玩取证驱动」(W-AXIS-V2 波1 产出①)。 + * + * 一个多模态测试 agent 按 brief 把一款便宜档小游戏当真人一样玩起来:每步只看人类可见的证据 + * (截图 + 运行日志尾巴,绝不读 `_forensicsView` 契约状态)→ 决定动作(tap/key/wait)→ CDP 派发 → + * 最终给结构化裁决与修复反馈。它替代旧 E_live/G_input/H_progress 三门与 tap-targets 驱动器:验收证据 + * 不再由「一份固定代码玩几十种游戏」的取证契约生产,而由测试员亲手真玩产生。裁决权威见质量模型 SoT 裁定三。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【playtest/1 协议】(实现契约,runner=cheap_verify.run_playtest 与本文件双方遵守) + * + * · 坐标协议(§3 第1件):自截自用,`Emulation.setDeviceMetricsOverride` 390×844、DPR=1,截图像素与逻辑 + * 坐标 1:1,模型报的 tap 坐标即 CSS 坐标直接派发;与 play.cdp.cjs 的 DPR2 取证截图互不掺和(两套工具)。 + * + * · 命令行:node playtest.cdp.cjs + * --brief-file= brief 文本文件(诚实来源;runner 从生成时落盘的 brief 写出,避免命令行转义) + * --model=MiniMax-M3 测试员模型(runner 从 genconfig judge.model 透传;不硬编码) + * --steps-base=14 基础步数预算(§3 第6件) + * --steps-max=24 有进展证据时自动扩到的步数上限(§3 第6件) + * --seed= runner 显式生成的种子(记进转写;经 addScriptToEvaluateOnNewDocument 注入 + * window.__playtestSeed;禁隐式时钟种子——两掷独立性靠它 + 清 localStorage) + * --roll=1 第几掷(证据落 evidence/playtest/roll-/;fail 二掷=roll-2,§3 第7件) + * --evidence-dir= 证据根(缺省 games/_wg1-gen//evidence);实落 /playtest/roll-/ + * --base=http://localhost:4998 静态服务(runner 起) + * --cdp=http://localhost:9331 Chrome CDP(runner 起) + * + * · 动作 JSON(模型每步输出恰一个,无 markdown 围栏): + * {"act":"tap","x":195,"y":489,"why":"点开始按钮"} 坐标系 390×844,原点左上 + * {"act":"key","key":"ArrowLeft","why":"向左"} + * {"act":"wait","ms":800,"why":"等动画"} + * {"act":"verdict","pass":true|false,"canSeeScreenshot":true|false, + * "problems":["现象:第N步点(x,y)后画面未变"], 现象必须带步号与落点(现象/推测分离) + * "feedback":"给写游戏 agent 的修复指引(先列现象、再标注推测)","summary":"一句话"} + * + * · 退出码:0=正常给出裁决(pass/fail 都算正常完成) | 2=运行错误(装载失败/未连上/异常) | + * 3=图像通道故障 fail-closed(测试员自报看不到截图,§3 第8件——归因测试员图像通道,非游戏缺陷) + * + * · evidence 目录布局(落 /playtest/roll-/): + * transcript.json 完整逐步转写(每步 act/why/落点/日志尾/画面是否变化/相对时间戳)+ 裁决 + firstPlay + * step-.jpg 每步喂给模型的截图(带坐标尺 + 落点回显),即验收硬证据链 + * + * · stdout 末行 = 单行 JSON 裁决(runner 解析);裁决含 firstPlay:{playableAtMs,firstFeedbackMs,loopClosed} + * 三字段(§5:从转写机械提取,首局门语义并入)。 + * ════════════════════════════════════════════════════════════════════════════ + */ +'use strict'; +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +// 复用 harness 的 CDP 会话类与像素抓取(与 play.cdp.cjs 同一份;cwd=game-runtime 已 npm i ws)。 +const H = require(path.resolve(__dirname, '../../../test/harness/browser-evidence.cjs')); +const { CdpSession, captureImageData, fnv1a32 } = H; +const WebSocket = require('ws'); + +const args = process.argv.slice(2); +const GID = args.find((a) => !a.startsWith('--')); +const opt = (k, d) => { const m = args.find((a) => a.startsWith(`--${k}=`)); return m ? m.split('=').slice(1).join('=') : d; }; +const MODEL = opt('model', 'MiniMax-M3'); +const STEPS_BASE = parseInt(opt('steps-base', '14'), 10); +const STEPS_MAX = parseInt(opt('steps-max', '24'), 10); +const SEED = parseInt(opt('seed', String(Date.now() % 1e9)), 10); // runner 应显式传;缺省仅兜底(会记进转写) +const ROLL = parseInt(opt('roll', '1'), 10); +const BASE = (opt('base', 'http://localhost:4998') || '').replace(/\/$/, ''); +const CDP = (opt('cdp', 'http://localhost:9331') || '').replace(/\/$/, ''); +const BRIEF_FILE = opt('brief-file', ''); +const EVIDENCE_ROOT = opt('evidence-dir', path.resolve(__dirname, '..', GID || '', 'evidence')); +const OUT_DIR = path.join(EVIDENCE_ROOT, 'playtest', `roll-${ROLL}`); + +const NEWAPI_BASE = process.env.NEWAPI_BASE_URL || 'http://100.64.0.8:3000'; +const KEY = process.env.NEWAPI_KEY; +if (!GID) { console.error('缺 '); process.exit(2); } +if (!KEY) { console.error('缺 NEWAPI_KEY(runner 应从凭据档注入)'); process.exit(2); } + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** 极简 HTTP(PUT 建 target / GET 列表);Node http 模块直连,天然绕系统代理(内网直连红线)。 */ +function httpJson(method, urlStr) { + return new Promise((resolve, reject) => { + const u = new URL(urlStr); + const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, timeout: 15000 }, + (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { try { resolve(b ? JSON.parse(b) : {}); } catch (e) { reject(e); } }); }); + req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout'))); req.end(); + }); +} + +/** 调 new-api /v1/chat/completions(OpenAI 兼容,image_url data-URI);http 模块直连不走系统代理。 */ +function chat(messages, maxTokens) { + const body = JSON.stringify({ model: MODEL, max_tokens: maxTokens || 3000, messages }); + const u = new URL(NEWAPI_BASE + '/v1/chat/completions'); + return new Promise((resolve, reject) => { + const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', + headers: { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 120000 }, + (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { + try { + const d = JSON.parse(b); + if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}: ${b.slice(0, 200)}`)); + const msg = (d.choices || [{}])[0].message || {}; + resolve({ text: msg.content || '', usage: d.usage || {} }); + } catch (e) { reject(new Error('响应非 JSON: ' + b.slice(0, 200))); } + }); }); + req.on('error', reject); req.on('timeout', () => req.destroy(new Error('LLM 调用超时'))); req.write(body); req.end(); + }); +} + +// 测试员系统提示。现象/推测强制分离(§3 修复反馈契约)= 治 spike 三次「失手后错误归因」失效模式的直接约束: +// problems 里凡断言游戏有问题,必须先写【现象】(第 N 步点 (x,y) 后画面/日志有无变化——可复核的客观事实), +// 再把「可能是事件没绑定」这类【推测】另起并显式标注;续修只吃现象段。 +const SYSTEM = `你是小游戏【真人视角测试员】。你只依赖两样人类可见的证据:游戏截图 + 运行日志尾巴。绝不臆测截图里没有的东西。 +任务:按 brief 把这款游戏当真人一样玩起来(点按钮/点目标/等待),验证三件事:①玩得通(能从菜单进游戏、操作有响应、能推进到有结果)②有决策层(点哪有区别、有输赢/取舍)③与 brief 是同一款游戏。 +【坐标必读】截图上烧了粉色坐标尺:细格 50px、粗格 100px,左缘 y100..y800、上缘 x100..x300 是刻度标签。 +报 tap 坐标前,先对着刻度读出目标的位置(如:按钮中心在 y400 与 y500 粗线正中间 → y=450),绝不凭感觉估。 +【落点回显】截图上的亮黄色圆环+十字 = 你上一步 tap 的真实落点。每一步先看它:落点不在你想点的目标上, +就按偏差校正坐标(如圆环在按钮下方 60px → 下一步 y 减 60);落点在目标上而画面没反应,才可能是游戏的问题。 +【现象与推测分离(硬要求)】你判 fail 时,problems 每一条先写【现象】:第几步点了哪个坐标 (x,y)、之后画面/日志有没有变化—— +这是可复核的客观事实;若要猜原因(如「可能是点击事件没绑定」),必须另起一句并以「推测:」开头。绝不把推测当现象。 +每一步你只输出一个 JSON(不要 markdown 围栏): + {"act":"tap","x":195,"y":489,"why":"点开始按钮(按钮上沿贴 y500 线上方约半格)"} + {"act":"key","key":"ArrowLeft","why":"向左"} + {"act":"wait","ms":800,"why":"等动画/观察窗"} + {"act":"verdict","pass":true,"canSeeScreenshot":true,"problems":[],"feedback":"通关说明或修复指引","summary":"一句话"} +规则:每次 tap 后对照上一步截图判断画面变没变;没变就【必须换坐标】(先沿 y 轴上下各挪 50 试)或 wait,同一坐标最多连点 2 次; +若换过 ≥3 处坐标画面仍毫无响应,判 fail 并把「点了哪些坐标都没反应」写进 problems(带步号与坐标); +看不到截图时如实报 canSeeScreenshot=false 并判 fail(problems 注明「测试员看不到画面」)。 +【步数纪律】每步提示会告诉你「第 N 步 / 共 M 步预算」。你【必须】在预算用尽前给出 verdict,绝不要把步数耗光: +玩通到有结果就判 pass、确认玩不通(换过 ≥3 处坐标仍无响应)就判 fail;越接近预算上限越要尽快收敛给裁决。`; + +(async () => { + fs.mkdirSync(OUT_DIR, { recursive: true }); + // 读 brief(诚实来源):优先 --brief-file(runner 写),回退生成时落盘的 amgen-/evidence/brief.json。 + let brief = ''; + try { + if (BRIEF_FILE && fs.existsSync(BRIEF_FILE)) { + brief = fs.readFileSync(BRIEF_FILE, 'utf-8'); + } else { + const bp = path.resolve(__dirname, '..', '..', `amgen-${GID}`, 'evidence', 'brief.json'); + if (fs.existsSync(bp)) brief = JSON.parse(fs.readFileSync(bp, 'utf-8')).brief || ''; + } + } catch (_) { brief = ''; } + + // 连页面(DPR=1 省 token,agent 看 390×844 原比例)。清 localStorage/sessionStorage + 注入 seed: + // 经 addScriptToEvaluateOnNewDocument 在文档创建时最先执行(早于游戏脚本),保证两掷从干净存档起、 + // seed 可复现(禁隐式时钟种子)——§3 第7件二掷独立性的地基。 + const created = await httpJson('PUT', `${CDP}/json/new?${encodeURI('about:blank')}`); + const ws = await new Promise((res, rej) => { const s = new WebSocket(created.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 }); s.on('open', () => res(s)); s.on('error', rej); }); + const cdp = new CdpSession(ws, created.id); + await cdp.send('Page.enable'); await cdp.send('Runtime.enable'); + await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }); + try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch (_) {} + await cdp.send('Page.addScriptToEvaluateOnNewDocument', { + source: `try{localStorage.clear();sessionStorage.clear();}catch(e){} window.__playtestSeed=${Number.isFinite(SEED) ? SEED : 0};`, + }); + const navStart = Date.now(); // firstPlay 三字段的相对时间基准 + const nav = await cdp.send('Page.navigate', { url: `${BASE}/games/_wg1-gen/${GID}/index.html` }); + // navigate 级失败 fail-fast(2026-07-10 考卷实锤:静态服务端口撞 Chrome unsafe 清单时页面被 + // net::ERR_UNSAFE_PORT 拒——curl 就绪检查照过、__genBooted 永不置位,干等 30s 只会把根因掩成 + // 假 boot-timeout)。navigate 返回 errorText 即立即带因报错,退出码 2(runner 归 degraded 待人工)。 + if (nav && nav.errorText) { + const out = { gid: GID, model: MODEL, seed: SEED, roll: ROLL, pass: false, canSee: null, + problems: [`页面导航失败:${nav.errorText}(runner 环境问题,非游戏缺陷——如端口撞 Chrome unsafe 清单)`], + feedback: '', summary: 'nav-error', runnerError: true, + steps: 0, tokens: { in: 0, out: 0 }, firstPlay: { playableAtMs: null, firstFeedbackMs: null, loopClosed: false } }; + fs.writeFileSync(path.join(OUT_DIR, 'transcript.json'), JSON.stringify({ out, transcript: [] }, null, 1)); + console.log(JSON.stringify(out)); process.exit(2); + } + const deadline = Date.now() + 30000; await delay(800); + for (;;) { + const st = await cdp.evaluate('({ b: !!window.__genBooted, e: !!window.__gameHostEngineInitFired, err: (window.__genBootError||window.__gameHostBootError||null) })'); + if (st && st.err) { + // A 门职责,测试员不接:boot 死掉直接如实报,退出码 2(编排器据 floor A 门也会拦)。 + const out = { gid: GID, model: MODEL, seed: SEED, roll: ROLL, pass: false, canSee: null, + problems: [`装载失败:${st.err}`], feedback: '', summary: 'boot-dead', bootDead: true, + steps: 0, tokens: { in: 0, out: 0 }, firstPlay: { playableAtMs: null, firstFeedbackMs: null, loopClosed: false } }; + fs.writeFileSync(path.join(OUT_DIR, 'transcript.json'), JSON.stringify({ out, transcript: [] }, null, 1)); + console.log(JSON.stringify(out)); process.exit(2); + } + if (st && st.b && st.e) break; + if (Date.now() > deadline) { + const out = { gid: GID, model: MODEL, seed: SEED, roll: ROLL, pass: false, canSee: null, + problems: ['装载超时(30s 未 booted)'], feedback: '', summary: 'boot-timeout', bootDead: true, + steps: 0, tokens: { in: 0, out: 0 }, firstPlay: { playableAtMs: null, firstFeedbackMs: null, loopClosed: false } }; + fs.writeFileSync(path.join(OUT_DIR, 'transcript.json'), JSON.stringify({ out, transcript: [] }, null, 1)); + console.log(JSON.stringify(out)); process.exit(2); + } + await delay(300); + } + + // 坐标尺覆盖层(§3 第2件):细网格 50px、粗线+刻度标签 100px;pointer-events:none 不挡输入;只进截图。 + await cdp.evaluate(`(function(){ + if (document.getElementById('__testgrid')) return; + var d = document.createElement('div'); + d.id = '__testgrid'; + d.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:99999;' + + 'background-image:linear-gradient(rgba(255,0,90,.28) 1px,transparent 1px),linear-gradient(90deg,rgba(255,0,90,.28) 1px,transparent 1px),' + + 'linear-gradient(rgba(255,0,90,.14) 1px,transparent 1px),linear-gradient(90deg,rgba(255,0,90,.14) 1px,transparent 1px);' + + 'background-size:100px 100px,100px 100px,50px 50px,50px 50px;'; + for (var y = 100; y < 844; y += 100) { + var t = document.createElement('span'); + t.textContent = 'y' + y; + t.style.cssText = 'position:absolute;left:2px;top:' + (y - 14) + 'px;font:12px monospace;color:#ff005a;background:rgba(255,255,255,.75);padding:0 2px;'; + d.appendChild(t); + } + for (var x = 100; x < 390; x += 100) { + var s = document.createElement('span'); + s.textContent = 'x' + x; + s.style.cssText = 'position:absolute;top:2px;left:' + (x - 14) + 'px;font:12px monospace;color:#ff005a;background:rgba(255,255,255,.75);padding:0 2px;'; + d.appendChild(s); + } + document.body.appendChild(d); + })()`); + + const shot = async () => { + const r = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 62 }); + return 'data:image/jpeg;base64,' + r.data; + }; + const logTail = async () => cdp.evaluate('(function(){ var lg=window.__gameLog||[]; return lg.slice(-6).map(function(e){ return (e.scope||"")+"/"+(e.tag||"")+":"+String(e.msg||"").slice(0,60); }); })()'); + const logLen = async () => cdp.evaluate('(function(){ return (window.__gameLog||[]).length; })()'); + // 画面帧哈希(§5/§3 第6件的确定性信号):抓 canvas 像素算 fnv1a32,判「这一步动作后画面真变了没」。 + // 与测试员的视觉判断解耦——firstPlay 与预算进展扩展都靠这个机械信号,不靠模型自觉。 + const frameHash = async () => { + // selector 与 play.cdp.cjs D 门同口径:LittleJS 引擎 canvas id=#game-engine(WebGL 主画布经 2d 回读); + // 逐个回退保鲁棒——找不到就返 0(firstPlay/预算扩展降精度,绝不崩主流程)。 + for (const sel of ['#game-engine', '#game', 'canvas']) { + try { const img = await captureImageData(cdp, sel); if (img && img.data && img.data.length) return fnv1a32(img.data) || 0; } + catch (_) { /* 试下一个 selector */ } + } + return 0; + }; + + // 落点回显(§3 第3件):tap 后放亮黄圆环+十字(pointer-events:none),下一帧截图里 agent 能看到自己点在哪。 + const placeTapMarker = (x, y) => cdp.evaluate(`(function(){ + var m = document.getElementById('__tapmark'); + if (!m) { m = document.createElement('div'); m.id='__tapmark'; document.body.appendChild(m); } + m.style.cssText = 'position:fixed;left:${x - 14}px;top:${y - 14}px;width:28px;height:28px;pointer-events:none;z-index:100000;' + + 'border:3px solid #ffe000;border-radius:50%;box-shadow:0 0 0 1px #000;' + + 'background:radial-gradient(circle,rgba(255,224,0,.9) 2px,transparent 3px);'; + })()`); + + const transcript = []; + const history = []; + let usageIn = 0, usageOut = 0, verdict = null; + let lastTap = null, sameTapStreak = 0, pushedBack = false; + const tapPoints = new Set(); + let prevLogLen = await logLen(); // 上一步日志长度(逐步 delta:这一步游戏有没有 emit 事件) + let prevHash = await frameHash(); + let firstPlayableStep = null, firstFeedbackStep = null; // firstPlay 机械提取的步索引 + const stepTimes = []; // 每步相对导航的毫秒(供 firstPlay 时间戳) + let stepsCap = STEPS_BASE; // 预算随进展扩展:基础预算,有进展则升到 STEPS_MAX(§3 第6件) + let lastSignal = null; // 上一步动作后的机械信号(反馈给测试员,治「失手后错误归因」) + + for (let step = 0; step < stepsCap && !verdict; step++) { + const tNow = Date.now() - navStart; + stepTimes.push(tNow); + const img = await shot(); + const lg = await logTail(); + // 防误导硬提示(§3 第4件;runner 侧确定性注入,不靠模型自觉)。 + let hint = ''; + if (sameTapStreak >= 2) hint = `\n⚠ 你已连续 ${sameTapStreak} 次点同一坐标(${lastTap});规则:立即换点(先 y±50)或 wait,不许再点原处。`; + // 机械信号反馈(runner 侧确定性护栏,治「测试员失手后错误归因」头号风险):把上一步操作后画面/日志的 + // 客观变化告诉测试员——它常在自己坐标点偏时误判成「游戏没反应/有 bug」,而机械测得的画面像素/日志变化 + // 是客观事实。给它这个事实,让它把「落点偏了」与「游戏真没响应」分开,不冤枉在响应的游戏。 + if (lastSignal && lastSignal.isInput) { + if (lastSignal.screenChanged || lastSignal.logGrew) { + hint += `\n⚙ 客观信号(runner 机械测得,非你的视觉判断):上一步操作后画面像素${lastSignal.screenChanged ? '已变化' : '未变化'}、游戏日志${lastSignal.logGrew ? `新增 ${lastSignal.logDelta} 条` : '无新增'}——游戏【确实响应了】你的操作,别误判成「没反应/判错」;你想点的目标若没动,多半是落点偏了(看黄圆环校正坐标),不是游戏坏了。`; + } else { + hint += `\n⚙ 客观信号(runner 机械测得):上一步操作后画面像素与游戏日志【均无变化】——可能落点点空了(看黄圆环校正坐标),或此处确实无可交互元素;换个坐标再试。`; + } + } + const remain = stepsCap - step - 1; // 剩余步数(临近上限时确定性催收裁决,不靠模型自觉) + if (remain <= 3) hint += `\n⏳ 步数预算将尽:本步之后只剩 ${remain} 步,请尽快收敛给出 verdict(玩通判 pass / 玩不通判 fail)。`; + const userParts = [ + { type: 'text', text: `brief:${brief}\n已执行历史:${history.length ? history.join(' → ') : '(第一步)'}\n运行日志尾:${JSON.stringify(lg)}${hint}\n当前截图(第 ${step + 1} 步 / 共 ${stepsCap} 步预算;黄圆环=你上一步落点):` }, + { type: 'image_url', image_url: { url: img } }, + { type: 'text', text: '输出下一个动作 JSON(或 verdict):' }, + ]; + // 每步截图落盘(验收硬证据链)。 + try { fs.writeFileSync(path.join(OUT_DIR, `step-${String(step + 1).padStart(2, '0')}.jpg`), Buffer.from(img.split(',')[1], 'base64')); } catch (_) {} + + let resp; + try { resp = await chat([{ role: 'system', content: SYSTEM }, { role: 'user', content: userParts }], 3000); } + catch (e) { history.push(`step${step}:LLM 调用失败(${String(e.message).slice(0, 40)})`); transcript.push({ step, tMs: tNow, err: String(e.message).slice(0, 120) }); await delay(500); continue; } + usageIn += resp.usage.prompt_tokens || 0; usageOut += resp.usage.completion_tokens || 0; + let act; + try { act = JSON.parse(resp.text.replace(/^```json?\s*|```\s*$/g, '').trim()); } + catch (_) { history.push(`step${step}:输出非JSON(截断记录)`); transcript.push({ step, tMs: tNow, raw: resp.text.slice(0, 300) }); continue; } + + if (act.act === 'verdict') { + // 反早退(§3 第5件):fail 裁决若探索不足(独立落点 <3)且非图像通道故障,驳回一次逼继续测。 + if (act.pass === false && act.canSeeScreenshot !== false && tapPoints.size < 3 && !pushedBack) { + pushedBack = true; + history.push(`裁决被驳回(仅试过 ${tapPoints.size} 处落点,规则要求 ≥3 处;继续测试)`); + transcript.push({ step, tMs: tNow, act, pushedBack: true }); + continue; + } + transcript.push({ step, tMs: tNow, act }); + verdict = act; break; + } + + if (act.act === 'tap' && typeof act.x === 'number') { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: act.x, y: act.y }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await placeTapMarker(act.x, act.y); + const key = `${Math.round(act.x)},${Math.round(act.y)}`; + sameTapStreak = (lastTap === key) ? sameTapStreak + 1 : 1; + lastTap = key; + tapPoints.add(key); + history.push(`tap(${key})[${act.why || ''}]`); + await delay(450); + } else if (act.act === 'key' && act.key) { + await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', code: act.key, key: act.key }); + await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', code: act.key, key: act.key }); + history.push(`key(${act.key})`); + sameTapStreak = 0; + await delay(300); + } else if (act.act === 'wait') { + history.push(`wait(${act.ms || 500})`); + sameTapStreak = 0; + await delay(Math.min(act.ms || 500, 3000)); + } else { + history.push(`step${step}:未知动作`); + } + + // 动作后的机械信号:画面变化 + 逐步日志推进(§5 firstPlay + §3 第6件预算进展 + 机械信号反馈护栏)。 + const curHash = await frameHash(); + const curLogLen = await logLen(); + const screenChanged = curHash !== prevHash; + const logDelta = curLogLen - prevLogLen; // 这一步游戏 emit 的事件数(逐步,非累计) + const logGrew = logDelta > 0; + const isInput = act.act === 'tap' || act.act === 'key'; + if (isInput && screenChanged && firstPlayableStep === null) firstPlayableStep = step; // 首次有效交互 + if (logGrew && firstFeedbackStep === null) firstFeedbackStep = step; // 首个可感知反馈 + // 有进展证据(画面变化或日志推进)→ 预算从基础扩到上限(§3 第6件):难品类节拍长,基础 14 装不下四工序。 + if ((screenChanged || logGrew) && stepsCap < STEPS_MAX) stepsCap = STEPS_MAX; + lastSignal = { isInput, screenChanged, logGrew, logDelta }; // 下一步反馈给测试员(治错误归因) + transcript.push({ step, tMs: tNow, act, log: lg, screenChanged, logGrew, logDelta, tapPoints: tapPoints.size }); + prevHash = curHash; + prevLogLen = curLogLen; + } + + if (!verdict) verdict = { act: 'verdict', pass: false, canSeeScreenshot: true, + problems: [`步数预算内(${stepsCap} 步)未给出裁决`], feedback: '', summary: 'no-verdict', noVerdict: true }; + + // firstPlay 三字段机械提取(§5:首局门语义并入测试员转写;时间戳取该步相对导航毫秒): + // playableAtMs=首次有效交互(输入后画面真变)、firstFeedbackMs=首个可感知反馈(日志推进)、 + // loopClosed=测试员判 pass(玩通到有结果)即闭环达成。三值机械可判,不含模型主观。 + const firstPlay = { + playableAtMs: firstPlayableStep !== null ? (stepTimes[firstPlayableStep] ?? null) : null, + firstFeedbackMs: firstFeedbackStep !== null ? (stepTimes[firstFeedbackStep] ?? null) : null, + loopClosed: verdict.pass === true, + }; + + // 图像通道自检(§3 第8件):测试员自报看不到截图 → 退出码 3,归因测试员图像通道故障、非游戏缺陷。 + const canSee = verdict.canSeeScreenshot; + const imageBlind = canSee === false; + + const out = { gid: GID, model: MODEL, seed: SEED, roll: ROLL, pass: verdict.pass === true, + canSee, imageBlind, problems: verdict.problems || [], feedback: verdict.feedback || '', + summary: verdict.summary || '', steps: transcript.length, stepsCap, + tapPoints: tapPoints.size, firstPlay, tokens: { in: usageIn, out: usageOut } }; + fs.writeFileSync(path.join(OUT_DIR, 'transcript.json'), JSON.stringify({ out, transcript }, null, 1)); + console.log(JSON.stringify(out)); + try { ws.close(); } catch (_) {} + process.exit(imageBlind ? 3 : 0); +})().catch((e) => { + console.error('PLAYTEST-ERR:', e && e.message); + try { + fs.mkdirSync(OUT_DIR, { recursive: true }); + const out = { gid: GID, model: MODEL, seed: SEED, roll: ROLL, pass: false, canSee: null, + problems: [`测试员运行错误:${e && e.message}`], feedback: '', summary: 'runner-error', runnerError: true, + steps: 0, tokens: { in: 0, out: 0 }, firstPlay: { playableAtMs: null, firstFeedbackMs: null, loopClosed: false } }; + fs.writeFileSync(path.join(OUT_DIR, 'transcript.json'), JSON.stringify({ out, transcript: [] }, null, 1)); + console.log(JSON.stringify(out)); + } catch (_) {} + process.exit(2); +}); diff --git a/game-runtime/games/_wg1-gen/_shared/serve-and-playtest.sh b/game-runtime/games/_wg1-gen/_shared/serve-and-playtest.sh new file mode 100755 index 00000000..f59c8dba --- /dev/null +++ b/game-runtime/games/_wg1-gen/_shared/serve-and-playtest.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# serve-and-playtest.sh —— 便宜档验收 v2「测试 agent 真玩」一键编排(W-AXIS-V2 波1)。 +# 与 serve-and-play.sh 同一套起服/Chrome/净场/就绪等待编排(唯一区别:末步换调 playtest.cdp.cjs 而非 +# play.cdp.cjs),让测试员回路复用久经考验的起服链。cwd 必须 = game-runtime。 +# 用法:bash games/_wg1-gen/_shared/serve-and-playtest.sh [port] [cdpPort] [-- 透传给 playtest.cdp.cjs 的额外参数...] +# 例:... hard-puzzle-r1 4998 9331 -- --model=MiniMax-M3 --roll=1 --seed=42 --brief-file=/tmp/b.txt --evidence-dir=/abs/evidence +# 依赖:NEWAPI_KEY 由 runner(cheap_verify.run_playtest)从凭据档注入环境;playtest.cdp.cjs 用 http 模块直连不走代理。 +set -u +GAME_ID="${1:?用法: serve-and-playtest.sh [port] [cdpPort] [-- 额外参数...]}" +PORT="${2:-4998}" +CDP_PORT="${3:-9331}" +shift $(( $# < 3 ? $# : 3 )) +[ "${1:-}" = "--" ] && shift # 吞掉分隔符,其余原样透传给 playtest.cdp.cjs + +# Chrome 路径探测(跨平台;与 serve-and-play.sh 同口径:CHROME_BIN 覆盖 > Linux > macOS)。 +CHROME="" +if [ -n "${CHROME_BIN:-}" ] && [ -x "${CHROME_BIN}" ]; then + CHROME="${CHROME_BIN}" +else + for p in "/usr/bin/google-chrome" "/usr/bin/google-chrome-stable" \ + "/usr/bin/chromium" "/usr/bin/chromium-browser" "/opt/google/chrome/chrome" \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" \ + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"; do + [ -x "$p" ] && CHROME="$p" && break + done + [ -z "$CHROME" ] && CHROME="$(command -v google-chrome chromium chromium-browser 2>/dev/null | head -1)" +fi +[ -z "$CHROME" ] && { echo "❌ 未找到 Chrome(探测 /usr/bin、/Applications、PATH、CHROME_BIN 均无)"; exit 3; } + +USER_DATA_DIR="$(mktemp -d -t wg1-playtest-XXXX)" +SERVE_PID=""; CHROME_PID="" +cleanup() { + [ -n "$CHROME_PID" ] && kill "$CHROME_PID" 2>/dev/null + [ -n "$SERVE_PID" ] && kill "$SERVE_PID" 2>/dev/null + rm -rf "$USER_DATA_DIR" 2>/dev/null +} +trap cleanup EXIT INT TERM + +# 净场:杀掉占用端口的旧进程(保守,仅本脚本端口)。 +lsof -ti tcp:"$PORT" 2>/dev/null | xargs kill 2>/dev/null || true +lsof -ti tcp:"$CDP_PORT" 2>/dev/null | xargs kill 2>/dev/null || true +sleep 0.3 + +# 1) 静态服务器:serve game-runtime 根(cwd)。 +node test/harness/static-serve.cjs . "$PORT" >/tmp/wg1-playtest-serve-$PORT.log 2>&1 & +SERVE_PID=$! + +# 2) Chrome headless(CDP 必备旗标:--remote-allow-origins=* + --autoplay-policy=...)。 +"$CHROME" --headless=new --no-sandbox --no-zygote --disable-gpu \ + --remote-debugging-port="$CDP_PORT" --remote-allow-origins='*' \ + --autoplay-policy=no-user-gesture-required \ + --user-data-dir="$USER_DATA_DIR" about:blank >/tmp/wg1-playtest-chrome-$PORT.log 2>&1 & +CHROME_PID=$! + +# 3) 就绪等待。 +ok_serve=0; ok_cdp=0 +for i in $(seq 1 20); do + curl -sf "http://localhost:$PORT/games/_wg1-gen/$GAME_ID/index.html" -o /dev/null 2>/dev/null && ok_serve=1 + curl -sf "http://localhost:$CDP_PORT/json/version" -o /dev/null 2>/dev/null && ok_cdp=1 + [ "$ok_serve" = 1 ] && [ "$ok_cdp" = 1 ] && break + sleep 0.5 +done +[ "$ok_serve" != 1 ] && { echo "❌ static-serve 未就绪(见 /tmp/wg1-playtest-serve-$PORT.log)"; cat /tmp/wg1-playtest-serve-$PORT.log; exit 4; } +[ "$ok_cdp" != 1 ] && { echo "❌ Chrome CDP 未就绪(见 /tmp/wg1-playtest-chrome-$PORT.log)"; tail -5 /tmp/wg1-playtest-chrome-$PORT.log; exit 5; } + +# 4) 测试员真玩驱动(其余参数由 runner 透传:--model/--roll/--seed/--brief-file/--evidence-dir/--steps-*)。 +node games/_wg1-gen/_shared/playtest.cdp.cjs "$GAME_ID" --base="http://localhost:$PORT" --cdp="http://localhost:$CDP_PORT" "$@" +PLAYTEST_RC=$? +exit "$PLAYTEST_RC" diff --git a/tier2/config/generation.yaml b/tier2/config/generation.yaml index 639f002c..238bb0da 100644 --- a/tier2/config/generation.yaml +++ b/tier2/config/generation.yaml @@ -107,6 +107,23 @@ judge: max_frames: 6 # 最多喂几帧证据截图(首帧 + 局中连拍 + 局末,控 token/成本;超出保头尾均匀采样) cost_target_rmb: 0.3 # 单局判定成本目标(¥;仅记账对照/告警,不阻断——实测约 ¥0.10/4-6 帧) +# ── acceptance · 便宜档验收 v2 版本开关(消费方:cheap-worker/cheap_verify.run_acceptance;W-AXIS-V2 波1)── +# 三态(§2):v2=测试员真玩阻断放行;shadow=测试员照跑照落证据但 accepted 仍取旧口径(新旧并跑对照,批次账落对照表); +# v1=W-AXIS 旧口径(driven 九门预筛 + 判定器读驱动器证据)。波1 灰度窗口默认 shadow——安全侧:测试员未稳时不误拒 +# 生产;验收真跑/切换时改此值为 v2(或临时 env TIER2_GEN__ACCEPTANCE__MODE=v2)。 +acceptance: + mode: shadow # v2 | shadow | v1 + +# ── playtest · 测试 agent 真玩回路旋钮(消费方:cheap-worker/cheap_verify.run_playtest;测试员模型走 judge.model)── +# §3 八件里可配的两组:步数预算(base 随进展扩到 max)与二掷/超时/成本上限;其余护栏(坐标尺/落点回显/ +# 同点硬提示/反早退/图像通道自检)是 runner 侧确定性代码、不留旋钮。 +playtest: + steps_base: 14 # 基础步数预算(§3 第6件) + steps_max: 24 # 有进展证据(画面变化/日志推进)时自动扩到的步数上限 + second_roll: true # fail 二掷确认(§3 第7件):roll-1 判 fail 且四门地板全绿 → 重开一局二掷,两掷同 fail 才落 fail + timeout_s: 600 # 单掷子进程墙钟超时(秒;一轮 8~24 步 × M3 每步 10~40s ≈ 2~10 分钟) + cost_cap_rmb: 1.5 # 单局验收成本上限(¥;含二掷,单轮设计上限 ¥0.5,对照旧链路契约困死单局 ¥10+ 净赚) + # ── archetype · business-sim 品类 driver 参数(消费方:worker/archetypes.py)───────────────── # 注:这里只放 driver 真玩规格里的【可调数值】(怎么玩);品类的【结构】(三系统/数据表 schema) # 不在配置层,仍由 fixture 与品类注册表定。改这些 = 调 business-sim driver 怎么把游戏玩到终态。 diff --git a/tier2/gen-worker/worker/gate_judge.py b/tier2/gen-worker/worker/gate_judge.py index f94f28b0..a9022ffd 100644 --- a/tier2/gen-worker/worker/gate_judge.py +++ b/tier2/gen-worker/worker/gate_judge.py @@ -459,13 +459,23 @@ def _cheap_verdict_feedback(v: dict, *, game_id=None, staged_dir=None) -> str: return _render_cheap_feedback(obj) +# W-AXIS-V2 波1:便宜档四门投影(契约无关 A_boot/B_uncaught/C_frame/D_render 求 AND)——续修收敛判据的地板, +# 与 cheap_verify.project_floor 同口径(此处内联常量,避免 tier2 worker 跨目录 import cheap-worker)。 +_CHEAP_FLOOR_GATES = ("A_boot", "B_uncaught", "C_frame", "D_render") + + +def _floor_pass(guards: dict) -> bool: + """四门全绿且逐门真实存在 → True;任一门缺失/挂 → False(fail-closed)。""" + return all(isinstance(guards.get(g), dict) and guards[g].get("pass") is True for g in _CHEAP_FLOOR_GATES) + + def judge_cheap_verdict(play_result: dict, *, game_id=None, staged_dir=None) -> GateJudgment: """把便宜档 cheap_run.play 的九门 verdict({pass, guards{门:{pass,...}}})归一成 GateJudgment(与 judge_tier2_verdict 并列)。 - 放行判据 = 顶层 pass **且 guards 非空**(便宜档 verdict.pass 已是九门 AND 后的总放行,等价 gen.mjs done 门;判据不放松)。 - **I3 防假绿**:只看 `bool(v.get("pass"))` 会把「play 没跑出逐门却被强标 pass」当绿——故要求 `guards` 非空 - (九门真跑必产逐门 map);空 guards + pass=True → passed=False、feedback 落 harness 说明。 - failed_gates 复用 _verdict_brief 口径(guards 里 pass is False 的门名;H_progress 嵌套已在 g.pass 层判)。 + 放行判据(W-AXIS-V2 波1)= **四门投影**(契约无关 A/B/C/D 求 AND)**且 guards 非空**,不再要求九门 verdict.pass—— + E/G/H/I/F 依赖取证契约/驱动器,随契约退役会坍缩;续修只需游戏「起得来、没崩、有帧、有渲染」四门地板即算未见明显死。 + **I3 防假绿**:空 guards 无从证明门真跑过,不放行(四门真跑必产逐门 map)。 + failed_gates 仍列 guards 里 pass is False 的门名(诊断/反馈用,含降观测门;四门过即放行、不进 feedback 分支)。 C6 接线(W-S1 单③):feedback 由 C6 结构化对象渲染(build_cheap_feedback_obj → _render_cheap_feedback, 单一来源防拼串丢字段);带 phaseNow(latch 两失败支都读)、driverType(判卷驱动器族)、game-log 摘要。 @@ -475,8 +485,8 @@ def judge_cheap_verdict(play_result: dict, *, game_id=None, staged_dir=None) -> """ v = play_result or {} guards = v.get("guards") or {} - # 放行必须「顶层 pass 且有逐门 guards」:空 guards 无从证明九门真跑过,不放行(防空 verdict 假绿)。 - passed = bool(v.get("pass")) and bool(guards) + # 放行 = 四门投影(A/B/C/D)全绿 且 guards 非空(空 guards 无从证明门真跑过,fail-closed 不放行)。 + passed = _floor_pass(guards) and bool(guards) failed_gates = [k for k, g in guards.items() if isinstance(g, dict) and g.get("pass") is False] if passed: # 全绿:feedback 只作日志/trace 用(GateJudgment 契约:passed=True 时不回喂),给准确描述。 diff --git a/tier2/gen-worker/worker/genconfig.py b/tier2/gen-worker/worker/genconfig.py index 20fdabcd..2747e751 100644 --- a/tier2/gen-worker/worker/genconfig.py +++ b/tier2/gen-worker/worker/genconfig.py @@ -146,6 +146,23 @@ _BUILTIN_DEFAULTS: dict[str, dict[str, Any]] = { "max_frames": 6, # 最多喂几帧证据截图(首帧+局中连拍+局末) "cost_target_rmb": 0.3, # 单局判定成本目标(¥;仅记账对照,不阻断) }, + # ── acceptance:便宜档验收 v2 版本开关(cheap-worker/cheap_verify.run_acceptance;W-AXIS-V2 波1)── + # 三态开关(§2):v2=测试员真玩阻断放行;shadow=测试员照跑照落证据但 accepted 取旧口径(新旧并跑对照); + # v1=W-AXIS 旧口径(driven 九门预筛 + 判定器读驱动器证据)。波1 灰度窗口默认 shadow——安全侧: + # 测试员未稳时不误拒生产,验收真跑时显式切 v2(env TIER2_GEN__ACCEPTANCE__MODE=v2 或改 YAML)。 + "acceptance": { + "mode": "shadow", # v2 | shadow | v1 + }, + # ── playtest:测试 agent 真玩回路旋钮(cheap-worker/cheap_verify.run_playtest;测试员模型走 judge.model)── + # §3 八件里可配的两组:预算(base→max 随进展扩)与二掷/超时/成本上限;其余(坐标尺/落点回显/硬提示/ + # 反早退/图像自检)是 runner 侧确定性护栏、不留配置。 + "playtest": { + "steps_base": 14, # 基础步数预算(§3 第6件) + "steps_max": 24, # 有进展证据(画面变化/日志推进)时自动扩到的上限 + "second_roll": True, # fail 二掷确认(§3 第7件:roll-1 fail 且四门绿 → 二掷,两掷同 fail 才 fail) + "timeout_s": 600.0, # 单掷子进程墙钟超时(一轮 8~24 步 × M3 每步 10~40s ≈ 2~10 分钟) + "cost_cap_rmb": 1.5, # 单局验收成本上限(¥;含二掷,单轮设计上限 ¥0.5) + }, # 注:business-sim driver 参数(steps/stepMs/winThreshold/bankruptSteps/streakLose)不在本配置层—— # 曾登记过一个 "archetype" 区声明这五个键,但生成路(archetypes._business_sim_gate_spec / # run.DEFAULT_BUSINESS_SIM_PLAY_SPEC)从不读它、真值一直硬编码在那两处 = 死旋钮(改配置无效、误导)。