From 5b35e61f89e7c30fb61cf2b868d47554b58d26d9 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 24 Jun 2026 04:49:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(tier2):=20B=E9=97=A8-P3=20=E8=A3=85?= =?UTF-8?q?=E8=BD=BD=E4=B8=8E=E5=93=81=E7=B1=BB=E2=80=94=E2=80=94=E8=90=BD?= =?UTF-8?q?=E5=BA=93seam+versionId+projectType=E5=88=86=E6=B5=81=20+=20?= =?UTF-8?q?=E5=93=81=E7=B1=BB=E5=8F=AF=E6=8F=92=E6=8B=94=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【U1 第二装载落库(F族)】store.py + run.persist + studio收口 + index.template分流: SourceProjectStore 接口 + LocalFsStore 真实现(GEN_DIR/_store 内容哈希寻址/版本归档/可重建,9 round-trip 测试过) + BackendStore seam(MySQL+OSS 交 Java 后端,forbidden-import 守着不实连)。versionId=v<秒>-, 改源得新版本/保留旧版/fetch 按 version 取回。projectType 分流闸:index.template 从"工厂存在性隐式判断" 升为显式分流(tier2-phaser 现行路 / gamedef-legacy / amodel-2.0 留 seam 报错不静默)。result 新增 store_addressing。 【U2 品类可插拔机制(G/E族)】archetypes.py 注册表 + run/harness 泛化: 品类注册表(key→{数据表schema/脚手架/driver/门/fixture}),business-sim 唯一注册、五件套全指现有资产。 scaffold_init/gate_spec/prompt_hint 改读注册表;business-sim 铺出脚手架/spec/hint 与旧逐字节一致(回归验证)。 富游戏门按品类选(business-sim→现三门;未注册品类→"门待建"占位 passed:false、decision 不会 accept)。 "加新品类=补 5 样内容(非引擎码)"配方写进 archetypes.py。 全 tier2/ 内、零碰 Tier0/1、py_compile+node --check+forbidden-import 通过、金标装配冒烟不受影响。token 真跑验证待 mini-desktop。 Co-Authored-By: Claude Opus 4.8 (1M context) --- tier2/contracts/boot-phaser-host.d.ts | 10 +- tier2/gen-worker/run_engine.py | 78 +--- tier2/gen-worker/worker/agent_loop/studio.py | 12 + tier2/gen-worker/worker/archetypes.py | 256 +++++++++++++ tier2/gen-worker/worker/run.py | 69 +++- tier2/gen-worker/worker/store.py | 357 +++++++++++++++++++ tier2/harness/index.template.html | 38 +- tier2/harness/play-phaser.cdp.cjs | 47 ++- 8 files changed, 785 insertions(+), 82 deletions(-) create mode 100644 tier2/gen-worker/worker/archetypes.py create mode 100644 tier2/gen-worker/worker/store.py diff --git a/tier2/contracts/boot-phaser-host.d.ts b/tier2/contracts/boot-phaser-host.d.ts index d8d48744..ae2974d6 100644 --- a/tier2/contracts/boot-phaser-host.d.ts +++ b/tier2/contracts/boot-phaser-host.d.ts @@ -12,9 +12,13 @@ // 场景树驱动 update/render,绘制面与生命周期语义完全不同。两条装载路解耦并存、各自演进互不牵连(F2「伸缩·并存」)。 // tier2 第二装载分支的 boot 序列独立,不改左支 boot-game-host.js 的 opts.engine 注入边界、不碰 ECS-lite 即取即跑装载契约。 // -// 【装载分流】(口径与代码对齐) -// 现状装载分流的事实载体是 pkg.engineBundle 是否存在(有→引擎包路、无→旧 startRuntime 兜底,见 game-studio/src/host/inject.ts); -// tier2 的 projectType 类型标记分流(projectType==='tier2-phaser',对接 tier2-source-project.schema.json)是 tier2 待建语义。 +// 【装载分流】(口径与代码对齐 · U1 起 tier2 宿主侧已显式分流) +// 左支(Tier0/1)装载分流的事实载体仍是 pkg.engineBundle 是否存在(有→引擎包路、无→旧 startRuntime 兜底, +// 见 game-studio/src/host/inject.ts,红线不碰)。 +// tier2 第二装载分支(本契约宿主)从 U1 起把分流从「engineBundle 存在性隐式判断」升级为【显式 projectType 分流】: +// 宿主页模板(tier2/harness/index.template.html)读 projectType(meta[name=tier2-project-type] / window.__tier2ProjectType, +// 缺省 'tier2-phaser'),projectType==='tier2-phaser' 才走本 Phaser 装载路;'gamedef-legacy'(左支 ECS-lite,在 inject.ts)/ +// 'amodel-2.0'(A-model 线,跨分支未合并)留 seam 报错而非静默兜底。projectType 字段定义见 tier2-source-project.schema.json 要素①。 // 右支取回后须先 esbuild 构建出可玩 bundle,再 boot。 // // 【latch 终态轮询】(A4 铁律) diff --git a/tier2/gen-worker/run_engine.py b/tier2/gen-worker/run_engine.py index 07e2f7b1..2c8ccb7a 100644 --- a/tier2/gen-worker/run_engine.py +++ b/tier2/gen-worker/run_engine.py @@ -37,87 +37,39 @@ from pathlib import Path # 包内/直跑兼容:本文件在 tier2/gen-worker/run_engine.py;把 gen-worker/ 加进 sys.path 使顶层包 `worker` 可解析。 sys.path.insert(0, str(Path(__file__).resolve().parent)) -from worker import client, config # noqa: E402 —— client 先 import(装代理旁路) +from worker import archetypes, client, config # noqa: E402 —— client 先 import(装代理旁路) from worker.agent_loop.studio import run_studio # noqa: E402 # 结果落点(承袭 wg1 results/ 惯例;tier2 独立目录)。 RESULTS_DIR = Path(__file__).resolve().parent / "results" -# ── mini-肥鹅 business-sim 品类的默认 play_spec(经营/合成富游戏真玩驱动规格)── +# ── 品类相关物(play_spec / 提示)已收敛进品类注册表 worker/archetypes.py(U2 单一事实源)── +# 下面两个符号保留为**向后兼容薄壳**,直接委托注册表,避免别处旧引用失效、且与注册表零漂移。 # richGame.{mergeChains,orders} 故意不写死:run_gates 会从 agent 真填的 data/datatable.gold.json 派生 # (见 worker/run.py _derive_richgame_from_datatable),故三联动门静态校验查的是 agent 的实际表。 def default_business_sim_spec(game_id: str) -> dict: """构造 business-sim 品类的默认真玩规格(driver + 经济门双路 + latch + 机制断言)。 - 与 tier2/harness/mini-feie.play-spec.example.json 对齐,但留空 richGame(由数据表派生)。 + 【兼容薄壳】实体已搬进 archetypes.ARCHETYPES['business-sim'].gate_spec_factory;此处委托它, + 保持与注册表逐字节同构(回归安全)。与 tier2/harness/mini-feie.play-spec.example.json 对齐。 """ - return { - "taskId": game_id, - "round": 0, - # 真玩驱动:business-sim 经营 driver(赢路点合成→凑齐订单→交单→金币 20→100;破产路放任流失 3 次)。 - "driver": { - "type": "business-sim", - "steps": 80, "stepMs": 280, - "winThreshold": 100, "bankruptSteps": 60, "streakLose": 3, - }, - # latch 终态门:终态落定不回弹 + 宿主可读。 - "expectLatch": True, - "latchTerminals": ["win", "lose", "gameover"], - # 经济门:盈利路(coins≥100∧win)+ 破产路(连续 3 流失∧lose),两路真输入驱动到终态。 - "economy": {"winThreshold": 100, "bankruptPath": True}, - # F 门真接线期望前缀(经营档看资源能力真调)。 - "expectedEngineCallPrefixes": ["resource.addCoins", "resource.consumeIngredient"], - # 机制断言(玩后语义 state 校验:完单金币真涨 + 没卡死)。 - "assertAfterPlay": [ - {"path": "coins", "op": "increased", "why": "完单后金币上升(正循环真有进展)"}, - {"path": "phase", "op": "in", "value": ["play", "win"], "why": "真玩中或赢,非卡死"}, - ], - } + return archetypes.gate_spec_for("business-sim", game_id) -# ── mini-肥鹅 品类约定提示(喂单写 agent,引导语义 state 导出与门对齐)── -FIXTURE_HINT_BUSINESS_SIM = """\ -品类 = business-sim(经营/合成富游戏,mini-肥鹅 三系统:资源/合成/订单)。scaffold_init 已铺平台预建骨架: -- 三系统纯逻辑(resource/merge/order)+ game-core 编排 + 状态机 + 结算 + latch 终态 + _forensicsView 语义 state 导出,均已写死、别动。 -- src/main.js 装载胶水(PhaserGameFactory)已写死、别动;src/data/tables.js 从 data/datatable.gold.json 读数据,别另立平行数据源。 -你要做两件事: -① 填 data/datatable.gold.json:**就地填**留空 sentinel(name='待填'、reward/patience=1、assetId 空)的值, - **绝不改它的 key、绝不自创 schema**。平台锁定文件 src/data/tables.js 按固定 key 读这张表,你换了 key(比如 - 把 mergeChains 写成 recipes、把 currencies 拍平成 initialCoins、把 requires 写成 [{sku,qty}] 数组)就会让 - 合成系统空转——游戏还能 boot,但合成链=0、订单全不可达、金币不动,三联动门/F门必挂。平台读的确切 key: - - currencies.coins.initial(整数,开局金币=20)、currencies.ingredients.initial({itemId:count} 开局库存映射) - - items[{id,name,tier,assetId}](12 个:6 个 tier1 + 6 个 tier2) - - mergeChains[{from,to,cost,unlockCoins?}](6 条合成链,from/to 是 item id,DAG 无环;**不是 recipes/in/out**) - - orders[{id,requires:{itemId:qty},reward,patience}](5 单;requires 是 {物品id:数量} 映射,**不是数组**; - 每单 requires 的物品必须能被某条 mergeChain 产出(to)= requiresReachable) - - winCondition.coinsTarget=100、loseCondition.consecutiveOrderFails=3 - 按主题给 item id 起有意义的小写短横线 slug(如 flour/dough/bread),重排合成链与订单耦合,置 _filledByLlm=true。 - **填完先调 validate_datatable**(平台预检:查 schema/DAG/可达性),过了再 build——build 也会自动跑这道预检。 -② 在 src/scenes/play-scene.js 的 TODO 处填表现层(约 56%):3×3 棋盘格真渲染、订单面板、金币/库存 HUD、合成命中反馈。 - 逻辑命令(core.tapCell/core.fulfillOrder)、命中映射坐标系(BOARD_X/Y/CELL)、readState 语义约定都不要改。 - **两件平台命脉绝不能删**(它俩已抽进锁定文件 src/scenes/play-runtime.js,你写不动它): - - create() 里必须保留 `bindInput(this, core)` —— 接 driver-tap 输入路由。删了它 driver 点击不进 core, - 合成永不发生,F/G 门挂、calls=0。 - - update(_time, delta) 里必须保留 `tick(core, delta)` —— 逐帧推进 core(订单耐心倒计时 + 赢/输 latch)。 - 删了它游戏永不到终态,E_live/H_progress/经济/latch 门全挂。 - 你只管画(render),别碰输入路由与 tick;它俩从 play-runtime.js import,坐标与 driver 同源、已对齐。 - **play-scene 必须保持工厂模式**(锁定的 main.js 这样建 scene,你改不了 main.js): - `export function createPlayScene(deps) { const core = deps.core; const Phaser = deps.Phaser; … - return { create(){ /*画+*/ bindInput(this, core); }, update(_t,delta){ tick(core,delta); /*重画*/ }, - handleNormalizedInput(ev){ /*委托 play-runtime 的 handleInput(core,ev)*/ } }; }` - —— 工厂收 deps、闭包捕获 core、返回【配置对象】(create/update/handleNormalizedInput)。 - **绝不要写成 `class extends Phaser.Scene` + init(data) 取 core**:main.js 不走 Phaser 的 init(data)、不传 data, - 那样 this.core=undefined、scene 整个空转、所有门挂。沿用 scaffold 给你的 play-scene 骨架结构,只在 TODO 处填画。 -语义 state(readState 已导出,别改):{phase, coins, ingredients, board, orders, failStreak} —— 富游戏三门据它真玩取证。 -""" +# ── mini-肥鹅 品类约定提示(喂单写 agent;兼容薄壳,实体在注册表 promptHint)── +FIXTURE_HINT_BUSINESS_SIM = archetypes.prompt_hint_for("business-sim") async def _run(game_id: str, brief: str, *, model_name, max_tokens, thinking_budget, max_iters, do_design, template) -> dict: - """异步主流程:构造 spec + hint → run_studio。""" - play_spec = default_business_sim_spec(game_id) if template == "business-sim" else None - fixture_hint = FIXTURE_HINT_BUSINESS_SIM if template == "business-sim" else "" + """异步主流程:据品类注册表取 play_spec + 提示 → run_studio(U2 泛化:不再硬编码 business-sim)。 + + 未注册品类 → play_spec=None + 提示空串(诚实跳过富游戏门规格,只走通用九门),不静默当成 business-sim。 + """ + # 据品类注册表取真玩门规格(gateSpec)与品类约定提示(promptHint);未注册返 None/空串。 + play_spec = archetypes.gate_spec_for(template, game_id) + fixture_hint = archetypes.prompt_hint_for(template) return await run_studio( game_id, brief, play_spec=play_spec, model_name=model_name, max_tokens=max_tokens, thinking_budget=thinking_budget, diff --git a/tier2/gen-worker/worker/agent_loop/studio.py b/tier2/gen-worker/worker/agent_loop/studio.py index f543d839..6da92732 100644 --- a/tier2/gen-worker/worker/agent_loop/studio.py +++ b/tier2/gen-worker/worker/agent_loop/studio.py @@ -455,6 +455,14 @@ async def run_studio( breaker_tripped = {"kind": None, "reason": f"未捕获异常:{type(e).__name__}: {e}"} print(f"[tier2-studio] game={game_id} 异常:{type(e).__name__}: {e}", flush=True) + # ── 收口落库(F1 要素⑦ / 版本寻址 · 加性,不改 GEN_DIR 落盘)────────────────────────── + # ReAct 收敛后:若真 finish 了(session.finished = 源工程七要素形状),把它经 store 持久化进落库面, + # 返回 {id, versionId, sourceHash} 写进 result(改源重建会得新 versionId,旧版本仍可按 versionId 取回)。 + # spike 期 store = LocalFsStore(本地真落 _store/);产线接后端切 BackendStore(seam)。 + # 时间戳显式传入(与 derive_version_id 同口径);未 finish → addressing=None(不伪造落库)。best-effort,绝不抛。 + store_addressing = run.persist_source_project( + game_id, session.finished, session.file_list(), now_ts=time.time()) + # ── L2 设计符合层 full(observe-only · 收口后算一次 · 零 LLM · 绝不参与 accept/reject)────── # 时机:ReAct 收敛、跑过门后(无论 finish/kill/熔断)算一次。它从已落盘源工程(数据表 + src/systems/) # 确定性算「设计声明的系统在不在 / 合成链 DAG / 订单可达 / 经济胜负自洽」四项硬结构信号 + 设计稿弱对账, @@ -587,6 +595,10 @@ async def run_studio( # 单写 agent 交付的源工程(finish 工具组装的 A3 形状;未 finish 则 None)。 "source_project": session.finished, "finished": session.finished is not None, + # 收口落库寻址(F1 要素⑦):{id, versionId, sourceHash} 或 None(未 finish / 落库失败)。 + # id=game_id(同款多次改源重建挂同一 id);versionId 改源重建即新;sourceHash=内容指纹幂等键。 + # spike 期落本地 LocalFsStore;产线接后端 BackendStore。接线方(RunRecord)可据此关联落库版本。 + "store_addressing": store_addressing, # 最近一次 run_gates 的 verdict(tier2-verdict 形状;judge 纯代码产出,零自评;若真跑过门, # 其 layerResults.L3.score/notes 已由上面 L3 软检 observe-only 回填)。 "last_verdict": session.last_verdict, diff --git a/tier2/gen-worker/worker/archetypes.py b/tier2/gen-worker/worker/archetypes.py new file mode 100644 index 00000000..f345303e --- /dev/null +++ b/tier2/gen-worker/worker/archetypes.py @@ -0,0 +1,256 @@ +"""archetypes.py —— tier2 富游戏「品类(archetype)注册表」(把"加一个新品类"从改引擎码降为加一份内容包)。 + +【这份解决什么问题(U2)】 +spike 期整条 tier2 富游戏线只有 business-sim 一个品类(mini-肥鹅 经营/合成游戏),它的五样东西散落四处: + - 脚手架根(fixture src/ 平台预建骨架)在 run.py 的 TEMPLATE_FIXTURES; + - 真玩门规格(play_spec:driver + 经济门双路 + latch + 机制断言)在 run_engine.py 的 default_business_sim_spec; + - 喂单写 agent 的品类约定提示在 run_engine.py 的 FIXTURE_HINT_BUSINESS_SIM; + - driver 实现(business-sim.driver.js)在 harness,经 play-phaser.cdp.cjs 按 driver.type 分发; + - 数据表 schema(datatable.schema.json)在 fixture 内。 +散落 = 加第二个品类要在四个文件各改一处,且容易漏。本模块把这五样收敛成**一张注册表**: +品类 key → {datatableSchemaRef, scaffoldTemplate, driverType, gateSpecRef, fixtureRef}, +让"加一个新品类"退化成"在这张表里加一条 + 备齐它指向的 5 份内容(非引擎码)"。 + +【边界纪律 · 本单元只建机制 + 注册 business-sim 一个品类】 +不投机造第二个品类——第二品类需要新数据表 schema / 新 driver / 新富游戏门,待 business-sim 经 0号 spike +证可靠后再加(见文件末「加一个新品类的配方」与 followups)。business-sim 这一条指向的全是**现有**资产 +(mini-肥鹅 fixture / business-sim.driver.js / 现有富游戏三门 / 现有 schema),不新造任何东西,故零行为变化。 + +【为什么 gateSpec 是"工厂函数"而非静态 dict】 +play_spec 要带 game_id(写进 taskId,且未来可能据 game_id 派生 traceId 等),故注册的是一个 +`gate_spec_factory(game_id) -> dict` 工厂,而非一份写死的 dict。business-sim 的工厂就是把原 +run_engine.default_business_sim_spec 整体搬过来——逐字节同构,回归安全。 + +【依赖方向(避免环 import)】 +archetypes ← run(load_fixture_scaffold 读 ARCHETYPES 取 fixtureRef)、run_engine(取 gateSpec/promptHint)。 +本模块**不** import run / run_engine(否则成环);它只持有"指针"(fixture 根路径、driverType 字符串、 +gateSpec 工厂、promptHint 文本),具体落盘 / 真玩由 run / harness 按这些指针执行。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +# ── 路径常量(与 run.py 同源口径:本文件在 tier2/gen-worker/worker/archetypes.py → 上溯 3 级到 tier2/)── +# 注:不从 run.py import FIXTURE_ROOT,避免 archetypes↔run 循环依赖;两处各自从 __file__ 推 tier2/ 根, +# 指向同一物理目录(tier2/fixtures/mini-fei-e),无 split-brain。 +_TIER2_DIR = Path(__file__).resolve().parents[2] +_FIXTURES_DIR = _TIER2_DIR / "fixtures" + +# mini-肥鹅 fixture 根(business-sim 品类的脚手架 + 金标 + schema 都在此目录树下)。 +_MINI_FEIE_ROOT = _FIXTURES_DIR / "mini-fei-e" + + +@dataclass(frozen=True) +class Archetype: + """一个富游戏品类的"内容包指针"五件套(不含引擎码;引擎码是平台的、所有品类共享)。 + + 五件套对应「加一个新品类要补哪 5 样」(详见文件末配方): + - datatable_schema_ref : 数据表 schema 文件路径(机器可校验该品类数值表形状;agent 据它填值)。 + - scaffold_template_root: 脚手架根目录(平台预建 src/ 骨架 + 留空数据表;scaffold_init 起手铺它)。 + - driver_type : harness driver 类型字符串(play-phaser.cdp.cjs runDriver 按它分发到对应 .driver.js)。 + - gate_spec_factory : 真玩门规格工厂(game_id → play_spec dict:driver + 富游戏门 + latch + 断言)。 + - fixture_ref : 金标 fixture 根目录(已填真值的对照样例;driver / 门 / 冒烟据它测,不依赖 LLM)。 + + 其中 scaffold_template_root 与 fixture_ref 在 spike 期是**同一个目录**(mini-肥鹅 fixture 既是脚手架来源 + 又是金标来源——脚手架取它的 src/ + datatable.empty.json,金标取它的 datatable.gold.json)。保留两个字段是 + 为了语义清晰(脚手架来源 vs 金标对照),将来若某品类二者分置(如脚手架在 A 目录、金标在 B 目录)可分别指。 + """ + + key: str + datatable_schema_ref: str + scaffold_template_root: Path + driver_type: str + gate_spec_factory: Callable[[str], dict] + fixture_ref: Path + # 喂单写 agent 的品类约定提示(引导语义 state 导出 / 数据表 key / 表现层 TODO 与门对齐);可空。 + prompt_hint: str = "" + # 人读一句话(这是什么品类);仅文档/日志用。 + human_label: str = "" + + +# ────────────────────────────────────────────────────────────────────────── +# business-sim 品类 · 真玩门规格工厂(gate_spec_factory) +# = 原 run_engine.default_business_sim_spec 整体搬来,逐字节同构(回归安全)。 +# richGame.{mergeChains,orders} 故意不写死:run_gates 会从 agent 真填的 data/datatable.gold.json 派生 +# (见 worker/run.py _derive_richgame_from_datatable),故三联动门静态校验查的是 agent 的实际表。 +# ────────────────────────────────────────────────────────────────────────── +def _business_sim_gate_spec(game_id: str) -> dict: + """构造 business-sim 品类的默认真玩规格(driver + 经济门双路 + latch + 机制断言)。 + + 与 tier2/harness/mini-feie.play-spec.example.json 对齐,但留空 richGame(由数据表派生)。 + """ + return { + "taskId": game_id, + "round": 0, + # 真玩驱动:business-sim 经营 driver(赢路点合成→凑齐订单→交单→金币 20→100;破产路放任流失 3 次)。 + "driver": { + "type": "business-sim", + "steps": 80, "stepMs": 280, + "winThreshold": 100, "bankruptSteps": 60, "streakLose": 3, + }, + # latch 终态门:终态落定不回弹 + 宿主可读。 + "expectLatch": True, + "latchTerminals": ["win", "lose", "gameover"], + # 经济门:盈利路(coins≥100∧win)+ 破产路(连续 3 流失∧lose),两路真输入驱动到终态。 + "economy": {"winThreshold": 100, "bankruptPath": True}, + # F 门真接线期望前缀(经营档看资源能力真调)。 + "expectedEngineCallPrefixes": ["resource.addCoins", "resource.consumeIngredient"], + # 机制断言(玩后语义 state 校验:完单金币真涨 + 没卡死)。 + "assertAfterPlay": [ + {"path": "coins", "op": "increased", "why": "完单后金币上升(正循环真有进展)"}, + {"path": "phase", "op": "in", "value": ["play", "win"], "why": "真玩中或赢,非卡死"}, + ], + } + + +# ────────────────────────────────────────────────────────────────────────── +# business-sim 品类 · 喂单写 agent 的品类约定提示(promptHint) +# = 原 run_engine.FIXTURE_HINT_BUSINESS_SIM 整体搬来,逐字符同构(回归安全)。 +# ────────────────────────────────────────────────────────────────────────── +_BUSINESS_SIM_PROMPT_HINT = """\ +品类 = business-sim(经营/合成富游戏,mini-肥鹅 三系统:资源/合成/订单)。scaffold_init 已铺平台预建骨架: +- 三系统纯逻辑(resource/merge/order)+ game-core 编排 + 状态机 + 结算 + latch 终态 + _forensicsView 语义 state 导出,均已写死、别动。 +- src/main.js 装载胶水(PhaserGameFactory)已写死、别动;src/data/tables.js 从 data/datatable.gold.json 读数据,别另立平行数据源。 +你要做两件事: +① 填 data/datatable.gold.json:**就地填**留空 sentinel(name='待填'、reward/patience=1、assetId 空)的值, + **绝不改它的 key、绝不自创 schema**。平台锁定文件 src/data/tables.js 按固定 key 读这张表,你换了 key(比如 + 把 mergeChains 写成 recipes、把 currencies 拍平成 initialCoins、把 requires 写成 [{sku,qty}] 数组)就会让 + 合成系统空转——游戏还能 boot,但合成链=0、订单全不可达、金币不动,三联动门/F门必挂。平台读的确切 key: + - currencies.coins.initial(整数,开局金币=20)、currencies.ingredients.initial({itemId:count} 开局库存映射) + - items[{id,name,tier,assetId}](12 个:6 个 tier1 + 6 个 tier2) + - mergeChains[{from,to,cost,unlockCoins?}](6 条合成链,from/to 是 item id,DAG 无环;**不是 recipes/in/out**) + - orders[{id,requires:{itemId:qty},reward,patience}](5 单;requires 是 {物品id:数量} 映射,**不是数组**; + 每单 requires 的物品必须能被某条 mergeChain 产出(to)= requiresReachable) + - winCondition.coinsTarget=100、loseCondition.consecutiveOrderFails=3 + 按主题给 item id 起有意义的小写短横线 slug(如 flour/dough/bread),重排合成链与订单耦合,置 _filledByLlm=true。 + **填完先调 validate_datatable**(平台预检:查 schema/DAG/可达性),过了再 build——build 也会自动跑这道预检。 +② 在 src/scenes/play-scene.js 的 TODO 处填表现层(约 56%):3×3 棋盘格真渲染、订单面板、金币/库存 HUD、合成命中反馈。 + 逻辑命令(core.tapCell/core.fulfillOrder)、命中映射坐标系(BOARD_X/Y/CELL)、readState 语义约定都不要改。 + **两件平台命脉绝不能删**(它俩已抽进锁定文件 src/scenes/play-runtime.js,你写不动它): + - create() 里必须保留 `bindInput(this, core)` —— 接 driver-tap 输入路由。删了它 driver 点击不进 core, + 合成永不发生,F/G 门挂、calls=0。 + - update(_time, delta) 里必须保留 `tick(core, delta)` —— 逐帧推进 core(订单耐心倒计时 + 赢/输 latch)。 + 删了它游戏永不到终态,E_live/H_progress/经济/latch 门全挂。 + 你只管画(render),别碰输入路由与 tick;它俩从 play-runtime.js import,坐标与 driver 同源、已对齐。 + **play-scene 必须保持工厂模式**(锁定的 main.js 这样建 scene,你改不了 main.js): + `export function createPlayScene(deps) { const core = deps.core; const Phaser = deps.Phaser; … + return { create(){ /*画+*/ bindInput(this, core); }, update(_t,delta){ tick(core,delta); /*重画*/ }, + handleNormalizedInput(ev){ /*委托 play-runtime 的 handleInput(core,ev)*/ } }; }` + —— 工厂收 deps、闭包捕获 core、返回【配置对象】(create/update/handleNormalizedInput)。 + **绝不要写成 `class extends Phaser.Scene` + init(data) 取 core**:main.js 不走 Phaser 的 init(data)、不传 data, + 那样 this.core=undefined、scene 整个空转、所有门挂。沿用 scaffold 给你的 play-scene 骨架结构,只在 TODO 处填画。 +语义 state(readState 已导出,别改):{phase, coins, ingredients, board, orders, failStreak} —— 富游戏三门据它真玩取证。 +""" + + +# ────────────────────────────────────────────────────────────────────────── +# 品类注册表(ARCHETYPES) —— spike 期唯一一条:business-sim。 +# key = 品类 id(与 scaffold_init(template_id) / run_engine --template 同口径)。 +# ────────────────────────────────────────────────────────────────────────── +ARCHETYPES: dict[str, Archetype] = { + "business-sim": Archetype( + key="business-sim", + human_label="经营/合成富游戏(mini-肥鹅:资源/合成/订单三系统)", + # ① 数据表 schema:fixture 内 mini-肥鹅 数据表形状(12 物品 / 6 链 / 5 订单 / 2 货币)。 + # 路径相对 fixture_ref 根(scaffold_init 把 data/datatable.schema.json 一并铺进工程供 agent 参照)。 + datatable_schema_ref="data/datatable.schema.json", + # ② 脚手架根:mini-肥鹅 fixture 的平台预建 src/ 骨架 + 留空数据表(load_fixture_scaffold 起手铺它)。 + scaffold_template_root=_MINI_FEIE_ROOT, + # ③ driver 类型:harness 的 business-sim.driver.js(play-phaser.cdp.cjs runDriver 按此字符串分发)。 + driver_type="business-sim", + # ④ 真玩门规格工厂:经营/合成富游戏专属(三联动门 + 经济门双路 + latch;现有富游戏三门)。 + gate_spec_factory=_business_sim_gate_spec, + # ⑤ 金标 fixture 根:= 脚手架根同目录(mini-肥鹅 fixture 既供脚手架又供金标;见 Archetype docstring)。 + fixture_ref=_MINI_FEIE_ROOT, + prompt_hint=_BUSINESS_SIM_PROMPT_HINT, + ), +} + +# 默认品类(scaffold_init / run_engine 未显式指定时;spike 期 = 唯一品类 business-sim)。 +DEFAULT_ARCHETYPE = "business-sim" + + +def get_archetype(key: str | None) -> Archetype | None: + """按 key 取品类注册项;未知 key 返 None(由调用方诚实兜底,不静默放过)。 + + Args: + key: 品类 id(如 'business-sim');None / 空串 → 返回 None(调用方决定是否回落 DEFAULT_ARCHETYPE)。 + + Returns: + Archetype 或 None(未注册)。 + """ + if not key: + return None + return ARCHETYPES.get(key) + + +def known_keys() -> list[str]: + """已注册品类 key 列表(用于错误提示「已知品类:[...]」)。""" + return list(ARCHETYPES) + + +def gate_spec_for(key: str | None, game_id: str) -> dict | None: + """取某品类的真玩门规格(play_spec);未注册或品类未定义门规格 → None(诚实跳过,不伪造)。 + + Args: + key: 品类 id。 + game_id: 工程标识(写进 play_spec.taskId)。 + + Returns: + play_spec dict 或 None。None 表示"该品类没有专属门规格"—— + 调用方据此走通用门(九门)而不带富游戏门,不静默当作 business-sim。 + """ + arc = get_archetype(key) + if arc is None or arc.gate_spec_factory is None: + return None + return arc.gate_spec_factory(game_id) + + +def prompt_hint_for(key: str | None) -> str: + """取某品类喂 agent 的约定提示;未注册或无提示 → 空串(由调用方决定是否给通用提示)。""" + arc = get_archetype(key) + return arc.prompt_hint if arc else "" + + +# ════════════════════════════════════════════════════════════════════════════ +# 【加一个新品类的配方 —— 加内容,不改引擎码】 +# ════════════════════════════════════════════════════════════════════════════ +# 本模块把"加品类"从"改引擎代码"降为"加一份品类内容包"。新增一个品类(比如塔防 tower-defense +# 或跑酷 endless-runner),要补的是下面 5 样**内容**(不碰引擎码、不碰 run.py/run_engine.py 主链): +# +# ① 数据表 schema(datatableSchemaRef) +# 在 tier2/fixtures/<新品类>/data/datatable.schema.json 写该品类的数值表 schema +# (机器可校验形状)。business-sim 用 currencies/items/mergeChains/orders;新品类按自己的 +# 系统结构定(塔防可能是 waves/towers/enemies)。同时给 datatable.empty.json(留空 sentinel, +# 先天过 boot 的占位)与 datatable.gold.json(人工金标,供 driver/门对照测,不依赖 LLM)。 +# +# ② 脚手架(scaffoldTemplate) +# 在 tier2/fixtures/<新品类>/src/ 预建该品类的平台骨架:三系统纯逻辑 + game-core 编排 + +# 状态机 + 结算 + latch 终态 + _forensicsView 语义 state 导出 + play-runtime 输入路由/tick, +# 先天过 boot(A_boot 绿)。agent 只填留空数据表 + play-scene 表现层。 +# ⚠ 注意:run.py 的 load_fixture_scaffold 与 toolkit.py 的 LOCKED_PLATFORM_FILES 当前对 +# business-sim 的文件名(resource/merge/order-system、reachability、tables.js、play-runtime…) +# 按固定名识别角色与锁定。新品类若系统文件名不同,需让这两处的"角色推断 / 锁定集"也认新品类 +# 的文件名(这是机制泛化的下一步,见 followups——本单元只注册 business-sim,未做该泛化)。 +# +# ③ driver(driverType) +# 在 tier2/harness/<新品类>.driver.js 写该品类的确定性 harness driver(读语义 state 自适应出招、 +# 把核心循环真输入驱动到终态),并在 play-phaser.cdp.cjs 的 runDriver 里加一条 `driver.type===<新>` +# 的分发(现有代码已是 type 分发结构,加 else-if 即可;未注册 type 已有"门待建"占位,不会静默放过)。 +# +# ④ 富游戏门(gateSpecRef) +# 在本注册表给新品类写 gate_spec_factory(game_id → play_spec)。**关键**:business-sim 的富游戏三门 +# (tripleLink/economy/latch)是**经营品类专属语义**(订单↔合成可达、双路经济、终态 latch),塔防/跑酷 +# 不适用——新品类多半要**新的富游戏门**(如塔防:波次可清空门、塔↔敌数值平衡门)。这些新门要落进 +# rich-game-gates.cjs(纯逻辑部分)+ play-phaser.cdp.cjs(真玩部分),并在 play_spec 里声明启用。 +# 富游戏门是否需按品类抽象成"门集注册",见 followups。 +# +# ⑤ 金标 fixture(fixtureRef) +# = ① 的 datatable.gold.json + ② 的 src/ 合在一起,构成一份"先天能过该品类全部门"的对照工程, +# 供 run-on-mini-desktop-smoke.sh 式的无 token 装配冒烟与 driver/门自测用(spike 第一段对照组)。 +# +# 补齐这 5 样后,在 ARCHETYPES 里加一条 Archetype(...) 指向它们即可——run.py / run_engine.py / +# toolkit.py 的主链代码不必为新品类改逻辑(只有上面 ② 标注的"按文件名锁定/角色推断"那处泛化是已知 TODO)。 diff --git a/tier2/gen-worker/worker/run.py b/tier2/gen-worker/worker/run.py index d1db2a46..c26ebc87 100644 --- a/tier2/gen-worker/worker/run.py +++ b/tier2/gen-worker/worker/run.py @@ -23,6 +23,10 @@ import subprocess import sys from pathlib import Path +# 品类注册表(U2):scaffold_init 按品类铺脚手架的单一事实源(business-sim → mini-肥鹅 fixture)。 +# archetypes 不 import run(避免环),run 这里单向 import archetypes 取 fixtureRef/scaffold 根。 +from . import archetypes + # ── 路径常量(全部由本模块计算,不读 wg1 全局)── # REPO_ROOT = 仓根(本文件在 tier2/gen-worker/worker/run.py → 上溯 4 级:worker→gen-worker→tier2→仓根)。 REPO_ROOT = Path(__file__).resolve().parents[3] @@ -39,16 +43,13 @@ PHASER_SERVE_PLAY_SCRIPT = TIER2_DIR / "harness" / "serve-and-play-phaser.sh" PHASER_PLAY_SCRIPT = TIER2_DIR / "harness" / "play-phaser.cdp.cjs" # tier2 宿主页面模板(scaffold 时拷进 gen 目录;经绝对路径 /tier2/engine/ 引 boot-phaser-host,故 serve 仓根)。 PHASER_INDEX_TEMPLATE = TIER2_DIR / "harness" / "index.template.html" -# mini-肥鹅 fixture 根(M4/M1 平台预建骨架 + 留空数据表;scaffold_init business-sim 模板的种子)。 +# mini-肥鹅 fixture 根(M4/M1 平台预建骨架 + 留空数据表;business-sim 品类的脚手架种子)。 +# 单一事实源已迁到 archetypes.ARCHETYPES['business-sim'].scaffold_template_root;此处保留同值常量, +# 是因为本模块多处仍引 FIXTURE_ROOT(注释/路径常量),且与注册表指向同一物理目录(无 split-brain)。 FIXTURE_ROOT = TIER2_DIR / "fixtures" / "mini-fei-e" # 默认 Phaser iife 全局挂载名(A3 buildProfile.globalName;宿主页面 index.template.html 取此全局名建工厂)。 DEFAULT_GLOBAL_NAME = "__Tier2GameBundle" -# scaffold_init 已知品类模板 → fixture 根映射(spike 期只有 business-sim → mini-肥鹅;后续品类增条目)。 -TEMPLATE_FIXTURES = { - "business-sim": FIXTURE_ROOT, # 经营/合成富游戏(mini-肥鹅:资源/合成/订单三系统) -} - def load_fixture_scaffold(template_id: str) -> dict: """读品类模板对应的 fixture 平台预建骨架(返回 {path: content} + roles),供 scaffold_init 起手铺进工程。 @@ -60,17 +61,25 @@ def load_fixture_scaffold(template_id: str) -> dict: tables.js 实际 import 的 **data/datatable.gold.json**——LLM 自治循环往里填真值。绝不拿金标当种子 (金标 = 参照答案,会让 spike 变成抄答案而非真生成)。 + 【U2 泛化】脚手架根不再硬编码 TEMPLATE_FIXTURES,改从品类注册表(archetypes.ARCHETYPES)按 + template_id 取 scaffold_template_root。business-sim 仍指向 mini-肥鹅 fixture 同一目录,故铺出的 + 文件树与改造前**字节级一致**(回归安全);未知品类则诚实返回 ok=False + 已知品类清单,由调用方兜底。 + Args: - template_id: 品类模板 id(如 'business-sim')。未知 id → 返回空骨架(由调用方兜底)。 + template_id: 品类模板 id(如 'business-sim')。未注册 id → 返回空骨架(由调用方兜底)。 Returns: {files: [{path, content, role}]}。files 是 fixture src/ + 留空数据表 + 资产占位。 """ - root = TEMPLATE_FIXTURES.get(template_id) + arc = archetypes.get_archetype(template_id) files: list[dict] = [] - if root is None or not root.exists(): + if arc is None: return {"files": files, "ok": False, - "reason": f"未知品类模板 template_id={template_id}(已知:{list(TEMPLATE_FIXTURES)})"} + "reason": f"未注册品类 template_id={template_id}(已知:{archetypes.known_keys()})"} + root = arc.scaffold_template_root + if not root.exists(): + return {"files": files, "ok": False, + "reason": f"品类 {template_id} 脚手架根不存在:{root}"} # 角色推断(对齐 A3 fileTree[].role;按目录/文件名)。 def _role(rel: str) -> str: @@ -819,6 +828,46 @@ def run_gates(game_id: str, play_spec: dict | None = None, *, return {"rc": rc, "verdict": verdict, "log": log} +def persist_source_project(game_id: str, source_project: dict | None, + file_list: list[dict] | None, *, now_ts: float, + store=None) -> dict | None: + """收口处持久化 finish 交付的源工程(F1 要素⑦落库寻址 / 版本寻址)。 + + 【加性 · 不改既有落盘行为】本函数是 run 收口的【额外】一步:GEN_DIR 下的 workdir 源文件仍照常留盘 + (兼容现有 build/run_gates/smoke 全链路),本函数只是【再】把交付的源工程经 store 持久化进落库面 + (本地 LocalFsStore 真落 _store/;产线接后端时切 BackendStore)。未 finish(source_project=None)→ 跳过。 + + 【为什么 store 由参数传入(默认 default_store)】落库实现可注入(spike=LocalFsStore / 产线=BackendStore), + 收口不在主链硬编码具体类;时间戳显式传入(与 derive_version_id / run_record 同口径,测试可复现)。 + + Args: + game_id: 工程标识(作落库 id,使一款游戏的多次改源重建挂同一 id → 版本历史)。 + source_project: toolkit.finish 组装的源工程七要素形状;None(未收敛/未 finish)→ 不落库。 + file_list: 源文件全文清单 [{path, content}](支持 fetch 真重建;= session.file_list())。 + now_ts: 调用方传入的 Unix 时间戳(秒),派生 versionId。 + store: 落库实现;缺省 = store.default_store()(spike 期 LocalFsStore)。 + + Returns: + {id, versionId, sourceHash} 或 None(未 finish / 落库失败——失败已记日志,不中断主链)。 + """ + if not source_project: + # 未 finish:无可落库的源工程(诚实跳过,不伪造落库结果)。 + return None + # 延迟 import(store 仅在真有产物要落库时才需要;保 run 其余路径零额外依赖)。 + from . import store as _store_mod + impl = store or _store_mod.default_store() + try: + addr = impl.save(source_project, now_ts=now_ts, file_list=file_list, id=game_id) + print(f"[tier2-run] 收口落库源工程: game={game_id} " + f"id={addr.get('id')} versionId={addr.get('versionId')} " + f"sourceHash={(addr.get('sourceHash') or '')[:12]}", flush=True) + return addr + except Exception as e: # noqa: BLE001 —— 落库失败响亮记日志,但不中断收口(产物仍在 GEN_DIR workdir) + print(f"[tier2-run] ⚠ 收口落库失败(产物仍在 GEN_DIR workdir,可重试): game={game_id} " + f"{type(e).__name__}: {e}", flush=True) + return None + + def verdict_feedback(verdict: dict | None, harness_log: str = "") -> str: """把失败 verdict / harness 日志摘成给 agent 的回喂文字(承袭 wg1 _verdict_feedback 范式)。 diff --git a/tier2/gen-worker/worker/store.py b/tier2/gen-worker/worker/store.py new file mode 100644 index 00000000..49dc44b8 --- /dev/null +++ b/tier2/gen-worker/worker/store.py @@ -0,0 +1,357 @@ +"""worker/store.py —— tier2 富游戏自治线 · 源项目落库取回 seam(F 族 F1 要素⑦ / F3 finish 共用 schema)。 + +【这个模块解决什么 · 对照 F 族图说】 +F1 把源项目契约钉成七要素四组,最后一组「落库取回」两样:要素⑥ contentHash(整工程指纹, +做缓存命中 + 完整性校验)、要素⑦ addressing(工程怎么存进 MySQL+对象存储、怎么按 id 取回来重建)。 +在本单元(U1)之前,这两样在契约里只是【声明式字面量 + 注释「后端回填」】——finish 工具组装出 +source_project 的形状,但产物全落本地 GEN_DIR,没有一个「存进去 / 按 id+版本取回来」的稳定入口, +versionId 也只是注释里一句「由后端回填」。本模块把这个落库取回面补成一个【接口 + 本地实现 + 后端占位】: + + - SourceProjectStore —— 抽象接口:save(持久化 finish 交付的源工程)、fetch(按 id[+versionId] 取回重建); + - LocalFsStore —— 本地文件系统实现(落 GEN_DIR、内容哈希寻址、幂等、留版本历史),spike 期真用; + - BackendStore —— 后端落库占位(MySQL manifest + OSS 源文件):raise NotImplementedError + 接口注释, + 真实落库由 Java 后端按契约填(forbidden-import 守着 tier2 不实连后端)。 + +【建代码 + 接缝纪律(本分支硬约束)】 + - 本地 LocalFsStore 是【真建】的能可跑实现(6c6g 上可 import / 可往返存取,不依赖 chrome/esbuild/网络); + - BackendStore 是【seam】(接口签名 + 本地占位 + 后端填充点注释),tier2 内绝不实连 MySQL/OSS—— + 那是 game-cloud Java 后端的活,落库后端约定见 contracts/agent-loop/tier2-source-project.schema.json + 的 addressing 字段(store='mysql+oss' / fetchById)。 + +【版本寻址(F1 版本寻址 / 改源重建新 versionId)】 + 「游戏 = 长生命周期项目」:改源 → 重建 → 落一个【新 versionId】,旧版本仍可按 versionId 取回。 + versionId = 基于 sourceHash + 时间序派生(见 derive_version_id)。**时间戳由调用方显式传入** + (与 run_record 同口径,不在本模块 time.time() 硬取),这样测试可复现、接线方能统一用同一口径时钟。 + +【sourceHash 口径】 + sourceHash = source_project.contentHash(F1 要素⑥:sha256(fileTree 各文件内容规范化拼接))。 + finish 工具(toolkit.finish)已用 _content_hash 算好填进 contentHash,本模块直接采它作落库的可寻址键 + (同源同指纹 → 幂等命中,不重复落一份)——与 Tier0/1 source-project.schema.json 的 sourceHash 语义对齐。 + +设计源:docs/architecture/架构/生成引擎/tier2细节图说-F-源项目契约与装载.md(F1/F2/F3)。 +""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import time +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Optional + +# ── 落库根:沿用 run.GEN_DIR 同源定位,但本模块零运行期依赖 run(保 6c6g 可独立 import/自检)── +# 本文件在 tier2/gen-worker/worker/store.py → 上溯 3 级到仓根(worker→gen-worker→tier2... 实为 parents[3])。 +# 口径与 run.py 完全一致:GEN_DIR = game-runtime/games/_tier2-gen(tier2 独立目录,绝不碰 _wg1-gen)。 +_REPO_ROOT = Path(__file__).resolve().parents[3] +_GEN_DIR = _REPO_ROOT / "game-runtime" / "games" / "_tier2-gen" +# 本地落库的归档子目录(与各 game_id 的 workdir 同级,集中放「已落库的源工程版本档」)。 +# 形态:_tier2-gen/_store///source-project.json + files/<工程内相对路径>。 +_STORE_SUBDIR = "_store" + + +def _canonical_content_hash(file_list: list[dict]) -> str: + """整工程内容指纹 = sha256(各文件 path+content 规范化拼接)。 + + 与 toolkit._content_hash 同口径(按 path 排序后拼 path\\0content\\0),保证「同内容 → 同指纹」。 + 本模块独立实现一份(不 import toolkit),既避免循环依赖,也让 store 可脱离 agent 链单独自检/复用。 + 入参 file_list = [{path, content}](finish 交付的源工程文件全文清单)。 + """ + h = hashlib.sha256() + for f in sorted(file_list or [], key=lambda x: (x or {}).get("path", "")): + h.update(((f or {}).get("path", "") + "\0").encode("utf-8")) + h.update(((f or {}).get("content", "") + "\0").encode("utf-8")) + return h.hexdigest() + + +def derive_version_id(source_hash: str, now_ts: float) -> str: + """据 sourceHash + 时间序派生一个版本 id(F1 版本寻址:改源重建生成新 versionId)。 + + 形态:v<秒级时间戳>-。例:v1718900000-2b6f4d272fc5。 + - 时间序前缀保证「改源重建」总得到字典序递增的新版本(同一 id 下按版本时间排序即版本历史); + - 拼 sourceHash 前缀使版本 id 自带内容指纹线索(同源同构建 → 同 hash 段,便于人工对账)。 + **时间戳显式传入**(不 time.time() 硬取):测试传固定值往返可复现,接线方统一用 run 收口时钟口径。 + + Args: + source_hash: 源工程内容指纹(= source_project.contentHash;须为 64 位 hex)。 + now_ts: 调用方传入的 Unix 时间戳(秒;run 收口时刻或测试固定值)。 + """ + sec = int(now_ts) + # 容错:sourceHash 异常(空/短)时退化用全量,绝不抛(落库出口不能因指纹格式问题中断)。 + short = (source_hash or "")[:12] if source_hash else "nohash" + return f"v{sec}-{short}" + + +class SourceProjectStore(ABC): + """源项目落库取回接口(F1 要素⑦ addressing 的代码契约 · 落库后端无关)。 + + 两个动作钉死 F 族「存进库 / 按 id 取回重建」这条闭环的两端,且与 finish 交付的形状(F3)对齐: + - save(source_project, now_ts) → {id, versionId, sourceHash}:把 finish 交付的源工程(七要素形状) + 持久化,返回落库寻址三元组。改源重建再调一次 save 得到【新 versionId】(旧版本仍在,按版本取回)。 + - fetch(id, versionId=None) → source_project:按 id 取回源工程重建;versionId 缺省取最新版本, + 给定则取该历史版本(F1 版本寻址)。取回的形状 = save 时认的那个形状(F3:交付↔落库同一份)。 + + 【实现约定】 + - id 的语义由实现决定:LocalFsStore 用 game_id(一款游戏一个 id,其下挂多个 versionId); + BackendStore 用后端主键(game_id 或 DB 自增 id,见占位注释)。 + - 幂等:同一 id 下,sourceHash 未变(改了源才会变)→ save 不重复落一份新版本,返回已有版本。 + - 失败可追溯:save/fetch 的核心路径与错误路径都打可追溯日志(本线生成产物的落库是审计点)。 + """ + + @abstractmethod + def save(self, source_project: dict, *, now_ts: float, + file_list: Optional[list[dict]] = None, id: Optional[str] = None) -> dict: + """持久化一份源工程(finish 交付的七要素形状),返回落库寻址三元组。 + + 【为什么除了 source_project 还要 file_list】F1 要素②fileTree 只记 {path, role}(不含文件内容), + 而要素⑦「按 id 取回来重建」要的是能真重建出工程的东西——故落库须同时拿到【源文件全文】。 + 本地版把 file_list 归档进 files/;后端版把它落 OSS(见 BackendStore 占位注释)。两实现共用此签名。 + 接线方 = toolkit.finish 处的 session.file_list()(已有现成的 [{path, content}])。 + + Args: + source_project: tier2 源项目契约形状(projectType/fileTree/entry/buildProfile/depLock/ + contentHash/addressing;由 toolkit.finish 组装)。 + now_ts: 调用方传入的 Unix 时间戳(秒),用于派生 versionId(显式传入便于测试 + 统一时钟口径)。 + file_list: 源文件全文清单 [{path, content}](支持 fetch 时真重建;强烈建议传)。 + id: 这款游戏的稳定标识(同一款的多次改源重建共享同一 id);缺省回落用 contentHash 前缀。 + + Returns: + {id, versionId, sourceHash}:落库后用于后续 fetch 的寻址键。 + - id:这款游戏的稳定标识(同一款的多次改源重建共享同一 id); + - versionId:本次落库的版本(改源重建 → 新 versionId); + - sourceHash:本次源工程内容指纹(= source_project.contentHash;幂等键)。 + """ + raise NotImplementedError + + @abstractmethod + def fetch(self, id: str, version_id: Optional[str] = None) -> Optional[dict]: + """按 id(+ 可选 versionId)取回源工程重建。 + + Args: + id: save 返回的 id。 + version_id: 版本 id;None = 取最新版本(改源重建后的最新),给定则取该历史版本。 + + Returns: + source_project(与 save 入参同形状)或 None(无此 id / 无此版本)。 + """ + raise NotImplementedError + + +class LocalFsStore(SourceProjectStore): + """本地文件系统实现 —— spike 期真用的落库取回(内容哈希寻址 + 幂等 + 版本历史)。 + + 落点形态(GEN_DIR/_store//): + _store//index.json —— 该 id 的版本索引([{versionId, sourceHash, savedAtTs}],按时间序) + _store///source-project.json —— 该版本的源工程七要素形状(= save 入参) + _store///files/<工程内相对路径> —— 该版本的源文件全文(可据 source-project.json 重建工程) + + 为什么连源文件也落一份:F1 要素⑦「按 id 取回来重建」要的是能真重建出工程的东西。source_project.fileTree + 只记 {path, role}(不含文件内容),故落库时把 finish session 的文件全文(file_list)一并归档, + fetch 时把内容回填进 fileTree——这样取回的 source_project 自带各文件 content,可直接重新 esbuild 构建。 + (后端版同理:manifest 落 MySQL、源文件全文落 OSS,见 BackendStore 占位注释。) + """ + + def __init__(self, store_root: Optional[Path] = None) -> None: + """ + Args: + store_root: 落库根目录;缺省 = GEN_DIR/_store(与各 game_id workdir 同级,集中归档)。 + 测试可传一个临时目录隔离,不污染真 GEN_DIR。 + """ + self._root = Path(store_root) if store_root else (_GEN_DIR / _STORE_SUBDIR) + + def _id_dir(self, id: str) -> Path: + """某 id 的版本归档根目录。""" + return self._root / id + + def _index_path(self, id: str) -> Path: + """某 id 的版本索引文件路径。""" + return self._id_dir(id) / "index.json" + + def _read_index(self, id: str) -> list[dict]: + """读某 id 的版本索引(无 → 空列表;损坏 → 空列表 + 告警,绝不抛)。""" + p = self._index_path(id) + if not p.exists(): + return [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception as e: # noqa: BLE001 —— 索引损坏不致命:当作无历史 + 告警(落库出口不中断) + print(f"[tier2-store] ⚠ 版本索引解析失败(当作无历史): id={id} " + f"{type(e).__name__}: {e}", flush=True) + return [] + + def _write_index(self, id: str, index: list[dict]) -> None: + """写某 id 的版本索引(原子:先写临时文件再 replace,避免半写损坏)。""" + p = self._index_path(id) + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(p) # 原子替换(同目录 rename) + + def save(self, source_project: dict, *, now_ts: float, file_list: Optional[list[dict]] = None, + id: Optional[str] = None) -> dict: + """落库一份源工程(本地实现:内容哈希寻址 + 幂等 + 版本历史)。 + + 【幂等】同一 id 下,sourceHash(= contentHash)未变 → 不重复落新版本,直接返回已有版本的寻址三元组。 + 只有改了源(contentHash 变了)才落一个新 versionId,旧版本保留(F1 版本寻址)。 + + Args: + source_project: 源工程七要素形状(toolkit.finish 组装;须含 contentHash)。 + now_ts: 调用方传入的 Unix 时间戳(秒),派生 versionId。 + file_list: 源文件全文清单 [{path, content}];缺省时仅落 manifest(fileTree 只有 path/role, + 无法重建文件内容,fetch 取回的 source_project 不含 content)。强烈建议传(LocalFsStore + 靠它把源文件全文归档进 files/ 以支持真重建)。接线方 = toolkit.finish 的 session.file_list()。 + id: 这款游戏的稳定标识;缺省回落用 contentHash 前 16 位(无外部 id 时也能落,但同一游戏改源会 + 换 id——故接线方应显式传 game_id,让一款游戏的多版本挂同一 id)。 + + Returns: + {id, versionId, sourceHash}。 + """ + source_hash = (source_project or {}).get("contentHash") or _canonical_content_hash(file_list or []) + # id 缺省回落:无外部 game_id 时用 contentHash 前缀(可落库,但同款改源会换 id;接线方应显式传 game_id)。 + gid = id or f"sp-{source_hash[:16]}" + index = self._read_index(gid) + + # ── 幂等:同 id 下已存在相同 sourceHash 的版本 → 不重复落,返回已有 ── + for entry in index: + if entry.get("sourceHash") == source_hash: + print(f"[tier2-store] save 幂等命中(源未变,不重复落库): id={gid} " + f"versionId={entry.get('versionId')} sourceHash={source_hash[:12]}", flush=True) + return {"id": gid, "versionId": entry.get("versionId"), "sourceHash": source_hash} + + # ── 新版本:派生 versionId,落 manifest + 源文件全文 ── + version_id = derive_version_id(source_hash, now_ts) + ver_dir = self._id_dir(gid) / version_id + try: + ver_dir.mkdir(parents=True, exist_ok=True) + # ① 落 manifest(源工程七要素形状)。 + (ver_dir / "source-project.json").write_text( + json.dumps(source_project, ensure_ascii=False, indent=2), encoding="utf-8") + # ② 落源文件全文(支持真重建;后端版这步对应「源文件落 OSS」)。 + files_root = ver_dir / "files" + written = 0 + for f in (file_list or []): + rel = (f or {}).get("path") or "" + content = (f or {}).get("content") + # 安全:禁 '..' / 绝对路径越界(与 run.scaffold / A3 path pattern 同口径)。 + if not rel or ".." in rel.split("/") or rel.startswith("/") or not isinstance(content, str): + continue + dst = files_root / rel + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(content, encoding="utf-8") + written += 1 + # ③ 追加版本索引(时间序;_write_index 原子写)。 + index.append({"versionId": version_id, "sourceHash": source_hash, "savedAtTs": int(now_ts)}) + self._write_index(gid, index) + print(f"[tier2-store] save 落库成功: id={gid} versionId={version_id} " + f"sourceHash={source_hash[:12]} files={written}", flush=True) + except Exception as e: # noqa: BLE001 —— 落库失败响亮记日志(审计点),抛给调用方处置(不静默成功) + print(f"[tier2-store] ❌ save 落库失败: id={gid} versionId={version_id} " + f"{type(e).__name__}: {e}", flush=True) + raise + return {"id": gid, "versionId": version_id, "sourceHash": source_hash} + + def fetch(self, id: str, version_id: Optional[str] = None) -> Optional[dict]: + """按 id(+ 可选 versionId)取回源工程重建(本地实现)。 + + version_id=None → 取最新版本(索引里 savedAtTs 最大者);给定 → 取该历史版本(F1 版本寻址)。 + 取回时把归档的源文件全文回填进 source_project.fileTree[].content,使返回值可直接重新构建。 + + Returns: + source_project(含各文件 content)或 None(无此 id / 无此版本 / 归档损坏)。 + """ + index = self._read_index(id) + if not index: + print(f"[tier2-store] fetch 未命中: 无此 id 的落库记录 id={id}", flush=True) + return None + # 选版本:给定 versionId 取之,否则取 savedAtTs 最大(最新)的。 + if version_id is None: + chosen = max(index, key=lambda e: e.get("savedAtTs") or 0) + else: + chosen = next((e for e in index if e.get("versionId") == version_id), None) + if chosen is None: + print(f"[tier2-store] fetch 未命中: id={id} 无此版本 versionId={version_id} " + f"(已有: {[e.get('versionId') for e in index]})", flush=True) + return None + vid = chosen.get("versionId") + ver_dir = self._id_dir(id) / vid + manifest_path = ver_dir / "source-project.json" + if not manifest_path.exists(): + print(f"[tier2-store] ❌ fetch 归档损坏: id={id} versionId={vid} 缺 source-project.json", flush=True) + return None + try: + sp = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 —— manifest 损坏 → 返 None + 告警(取回不抛,交调用方兜底) + print(f"[tier2-store] ❌ fetch manifest 解析失败: id={id} versionId={vid} " + f"{type(e).__name__}: {e}", flush=True) + return None + # 把归档源文件全文回填进 fileTree[].content(使返回值可直接重新构建;归档缺文件则该项无 content + 标注)。 + files_root = ver_dir / "files" + for item in (sp.get("fileTree") or []): + rel = (item or {}).get("path") or "" + fp = files_root / rel + if rel and fp.exists(): + try: + item["content"] = fp.read_text(encoding="utf-8") + except Exception as e: # noqa: BLE001 —— 单文件读失败不致命:标注缺内容,继续(尽量重建) + item["missingContent"] = f"{type(e).__name__}: {e}" + else: + item["missingContent"] = "归档缺该文件全文(无法重建此文件)" + print(f"[tier2-store] fetch 命中: id={id} versionId={vid} " + f"fileTree={len(sp.get('fileTree') or [])}", flush=True) + return sp + + +class BackendStore(SourceProjectStore): + """后端落库占位(seam)—— MySQL manifest + OSS 源文件,由 game-cloud Java 后端实现。 + + ════════════════════════════════════════════════════════════════════════ + 【这是 seam,不是本分支要实连的东西】 + tier2 是独立 Python 生成 service,落库后端(MySQL + 对象存储 OSS)是 game-cloud Java 后端的职责, + forbidden-import CI(tier2/ci/check-forbidden-import.sh)守着 tier2 不实连后端 / 不 import 廉价线运行态。 + 本类只声明【后端要兑现的接口形状 + 填充点】,让接线契约清晰、不留孤儿设计;真实现落在 Java 后端。 + + 【Java 后端填充点(对接 contracts/agent-loop/tier2-source-project.schema.json 的 addressing)】 + save(source_project, now_ts): + 1) manifest(七要素去掉源文件全文)落 MySQL 一张源工程版本表(game_id, version_id, source_hash, + build_profile, dep_lock, content_hash, addressing, created_at);幂等键 = (game_id, source_hash)。 + 2) 源文件全文(file_list)落对象存储 OSS,key 形如 tier2-src///<相对路径>; + manifest 记 sourceUrl(addressing.sourceUrl,OSS 只读镜像,同 GamePackage.packageUrl 范式)。 + 3) versionId 由后端按 derive_version_id 同口径(sourceHash + 时间序)或 DB 序列生成, + 改源重建 → 新 versionId,旧版本保留(F1 版本寻址 / 长生命周期项目)。 + 4) 返回 {id, versionId, sourceHash}。 + fetch(id, versionId): + 1) versionId=None → 查该 game_id 最新版本;给定 → 查该版本。 + 2) 读 MySQL manifest + 据 sourceUrl 从 OSS 拉源文件全文,回填进 fileTree[].content,返回可重建的 source_project。 + + 【为什么留这个占位类而非只写注释】 + AGENTS §6 条款 7「不留孤儿设计」:落库接口要连同其【后端入口】一起交付,接缝才闭得上。 + 本类是那个明确的后端入口锚点——主链需要时可注入 BackendStore(经依赖注入切换实现), + 接线时一眼看清后端该兑现什么;现在调它会 raise NotImplementedError(诚实未实现,不静默假成功)。 + ════════════════════════════════════════════════════════════════════════ + """ + + def save(self, source_project: dict, *, now_ts: float, + file_list: Optional[list[dict]] = None, id: Optional[str] = None) -> dict: + # Java 后端填(MySQL manifest + OSS 源文件);tier2 内不实连后端(forbidden-import 守着)。 + # 入参与抽象契约一致:source_project(manifest)+ file_list(落 OSS 的源文件全文)+ id(game_id)+ now_ts。 + raise NotImplementedError( + "BackendStore.save 待 Java 后端实现(MySQL manifest + OSS 源文件落库,见类注释填充点 1-4);" + "tier2 spike 期用 LocalFsStore。") + + def fetch(self, id: str, version_id: Optional[str] = None) -> Optional[dict]: + # Java 后端填(读 MySQL manifest + OSS 源文件回填 content);tier2 内不实连后端。 + raise NotImplementedError( + "BackendStore.fetch 待 Java 后端实现(读 MySQL manifest + OSS 源文件回填 fileTree content,见类注释);" + "tier2 spike 期用 LocalFsStore。") + + +# ── 默认 store 工厂(收口处取实现的单一入口;spike 期恒回 LocalFsStore)── +def default_store() -> SourceProjectStore: + """返回当前阶段的默认落库实现。 + + spike 期 = LocalFsStore(本地真落,6c6g 可往返自检);产线接后端时,这里据配置/注入切到 BackendStore + (一处切换,run 收口与测试都经本工厂取实现,不在主链硬编码具体类)。 + """ + return LocalFsStore() diff --git a/tier2/harness/index.template.html b/tier2/harness/index.template.html index 9c6d2900..f15990fb 100644 --- a/tier2/harness/index.template.html +++ b/tier2/harness/index.template.html @@ -41,7 +41,43 @@ import { bootPhaserHost } from '/tier2/engine/boot-phaser-host.js'; const statusEl = document.getElementById('status'); - // 取构建产物里的工厂(main.js 默认导出 / createGame 具名导出)。 + + /* + * 【projectType 分流闸(F2 第一道闸)】 + * 游戏怎么进浏览器是两条装载路:左支 ECS-lite 数据壳(即取即跑,在 game-studio inject.ts,本模板不碰), + * 右支 tier2 真 Phaser 工程(本模板 = 右支宿主)。在 U1 之前,这里靠「__Tier2GameBundle 工厂是否存在」 + * 隐式判断走哪条;现在升级为【显式 projectType 分流】(F1 要素①身份 / boot-phaser-host.d.ts §装载分流: + * projectType 分流是 tier2 待建语义,本模板即承载它)。 + * + * projectType 来源(显式,可被 harness/scaffold 注入;缺省 = 'tier2-phaser',因本模板就是 tier2 Phaser 宿主): + * ① (scaffold 可写;静态可见); + * ② window.__tier2ProjectType(harness 经 CDP 注入,优先级更高); + * 两者皆无 → 默认 'tier2-phaser'(本宿主的本职路;不破现有 smoke/真跑行为)。 + */ + const _metaType = (document.querySelector('meta[name="tier2-project-type"]') || {}).content; + const projectType = window.__tier2ProjectType || _metaType || 'tier2-phaser'; + statusEl.textContent = 'dispatch projectType=' + projectType + ' …'; + + /* + * 显式分流:据 projectType 选装载路。 + * - 'tier2-phaser' → 本模板的 Phaser 第二装载分支(下方 bootPhaserHost,现行已落)。 + * - 'gamedef-legacy'→ 【seam】Tier0/1 ECS-lite 数据壳路:不在本 tier2 宿主模板内(红线,绝不回归产线), + * 其装载在 game-studio/src/host/inject.ts(engineBundle 内嵌 + bootGameHost)。本模板遇它直接报错, + * 不静默兜底——因为落到 tier2 宿主页的就该是 tier2 工程(分流应在更上游 feed/inject 完成)。 + * - 'amodel-2.0' → 【seam】A-model 富游戏装载分支(跨分支,本分支未合并;装载路待该线落地后定义)。 + */ + if (projectType !== 'tier2-phaser') { + const seamMsg = (projectType === 'gamedef-legacy') + ? 'gamedef-legacy(Tier0/1 ECS-lite)不走 tier2 Phaser 宿主——其装载在 game-studio inject.ts(红线:不回归产线)' + : (projectType === 'amodel-2.0') + ? 'amodel-2.0 装载分支待 A-model 线落地(seam,本分支未合并)' + : 'projectType=' + projectType + ' 无对应装载路(本模板只认 tier2-phaser)'; + statusEl.textContent = 'ERROR: ' + seamMsg; + window.__tier2BootError = seamMsg; // 守卫A 据它快失败而非干等超时 + throw new Error('unsupported projectType: ' + projectType); + } + + // ── 'tier2-phaser' 路:取构建产物里的工厂(main.js 默认导出 / createGame 具名导出)── const bundle = window.__Tier2GameBundle || {}; const factory = bundle.default || bundle.createGame; if (typeof factory !== 'function') { diff --git a/tier2/harness/play-phaser.cdp.cjs b/tier2/harness/play-phaser.cdp.cjs index 33e89cf5..28988437 100644 --- a/tier2/harness/play-phaser.cdp.cjs +++ b/tier2/harness/play-phaser.cdp.cjs @@ -316,18 +316,34 @@ function stateProgressed(s0, s1) { return false; } -/** 驱动器分发(tier2 富游戏品类:business-sim 经营 driver)。缺省=business-sim。 */ +/* ────────────────────────────────────────────────────────────────────────── + * 【品类 → driver / 富游戏门 分发(U2 可插拔机制 · harness 侧)】 + * 九门(A_boot…I_control)是引擎级、所有品类共用,与品类无关。 + * driver 与「富游戏门」(九门之上的接线地板)才是品类专属: + * - driver:每品类一份确定性序列(business-sim.driver.js…),runDriver 按 driver.type 分发; + * - 富游戏门:business-sim 的 三联动/经济/latch 是**经营品类专属语义**(订单↔合成可达、双路经济), + * 别的品类(塔防/跑酷)不适用,要各自的门。spike 期只 business-sim 一个品类接了富游戏门; + * 其余品类在 assembleRichGameGates 走「门待建」清晰占位,**不静默套用经营门、也不静默放过**。 + * 纪律(对齐 E2 克制警示):不为一个实现先建满"门集注册"抽象——先用一张最小品类表立住分发口径, + * 待第二品类落地、有两套富游戏门做依据,再抽通用门集接口(见 followups)。 + * ────────────────────────────────────────────────────────────────────────── */ +// 已接「富游戏三门」的品类集合(spike 期唯一:business-sim)。其余品类 → 门待建占位。 +const RICHGAME_GATED_ARCHETYPES = new Set(['business-sim']); + +/** 驱动器分发(tier2 富游戏品类:按 driver.type 选对应 .driver.js)。缺省 type=business-sim。 */ async function runDriver(cdp, driver, hashes, deps) { - if (driver.type === 'business-sim') { + const type = (driver && driver.type) || 'business-sim'; + if (type === 'business-sim') { const businessSim = require('./business-sim.driver.js'); return businessSim.run(cdp, driver, hashes, deps); } - // 未知 driver → 空转(H/G 门会判红线)。 + // 未注册 driver.type → 空转 + 响亮记 note(H/G 门判红线;不静默假装驱动过)。 + // 加新品类:在 tier2/harness/<品类>.driver.js 写 driver,并在此加一条 `type===<品类>` 分发(见 archetypes.py 配方)。 for (let i = 0; i < (driver.steps || 10); i++) { try { hashes.push(await sampleHash(cdp)); } catch (_) {} await delay(driver.stepMs || 300); } - return { drove: 0, steps: driver.steps || 10, note: '未知 driver.type=' + driver.type }; + return { drove: 0, steps: driver.steps || 10, note: '未注册 driver.type=' + type + '(在 runDriver 加分发 + 写 <品类>.driver.js)' }; } /* ────────────────────────────────────────────────────────────────────────── @@ -858,7 +874,12 @@ async function main() { guards.I_control = { pass: true, detail: '未声明 controlCheck(向后兼容 SKIP)', skipped: true }; } - /* ── 富游戏三门(D3/A5) ── */ + /* ── 富游戏门(D3/A5)· 按品类选(U2)── + * 富游戏三门(三联动/经济/latch)是 business-sim 经营品类专属语义。品类取自 spec.driver.type(缺省 business-sim)。 + * 仅当品类 ∈ RICHGAME_GATED_ARCHETYPES 才跑这三门;未注册品类 → 三门置「门待建」清晰占位 + * (passed:false + reason),既不静默套用经营门、也不静默放过(richAllPass=false → decision 不会是 accept)。 */ + const archetype = (spec.driver && spec.driver.type) || 'business-sim'; + if (RICHGAME_GATED_ARCHETYPES.has(archetype)) { // ① 三联动门:静态(可达+DAG) + 真玩(完单真调 addCoins / 合成真调 consumeIngredient)。 richGameGates.tripleLink = evalTripleLinkGate(spec.richGame, callTrace, state0, state1); @@ -901,6 +922,20 @@ async function main() { const lg = await evalLatchGate(cdp, { terminals: spec.latchTerminals || ['win', 'lose', 'gameover'] }); richGameGates.latch = { passed: lg.passed, checks: lg.checks }; } + } else { + // 未注册富游戏门的品类:清晰占位,绝不静默放过(套经营门会误判,放过会假绿)。 + // 加新品类的富游戏门:落进 rich-game-gates.cjs(纯逻辑)+ 本文件真玩部分,并把品类加进 + // RICHGAME_GATED_ARCHETYPES(见 worker/archetypes.py 配方④ + followups「富游戏门是否需按品类抽象」)。 + const pending = (name) => ({ + passed: false, + checks: [{ name: 'archetypeGatesPending', ok: false, + detail: `品类「${archetype}」的富游戏门(${name})未接线 —— 门待建,不静默放过(见 archetypes.py 配方④)` }], + }); + richGameGates.tripleLink = pending('tripleLink'); + richGameGates.economy = pending('economy'); + richGameGates.latch = pending('latch'); + console.log(` ⚠ 品类「${archetype}」无注册富游戏门 → 三门置「门待建」占位(decision 不会 accept)`); + } // ── 人可玩 advisory ② render-sanity / ③ human-timing(真玩末尾·session 仍活时取)── // ② 扫渲染文本漏底([object Object]/undefined/NaN);③ 读数据表 patience 与候选人类阈值比对。全 advisory。 @@ -988,6 +1023,8 @@ if (require.main === module) { module.exports = { PHASER_PROBE, HUMAN_MIN_PATIENCE_MS, + RICHGAME_GATED_ARCHETYPES, // U2:已接富游戏门的品类集合(spike 期唯一 business-sim;其余走「门待建」占位) + runDriver, // U2:品类 → driver 分发(按 driver.type) checkAssertion, getPath, stateProgressed, evalTripleLinkGate, evalEconomyGate, evalLatchGate, assembleVerdict, // 人可玩 advisory 检查(单元 A;纯函数部分可本机单测)。