Merge wg1/gen-worker: L1 生成 worker 收口——HJ-GEN-001 成立(便宜模型产可玩轻游戏·九门 harness 兜底)+ 三层生产 reframe(创始人亲玩驱动)
@ -53,7 +53,8 @@
|
||||
| `skills/wave-close-checklist.md` | 波次收口七步检查单——所有收口铁律指向的唯一可执行清单 |
|
||||
| `skills/staging-ops.md` | staging 运维配方:机器分工/代码同步/后端重部署/前端构建门/冒烟门 |
|
||||
| `skills/ui-walkthrough-cdp.md` | 真 UI 走查配方(CDP on mini-desktop):studio/admin 走查 + CDP 七坑 + 信道探针 |
|
||||
| `skills/game-e2e-cdp-harness.md` | Canvas 游戏 e2e 证据 harness:编排形制/驱动器六律/出厂红线/四件套口径(T1b-α 实证,W-G1 复用) |
|
||||
| `skills/game-e2e-cdp-harness.md` | Canvas 游戏 e2e 证据 harness:编排形制/驱动器六律/出厂红线/四件套口径(T1b-α 实证;§7 = W-G1 生成游戏真玩七门 + 假绿守卫 G 门) |
|
||||
| `skills/cheap-model-game-generation.md` | 便宜模型(new-api DeepSeek/MiniMax)直出可真玩轻游戏 L1 worker:链路/模型与成本/L1 纪律/坑红线(W-G1 HJ-GEN-001 实证) |
|
||||
|
||||
**workflows/**
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
|
||||
| # | 待确认项 | 候选 | 出处 |
|
||||
|---|---|---|---|
|
||||
| 1 | ~~MVP 首选 LLM~~ → **已拍板**(D1,2026-06-08):主 DeepSeek + 备 Qwen,不接 OpenAI;**现行实际 = MiniMax-M2.7 经 new-api 网关**(DeepSeek key 未激活;激活后切换或修订 D1) | — | `2026-06-08-mvp业务决策.md` D1 |
|
||||
| 1 | ~~MVP 首选 LLM~~ → **已拍板**(D1,2026-06-08):主 DeepSeek + 备 Qwen,不接 OpenAI;**现行实际 = new-api 网关多模型可用**(2026-06-14 网关探活实测:`deepseek-v4-flash`/`deepseek-v4-pro` + `MiniMax-M2.7`/`M3` 渠道全活;W-G1 L1 冒烟用 `deepseek-v4-flash` 打底、`pro` 升难档——原「DeepSeek key 未激活」已过时) | — | `2026-06-08-mvp业务决策.md` D1 |
|
||||
| 2 | v1 运行时资产移植方式 | Canvas2D 渲染 + 小游戏转换逻辑:移植为 Java 服务,还是保留 Node 微服务 | 选型审阅版 §11.2 |
|
||||
| 3 | 产品端域名方案 | game-studio 与 game-admin 同域不同路径,还是不同子域名 | 选型审阅版 §11.3 |
|
||||
| 4 | ~~MVP 登录方式~~ → **已拍板**(2026-06-10 七项全拍,见 `2026-06-10-真实鉴权与匿名玩家-review.md` §9.1):**C 验证码为主+邀请码旁路**、玩家开放注册(受限期凭邀请码)、创作/发布限 A2 白名单、互动即弹一键登录、实名提现前收、匿名=纯客户端 anonId+聚合侧剔除(glossary/contracts 已同步修订);待出 execution 版排建设(建议 M-b 后) | — | 鉴权评审版 §9.1 |
|
||||
@ -79,7 +79,7 @@
|
||||
| **时间线** | 双路线并存:投资人版 5 人/11 周、执行 spec 10 人/3 周;执行排期以 mvp-execution-spec 为准(含逐日计划与里程碑) |
|
||||
| **MVP 范围/口径** | MVP 验收以 Doc A 的 **55 项 P0 产品功能**为准;技术实现范围经 Doc C 反查 Doc B(工作量 ≈137 技术项)。详见 [`mvp-scope-and-milestones.md`](./mvp-scope-and-milestones.md) §3 |
|
||||
| **模块划分** | **13 模块**(含 game-module-studio 创作编排域,aigc 为无状态生成原子);能力清单拆为产品功能(Doc A)/技术功能(Doc B)/映射(Doc C) 三文档分离 |
|
||||
| **生成主线** | ~~MVP=模板驱动(LLM 直出 GameConfig+固定模板)~~ → **agent 写码于插件库**(HJ-GEN-001 终审 2026-06-12;旧填参线随模板废除退役〔M-b 执行器写链语义保留复用〕;生产=便宜模型经 new-api+harness 门待 W-G1 竞标实测;agentic 基建=AgentScope 2.x,HJ-AGI-001);Dify/OpenGame=远期增强未部署 |
|
||||
| **生成主线** | ~~MVP=模板驱动(LLM 直出 GameConfig+固定模板)~~ → **agent 写码于插件库**(HJ-GEN-001 终审 2026-06-12;旧填参线随模板废除退役〔M-b 执行器写链语义保留复用〕;生产=便宜模型经 new-api+harness 门〔W-G1 L1 worker + 七门真玩 harness + 3 款冒烟已实证地板成立 HJ-GEN-001(¥0.01–0.03<¥0.15 闸);全 20 款 bake-off + 四模型横比 + Claude-free judge 门待跑,配方见 [`skills/cheap-model-game-generation.md`](../skills/cheap-model-game-generation.md)〕;agentic 基建=AgentScope 2.x,HJ-AGI-001);Dify/OpenGame=远期增强未部署 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
59
.agents/skills/cheap-model-game-generation.md
Normal file
@ -0,0 +1,59 @@
|
||||
# 便宜模型直出可玩轻游戏 · L1 生成 worker 配方(W-G1 实证)
|
||||
|
||||
> 适用:用 new-api 便宜模型(DeepSeek/MiniMax)在**插件库 + 装载契约**上生成**可真玩**的 Tier1 轻游戏;**质量由 harness 门保证、非模型自觉**(HJ-GEN-001)。
|
||||
> 真玩取证门见 [`game-e2e-cdp-harness.md`](./game-e2e-cdp-harness.md) §7(七门 + 假绿守卫);本篇讲**生成侧**(谁生成 / 怎么校验 / 成本 / 坑)。
|
||||
> 活样板:worker = `wg1/gen-worker/`(lane 分支 `wg1/gen-worker`);生成产物 + harness = `game-runtime/games/_wg1-gen/`;交付报告 = `wg1/gen-worker/REPORT.md`。
|
||||
|
||||
## 1. 链路(能生成代码 ≠ 能玩)
|
||||
|
||||
brief 题面 → 便宜模型写 `generated-factory.js`(一个 `GameHostFactory`)→ **静态校验门** → 套**官方 generic 壳**(`entry-generic.js` + `buildGenericHostConfig`,§6)→ esbuild `--global-name=__GameBundle` 打 iife(引擎内联 ~265KB)→ P1 宿主 `bootGameHost` 装载 → 本机 Chrome+CDP **九门真玩**(A–G 假绿守卫 + H 机制/latch + I 控制手感,harness §7)→ 失败回喂错因重试 ≤N。**必须过真玩门才算 pass**——能编译 / 能渲染 / 能动 ≠ 能玩。
|
||||
|
||||
## 2. 模型与成本(2026-06-14 网关实测)
|
||||
|
||||
| 模型 | 定位 | 实测 |
|
||||
|---|---|---|
|
||||
| `deepseek-v4-flash` | **打底**(易档) | reasoning 模型;易档 0 重试 ¥0.01;三款冒烟七门全过 |
|
||||
| `deepseek-v4-pro` | 难档**升档** | Simon 0 重试 ¥0.06 |
|
||||
| `MiniMax-M2.7` | 备选,**不优先** | completion_ratio=4 拉高成本 ¥0.117 贴 ¥0.15 闸 |
|
||||
| `MiniMax-M3` | 备选 | 非 reasoning,响应干脆(1-token 回 ok) |
|
||||
|
||||
> 网关 `100.64.0.8:3000` 共 11 模型,上四款 + `MiniMax-M2.7-highspeed` 渠道全活(各打 1 发 ping 实测);MiniMax 另有 M2/M2.1/M2.5 + highspeed 阶梯,横比可按需扩。原「DeepSeek key 未激活」(作战清单:32)**已过时**——经 new-api 网关四模型即时可用。
|
||||
|
||||
**成本口径**(¥上界,不计缓存折扣):`quota =(prompt + completion×completion_ratio)× model_ratio × group_ratio`;`¥ = quota / quota_per_unit(500000)× usd_rate(7.3)`。闸门 **¥0.15/款**。取价 `/api/pricing`、取率 `/api/status`;精确 `logs.quota` 需网关 access token(`sk-` key 无管理权,故只给上界)。
|
||||
|
||||
## 3. L1 纪律 + 坑红线
|
||||
|
||||
- **裸 openai client,不引 AgentScope**(spike VERDICT:框架 token 膨胀 + 本地开销吃便宜档单价);唯一出口 = new-api OpenAI 兼容网关,绝不直连厂商 SDK;key 走 `NEWAPI_KEY` 环境变量 / `.env`(gitignored)**绝不入库**。
|
||||
- **reasoning 模型(flash/pro/M2.7)烧 max_tokens 于 reasoning_tokens** → `max_tokens` 必须给够,否则 `finish=length`、`content` 空。
|
||||
- **代理旁路坑**:本机 clash(`HTTP(S)_PROXY=127.0.0.1:7897`)会把发往 `100.64.0.8` 的请求经代理转发 → 502;**`NO_PROXY` 大小写两版都要含网关 host**,且须在 `import openai` **之前**设。
|
||||
- **输入坐标契约坑**(便宜模型与人皆易错):输入是**逻辑像素** `x∈[0,390] / y∈[0,844]`,**非归一 [0,1]**(`game-runtime/src/host/boot-game-host.js:295`)。写错 → 能编译能渲染能动但**点击全失灵**(详见 harness §7 G 门,正是这门逮住的)。
|
||||
- **静态门禁用 token**:`import` / `littlejsengine` / `Math.random` / `Date.now` / `addEventListener` / `requestAnimationFrame` / `new AudioContext` / `document.` / `window.`——生成代码只许调插件公开面 + 6 受控面(getContext2d/onFrame/getInput/getAudioContext/time/random),随机与时钟一律走受控 `random` / `time`(确定性可复现)。
|
||||
- **bundle 入库**:`dist/` 被 `.gitignore` 吞,故 bundle 输出文件名 `bundle.iife.js`(不落 `dist/`)——它是交付物须入库。
|
||||
|
||||
## 4. 静态校验门(node 侧,出厂前省 CDP 开销)
|
||||
|
||||
抽码(` ```js ` 块)→ 契约扫描(default export + `init/update/render/destroy` 四方法齐 + `getEngine` 用法 + 禁用 token 扫描)→ `node --check` 语法。不过门不进打包,把"必失败件"挡在真玩门前。
|
||||
|
||||
## 5. 边界与未做
|
||||
|
||||
- 只消费冻结契约(`game-host.d.ts` / 6 受控面 / `__GameBundle` / `bootGameHost`)**零修改**;只在 lane 分支,不推 dev/2.0.0。
|
||||
- **未做**(创始人并行 / 后续波次):全 20 款 bake-off、四模型横比(过门率/重试/¥每过门)、AigcGenerateExecutor 生产接线、碰撞穿透门 J、落一个过九门的 flash 正样本(v5 因挡板太慢挂 H/I,需 prompt 给具体挡板速度 + driver 持续按压 + 阈值校准)——规划见 [`2026-06-14-WG1-bakeoff天花板与Claude-free评估门-review.md`](../../docs/agent-specs/2026-06-14-WG1-bakeoff天花板与Claude-free评估门-review.md)。
|
||||
|
||||
## 6. 官方 latch 终态契约 + 套壳(P3 对齐,2026-06-14)
|
||||
|
||||
P3 主线把 L1 产出靶定死为**官方通用装载壳**(`game-runtime/games/_generic/`,dev/2.0.0 `3108009`)。L1 **只产 `generated-factory.js`**(一个 `GameHostFactory`),套官方壳打包:
|
||||
|
||||
- **套壳**:`entry-generic.js`(import `./generated-factory.js` + `src/host/generic-host-config.js` 的 `buildGenericHostConfig` 标准 7 插件装配)→ esbuild `--global-name=__GameBundle`。本 lane harness 入口(`_wg1-gen/_shared/entry-bundle.template.js`)= 同 `buildGenericHostConfig` 装配的**取证超集**(多透传 recHook/onReady 供 F 门;studio 调 `{canvas,seed}` 时 recHook no-op,一份 bundle 两用)。
|
||||
- **可测性红线**:工厂须导出 `_forensicsView() → { state: () => ({ phase, score, remaining, progress, gameoverReason, ...实体位置 }) }`(纯只读、闭包实时值);`host.state()`(`boot-game-host.js:283`)透传它。
|
||||
- **★ P0 latch 终态**:`phase ∈ 'booting'|'playing'|'gameover'`,进 `gameover` 后**恒驻留**(禁自动重开)。宿主 `inject.ts` 每 500ms 轮询 `phase` 代发 `game_end` 结算——**瞬时终态会被漏读→闭环静默断裂**,故必须驻留。实证:flash 能遵此契约(v4/v5 latch ✓)。
|
||||
- **位置态(技巧游戏)**:题面要求时额外导出 `ball/paddle` 等实体 `{x,y,vx,vy,w}`——供 harness **适配性真玩**(读球位接球)+ 确定性手感门(控制/碰撞)。
|
||||
|
||||
## 7. 质量评估:确定性门兜底 + 人锚天花板(W-G1 校准实证,2026-06-14)
|
||||
|
||||
**确定性门(Claude-free,优先)** 已能把多数"坏"变可判:九门 + latch + 控制手感门 + 适配真玩。**"上下文是杠杆"实证**:同 flash 仅改接地,成本 −50%、修复轮次 −80%;player rubric 锚定(1-5 分级 + 空心→≤2)把 MAE 1.25→0.75。
|
||||
|
||||
**但便宜 player agent 做"好玩裁判"有结构性天花板**:
|
||||
- **静态截图(M3 视觉)+ 运行数据(flash 文本)判不出交互动态质量**(物理/碰撞/控制手感)。实证:创始人亲玩把碰撞有问题的 pro 判 fix,两个 player 都给 fun=4(高估);text 还漏了 m27 的控制 bug。
|
||||
- **gate-H 盲打脚本会假阴性**:固定坐标 taps 打不动技巧游戏→"0 进展"≠空心(创始人把被判空心的 v2 排第 2)。解法 = **适配性真玩 driver**(读 state 接球,harness §7)。
|
||||
- **founder/人锚不可替代**(至少现阶段):没有它会把碰撞有问题的 pro 当"最佳"发出去。**校准 harness** = `wg1/gen-worker/worker/agent_loop/calibrate.py`(吃 `calibration/labels.json` 人锚,算 player vs 人锚 MAE/吻合/反样本否决)。
|
||||
- **路线**:确定性门做"地板"(能跑/真接线/有进展/latch/跟手),手感尽量拆成确定性检查(碰撞穿透门 J 规划中);拆不掉的主观手感留**创始人抽检**。
|
||||
@ -1,7 +1,7 @@
|
||||
# Canvas 游戏 e2e 证据 harness 配方(CDP on mini-desktop)
|
||||
|
||||
> 适用:插件库/生成游戏的**真浏览器输入级 e2e**(触摸轨迹→状态机推进→四件套证据),区别于 DOM 走查(那个见 [`ui-walkthrough-cdp.md`](./ui-walkthrough-cdp.md))。
|
||||
> 首跑实证:T1b-α 参考件 7 轮调试链(教训全在 harness 侧,游戏逻辑零返工)。活样板=`game-runtime/games/wanglanmei-ref/test/{e2e-input.cdp.cjs,ref-e2e-runbook.sh}`。W-G1 竞标同 harness 复用。
|
||||
> 首跑实证:T1b-α 参考件 7 轮调试链(教训全在 harness 侧,游戏逻辑零返工)。活样板=`game-runtime/games/wanglanmei-ref/test/{e2e-input.cdp.cjs,ref-e2e-runbook.sh}`。W-G1 生成游戏真玩(九门:假绿守卫 + latch 终态 + 控制手感)见 §7,样板 `game-runtime/games/_wg1-gen/_shared/play.cdp.cjs`。
|
||||
|
||||
## 1. 编排形制(runbook 骨架)
|
||||
|
||||
@ -47,3 +47,27 @@
|
||||
2. **引擎渲出色=spec,非旧 sim 口径**:删 sim 后粒子由引擎 ParticleEmitter 渲,颜色取 `spec.colorStart`(如 particles-juice burst 给**白** r=g=b=1)。A0 G0-7 的青蓝(`b>r+30`)口径对引擎渲染**失效**→像素门判据改「任意亮像素 `max(r,g,b)>80` 计数 + `maxCh` 全屏最亮」;`maxCh≈0`=确凿反证「GL 关致引擎粒子不落 2D」的全黑假象(实测 maxCh=240,引擎真落 2D,GL 关无碍 fillRect 路)。**换引擎能力后,像素门的颜色假设必同步重核**。
|
||||
3. **音频合成需 AudioContext 非空**:插件 playSfx 在 `audioOrSilent()==null` 时早返不合成→rec 不触发→探针须先 `unlockAudio`;chrome `--autoplay-policy=no-user-gesture-required` 下 headless 可合成(此项软门兜,headless 不可用时 deferred 不阻断,正确性由单测 mock-synth 注入兜)。
|
||||
4. **mini-desktop scratch 部署**:6c6g `./` rsync 会吞整个 monorepo(cwd 易被重置到仓根→`tar -C <abs game-runtime>` 或绝对路径,**严禁裸 `./`**);A6 仅需 host-dev/index.html + 预建 host-bundle.js(本机 esbuild 出,含探针)+ test/harness/*.cjs + `npm i ws`,tar-pipe 几文件即可,无须全树同步。
|
||||
|
||||
## 7. 生成游戏真玩九门 + 假绿守卫 + latch 终态 + 手感门(W-G1 实证,2026-06-13/14)
|
||||
|
||||
便宜模型生成游戏的真玩门(driver=`game-runtime/games/_wg1-gen/_shared/play.cdp.cjs`),在 §1-6 基础上收敛为**九门全过才 PASS**(A–G 通用假绿守卫 + H 机制/latch + I 控制手感;**H/I 逐游戏可选**,play-spec 未声明则 SKIP 向后兼容)。生成侧配方见 [`cheap-model-game-generation.md`](./cheap-model-game-generation.md)。
|
||||
|
||||
**真玩驱动两态**:`play-spec.driver` 在=**适配性真玩**(读 `_forensicsView().state()` 的实体位置自动出招,如挡板追球);否则=`play-spec.inputs` 固定序列。**盲打固定坐标打不动技巧游戏**(球没接住→0进展)是 H 假阴性根因(创始人亲玩 + v4/v5 regen 双重实证),故技巧类必用 driver。
|
||||
|
||||
| 门 | 判据 |
|
||||
|---|---|
|
||||
| A 装载 | `__genBooted` 真、无 `__genBootError` |
|
||||
| B 未捕获 | 无 page error / unhandledrejection |
|
||||
| C 掌帧 | 500ms 内 frame 增量 ∈ [20,45](@60fps,排单步假推进 + 失控空转) |
|
||||
| D 真渲染 | mainContext 有亮像素(`max(r,g,b)>80`) |
|
||||
| E 活性 | 多采样 **≥2 个不同整帧哈希**(排静止假死;**静态待机游戏如 Simon 须多采样**,单采样会假阴) |
|
||||
| F 真接线 | `__engineCalls` 含期望 call-ID(插件能力经 `ctx.getEngine()` 真调引擎,recHook 埋点;同 §6 门②) |
|
||||
| **G 输入有效** | **确定性 A/B 对照**:同 seed **无输入** vs **有输入**两实例各推进到**同帧号**,比整帧哈希——**必须不同**才证输入真生效 |
|
||||
| **H 机制+latch** | `play-spec.assertAfterPlay[]` 进展断言(如剩余↓,证非空心)全过 + `expectLatch` 时真玩到 `state().phase==='gameover'` 且**驻留**(600ms 后仍 gameover)。依赖游戏经 `_forensicsView()` 导出 phase(官方 latch 契约,见 cheap-model §latch) |
|
||||
| **I 控制手感** | `play-spec.controlCheck`:连点目标 x,读控制体位置(如 `paddle.x`)验**平滑逼近**(\|moved\|≥阈 ∧ dist≤容差),逮「一格一跳」/卡死/不跟手 |
|
||||
|
||||
**H/I = 把"能玩/手感"从主观变确定性**(W-G1 2026-06-14):H 借官方 latch 终态契约(phase 驻留 gameover)同时验"真结束(关得上闭环)+ 真有进展(非空心)";I 借导出的控制体位置验"跟手"。前提=游戏导出可观测态(可测性红线)。**尚未确定性覆盖**:碰撞穿透(规划门 J);**物理/碰撞/控制的主观手感** player agent(静态截图)判不出、须创始人/人锚(见 cheap-model §质量评估)。
|
||||
|
||||
**G 门 = 本 lane 关键发明,专逮"假绿"**:Pong 这类带**自走动画**的游戏,输入全失灵也"能动"、能骗过 C/D/E;G 门用同帧号 A/B 哈希对照隔离出"输入到底改没改变走向"。实证逮住一处**输入坐标契约写错**(生成 prompt 误称归一坐标、实为逻辑像素 `boot-game-host.js:295`)致"能编译能渲染能动但点击全失灵"的假绿。
|
||||
|
||||
**CDP 触摸下发坑**(承 §2 律3,生成游戏侧细化):tap 须**镜像 ref `realTapAt`**——裸 `{x,y}`(逻辑像素,经 §2 律2 rect 映射)、`touchStart`/`touchEnd` **背靠背**(无 radius/force/长按),否则触摸不达 `getInput()`。坐标契约 = **逻辑像素 [0..390]/[0..844],非归一**(便宜模型与人皆易错)。
|
||||
|
||||
@ -0,0 +1,173 @@
|
||||
# 《W-G1 · 最小 agentic studio(黄金模板先行)+ 模型路由三阶段》执行版 master spec
|
||||
|
||||
> **状态**:执行版 master spec · 2026-06-14 重写(supersede 单角色 bake-off 框架)· 待 Round-2 review。
|
||||
> **上游**:[review 版 §8](2026-06-14-WG1-bakeoff天花板与Claude-free评估门-review.md)(决策+战略扩展+守护栏,创始人已确认);Round-1 四评审(对抗/可行/范围/一致)findings 已纳入本版。
|
||||
> **本 lane**:`wg1/gen-worker`。代码 `wg1/gen-worker/` + `game-runtime/games/_wg1-gen/`;judge rubric → `contracts/prompts/`(或先暂存 lane,见 §9)。**零改冻结契约 / 不碰主线 / 不推 dev·2.0.0**。
|
||||
> **定位转变(关键)**:这不是"便宜模型写代码",是 **agentic 多角色 studio**——一队便宜模型 agent(设计/代码/修复/**player panel**)在 AgentScope 上协作产出游戏,player agent 真玩真判,studio 闭环修到"真能玩+好玩"。**Opus=脚手架(建门/校准/改 agent scope),建好即退,生产无 Claude。**
|
||||
|
||||
---
|
||||
|
||||
## 结论先行
|
||||
|
||||
1. **主干**:最小 studio = 设计→代码→七门(确定性地板)→**player panel(M3 视觉+flash 文本)真玩真判**→回喂修复 ≤5。黄金模板先行:先在**一款(打砖块 Breakout)**打磨透整条闭环,再克隆扩。
|
||||
2. **模型 = 可替换配置旋钮,分三阶段**:Stage1 **flash/M2.7(便宜双家)**+M3 先跑通 → Stage2 pro+M3 再生成对比 → Stage3 逐 agent×逐模型 评分矩阵 → model routing。
|
||||
3. **命门验真闸(不可省)**:player agent 判"好玩"必须**对创始人亲玩锚定**(含"能跑但无聊"反例集),证便宜 player 真跟人类相关再信。否则是 vibes。
|
||||
4. **交付即资产**:打磨过的 agent 层(角色/循环/门/自修复)+ 模型性能矩阵(喂 routing)+ player 校准结论 + 引擎/插件库缺口清单。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
**本期目标(可验收)**
|
||||
1. **最小 studio 闭环**(on AgentScope 进程内多 agent):Breakout 一款,设计→代码→七门→player panel→自修复 ≤5,端到端真跑通(非桩)。
|
||||
2. **player panel + 校准件**:M3 视觉位 + flash 文本位多人格;校准 panel↔创始人亲玩一致率(含反例集)。
|
||||
3. **模型路由三阶段**:Stage1 跑通(flash+M3)→ Stage2 对比(pro+M3 再生成一版)→ Stage3 逐 agent×模型评分矩阵。
|
||||
4. **Opus 改 agent scope 的 lift**(单变量隔离、held-out、分布对照——见 §9 方法约束)。
|
||||
5. **缺口清单**:Breakout 暴露的引擎/插件库短板(为克隆扩款做准备)。
|
||||
|
||||
**非目标(本期不做)**
|
||||
- art/music 生成角色(**后续波次走 MiniMax/mmx-cli**)、全 20+ 款 bake-off、4 档全靶集、生产接线、quality_score MQ 化。
|
||||
- 不 fork AgentScope 内核(只改我方 agent 层);Opus 不进生产环;音乐质量不硬判(无验证音频模态)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前置条件(均已核实 · 2026-06-14)
|
||||
|
||||
| 前置 | 状态 | 证据 |
|
||||
|---|---|---|
|
||||
| AgentScope 2.0.1 | ✅ 三闸全过 | `spikes/agentscope-wg1/VERDICT.md`(分布式/持久化/L1 开销);**进程内多 agent 单机即可,避 #1868 多实例 bug** |
|
||||
| 文本模型 flash/pro | ✅ 可路由 | 网关探活;flash ¥0.0015/1k out、pro ¥0.0058/1k out |
|
||||
| **视觉模型 M3** | ✅ 能看且准 | 双色图实测答对"左红右绿";¥0.0175/1k out(贵 11×,输出限短) |
|
||||
| 七门真玩 harness | ✅ 可信 | `_wg1-gen/_shared/play.cdp.cjs`;golden-path 自检无假绿 |
|
||||
| L1 worker 底座 | ✅ 已落地 | `wg1/gen-worker/worker/`(含薄自修复循环,Simon 2 重试真过) |
|
||||
| MiniMax 生图/音乐 | ✅ 能力在 | `mmx-cli` skill;后续 art/music 波次用 |
|
||||
| 创始人好玩基线 v2 rubric | ⚠️ 未定稿 | W-T1b §4"待过目";**故 player 校准锚=创始人亲玩本人,不依赖未定稿 rubric**(见 §6) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
OPUS["Opus 脚手架(写角色prompt/人格·校准·改agent scope·建好即退)"]
|
||||
subgraph STUDIO["最小 agentic studio · AgentScope 进程内多 agent · 模型=配置旋钮"]
|
||||
BRIEF["brief"] --> DESIGN["设计 agent"]
|
||||
DESIGN --> CODE["代码 agent(玩法/机制/平衡/关卡/脚本=生成域)"]
|
||||
CODE --> VAL["静态校验门"] --> BUNDLE["esbuild __GameBundle"] --> PLAY
|
||||
PLAY["七门真玩(确定性地板·假绿守卫)"] -->|"挂:门+证据"| FIX["自修复 agent"]
|
||||
FIX -->|"≤5(含模型升档)"| CODE
|
||||
PLAY -->|"过七门"| PANEL["player panel:M3视觉位 + flash文本位·多人格<br/>真玩→判完整度+好玩+具体问题"]
|
||||
PANEL -->|"问题回喂"| FIX
|
||||
end
|
||||
OPUS -. "写/校准" .-> DESIGN & CODE & PANEL
|
||||
PANEL --> ANCHOR["校准件:panel ↔ 创始人亲玩一致率(含'能跑但无聊'反例集)"]
|
||||
style STUDIO fill:#1a2a3a,stroke:#4090c0,color:#fff
|
||||
style ANCHOR fill:#3a2a2a,stroke:#c06060,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 模型路由(本版新增核心 · per-agent 配置旋钮)
|
||||
|
||||
**原则**:模型可替换 → **per-agent 模型在 config(`worker/models.yaml`)声明,不硬编码**。换配置即换模型,支撑对比与后续路由。
|
||||
|
||||
**三阶段**:
|
||||
|
||||
| 阶段 | 配置 | 目的 |
|
||||
|---|---|---|
|
||||
| **Stage 1 先跑通** | 文本 `deepseek-v4-flash` + `MiniMax-M2.7`(便宜双家)· 视觉 `MiniMax-M3` | 验 studio 闭环端到端通 + 早期 DeepSeek×MiniMax 跨家对比信号(M2.7 输出贵些但要跨家信号) |
|
||||
| **Stage 2 出对比** | 文本 `deepseek-v4-pro` · 视觉 `M3`,**同款再生成一版** | 同 brief/同 studio 只换模型 → 对比真实效果(过门率/好玩/手感) |
|
||||
| **Stage 3 评+路由** | 逐 agent × 逐候选模型 跑,采集各 agent 输出 | **模型性能评估矩阵**(哪个模型配哪个角色)→ 喂 engine 优化 + model routing |
|
||||
|
||||
**默认起步配置(Stage 1)**:
|
||||
|
||||
| Agent | Stage1 模型 | 说明 |
|
||||
|---|---|---|
|
||||
| 设计 / 代码 / 自修复 | `flash` + `M2.7`(便宜双家,逐 agent 可配) | 先跑通 + 早期跨家对比(同款各跑一版) |
|
||||
| player·视觉位 | `MiniMax-M3` | 唯一视觉;输出强制短(结构化 verdict+一句理由)控成本 |
|
||||
| player·文本位 | `deepseek-v4-flash` | 只读结构态,给 panel 异构 |
|
||||
| (后续)art 生成/判定 | MiniMax 生图 / `M3` 判 | 走 mmx-cli;本期不接 |
|
||||
| (后续)music 生成 | MiniMax 音乐 | 走 mmx-cli;质量留人/指标 |
|
||||
|
||||
**成本纪律**:Stage1 最省(flash 统一);pro/对比开销 Stage2 才花;**实验期"测而不闸"**(照测 ¥/款,不拿 ¥0.15 卡质量),出分级结论。reasoning 档(flash/pro)给够 max_tokens;M3 非 reasoning 快、输出限短。
|
||||
|
||||
---
|
||||
|
||||
## 5. 角色契约(契约先行 · 最小集)
|
||||
|
||||
| Agent | 输入 | 输出 | 默认模型 |
|
||||
|---|---|---|---|
|
||||
| 设计 | brief 题面 | 设计稿{机制/胜负/操作/juice 要点} | flash |
|
||||
| 代码 | 设计稿 | GameHostFactory 源码(默认导出,只调插件面+6 受控面) | flash→(Stage2)pro |
|
||||
| 自修复 | 失败门(七门哪挂)+证据 + player 问题 | 修订源码 | 跟随代码档;**连败升档**(flash→pro) |
|
||||
| player(视觉) | 截图序列 + 结构态探针 | {完整度判, 好玩判, 具体问题[]} | M3 |
|
||||
| player(文本) | 结构态探针 | 同上(无截图) | flash |
|
||||
|
||||
> 玩法/机制/平衡/关卡/脚本本期**合于代码 agent 生成域**(模板哲学);拆细为独立 agent 属后续波次。
|
||||
|
||||
---
|
||||
|
||||
## 6. player panel 校准(命门验真闸 · Round-1 BLOCKER-2 存活形态)
|
||||
|
||||
- **锚 = 创始人亲玩本人**(非未定稿 rubric、非 Opus):一组样本(含**故意"能跑但无聊"反例**:单机制/无失败惩罚/无进度感)→ 创始人亲玩打分 vs player panel 打分 → 量一致率 + **区分力**(panel 能否把反例判否)。
|
||||
- **达标才信**:一致率/区分力达标→panel 判进流程;不达标→该维度退回创始人亲玩,并记"便宜 player 判不了 X"为天花板结论(**无论达标与否都是有效交付**)。
|
||||
- **七门是前置地板**:player 只玩过七门的件(挡"对坏游戏幻觉好玩")。
|
||||
|
||||
---
|
||||
|
||||
## 7. 涉及文件
|
||||
|
||||
| 模块 | 路径 | 动作 |
|
||||
|---|---|---|
|
||||
| 模型路由 config | `wg1/gen-worker/worker/models.yaml`(新) | per-agent 模型声明 |
|
||||
| agent 编排层 | `wg1/gen-worker/worker/agent_loop/`(新·框架中性名) | AgentScope 角色/进程内编排/状态;框架适配隔离 |
|
||||
| 复用底座 | `worker/{_client,prompt,validate,run,cost}.py` | 以现有薄自修复循环增量,**非搬 agent-loop-v1 1269 行** |
|
||||
| player 驱动 | `_wg1-gen/_shared/play.cdp.cjs` | 加 player 感知钩子(截图导出 + 结构态);**采集项待 panel 设计** |
|
||||
| 校准件 | `wg1/gen-worker/calibration/`(新) | 反例集 + 创始人亲玩打分录入 + 一致率算 |
|
||||
| judge rubric | `contracts/prompts/`(或暂存 lane) | Opus 派生(对齐创始人 eval 门,未定稿则暂存) |
|
||||
| 黄金款产物 | `game-runtime/games/_wg1-gen/breakout/` | factory + bundle + evidence |
|
||||
|
||||
---
|
||||
|
||||
## 8. 波次
|
||||
|
||||
| 波次 | 内容 | 完成条件 |
|
||||
|---|---|---|
|
||||
| **P0 跑通** | AgentScope 进程内 设计→代码→七门→player→自修复 闭环,Breakout 一款,**Stage1 配置(flash/M2.7 + M3)** | 一款端到端真跑通(非桩)+ shakedown 初裁 |
|
||||
| **P1 校准** | player panel 多人格 + 校准件(创始人亲玩 vs panel,含反例) | 一致率/区分力表 + "信哪些维度"结论 |
|
||||
| **P2 对比** | **Stage2**:pro+M3 同款再生成 → 与 Stage1 对比真实效果 | 模型对比表(过门率/好玩/手感/¥) |
|
||||
| **P3 评+路由** | **Stage3**:逐 agent×模型评分矩阵 + Opus 改 agent scope 测 lift | 模型性能矩阵 + lift 报告(单变量/held-out) |
|
||||
| (后续) | art/music 角色(MiniMax)、克隆扩款、4 档靶集、获奖压测 | 下一 spec |
|
||||
|
||||
---
|
||||
|
||||
## 9. 边界失败路径(纳入 Round-1 findings)
|
||||
|
||||
| 失败 | 处置 |
|
||||
|---|---|
|
||||
| AgentScope 进程内编排不稳 | 退薄 Python 编排(`--no-agentscope`);记 shakedown |
|
||||
| 创始人 eval 门 rubric 未定稿 | player 校准锚用**创始人亲玩本人**,不阻塞;rubric 我起草、事后采纳 |
|
||||
| player panel 一致率不达标 | 该维度退创始人亲玩 + 记"便宜 player 判不了 X"(有效交付,非失败) |
|
||||
| 自修复 5 次仍不过 | kill,记 `unrepairable`+失败门分布 + 缺口 |
|
||||
| **获奖/超界款** | 仅**源码证伪的硬边界(3D)**豁免;tilemap/网格/文本/swipe 是 🟡 半缺口,造不出**算真失败**、三分类归因(3D硬界/模型顶/未尽改进),**不许用"超界"洗低产出** |
|
||||
| 成本超 ¥0.15 | 运行时记 `over-budget`(测而不闸,主力档可加软闸) |
|
||||
| Opus 在环泄漏 | CI 断言生产路径(七门+断言+便宜 player)零 Claude 依赖 |
|
||||
| lift 归因混淆 | **单变量隔离改进**(一次只改一类)、**held-out brief 验集**(调测分离)、N≥3 重复取分布、temperature=0;≤2 轮收敛,禁反复调凑 lift |
|
||||
|
||||
---
|
||||
|
||||
## 10. 验证 / 完成 / 回滚
|
||||
|
||||
**验证**:P0 单款真跑通(非桩);七门全过;校准一致率/区分力表;Stage1↔Stage2 对比表;模型矩阵;lift(单变量/分布/显著性);CI 断 Claude;每结论标【实测】/【推断】/【假设】。
|
||||
**完成**:Breakout 闭环跑通 + player 校准结论 + 模型对比 + 矩阵 + lift + 缺口清单 + shakedown 裁决 + 沉淀 `.agents/`。
|
||||
**回滚**:全 lane,`git revert` 净回滚;`--no-repair` 退已验收 L1 裸路;`--no-agentscope` 退薄编排;judge rubric 先 lane 再并 Registry。
|
||||
|
||||
---
|
||||
|
||||
## 风险与假设(诚实标注)
|
||||
|
||||
- **[verified·2026-06-14]** AgentScope 2.0.1 三闸过、flash/pro/M3 可路由、M3 视觉准、七门可信、L1 底座在——均带证据(§2)。
|
||||
- **[inferred]** 便宜 agent 队能产 Breakout 级可玩+好玩件——待 P0/P1 验。
|
||||
- **[assumption·核心待验]** 便宜 player 判好玩跟人类相关——靠 §6 校准定论;不相关则退亲玩。
|
||||
- **[assumption]** agent-scope lift 显著为正(harness 复利)——单变量/held-out 测;证伪则记模型天花板。
|
||||
- **[守护栏·创始人确认]** 只改我方 agent 层、AgentScope 可替换不 fork、Opus 建好即退不进生产、便宜模型生产 in-loop、模型 per-agent 可配置可路由。
|
||||
@ -0,0 +1,177 @@
|
||||
# 《W-G1 · 便宜模型生成能力天花板 + Claude-free 评估门》设计稿(review 版)
|
||||
|
||||
> **状态**:review 版 · 2026-06-14 · 顶层走查稿(未改任何运行时代码)。
|
||||
> **定位**:在创始人 [WG1-20 经典轻游戏基准 review](2026-06-13-WG1-20经典轻游戏基准-review.md)(能力覆盖矩阵)之上,**叠加获奖款压测批**,把"便宜模型(DS-V4/M3/M2.7)+ 当前引擎/插件库"的**生成能力天花板**测出来;并一次性建成**不依赖 Claude 的好玩/完整度评估门**(Opus 仅作脚手架/校准器,生产退场),产出引擎/插件库缺口修复清单。
|
||||
> **本 lane**:分支 `wg1/gen-worker`。代码落 `wg1/gen-worker/`(Python worker)+ `game-runtime/games/_wg1-gen/`(生成产物+harness);**零修改冻结契约**(game-host.d.ts / boot-game-host.js / 6 受控面 / __GameBundle / bootGameHost,只消费)。
|
||||
> **关联**:七门真玩 harness 见 [`wg1/gen-worker/REPORT.md`](../../wg1/gen-worker/REPORT.md);好玩基线 v2 见 [`W-T1b review`](2026-06-12-W-T1b-Runner与双层模板-review.md);agent-loop-v1 judge 决策表 E1–E6 与 generation-spike pilot 三 agent 闭环为本设计直接前身。
|
||||
|
||||
---
|
||||
|
||||
## 结论先行(三句话)
|
||||
|
||||
1. **review/可玩性验证已是生产 agent 职责,且有成体系设计**——只是载体从「Claude review」换成「**确定性 harness 门 + 便宜模型自评/互评 + 数据回路**」。最大缺口是"**好玩/完整度的可机判 rubric 尚未定稿**"。本设计就补这块。
|
||||
2. **天花板 = 两条断崖**:① 便宜模型**生成过门率**断崖;② 便宜模型 **judge 与 Opus 金标准一致率**断崖。按 4 档难度梯(冒烟/主力/经典压测/获奖压测)各自定位,顺带暴露插件库硬边界。
|
||||
3. **Opus 只在"建门/校门"在场,建好即退**:把好玩基线 v2 五要素蒸馏成**生产可跑、Claude-free** 的 rubric+确定性断言+校准过的便宜模型 judge。生产由 harness 门 + 便宜模型 judge + quality_score 承接。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景(事实基,带出处)
|
||||
|
||||
- **谁生成**(创始人 2026-06-12 裁定):Fable 只造引擎,**生产生成 = 便宜模型**(new-api 网关 `100.64.0.8:3000`,只有 DeepSeek/MiniMax,**无 Claude**)。
|
||||
- **现有验证设计**(五块成一线,护城河=harness+评估+数据):
|
||||
- 意图层:XC6「质量门禁分层自动化(验收不能只看能运行)」/ XP5「商业化就绪评分」/ P2「永不交付坏游戏」+GP3 / C5-GC5「评估先行,通过率=放行条件」。
|
||||
- 落地层:① prompt 治理四道闸;② agent-loop-v1 **judge 决策表 E1–E6**(`ledger.jsonl` 实证 accept/fix/kill/schema_retry);③ generation-spike **pilot 三 agent 闭环**(策划/对抗红队/玩家裁决);④ **quality_score** 数据回路(`computeQualityScore=clamp(60×完玩+20×like+20×share)`→feed 重排,已 e2e);⑤ **好玩基线 v2** 五要素(手感/美术统一/音乐/结构深度/角色壳)。
|
||||
- **已落地实证**(本 lane):便宜档 deepseek-v4-flash 生成 Pong/Simon/打地鼠 **三款七门全过**,单款 ¥0.01–0.03(<¥0.15 闸门)。**但这三款偏简单,证地板不证天花板**——这正是本设计要解决的。
|
||||
- **创始人并行**:[WG1-20 经典轻游戏基准 review](2026-06-13-WG1-20经典轻游戏基准-review.md) 已盘点 20 款经典街机的能力覆盖(~12 款✅、4 类缺口:文本 HUD/网格/CCD/FSM、已规避 A*),给出 6/8/6 三批切分;**自动 judge 评分门尚未建**(自标"五要素精确判据需开工前从评估门 spec 取齐")。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标 / 非目标
|
||||
|
||||
**目标(本期交付)**
|
||||
1. **生成能力天花板画像**:便宜模型(DS-V4-Pro/Flash、M3、M2.7)在 4 档难度梯上的过门率梯度曲线 + 断崖点 + 四模型横比(过门率/重试/¥每过门 vs ¥0.15)。
|
||||
2. **Claude-free 好玩/完整度评估门**:Opus 蒸馏出**跨品类通用 rubric + 确定性行为断言集**,并**校准便宜模型 judge**(一致率达标才放行进生产)。
|
||||
3. **agentic 多步自修复 worker(L2)**:生成→七门真玩→失败回喂→便宜模型自修复→重试≤N;兼做 **AgentScope shakedown**(分布式/持久化/L1 开销;验不过切 LangGraph)。
|
||||
4. **引擎/插件库缺口修复清单**:4 类经典缺口 + 获奖款暴露的新硬边界(3D/tilemap/像素手感),各拖垮多少款 → 补门面优先级,喂插件库扩展波 v2。
|
||||
|
||||
**非目标(本期不做)**
|
||||
- 不改冻结契约、不碰主线、不推 dev/2.0.0。
|
||||
- 不做素材生成(ComfyUI/图音)、不接 AigcGenerateExecutor 生产接线、不做 quality_score 的 MQ 异步化(M5 大头,创始人轨)。
|
||||
- 不追求获奖压测批的高通过率(本档测上限,重在出缺口实证 + judge 断崖定位)。
|
||||
- **美术真好看/音乐真好听/真机 60fps 手感**等感知项**不假装自动判**——显式留创始人亲玩主判(≤3 轮)。Opus 不在生产环。
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐方案
|
||||
|
||||
### 3.1 四档难度梯靶集(下三档=创始人能力矩阵,第四档=真天花板)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["批1 冒烟·6款<br/>全✅易<br/>验管线打通"] --> B["批2 主力·8款<br/>✅为主·中<br/>量化文本HUD/网格/swipe出错率"]
|
||||
B --> C["批3 经典压测·6款<br/>含❌难<br/>证CCD/网格状态机缺口"]
|
||||
C --> D["批4 获奖压测·K款<br/>真·上线获奖showcase<br/>测好玩/完整度真天花板+插件库硬边界"]
|
||||
style D fill:#3a2a4a,stroke:#a060c0,color:#fff
|
||||
```
|
||||
|
||||
- **批1–3 = 创始人 WG1-20 的 6/8/6**:便宜地验管线 + 系统量化 4 类经典缺口出错率。
|
||||
- **批4 获奖压测(本设计新增,你拍板加)= 真天花板**:从你给的 20 款获奖清单里**按"映射到能力缺口 + 探硬边界"原则**挑选(终选见 §7 待确认),候选映射:
|
||||
|
||||
| 获奖款 | 压什么 | 预判 |
|
||||
|---|---|---|
|
||||
| LittleJS Platformer | tilemap 网格 + 文本 HUD + 相机 | 🟡 网格无门面,边界探针 |
|
||||
| Celeste Classic / 2 | **像素级动量手感(shougan 上限)** + tilemap | 🟡 手感是 judge 一致率主战场 |
|
||||
| Stolen Sword | 战斗 FSM + 多状态动作 | 🟡 FSM 自写 |
|
||||
| Dank Tombs / Tiny Dungeons | 俯视网格地牢 + 氛围光照 | 🟡 网格 + palette-post |
|
||||
| Invader Overload | 弹幕 + 粒子 + 高速碰撞 | 🟡 CCD 边界 |
|
||||
| **Q1K3(3D FPS)** | **3D 渲染** | **❌ 硬边界实证(预期造不出)** |
|
||||
|
||||
> Q1K3 单列为"硬边界实证":插件库 6 受控面无 3D,**预期 ❌**——这是**发现("哪些类型超出插件库"=W-G1 核心交付项),不是失败**。
|
||||
|
||||
### 3.2 生产闭环:五层叠门(Claude-free)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
G["便宜模型生成 GameHostFactory"] --> L0
|
||||
L0["① 可测性红线<br/>必导出取证清单·不可机测不许过门"] --> L1
|
||||
L1["② 确定性 harness 七门<br/>CDP真玩·零LLM·命门(含G输入有效A/B对照)"] --> L2
|
||||
L2["③ rubric派生确定性断言<br/>五要素→checklist机测字段·只判达没达阈"] --> L3
|
||||
L3["④ 便宜模型自评/互评agent<br/>策划/对抗红队/玩家裁决(pilot三角色生产化)"] --> L4
|
||||
L4["⑤ 好玩基线v2评估门<br/>均分≥4 + S锚点≤2s + B预算 + 合规"] --> ONLINE
|
||||
ONLINE["上线 quality_score 反哺<br/>完玩/like/share→feed重排"]
|
||||
L1 -. "任一门挂→回喂具体失败门+证据" .-> G
|
||||
style L1 fill:#2a3a4a,stroke:#4090c0,color:#fff
|
||||
style L2 fill:#2a3a4a,stroke:#4090c0,color:#fff
|
||||
```
|
||||
|
||||
- **②③ 确定性、零 LLM**;**④ 便宜模型**(经 Opus 校准过一致率);**感知项**留创始人亲玩。
|
||||
- **agentic 自修复** = 虚线回喂:七门里**哪门挂了 + 证据**(如 G 门:输入无效)→ 便宜模型针对性自修 → 重试 ≤N。这是 agent-loop-v1 E1–E6 思想 + 我七门 harness 的合并,迁到 AgentScope。
|
||||
|
||||
### 3.3 Opus 的三个且仅有职责(脚手架,生产退场)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
O["Opus(实验脚手架)"] --> R1["①产rubric+确定性断言<br/>五要素从单样例(暖阳小卖部checklist.json)<br/>抽象成跨品类通用门→进评估门spec"]
|
||||
O --> R2["②校准便宜模型judge<br/>Opus金标准 vs 便宜judge一致率<br/>达标放行/分歧回退确定性断言或亲玩"]
|
||||
O --> R3["③去LLM化优先<br/>能转确定性断言的(七门式A/B·字段阈值·闭环可达)<br/>优先转·最小化对任何LLM依赖"]
|
||||
R1 & R2 & R3 --> EXIT["蒸馏完成→Opus退场<br/>生产=harness门+便宜judge+quality_score"]
|
||||
style EXIT fill:#2a4a2a,stroke:#40a040,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键设计决策与取舍
|
||||
|
||||
| 决策 | 选择 | 取舍/理由 |
|
||||
|---|---|---|
|
||||
| 基准轴 | 经典阶梯 + **获奖压测批叠加** | 经典阶梯便宜验管线+能力矩阵;获奖款探真天花板+暴露插件库硬边界。additive,不撞创始人并行轨。 |
|
||||
| 生成模式 | L1 单发 → **L2 agentic 自修复** | 获奖款单发必造不出,必须多步;同时兼做 AgentScope shakedown。代价:token/¥上升、编排复杂度↑,故重试设上限 N、成本仍对照 ¥0.15 闸。 |
|
||||
| judge 门归属 | **我建**(Opus 蒸馏) | 最大缺口、最高价值。**风险**:须与创始人并行 judge 工作**契约对齐**——我产的 rubric/断言要能**并入评估门 spec**,不另起炉灶(见 §6)。 |
|
||||
| Opus 定位 | 仅脚手架/校准器 | 生产无 Claude;蒸馏产物必须确定性 or 便宜模型可执行,**严禁偷偷依赖 Opus 在环**。 |
|
||||
| 感知项(美术/音乐/手感) | 不自动判,留亲玩 | 现无多模态评判器;诚实分层,不冒充"自动判好玩"。 |
|
||||
| 框架 | agentic 闭环跑 AgentScope | 验分布式/持久化/L1 开销;若不抵其复杂度,换轨触发→LangGraph or 自研薄编排(遵框架选型 review)。 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
1. **生成天花板**:产出 4 档 × 4 模型的**过门率梯度表** + 断崖点;¥每过门款全 ≤¥0.15(获奖压测档可超,需标注并解释)。
|
||||
2. **judge 校准**:产出便宜模型 judge **vs Opus 金标准一致率表**(按品类×难度);给出"哪些要素便宜模型够判 / 哪些必须留确定性断言 or 亲玩"的结论。
|
||||
3. **评估门资产**:跨品类 rubric + 确定性断言集成文,且**确定性占比最大化**(可机判项 / 总项)。
|
||||
4. **缺口实证报告**:4 类经典缺口 + 获奖款新边界,各拖垮款数 + 补门面优先级(预判:文本 HUD > 网格 > swipe/拖拽 > CCD swept;获奖档另出 3D/tilemap/手感 边界结论)。
|
||||
5. **诚实分层**:每项结论标 【实测】/【推断】/【假设】,三层证据(结构/可运行真玩/可接受)互不顶替。
|
||||
|
||||
---
|
||||
|
||||
## 6. 影响面 / 风险 / 兼容性
|
||||
|
||||
- **[兼容·低风险]** 零修改冻结契约,只在 lane 新增;新建独立日期 review 文件,与创始人 WG1-20 review 不同文件 → **零合并冲突**。共享 `.agents/` **不碰**(待你后续通知再蒸馏入库)。
|
||||
- **[已核实·无阻塞 · 2026-06-14]** 探网关实测:11 模型,bake-off 四目标 **`deepseek-v4-flash` / `deepseek-v4-pro` / `MiniMax-M2.7` / `MiniMax-M3` 全部渠道活、可路由**(各打 1 发 ping;flash/pro/M2.7 为 reasoning 模型 finish=length 属正常,M3 1-token 回 `'ok'`)。早前"DeepSeek key 未激活"(作战清单:32)**已过时/或指厂商直连**——经 new-api 网关四模型即时可用,**全 20+ 款 bake-off 无人侧 blocker**。(另:MiniMax 实有 M2/M2.1/M2.5/M2.7/M3 + highspeed 阶梯,横比可按需扩。)
|
||||
- **[协调风险]** judge 门 rubric 与创始人并行评估门 spec **可能口径漂移**。缓解:我的 rubric/断言从**第一天就按"可并入评估门 spec"设计**,落地位置待 §7 拍(进 `contracts/prompts/`/评估门 spec 需对齐,先留 lane 亦可)。
|
||||
- **[能力边界]** 获奖款用**完整 LittleJS**(drawText/tilemap/camera/3D),我们受控子集表达不了的部分**预期 ❌**——这是设计内的发现,非失败。
|
||||
- **[假设]** 好玩基线 v2 五要素的精确阈值/权重/硬软门**至今未定义**(意图清单 XP5 五维/XC6 八维只做了并集)。本设计**产出**这些阈值作为 Opus 蒸馏物,**但意图清单已冻结**,落点是 master spec(HJ-GEN-001)或评估门 spec,**不改意图清单**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 待确认项(走查后拍)
|
||||
|
||||
1. **获奖压测批 K 款终选**:§3.1 候选映射够不够?Q1K3(3D)是否确定单列"硬边界实证"(预期 ❌)?其余 19 款里还想点名哪几款?
|
||||
2. **judge 门 rubric 落地位置**:进 `contracts/prompts/`(与创始人评估门 spec 同源)还是**先留 lane `wg1/gen-worker/`**,待创始人评估门 spec 定稿再合并?(关系到协调风险大小)
|
||||
3. **重试上限 N**(agentic 自修复)与**过门率断崖判定阈值**(如连续档过门率跌破 X% 即判天花板)。
|
||||
4. **AgentScope 引入粒度**:整条 agentic 编排上 AgentScope,还是先"裸 client + 薄编排"验自修复闭环、再判是否值得上 AgentScope?(影响 shakedown 结论口径)
|
||||
|
||||
---
|
||||
|
||||
## 风险与假设(诚实标注)
|
||||
|
||||
- **[verified]** 现有验证设计五块、quality_score 公式、七门 harness、创始人 WG1-20 能力矩阵——均带出处(见 §1 链接 + REPORT.md:38-54 + EventIngestServiceImpl.java:302-326)。
|
||||
- **[verified]** 本 lane 三款冒烟七门全过、¥0.01–0.03,已实证(REPORT.md)。
|
||||
- **[inferred]** "便宜模型能产出获奖款级可玩件"是**待验天花板**,本设计不预断;预期在某难度档出现过门率断崖。
|
||||
- **[inferred]** Q1K3 等 3D 款预期 ❌——基于插件库 6 受控面无 3D 透出(源码证伪,见创始人 review A.2)。
|
||||
- **[assumption]** 便宜模型够不够当 judge 判完整度,**无直接数据**;靠 Opus 校准一致率定论(本设计核心待验项之一)。
|
||||
- **[verified·2026-06-14]** bake-off 四模型(flash/pro/M2.7/M3)经 new-api 网关探活全部可路由,**无人侧 blocker**;早前"key 未激活"系引旧 doc(作战清单:32)过时表述,已核实纠正。
|
||||
|
||||
---
|
||||
|
||||
## 8. 决策记录 + 战略扩展(2026-06-14 拍板,supersede §7 待确认)
|
||||
|
||||
**§7 四项落定:**
|
||||
1. **获奖压测批 = 全 20 款获奖清单**入压测档(Witchcat / Penny the Pumpkin / 13th Floor / Killed By A Pixel / Celeste Classic·1k·2 / Q1K3 / Tiny Dungeons / Greeble·2 / LOSSST / LittleJS Platformer / Stolen Sword / MINIPUNK / Evil Glitch·2 / Dank Tombs / Invader Overload / Lost Beacons)。Q1K3 等 3D 仍按"预期 ❌ 硬边界实证"处理(发现而非失败)。
|
||||
2. **judge 门 rubric 落地 = canonical `contracts/prompts/`**(第 8 契约 Registry,与创始人评估门 spec 同源)→ master spec 含"与创始人 eval 门契约对齐"步,降协调风险。
|
||||
3. **agentic 自修复重试上限 N=5**;过门率断崖阈值(我提默认,待确认):某难度档过门率跌破 **50%** 即判该档为便宜模型生成天花板边界。
|
||||
4. **AgentScope 引入 = 整条 agentic 编排上 AgentScope**(不走"裸 client+薄编排"过渡档)。
|
||||
|
||||
**战略扩展(本次最重要):**
|
||||
- **Agent(AgentScope / 我方 agent 层)= 核心资产**(呼应 XC1/GC1「harness=复利核心资产」)。
|
||||
- **Opus 职责扩展**:不止建 Claude-free judge 门,**还要 debug / 改进 agent scope**——使便宜模型在打磨过的 agent 框架内产出**高质量任务**。目标从"测便宜模型天花板"升级为"**用更好的 agent harness 抬高天花板**"(harness 复利:工程沉淀在 harness,单次生成是消耗品)。
|
||||
- **Fable 暂不可用 → 脚手架 / 教师 / 编排层统一降级用 Opus**(生产 in-loop 仍是便宜模型)。
|
||||
|
||||
**架构守护栏(working 解读,待创始人纠正):**
|
||||
- "改进 agent scope" = 改进**我方 agent 层**(任务协议 / 状态模型 / 工具接口 / 循环模板 / 门禁 / 自修复逻辑 / judge 校准——GC9 明列的"要固化"项),**AgentScope 框架本身保持可替换**(不 fork 其内核,保留换轨 LangGraph 选项,遵 XC3/GC9 框架可替换铁律)。
|
||||
- 便宜模型仍是**生产 in-loop 角色**(生成 / 自修复 / judge);**Opus = builder / teacher / calibrator,建好即退,不进生产环**(否则"生产无 Claude"前提破)。
|
||||
|
||||
**质量评估架构 reframe(2026-06-14 创始人亲玩校准后拍定):**
|
||||
- **确定性 gate-H = 可玩性真值 / 地板(Claude-free)**:九门(七门假绿守卫 + H 机制/latch + I 控制手感)+ 适配性真玩 driver(读 state 接球,解盲打假阴性)。手感尽量**拆成确定性门**(latch 终态驻留 / 控制跟手 / 碰撞穿透[门 J 规划]);拆得掉的全自动判。
|
||||
- **player panel = 主观层,消费 gate-H,有结构性天花板**:便宜 player(M3 静态截图 + flash 数据)**判不出物理/碰撞/控制的交互手感**——实证创始人把碰撞有问题的件判 fix、两 player 均高估(fun=4),text 还漏控制 bug。故 player 只判**拆不掉的主观**好玩/美术/节奏,且须人锚校准(`calibrate.py` vs 创始人 `labels.json`)。
|
||||
- **创始人 / 人锚不可替代**(现阶段):是 gate-H 假阴性(盲打)+ player 漂移的最终纠偏。**Opus 退场后,生产 = 确定性门 + 便宜 player + 创始人抽检 三层**,无 Claude。
|
||||
- **gate-H 推广 = design agent 自产 gate-spec**(exportState / driver / controlCheck / assertAfterPlay / expectLatch)→ 确定性门适用**任意游戏**,不再手写 brief(已建:`roles.py` 产 spec、`studio.py` 抽取合入;实证 design agent 能产合法 spec)。
|
||||
38
game-runtime/games/_wg1-gen/.agent
Normal file
@ -0,0 +1,38 @@
|
||||
# .agent — games/_wg1-gen(W-G1 便宜模型生成游戏 · 隔离暂存区)
|
||||
|
||||
owner:W-G1 lane(branch `wg1/gen-worker`)| 状态:建设中(P1 harness 基建)
|
||||
|
||||
## 目标
|
||||
承载「便宜模型(new-api: deepseek-v4 / MiniMax)生成的 GameHostFactory → bundle(__GameBundle iife) → P1 宿主真玩」的全部产物与薄 harness 壳。
|
||||
验证 W-G1 核心假设:**便宜模型能在插件库+装载契约上写出可真玩的轻游戏**(质量由 harness 门保证,非模型自觉)。
|
||||
|
||||
## 为什么落在 game-runtime/games/ 而非 wg1/gen-worker/
|
||||
生成游戏【本质是 game-runtime 游戏】:entry-bundle 动态 import `littlejsengine`、gen wiring import `../../../src/plugins/*`、复用 `../../wanglanmei-ref/src/ref-host-config.js`。
|
||||
esbuild(scripts/build.mjs) 用默认解析(从入口文件上溯找 node_modules),故入口必须在 game-runtime 树内 node_modules 可达处。
|
||||
→ 放此新建子目录,**零修改任何既有/冻结文件**(game-host.d.ts / boot-game-host.js / 6 受控面 / __GameBundle / bootGameHost 只消费不改)。
|
||||
Python L1 worker(LLM 生成/校验/成本/编排)与最终报告留 `wg1/gen-worker/`。
|
||||
|
||||
## 边界(红线)
|
||||
- 只消费冻结契约,绝不改 P1/P2 产出靶。
|
||||
- bundle 输出文件名 `bundle.iife.js`(不落 `dist/`,因 `dist/` 被 .gitignore 吞,bundle 是交付物须入库)。
|
||||
- 不推 dev/2.0.0;只在 lane 分支。
|
||||
|
||||
## 目录
|
||||
- `_shared/entry-bundle.template.js`:通用 bundle 入口(复用 buildRefHostConfig,override factory)→ 逐字拷进每个 game 目录为 entry-bundle.js。
|
||||
- `_shared/index.template.html`:通用 host 页(调 __GameBundle.bootGameHost,注 recHook/__engineCalls/onReady 取证钩子)。
|
||||
- `_shared/play.cdp.cjs`:CDP 真玩驱动 + 四件套证据 + 假绿守卫(复用 test/harness/browser-evidence.cjs)。
|
||||
- `<game>/factory.js`:模型产出的工厂源码(默认导出 GameHostFactory)。
|
||||
- `<game>/{entry-bundle.js,index.html,bundle.iife.js,evidence/}`:每款产物。
|
||||
- `_goldenpath-ref/`:golden path——把 ref 工厂喂进本 harness 验证整条链(防假绿),不算交付游戏。
|
||||
|
||||
## 状态(2026-06-13 收口)
|
||||
- [x] P1:golden-path 跑通——ref 工厂 5/6(F=0 菜单态正确)、probe 工厂 7/7 → harness 可信、无假绿。
|
||||
- [x] P2:L1 worker(wg1/gen-worker/)——generate/validate/bundle/play/cost/run 全落地。
|
||||
- [x] P3:冒烟 Pong / Simon / 打地鼠——deepseek-v4-flash **三款七门全过**,¥0.01–0.03(<¥0.15)。
|
||||
- 结论:HJ-GEN-001 成立。详见 [`wg1/gen-worker/REPORT.md`](../../../wg1/gen-worker/REPORT.md)。
|
||||
|
||||
## 本目录内容
|
||||
- `_shared/`:通用 harness 壳(entry-bundle 模板 / index 模板 / play.cdp.cjs 七门 / serve-and-play.sh)。
|
||||
- `pong|simon|whack/`:交付冒烟游戏(factory.js + bundle.iife.js + evidence/)。
|
||||
- `simon_pro|simon_m27/`:模型对比产物(deepseek-v4-pro / MiniMax-M2.7,证最优模型)。
|
||||
- `_goldenpath-ref|_goldenpath-probe/`:harness 自检件(非交付游戏;probe 亦作生成 few-shot 参照)。
|
||||
34
game-runtime/games/_wg1-gen/_goldenpath-probe/bundle.iife.js
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* entry-bundle.template.js —— W-G1 通用 bundle 入口模板(逐字拷进每个生成 game 目录为 entry-bundle.js)
|
||||
* owner:W-G1 lane | 消费方:esbuild --global-name=__GameBundle → P1 宿主 bootGameHost 真玩
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把「生成的游戏工厂 + 通用宿主 + 标准 7 插件 wiring + 引擎」汇成一个 iife 全局 __GameBundle。
|
||||
* 产出靶契约(冻结·逐字对齐 entry-bundle.js / game-package.schema.json engineBundle):
|
||||
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory 与 bootGameHost。
|
||||
*
|
||||
* 【最大化复用·最小新代码】
|
||||
* - 宿主:复用 src/host/boot-game-host.js 的通用 bootGameHost(不改)。
|
||||
* - 插件 wiring:直接复用 wanglanmei-ref 的 buildRefHostConfig(标准 7 插件 + 偏序 + 视口 + 工厂 wiring,已 P2 验证),
|
||||
* 仅 override factory 为本目录生成的工厂 → 生成游戏跑在【与 ref 完全相同的插件库装配】上。
|
||||
* - 引擎:Option C 动态 import(node import 本入口不触引擎,保 import-safe;esbuild 把引擎内联进单文件 iife)。
|
||||
*
|
||||
* 【Q4 铁律】本入口是「集成段 host」唯一引擎 import 落点(动态 import 后经 opts.engine 注入);
|
||||
* ./factory.js 游戏工厂零引擎 import,能力一律经 boot.ctx.getEngine()。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../../src/host/boot-game-host.js';
|
||||
// 标准插件装配(复用 ref 同一份:collision/physics/gamefeel/juice/palettePost/audioMusic/save + 偏序 + 390×844 视口)。
|
||||
import { buildRefHostConfig } from '../../wanglanmei-ref/src/ref-host-config.js';
|
||||
// 本目录生成的游戏工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。
|
||||
import gameFactory from './factory.js';
|
||||
|
||||
/** 【契约导出】GameHostFactory:宿主从 window.__GameBundle.GameHostFactory 取它(契约冻结)。= 本目录生成的工厂。 */
|
||||
export const GameHostFactory = gameFactory;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default gameFactory;
|
||||
|
||||
/**
|
||||
* 【P2/W-G1 boot 入口】供 host 页在浏览器内启动本 bundle(引擎由本 bundle 注入后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(W-G1 真玩取证一律引擎掌帧)。
|
||||
* - 插件/视口/工厂 wiring 取自 buildRefHostConfig(与 ref 同一标准集),factory override 为生成工厂。
|
||||
* - recHook / onReady 透传:供取证 harness 注入「引擎 call-ID 探针」与「internals(uncaught) 收集」(假绿守卫)。
|
||||
*
|
||||
* @param {Object} opts host 页传入入参。
|
||||
* @param {HTMLCanvasElement} opts.canvas 页面 #game 画布(real 通道引擎自建 mainCanvas#game-engine,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性)。
|
||||
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// 主随机种子(缺省固定值,与 ref 取证同口径,保确定性可复现)。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
// 复用 ref 标准装配(7 插件 + 偏序 + 视口 + 工厂 wiring)。
|
||||
const cfg = buildRefHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;factory override 为本目录生成工厂;其余 wiring 取标准集。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: o.mode || 'play', // W-G1 真玩=play
|
||||
engineMode: 'real', // 一律引擎掌帧(real 通道)
|
||||
viewport: cfg.viewport, // 390×844(与 ref 同,移动竖屏基准)
|
||||
factory: gameFactory, // ★ override:本目录【生成的游戏】(其余 wiring 全复用 ref)
|
||||
plugins: cfg.plugins, // 标准 7 插件实例集(经 opts.runtime.plugins 注入给工厂)
|
||||
registerOrder: cfg.registerOrder, // 注册偏序(保六锚)
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle,getRealAudioCtx}
|
||||
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||
});
|
||||
}
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
@ -0,0 +1,48 @@
|
||||
{
|
||||
"gameId": "_goldenpath-probe",
|
||||
"url": "http://localhost:4320/games/_wg1-gen/_goldenpath-probe/index.html",
|
||||
"ts": null,
|
||||
"guards": {
|
||||
"A_boot": {
|
||||
"pass": true
|
||||
},
|
||||
"C_frame": {
|
||||
"pass": true,
|
||||
"f0": 49,
|
||||
"f1": 79,
|
||||
"delta": 30
|
||||
},
|
||||
"D_render": {
|
||||
"pass": true,
|
||||
"bright": 16482,
|
||||
"maxCh": 255
|
||||
},
|
||||
"E_live": {
|
||||
"pass": true,
|
||||
"distinctStates": 7,
|
||||
"samples": 7
|
||||
},
|
||||
"B_uncaught": {
|
||||
"pass": true,
|
||||
"uncaught": []
|
||||
},
|
||||
"F_wiring": {
|
||||
"pass": true,
|
||||
"callCount": 235,
|
||||
"sample": [
|
||||
"particles.spawnEmitter",
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx",
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx"
|
||||
],
|
||||
"expected": [
|
||||
"math.",
|
||||
"particles.",
|
||||
"audio.synth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"pass": true,
|
||||
"notes": []
|
||||
}
|
||||
78
game-runtime/games/_wg1-gen/_goldenpath-probe/factory.js
Normal file
@ -0,0 +1,78 @@
|
||||
'use strict';
|
||||
/**
|
||||
* factory.js —— 最小合法 GameHostFactory「探针」(golden-path)。
|
||||
* 双重用途:① 确定性触发 getEngine() 三类叶调用(math/particles/audio)→ 验 harness 的 F 真接线门能亮;
|
||||
* ② 作便宜模型生成的 few-shot 参照:一份「最小但处处合契约」的工厂范例。
|
||||
* 严守受控面铁律:零引擎 import、零 Math.random / Date.now / addEventListener / new AudioContext / RAF;
|
||||
* 能力一律经 boot.ctx;render 只画到入参 g(=boot.mainContext)。
|
||||
*/
|
||||
export default function createProbeGame(opts) {
|
||||
const o = opts || {}; // 防御:纯净工厂 opts 可能为 undefined
|
||||
let ctx = null; // 受控面(boot.ctx),init 内填充(勿在工厂顶层建态)
|
||||
let nowMs = 0; // 本帧逻辑钟快照(update 首行从 ctx.time 刷新)
|
||||
let t = 0; // 累计秒(动画用)
|
||||
let sub = null; // pointerdown 订阅句柄(destroy 取消)
|
||||
let bursts = []; // 点击反馈圈(纯几何,叠在引擎粒子之外)
|
||||
|
||||
/** init:建态 + 订阅受控输入 + 首帧探引擎(spawn 常驻发射器 → 触发 particles.spawnEmitter)。 */
|
||||
function init(boot) {
|
||||
ctx = boot.ctx;
|
||||
sub = ctx.getInput().on('pointerdown', onTap); // 禁 addEventListener,走受控订阅
|
||||
const eng = ctx.getEngine(); // 可能为 null,须容错
|
||||
if (eng && eng.particles) {
|
||||
try {
|
||||
eng.particles.spawnEmitter({
|
||||
pos: { x: 195, y: 300 }, emitRate: 18, emitTime: 0, particleTime: 0.7, speed: 40,
|
||||
colorStart: { r: 1, g: 0.8, b: 0.2, a: 1 }, colorEnd: { r: 1, g: 0.3, b: 0, a: 0 },
|
||||
});
|
||||
} catch (_) { /* 引擎缺省时不连坐游戏 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击:记反馈圈 + 触发引擎粒子爆发与音效(→ particles.spawnEmitter / audio.synth.synthSfx)。 */
|
||||
function onTap(e) {
|
||||
nowMs = ctx.time.nowMs();
|
||||
const px = e.x || 0, py = e.y || 0; // e.x/e.y 已是逻辑像素坐标(x∈[0,390], y∈[0,844]),直接用,勿再乘视口
|
||||
bursts.push({ x: px, y: py, born: nowMs });
|
||||
const eng = ctx.getEngine();
|
||||
if (eng) {
|
||||
if (eng.particles) {
|
||||
try { eng.particles.spawnEmitter({ pos: { x: px, y: py }, count: 24, speed: 130, particleTime: 0.5,
|
||||
colorStart: { r: 0.3, g: 0.9, b: 1, a: 1 }, colorEnd: { r: 0, g: 0.2, b: 0.6, a: 0 } }); } catch (_) {}
|
||||
}
|
||||
if (eng.audio && eng.audio.synth) {
|
||||
try { eng.audio.synth.synthSfx([1, 0.05, 220, , , 0.18, , 1.4]); } catch (_) {} // ZzFX 参数包
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** update:刷本帧钟 + 每帧触发一次 math.lerp(确定性引擎叶调用 → F 门有信号)+ 过期反馈圈淘汰。 */
|
||||
function update(dt) {
|
||||
nowMs = ctx.time.nowMs(); // ★并轨单钟:读 host 唯一推进的钟
|
||||
t = nowMs / 1000;
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.math) { try { eng.math.lerp(0, 1, (t % 1)); } catch (_) {} }
|
||||
bursts = bursts.filter((b) => nowMs - b.born < 600);
|
||||
}
|
||||
|
||||
/** render:画到入参 g(=boot.mainContext)。背景满幅(保 D 非空)+左右移动方块(保 E 活性)+点击圈+提示。 */
|
||||
function render(g) {
|
||||
g.fillStyle = '#101820'; g.fillRect(0, 0, 390, 844); // 满幅底色
|
||||
const x = 50 + 290 * (0.5 + 0.5 * Math.sin(t * 1.5)); // Math.sin 允许(禁的是 random/Date.now)
|
||||
g.fillStyle = '#ffcc33'; g.fillRect(x, 400, 60, 60); // 随时间移动 → 逐帧变
|
||||
for (const b of bursts) {
|
||||
const age = (nowMs - b.born) / 600;
|
||||
g.strokeStyle = 'rgba(80,220,255,' + (1 - age).toFixed(3) + ')';
|
||||
g.lineWidth = 3; g.beginPath(); g.arc(b.x, b.y, 8 + age * 40, 0, Math.PI * 2); g.stroke();
|
||||
}
|
||||
g.fillStyle = '#88aabb'; g.font = '16px monospace'; g.fillText('probe: tap me', 130, 120);
|
||||
}
|
||||
|
||||
/** destroy:幂等卸载(取消订阅)。 */
|
||||
function destroy() {
|
||||
try { sub && sub.cancel(); } catch (_) {}
|
||||
sub = null; bursts = [];
|
||||
}
|
||||
|
||||
return { init, update, render, destroy };
|
||||
}
|
||||
83
game-runtime/games/_wg1-gen/_goldenpath-probe/index.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.template.html —— W-G1 通用 host 页模板(逐字拷进每个生成 game 目录为 index.html)
|
||||
owner:W-G1 lane | 用法:
|
||||
· 同目录 esbuild 产物 bundle.iife.js(globalName=__GameBundle)由本页 <script src> 加载;
|
||||
· serve game-runtime 根,CDP 导航到 /games/_wg1-gen/<game>/index.html;
|
||||
· 就绪标记 __genBooted(bootGameHost().then 置);real 引擎接管标记 __gameHostEngineInitFired(boot-game-host 内置);
|
||||
· 假绿守卫钩子:window.__engineCalls(recHook 记每次引擎能力调用 call-ID)、window.__genInternals(onReady 收 uncaught[])。
|
||||
布局收编(对齐 ref 教训):canvas 顶在 (0,0) 满幅 390×844;状态行 absolute overlay + pointer-events:none,
|
||||
绝不下推/截触摸(否则 CDP 真输入坐标整体偏移 + 底部不可点)。引擎自建 mainCanvas#game-engine 叠加渲染。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;user-scalable=no 防缩放扰动像素哈希。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>W-G1 generated game</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #ccc;
|
||||
font-family: -apple-system, "PingFang SC", monospace; overflow: hidden; }
|
||||
#wrap { position: relative; width: 390px; margin: 0 auto; }
|
||||
#game { width: 390px; height: 844px; background: #000; display: block; touch-action: none; }
|
||||
#status { position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none;
|
||||
font-size: 12px; line-height: 1.4; min-height: 15px; text-align: center; word-break: break-all;
|
||||
opacity: 0.85; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<!-- 受控画布(opts.canvas 占位/输入兜底);real 引擎自建 #game-engine 叠加。 -->
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- esbuild 产物:iife 全局 __GameBundle(含 GameHostFactory + bootGameHost)。 -->
|
||||
<script src="./bundle.iife.js"></script>
|
||||
<script>
|
||||
// 引导:取 __GameBundle.bootGameHost 启动;注入取证钩子;失败留痕 + 重抛(假绿守卫要看 console 零未捕获)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
// 假绿守卫C·真接线探针:每次引擎能力被调,recHook 记一条 call-ID 进此数组(证「真调引擎/插件库」非自绘伪装)。
|
||||
window.__engineCalls = [];
|
||||
function recHook(id) { try { window.__engineCalls.push(id); } catch (_) {} }
|
||||
// 就绪/错误标记初值。
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = null;
|
||||
try {
|
||||
var G = window.__GameBundle;
|
||||
if (!G || typeof G.bootGameHost !== 'function') {
|
||||
throw new Error('__GameBundle.bootGameHost 未找到(bundle.iife.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGameHost({
|
||||
canvas: canvas,
|
||||
statusEl: statusEl,
|
||||
recHook: recHook,
|
||||
// onReady:收集 internals(uncaught[] 等)供假绿守卫B 读。
|
||||
onReady: function (host, internals) {
|
||||
window.__genHost = host;
|
||||
window.__genInternals = internals || null;
|
||||
}
|
||||
})
|
||||
.then(function (host) {
|
||||
window.__genHost = window.__genHost || host;
|
||||
window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e; // 重抛 → 'unhandledrejection' 被假绿守卫逮住
|
||||
});
|
||||
} catch (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
11
game-runtime/games/_wg1-gen/_goldenpath-probe/play-spec.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"_note": "probe:确定性触发引擎三类叶调用。update 每帧 math.lerp、init+tap 触 particles.spawnEmitter、tap 触 audio.synth.synthSfx。点几下即过 F。",
|
||||
"inputs": [
|
||||
{ "t": "wait", "ms": 400 },
|
||||
{ "t": "tap", "x": 195, "y": 500 },
|
||||
{ "t": "wait", "ms": 200 },
|
||||
{ "t": "tap", "x": 120, "y": 300 },
|
||||
{ "t": "wait", "ms": 300 }
|
||||
],
|
||||
"expectedEngineCallPrefixes": ["math.", "particles.", "audio.synth"]
|
||||
}
|
||||
34
game-runtime/games/_wg1-gen/_goldenpath-ref/bundle.iife.js
Normal file
75
game-runtime/games/_wg1-gen/_goldenpath-ref/entry-bundle.js
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* entry-bundle.template.js —— W-G1 通用 bundle 入口模板(逐字拷进每个生成 game 目录为 entry-bundle.js)
|
||||
* owner:W-G1 lane | 消费方:esbuild --global-name=__GameBundle → P1 宿主 bootGameHost 真玩
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把「生成的游戏工厂 + 通用宿主 + 标准 7 插件 wiring + 引擎」汇成一个 iife 全局 __GameBundle。
|
||||
* 产出靶契约(冻结·逐字对齐 entry-bundle.js / game-package.schema.json engineBundle):
|
||||
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory 与 bootGameHost。
|
||||
*
|
||||
* 【最大化复用·最小新代码】
|
||||
* - 宿主:复用 src/host/boot-game-host.js 的通用 bootGameHost(不改)。
|
||||
* - 插件 wiring:直接复用 wanglanmei-ref 的 buildRefHostConfig(标准 7 插件 + 偏序 + 视口 + 工厂 wiring,已 P2 验证),
|
||||
* 仅 override factory 为本目录生成的工厂 → 生成游戏跑在【与 ref 完全相同的插件库装配】上。
|
||||
* - 引擎:Option C 动态 import(node import 本入口不触引擎,保 import-safe;esbuild 把引擎内联进单文件 iife)。
|
||||
*
|
||||
* 【Q4 铁律】本入口是「集成段 host」唯一引擎 import 落点(动态 import 后经 opts.engine 注入);
|
||||
* ./factory.js 游戏工厂零引擎 import,能力一律经 boot.ctx.getEngine()。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../../src/host/boot-game-host.js';
|
||||
// 标准插件装配(复用 ref 同一份:collision/physics/gamefeel/juice/palettePost/audioMusic/save + 偏序 + 390×844 视口)。
|
||||
import { buildRefHostConfig } from '../../wanglanmei-ref/src/ref-host-config.js';
|
||||
// 本目录生成的游戏工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。
|
||||
import gameFactory from './factory.js';
|
||||
|
||||
/** 【契约导出】GameHostFactory:宿主从 window.__GameBundle.GameHostFactory 取它(契约冻结)。= 本目录生成的工厂。 */
|
||||
export const GameHostFactory = gameFactory;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default gameFactory;
|
||||
|
||||
/**
|
||||
* 【P2/W-G1 boot 入口】供 host 页在浏览器内启动本 bundle(引擎由本 bundle 注入后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(W-G1 真玩取证一律引擎掌帧)。
|
||||
* - 插件/视口/工厂 wiring 取自 buildRefHostConfig(与 ref 同一标准集),factory override 为生成工厂。
|
||||
* - recHook / onReady 透传:供取证 harness 注入「引擎 call-ID 探针」与「internals(uncaught) 收集」(假绿守卫)。
|
||||
*
|
||||
* @param {Object} opts host 页传入入参。
|
||||
* @param {HTMLCanvasElement} opts.canvas 页面 #game 画布(real 通道引擎自建 mainCanvas#game-engine,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性)。
|
||||
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// 主随机种子(缺省固定值,与 ref 取证同口径,保确定性可复现)。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
// 复用 ref 标准装配(7 插件 + 偏序 + 视口 + 工厂 wiring)。
|
||||
const cfg = buildRefHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;factory override 为本目录生成工厂;其余 wiring 取标准集。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: o.mode || 'play', // W-G1 真玩=play
|
||||
engineMode: 'real', // 一律引擎掌帧(real 通道)
|
||||
viewport: cfg.viewport, // 390×844(与 ref 同,移动竖屏基准)
|
||||
factory: gameFactory, // ★ override:本目录【生成的游戏】(其余 wiring 全复用 ref)
|
||||
plugins: cfg.plugins, // 标准 7 插件实例集(经 opts.runtime.plugins 注入给工厂)
|
||||
registerOrder: cfg.registerOrder, // 注册偏序(保六锚)
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle,getRealAudioCtx}
|
||||
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||
});
|
||||
}
|
||||
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 349 KiB |
@ -0,0 +1,42 @@
|
||||
{
|
||||
"gameId": "_goldenpath-ref",
|
||||
"url": "http://localhost:4320/games/_wg1-gen/_goldenpath-ref/index.html",
|
||||
"ts": null,
|
||||
"guards": {
|
||||
"A_boot": {
|
||||
"pass": true
|
||||
},
|
||||
"C_frame": {
|
||||
"pass": true,
|
||||
"f0": 73,
|
||||
"f1": 104,
|
||||
"delta": 31
|
||||
},
|
||||
"D_render": {
|
||||
"pass": true,
|
||||
"bright": 1313788,
|
||||
"maxCh": 250
|
||||
},
|
||||
"E_live": {
|
||||
"pass": true,
|
||||
"hash0": "8677bb05",
|
||||
"hash1": "82bc97f4"
|
||||
},
|
||||
"B_uncaught": {
|
||||
"pass": true,
|
||||
"uncaught": []
|
||||
},
|
||||
"F_wiring": {
|
||||
"pass": false,
|
||||
"callCount": 0,
|
||||
"sample": [],
|
||||
"expected": [
|
||||
"math.",
|
||||
"particles.",
|
||||
"audio.synth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"pass": false,
|
||||
"notes": []
|
||||
}
|
||||
6
game-runtime/games/_wg1-gen/_goldenpath-ref/factory.js
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* factory.js —— golden-path:把 ref 工厂(wanglanmei game-shop)喂进 W-G1 通用 harness。
|
||||
* 目的:先用【已知好】的工厂跑通整条「entry-bundle → __GameBundle → bootGameHost → CDP 真玩」链,
|
||||
* 证明 harness 自身可信(六道假绿守卫在真·可玩游戏上确实判 PASS),再喂便宜模型生成物。非交付游戏。
|
||||
*/
|
||||
export { default } from '../../wanglanmei-ref/src/game-shop.js';
|
||||
83
game-runtime/games/_wg1-gen/_goldenpath-ref/index.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.template.html —— W-G1 通用 host 页模板(逐字拷进每个生成 game 目录为 index.html)
|
||||
owner:W-G1 lane | 用法:
|
||||
· 同目录 esbuild 产物 bundle.iife.js(globalName=__GameBundle)由本页 <script src> 加载;
|
||||
· serve game-runtime 根,CDP 导航到 /games/_wg1-gen/<game>/index.html;
|
||||
· 就绪标记 __genBooted(bootGameHost().then 置);real 引擎接管标记 __gameHostEngineInitFired(boot-game-host 内置);
|
||||
· 假绿守卫钩子:window.__engineCalls(recHook 记每次引擎能力调用 call-ID)、window.__genInternals(onReady 收 uncaught[])。
|
||||
布局收编(对齐 ref 教训):canvas 顶在 (0,0) 满幅 390×844;状态行 absolute overlay + pointer-events:none,
|
||||
绝不下推/截触摸(否则 CDP 真输入坐标整体偏移 + 底部不可点)。引擎自建 mainCanvas#game-engine 叠加渲染。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;user-scalable=no 防缩放扰动像素哈希。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>W-G1 generated game</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #ccc;
|
||||
font-family: -apple-system, "PingFang SC", monospace; overflow: hidden; }
|
||||
#wrap { position: relative; width: 390px; margin: 0 auto; }
|
||||
#game { width: 390px; height: 844px; background: #000; display: block; touch-action: none; }
|
||||
#status { position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none;
|
||||
font-size: 12px; line-height: 1.4; min-height: 15px; text-align: center; word-break: break-all;
|
||||
opacity: 0.85; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<!-- 受控画布(opts.canvas 占位/输入兜底);real 引擎自建 #game-engine 叠加。 -->
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- esbuild 产物:iife 全局 __GameBundle(含 GameHostFactory + bootGameHost)。 -->
|
||||
<script src="./bundle.iife.js"></script>
|
||||
<script>
|
||||
// 引导:取 __GameBundle.bootGameHost 启动;注入取证钩子;失败留痕 + 重抛(假绿守卫要看 console 零未捕获)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
// 假绿守卫C·真接线探针:每次引擎能力被调,recHook 记一条 call-ID 进此数组(证「真调引擎/插件库」非自绘伪装)。
|
||||
window.__engineCalls = [];
|
||||
function recHook(id) { try { window.__engineCalls.push(id); } catch (_) {} }
|
||||
// 就绪/错误标记初值。
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = null;
|
||||
try {
|
||||
var G = window.__GameBundle;
|
||||
if (!G || typeof G.bootGameHost !== 'function') {
|
||||
throw new Error('__GameBundle.bootGameHost 未找到(bundle.iife.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGameHost({
|
||||
canvas: canvas,
|
||||
statusEl: statusEl,
|
||||
recHook: recHook,
|
||||
// onReady:收集 internals(uncaught[] 等)供假绿守卫B 读。
|
||||
onReady: function (host, internals) {
|
||||
window.__genHost = host;
|
||||
window.__genInternals = internals || null;
|
||||
}
|
||||
})
|
||||
.then(function (host) {
|
||||
window.__genHost = window.__genHost || host;
|
||||
window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e; // 重抛 → 'unhandledrejection' 被假绿守卫逮住
|
||||
});
|
||||
} catch (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
11
game-runtime/games/_wg1-gen/_goldenpath-ref/play-spec.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"_note": "golden-path ref:先不打具体玩法动作,验 harness 在已知好工厂上判 PASS。ref 启动即引擎掌帧+动画(易缓动/粒子),math/particles/audio 任一触发即过 F。",
|
||||
"inputs": [
|
||||
{ "t": "wait", "ms": 600 },
|
||||
{ "t": "tap", "x": 195, "y": 760 },
|
||||
{ "t": "wait", "ms": 400 },
|
||||
{ "t": "tap", "x": 195, "y": 420 },
|
||||
{ "t": "wait", "ms": 400 }
|
||||
],
|
||||
"expectedEngineCallPrefixes": ["math.", "particles.", "audio.synth"]
|
||||
}
|
||||
77
game-runtime/games/_wg1-gen/_shared/entry-bundle.template.js
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* entry-bundle.template.js —— W-G1 通用 bundle 入口模板(逐字拷进每个生成 game 目录为 entry-bundle.js)
|
||||
* owner:W-G1 lane | 消费方:esbuild --global-name=__GameBundle → P1 宿主 bootGameHost 真玩
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把「生成的游戏工厂 + 通用宿主 + 标准 7 插件 wiring + 引擎」汇成一个 iife 全局 __GameBundle。
|
||||
* 产出靶契约(冻结·逐字对齐 entry-bundle.js / game-package.schema.json engineBundle):
|
||||
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory 与 bootGameHost。
|
||||
*
|
||||
* 【最大化复用·最小新代码】
|
||||
* - 宿主:复用 src/host/boot-game-host.js 的通用 bootGameHost(不改)。
|
||||
* - 插件 wiring:用官方 L0 通用装载壳 src/host/generic-host-config.js 的 buildGenericHostConfig(标准 7 插件 + 偏序 + 视口 + 工厂 wiring,与官方 entry-generic.js 同一份),
|
||||
* override factory 为本目录生成的工厂 → 生成游戏跑在【官方 generic 标准装配】上。
|
||||
* - 注:本入口 = 官方 entry-generic.js 的「harness 可取证」超集——多透传 recHook/onReady 供 CDP 八门(F门真接线);
|
||||
* studio 渠道调 bootGameHost({canvas,seed}) 时 recHook 为空=no-op,feed 照常(一份 bundle 同时服务 feed + 取证门)。
|
||||
* - 引擎:Option C 动态 import(node import 本入口不触引擎,保 import-safe;esbuild 把引擎内联进单文件 iife)。
|
||||
*
|
||||
* 【Q4 铁律】本入口是「集成段 host」唯一引擎 import 落点(动态 import 后经 opts.engine 注入);
|
||||
* ./factory.js 游戏工厂零引擎 import,能力一律经 boot.ctx.getEngine()。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../../src/host/boot-game-host.js';
|
||||
// 标准插件装配(官方 L0 通用装载壳 buildGenericHostConfig:collision/physics/gamefeel/juice/palettePost/audioMusic/save + 偏序 + 390×844 视口;零 wanglanmei 专属)。
|
||||
import { buildGenericHostConfig } from '../../../src/host/generic-host-config.js';
|
||||
// 本目录生成的游戏工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件;官方靶名 generated-factory.js)。
|
||||
import gameFactory from './generated-factory.js';
|
||||
|
||||
/** 【契约导出】GameHostFactory:宿主从 window.__GameBundle.GameHostFactory 取它(契约冻结)。= 本目录生成的工厂。 */
|
||||
export const GameHostFactory = gameFactory;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default gameFactory;
|
||||
|
||||
/**
|
||||
* 【P2/W-G1 boot 入口】供 host 页在浏览器内启动本 bundle(引擎由本 bundle 注入后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(W-G1 真玩取证一律引擎掌帧)。
|
||||
* - 插件/视口/工厂 wiring 取自 buildRefHostConfig(与 ref 同一标准集),factory override 为生成工厂。
|
||||
* - recHook / onReady 透传:供取证 harness 注入「引擎 call-ID 探针」与「internals(uncaught) 收集」(假绿守卫)。
|
||||
*
|
||||
* @param {Object} opts host 页传入入参。
|
||||
* @param {HTMLCanvasElement} opts.canvas 页面 #game 画布(real 通道引擎自建 mainCanvas#game-engine,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性)。
|
||||
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// 主随机种子(缺省固定值,与 ref 取证同口径,保确定性可复现)。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
// 官方 generic 标准装配(7 插件 + 偏序 + 视口 + 工厂 wiring;与 entry-generic.js 同一 buildGenericHostConfig)。
|
||||
const cfg = buildGenericHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;factory override 为本目录生成工厂;其余 wiring 取标准集。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: o.mode || 'play', // W-G1 真玩=play
|
||||
engineMode: 'real', // 一律引擎掌帧(real 通道)
|
||||
viewport: cfg.viewport, // 390×844(与 ref 同,移动竖屏基准)
|
||||
factory: gameFactory, // ★ override:本目录【生成的游戏】(其余 wiring 全复用 ref)
|
||||
plugins: cfg.plugins, // 标准 7 插件实例集(经 opts.runtime.plugins 注入给工厂)
|
||||
registerOrder: cfg.registerOrder, // 注册偏序(保六锚)
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle,getRealAudioCtx}
|
||||
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||
});
|
||||
}
|
||||
89
game-runtime/games/_wg1-gen/_shared/index.template.html
Normal file
@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.template.html —— W-G1 通用 host 页模板(逐字拷进每个生成 game 目录为 index.html)
|
||||
owner:W-G1 lane | 用法:
|
||||
· 同目录 esbuild 产物 bundle.iife.js(globalName=__GameBundle)由本页 <script src> 加载;
|
||||
· serve game-runtime 根,CDP 导航到 /games/_wg1-gen/<game>/index.html;
|
||||
· 就绪标记 __genBooted(bootGameHost().then 置);real 引擎接管标记 __gameHostEngineInitFired(boot-game-host 内置);
|
||||
· 假绿守卫钩子:window.__engineCalls(recHook 记每次引擎能力调用 call-ID)、window.__genInternals(onReady 收 uncaught[])。
|
||||
布局收编(对齐 ref 教训):canvas 顶在 (0,0) 满幅 390×844;状态行 absolute overlay + pointer-events:none,
|
||||
绝不下推/截触摸(否则 CDP 真输入坐标整体偏移 + 底部不可点)。引擎自建 mainCanvas#game-engine 叠加渲染。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;user-scalable=no 防缩放扰动像素哈希。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>W-G1 generated game</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #ccc;
|
||||
font-family: -apple-system, "PingFang SC", monospace; overflow: hidden; }
|
||||
#wrap { position: relative; width: 390px; margin: 0 auto; }
|
||||
#game { width: 390px; height: 844px; background: #000; display: block; touch-action: none; }
|
||||
/* 状态行默认隐藏:boot-game-host 的「engine RAF running…」调试浮层会被截图带进画面、污染 player 视觉判分;
|
||||
harness 真玩门读 JS 变量(__genBooted/__genBootError/console)判定,不依赖此可视行,故隐藏不影响七门。
|
||||
仅 BOOT ERROR 时由下方脚本显式显示,便于人工排查。 */
|
||||
#status { display: none; position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none;
|
||||
font-size: 12px; line-height: 1.4; min-height: 15px; text-align: center; word-break: break-all;
|
||||
opacity: 0.85; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<!-- 受控画布(opts.canvas 占位/输入兜底);real 引擎自建 #game-engine 叠加。 -->
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- esbuild 产物:iife 全局 __GameBundle(含 GameHostFactory + bootGameHost)。 -->
|
||||
<script src="./bundle.iife.js"></script>
|
||||
<script>
|
||||
// 引导:取 __GameBundle.bootGameHost 启动;注入取证钩子;失败留痕 + 重抛(假绿守卫要看 console 零未捕获)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
// 假绿守卫C·真接线探针:每次引擎能力被调,recHook 记一条 call-ID 进此数组(证「真调引擎/插件库」非自绘伪装)。
|
||||
window.__engineCalls = [];
|
||||
function recHook(id) { try { window.__engineCalls.push(id); } catch (_) {} }
|
||||
// 就绪/错误标记初值。
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = null;
|
||||
try {
|
||||
var G = window.__GameBundle;
|
||||
if (!G || typeof G.bootGameHost !== 'function') {
|
||||
throw new Error('__GameBundle.bootGameHost 未找到(bundle.iife.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGameHost({
|
||||
canvas: canvas,
|
||||
statusEl: statusEl,
|
||||
recHook: recHook,
|
||||
// onReady:收集 internals(uncaught[] 等)供假绿守卫B 读。
|
||||
onReady: function (host, internals) {
|
||||
window.__genHost = host;
|
||||
window.__genInternals = internals || null;
|
||||
}
|
||||
})
|
||||
.then(function (host) {
|
||||
window.__genHost = window.__genHost || host;
|
||||
// 可测性别名:守卫H 经此读游戏可观测状态(host.state() 透传 game._forensicsView().state;游戏未实现则返回 {phase:'booting'})。
|
||||
// 宿主侧挂载,工厂仍零 window;与 ref 在 onReady 挂 __refForensics 同范式。
|
||||
window.__gameState = function () { try { return host && typeof host.state === 'function' ? host.state() : null; } catch (_) { return null; } };
|
||||
window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError; }
|
||||
throw e; // 重抛 → 'unhandledrejection' 被假绿守卫逮住
|
||||
});
|
||||
} catch (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
414
game-runtime/games/_wg1-gen/_shared/play.cdp.cjs
Normal file
@ -0,0 +1,414 @@
|
||||
/**
|
||||
* play.cdp.cjs —— W-G1 通用「CDP 真玩取证驱动」(模型无关,所有生成游戏共用一份)
|
||||
* owner:W-G1 lane | cwd 必须 = game-runtime | 复用 test/harness/browser-evidence.cjs
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把一个 __GameBundle iife 装进 P1 宿主页、经 CDP 驱动本机 Chrome 真玩几步,产出四件套证据,
|
||||
* 并以「九道假绿守卫」判定是否【真·可玩】——"能生成代码 ≠ 能玩",必须过真玩门才算 pass。
|
||||
* 真玩驱动:spec.driver 存在=【适配性真玩】(读 state 自动接球,解盲打固定坐标打不动技巧游戏的假阴性);否则走 spec.inputs 固定序列。
|
||||
*
|
||||
* 【九道假绿守卫(全过才 PASS)】
|
||||
* A 装载:__genBooted===true ∧ __gameHostEngineInitFired===true ∧ 无 bootError(白屏/装载失败拦截)。
|
||||
* B 未捕获:__genInternals.uncaught 为空 ∧ 无 __gameHostBootError(运行期报错拦截)。
|
||||
* C 掌帧:__engine.snapshot().frame 在 500ms 内增量 ∈ [20,45](活着且非失控/非单步假推进;@60fps 理论~30)。
|
||||
* D 真渲染:#game-engine canvas readback「有色像素(maxCh>80) > 阈值」(非白屏/非全黑)。
|
||||
* E 活性:真玩输入前后两张快照 FNV-1a 哈希【不同】(画面真在动/真在响应,非冻屏)。
|
||||
* F 真接线:__engineCalls 含期望 call-ID(spec.expectedEngineCallPrefixes 任一前缀命中;证真经 ctx.getEngine() 调引擎/插件库,非自绘伪装)。
|
||||
* G 输入有效:同 URL 同 seed 起【无输入】对照实例推进到同帧号,整帧哈希【不同】 ⇒ 输入真改变游戏走向(隔离「靠自走动画蒙混过 E」)。
|
||||
* H 机制进展 + latch 终态(逐游戏·确定性·可选):spec.assertAfterPlay[] 机制断言全过(真有进展、非空壳)+ spec.expectLatch 时真玩到 phase='gameover' 且【驻留】(官方闭环锚)。
|
||||
* 依赖游戏经【可选 _forensicsView()】导出可观测状态(host.state() 透传 → 页面 __gameState);未声明=老游戏→SKIP(向后兼容)。
|
||||
* I 控制跟手(逐游戏·确定性·可选):spec.controlCheck 声明时,连点目标 x 验玩家控制体平滑逼近(逮「一格一跳」/卡死/不跟手);依赖游戏导出控制体位置(如 paddle.x)。未声明→SKIP。
|
||||
*
|
||||
* 【用法】node games/_wg1-gen/_shared/play.cdp.cjs <gameId> [--base=http://localhost:4320] [--cdp=http://localhost:9222] [--spec=<spec.json>]
|
||||
* spec.json(缺省读 games/_wg1-gen/<gameId>/play-spec.json,缺失则用极简默认):
|
||||
* { "inputs":[{"t":"tap","x":195,"y":600},{"t":"key","code":"ArrowLeft","downMs":300},{"t":"wait","ms":400}],
|
||||
* "expectedEngineCallPrefixes":["particles.spawnEmitter","audio.synth"] }
|
||||
* 产物:games/_wg1-gen/<gameId>/evidence/{verdict.json, first-paint.png, after-play.png};退出码 0=PASS / 1=FAIL。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
// 复用纯计算口径 + CDP 会话类 + 像素抓取/截图(test/harness/browser-evidence.cjs)。
|
||||
const H = require(path.resolve(__dirname, '../../../test/harness/browser-evidence.cjs'));
|
||||
const { CdpSession, captureImageData, saveScreenshot, fnv1a32 } = H;
|
||||
// CDP 需 ws(仅本驱动用;cwd=game-runtime 已 npm i ws)。
|
||||
const WebSocket = require('ws');
|
||||
|
||||
/** 纯 Promise 延时(不走被拦的 sleep 命令)。 */
|
||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 极简 HTTP(PUT 建 target / GET 列表)。browser-evidence 的 httpJson 未导出,此处复刻。 */
|
||||
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(new Error('CDP HTTP 非 JSON:' + b.slice(0, 200))); } }); }
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('CDP HTTP 超时:' + urlStr)));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** 建 target → 连 WS → 启用域 → 设固定取证视口(390×844/DPR2/touch)→ 导航。返回 CdpSession。 */
|
||||
async function connect(url, cdpHttp) {
|
||||
// Chrome 111+ 必须 PUT /json/new?<url>(GET 不行)。
|
||||
const created = await httpJson('PUT', `${cdpHttp}/json/new?${encodeURI('about:blank')}`);
|
||||
const wsUrl = created.webSocketDebuggerUrl;
|
||||
if (!wsUrl) throw new Error('建 target 失败(无 webSocketDebuggerUrl):' + JSON.stringify(created).slice(0, 200));
|
||||
const ws = await new Promise((resolve, reject) => {
|
||||
const s = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 });
|
||||
s.on('open', () => resolve(s)); s.on('error', reject);
|
||||
});
|
||||
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: 2, mobile: true });
|
||||
try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch (_) {}
|
||||
await cdp.send('Page.navigate', { url });
|
||||
return cdp;
|
||||
}
|
||||
|
||||
/** 守卫A:轮询等装载完成(__genBooted ∧ 引擎接管 __gameHostEngineInitFired);拿到任一 bootError 即抛。 */
|
||||
async function waitBoot(cdp, timeoutMs) {
|
||||
const deadline = Date.now() + (timeoutMs || 30000);
|
||||
await delay(800); // 固定 settle,让脚本起步
|
||||
while (Date.now() < deadline) {
|
||||
const st = await cdp.evaluate(
|
||||
'({ booted: !!window.__genBooted, eng: !!window.__gameHostEngineInitFired, ' +
|
||||
'err: (window.__genBootError || window.__gameHostBootError || null) })'
|
||||
);
|
||||
if (st && st.err) throw new Error('装载失败(守卫A):' + st.err);
|
||||
if (st && st.booted && st.eng) return;
|
||||
await delay(300);
|
||||
}
|
||||
// 超时前再读一次错因,给确切信号。
|
||||
const last = await cdp.evaluate('({ booted: !!window.__genBooted, eng: !!window.__gameHostEngineInitFired, err: (window.__genBootError||window.__gameHostBootError||null) })').catch(() => null);
|
||||
throw new Error('装载超时(守卫A):' + JSON.stringify(last));
|
||||
}
|
||||
|
||||
/** 守卫C:测 500ms 内引擎帧增量。读 window.__engine.snapshot().frame(real 通道)。 */
|
||||
async function frameDelta(cdp, ms) {
|
||||
const read = '(function(){ var e=window.__engine; if(!e) return null; var s=(typeof e.snapshot==="function")?e.snapshot():e; return (s&&typeof s.frame==="number")?s.frame:null; })()';
|
||||
const f0 = await cdp.evaluate(read);
|
||||
await delay(ms);
|
||||
const f1 = await cdp.evaluate(read);
|
||||
return { f0, f1, delta: (f0 == null || f1 == null) ? null : (f1 - f0) };
|
||||
}
|
||||
|
||||
/** 推进(对照)实例到目标帧号(real 引擎自走,轮询 __engine.frame)。返回到达的帧号(-1=超时)。 */
|
||||
async function advanceToFrame(cdp, targetFrame, timeoutMs) {
|
||||
const read = '(function(){var e=window.__engine;var s=(e&&typeof e.snapshot==="function")?e.snapshot():e;return (s&&typeof s.frame==="number")?s.frame:0;})()';
|
||||
const deadline = Date.now() + (timeoutMs || 15000);
|
||||
while (Date.now() < deadline) {
|
||||
const f = await cdp.evaluate(read);
|
||||
if (f >= targetFrame) return f;
|
||||
await delay(100);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 守卫D:#game-engine readback,统计「有色像素(maxCh>80)」数 + 全屏最亮通道值。 */
|
||||
async function brightPixels(cdp, selector) {
|
||||
const img = await captureImageData(cdp, selector);
|
||||
const d = img.data; let bright = 0, maxCh = 0;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
if (d[i + 3] === 0) continue;
|
||||
const m = Math.max(d[i], d[i + 1], d[i + 2]);
|
||||
if (m > maxCh) maxCh = m;
|
||||
if (m > 80) bright++;
|
||||
}
|
||||
return { bright, maxCh, hash: fnv1a32(d), width: img.width, height: img.height };
|
||||
}
|
||||
|
||||
/** 轻量采样:只取 #game-engine 的 FNV 哈希(守卫E 多采样用,捕捉瞬时闪烁/命中态)。 */
|
||||
async function sampleHash(cdp, selector) {
|
||||
const img = await captureImageData(cdp, selector);
|
||||
return fnv1a32(img.data);
|
||||
}
|
||||
|
||||
/** 逻辑坐标 → 页面 client 坐标(镜像 ref toClient:用画布自身逻辑尺寸 c.width/dpr 算缩放,不硬编码 390/844)。 */
|
||||
async function mapToClient(cdp, xLogical, yLogical) {
|
||||
const m = await cdp.evaluate(
|
||||
'(function(){ var c=document.querySelector("#game-engine")||document.querySelector("#game"); if(!c) return null;' +
|
||||
' var r=c.getBoundingClientRect(); var dpr=window.devicePixelRatio||1;' +
|
||||
' return { l:r.left, t:r.top, sx:r.width/(c.width/dpr), sy:r.height/(c.height/dpr) }; })()'
|
||||
);
|
||||
if (!m) throw new Error('mapToClient:找不到 #game-engine/#game');
|
||||
return { x: m.l + xLogical * m.sx, y: m.t + yLogical * m.sy };
|
||||
}
|
||||
|
||||
/** 真触摸点按(touchStart→touchEnd 背靠背 + 裸 {x,y} 点;镜像 ref realTapAt 证可用范式)。 */
|
||||
async function tap(cdp, xLogical, yLogical) {
|
||||
const c = await mapToClient(cdp, xLogical, yLogical);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: c.x, y: c.y }] });
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await delay(120); // 等真事件经浏览器→输入桥→引擎下一拍消化
|
||||
}
|
||||
|
||||
/** 真键按(keyDown→downMs→keyUp)。code=DOM code(如 ArrowLeft/Space/KeyA)。 */
|
||||
async function key(cdp, code, downMs) {
|
||||
const map = { ArrowLeft: 37, ArrowRight: 39, ArrowUp: 38, ArrowDown: 40, Space: 32, Enter: 13 };
|
||||
const keyCode = map[code] || 0;
|
||||
const k = code === 'Space' ? ' ' : (code && code.startsWith('Arrow')) ? undefined : code;
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', code, key: k, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode });
|
||||
await delay(downMs || 200);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', code, key: k, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode });
|
||||
}
|
||||
|
||||
/** 读游戏可观测状态(可测性红线:游戏经 _forensicsView().state 导出 → host.state() 透传 → 页面 __gameState 别名)。读不到返回 null。 */
|
||||
async function readGameState(cdp) {
|
||||
return await cdp.evaluate(
|
||||
'(function(){ try {' +
|
||||
' var s = (typeof window.__gameState === "function") ? window.__gameState()' +
|
||||
' : (window.__genHost && typeof window.__genHost.state === "function") ? window.__genHost.state() : null;' +
|
||||
' return s; } catch (e) { return null; } })()'
|
||||
);
|
||||
}
|
||||
|
||||
/** 判一条机制断言(s0=玩前态 / s1=玩后态)。op:increased/decreased/changed/>/>=/</<=/==/in。返回逐条明细。 */
|
||||
function checkAssertion(a, s0, s1) {
|
||||
const p = a.path;
|
||||
const v0 = s0 ? s0[p] : undefined;
|
||||
const v1 = s1 ? s1[p] : undefined;
|
||||
const num = (x) => (typeof x === 'number' ? x : Number(x));
|
||||
let pass = false;
|
||||
switch (a.op) {
|
||||
case 'increased': pass = num(v1) > num(v0); break; // 玩后 > 玩前(如分数上升)
|
||||
case 'decreased': pass = num(v1) < num(v0); break; // 玩后 < 玩前(如剩余砖数下降)
|
||||
case 'changed': pass = v1 !== v0; break; // 任意变化
|
||||
case '>': pass = num(v1) > num(a.value); break;
|
||||
case '>=': pass = num(v1) >= num(a.value); break;
|
||||
case '<': pass = num(v1) < num(a.value); break;
|
||||
case '<=': pass = num(v1) <= num(a.value); break;
|
||||
case '==': pass = v1 === a.value; break;
|
||||
case 'in': case 'reached': pass = Array.isArray(a.value) && a.value.indexOf(v1) >= 0; break; // 取值落在允许集(如 status∈[playing,win,lose])
|
||||
default: pass = false;
|
||||
}
|
||||
return { path: p, op: a.op, before: v0, after: v1, value: a.value, pass, why: a.why || '' };
|
||||
}
|
||||
|
||||
/** 读嵌套路径(如 'ball.x' / 'paddle.x');任一层缺失返回 undefined。 */
|
||||
function getPath(obj, p) {
|
||||
if (!obj || !p) return undefined;
|
||||
return String(p).split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
||||
}
|
||||
|
||||
/** 适配性真玩驱动:读 state 自适应出招,解「盲打固定坐标打不动技巧游戏」的假阴性。
|
||||
* type='paddle-intercept':每步读球 x、tap 到球正下方把挡板移过去接(真玩到清砖);到 gameover 即停。 */
|
||||
async function runDriver(cdp, driver, hashes) {
|
||||
const ballPath = driver.ballPath || 'ball.x';
|
||||
const paddleY = driver.paddleY != null ? driver.paddleY : 800;
|
||||
const steps = driver.steps || 60;
|
||||
const stepMs = driver.stepMs || 130;
|
||||
let drove = 0;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const s = await readGameState(cdp);
|
||||
if (s && s.phase === 'gameover') break; // 已结束,停(剩交给 latch 校验)
|
||||
const bx = getPath(s, ballPath);
|
||||
if (typeof bx === 'number') { await tap(cdp, Math.max(8, Math.min(382, bx)), paddleY); drove++; }
|
||||
else { await delay(stepMs); } // 读不到球位=游戏没导出位置,空转(H 门会判红线)
|
||||
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
|
||||
await delay(stepMs);
|
||||
}
|
||||
return { drove, steps };
|
||||
}
|
||||
|
||||
/** 控制跟手校验(守卫I):连点目标 x,读玩家控制体位置是否平滑逼近(逮「一格一跳」/卡死/不跟手)。 */
|
||||
async function runControlCheck(cdp, cc) {
|
||||
const ppath = cc.paddlePath || 'paddle.x';
|
||||
const paddleY = cc.paddleY != null ? cc.paddleY : 800;
|
||||
const taps = cc.tapsPerTarget || 6;
|
||||
const moveMin = cc.moveMin != null ? cc.moveMin : 25;
|
||||
const tol = cc.tolerance != null ? cc.tolerance : 70;
|
||||
const results = [];
|
||||
for (const tx of (cc.tapXs || [50, 340])) {
|
||||
const before = getPath(await readGameState(cdp), ppath);
|
||||
for (let k = 0; k < taps; k++) { await tap(cdp, tx, paddleY); await delay(120); }
|
||||
const after = getPath(await readGameState(cdp), ppath);
|
||||
const moved = (typeof before === 'number' && typeof after === 'number') ? (after - before) : null;
|
||||
const dist = (typeof after === 'number') ? Math.abs(after - tx) : null;
|
||||
// 跟手 = 朝 tx 移动了可观距离(|moved|≥moveMin) 且 最终落在容差内(dist≤tol);一格一跳=moved 太小→判否。
|
||||
const ok = moved != null && dist != null && Math.abs(moved) >= moveMin && dist <= tol;
|
||||
results.push({ tapX: tx, before, after, moved, distToTarget: dist, pass: ok });
|
||||
}
|
||||
return { pass: results.length > 0 && results.every((r) => r.pass), paddlePath: ppath, results };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const gameId = argv[0];
|
||||
if (!gameId) { console.error('用法:node play.cdp.cjs <gameId> [--base=] [--cdp=] [--spec=]'); process.exit(2); }
|
||||
const getArg = (k, def) => { const a = argv.find((x) => x.startsWith(k + '=')); return a ? a.split('=').slice(1).join('=') : def; };
|
||||
const base = getArg('--base', 'http://localhost:4320');
|
||||
const cdpHttp = getArg('--cdp', 'http://localhost:9222');
|
||||
const gameDir = path.resolve('games/_wg1-gen', gameId);
|
||||
const evDir = path.join(gameDir, 'evidence');
|
||||
fs.mkdirSync(evDir, { recursive: true });
|
||||
|
||||
// 读 play-spec(缺省极简:无输入,仅验活性/掌帧/渲染/接线)。
|
||||
const specPath = getArg('--spec', path.join(gameDir, 'play-spec.json'));
|
||||
let spec = { inputs: [], expectedEngineCallPrefixes: [] };
|
||||
try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); } catch (_) { /* 用默认 */ }
|
||||
|
||||
const url = `${base}/games/_wg1-gen/${gameId}/index.html`;
|
||||
const verdict = { gameId, url, ts: null, guards: {}, pass: false, notes: [] };
|
||||
let cdp = null;
|
||||
try {
|
||||
cdp = await connect(url, cdpHttp);
|
||||
// 守卫A:装载 + 引擎接管。
|
||||
await waitBoot(cdp, 30000);
|
||||
verdict.guards.A_boot = { pass: true };
|
||||
|
||||
// 首帧截图 + 渲染快照(守卫D 初值 + 守卫E t0)。
|
||||
await saveScreenshot(cdp, path.join(evDir, 'first-paint.png'));
|
||||
const px0 = await brightPixels(cdp, '#game-engine');
|
||||
// 守卫H 玩前态:真玩输入前读一次可观测状态(基线,供机制断言对照)。
|
||||
const state0 = await readGameState(cdp);
|
||||
|
||||
// 守卫C:掌帧增量。
|
||||
const fd = await frameDelta(cdp, 500);
|
||||
verdict.guards.C_frame = { pass: fd.delta != null && fd.delta >= 20 && fd.delta <= 45, ...fd };
|
||||
|
||||
// 守卫I:控制跟手(声明 controlCheck 才启用)—— 读玩家控制体位置,验「点哪、挡板平滑跟到哪」,逮一格一跳/卡死/不跟手。
|
||||
if (spec.controlCheck) {
|
||||
verdict.guards.I_control = await runControlCheck(cdp, spec.controlCheck);
|
||||
} else {
|
||||
verdict.guards.I_control = { pass: true, skipped: '未声明 controlCheck(向后兼容)' };
|
||||
}
|
||||
|
||||
// 真玩:有 driver 走【适配性驱动】(读 state 自动接球,真玩技巧游戏,解盲打假阴性);否则走固定输入序列。每步采帧哈希供守卫E。
|
||||
const hashes = [px0.hash];
|
||||
if (spec.driver) {
|
||||
await runDriver(cdp, spec.driver, hashes);
|
||||
} else {
|
||||
for (const ev of (spec.inputs || [])) {
|
||||
if (ev.t === 'tap') await tap(cdp, ev.x, ev.y);
|
||||
else if (ev.t === 'key') await key(cdp, ev.code, ev.downMs);
|
||||
else if (ev.t === 'wait') await delay(ev.ms || 200);
|
||||
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
|
||||
await delay(50);
|
||||
}
|
||||
}
|
||||
await delay(250);
|
||||
|
||||
// 守卫D:真渲染(输入后再测,取较优)。
|
||||
const px1 = await brightPixels(cdp, '#game-engine');
|
||||
hashes.push(px1.hash);
|
||||
const bright = Math.max(px0.bright, px1.bright), maxCh = Math.max(px0.maxCh, px1.maxCh);
|
||||
verdict.guards.D_render = { pass: bright > 50 && maxCh > 80, bright, maxCh };
|
||||
|
||||
// 守卫E:活性 —— 整段真玩出现 ≥2 个不同画面态(真在动/真在响应);全程恒同一帧=冻屏判否。
|
||||
const distinct = new Set(hashes);
|
||||
verdict.guards.E_live = { pass: distinct.size >= 2, distinctStates: distinct.size, samples: hashes.length };
|
||||
|
||||
// 守卫B:未捕获错误。
|
||||
const unc = await cdp.evaluate('(function(){ var n=window.__genInternals; var u=(n&&n.uncaught)?n.uncaught:[]; return u.slice(0,10); })()');
|
||||
verdict.guards.B_uncaught = { pass: Array.isArray(unc) && unc.length === 0, uncaught: unc };
|
||||
|
||||
// 守卫F:真接线(在页面内扫【全量】__engineCalls,避免 math.lerp 每帧刷屏把后续 gameplay 调用挤出 200 切片)。
|
||||
const prefixes = spec.expectedEngineCallPrefixes || [];
|
||||
const fw = await cdp.evaluate(
|
||||
'(function(){ var c=window.__engineCalls||[]; var pre=' + JSON.stringify(prefixes) + ';' +
|
||||
' var hit=pre.length? c.some(function(x){return pre.some(function(p){return String(x).indexOf(p)===0;});}) : c.length>0;' +
|
||||
' var nonLerp=c.filter(function(x){return String(x).indexOf("math.lerp")!==0;});' +
|
||||
' return { hit:hit, total:c.length, sample:(nonLerp.length?nonLerp:c).slice(0,12) }; })()'
|
||||
);
|
||||
verdict.guards.F_wiring = { pass: !!(fw && fw.hit), callCount: (fw && fw.total) || 0, sample: (fw && fw.sample) || [], expected: prefixes };
|
||||
|
||||
// 守卫H 玩后态:真玩输入后读一次可观测状态(与 state0 对照,判机制是否真有进展)。
|
||||
const state1 = await readGameState(cdp);
|
||||
|
||||
await saveScreenshot(cdp, path.join(evDir, 'after-play.png'));
|
||||
|
||||
// 守卫G:输入有效性(确定性 A/B 对照,隔离「靠自走动画蒙混过 E」的假绿)。
|
||||
// 同 URL 同 seed 再开一个【无输入】对照实例,推进到与输入实例【同一帧号】,比对整帧哈希:
|
||||
// 不同 ⇒ 输入真改变了游戏走向(挡板位置/Simon 轮次/打地鼠分数都会持续到该帧);相同 ⇒ 输入无效(判否)。
|
||||
const hasInput = !!spec.driver || (spec.inputs || []).some((e) => e.t === 'tap' || e.t === 'key');
|
||||
if (hasInput) {
|
||||
let ctrl = null;
|
||||
try {
|
||||
const endFrame = await cdp.evaluate('(function(){var e=window.__engine;var s=(e&&typeof e.snapshot==="function")?e.snapshot():e;return (s&&typeof s.frame==="number")?s.frame:0;})()');
|
||||
ctrl = await connect(url, cdpHttp);
|
||||
await waitBoot(ctrl, 30000);
|
||||
const reached = await advanceToFrame(ctrl, endFrame, 20000);
|
||||
const ch = await brightPixels(ctrl, '#game-engine');
|
||||
verdict.guards.G_input = { pass: px1.hash !== ch.hash, inputHash: px1.hash, controlHash: ch.hash, atFrame: endFrame, ctrlFrame: reached };
|
||||
} catch (e2) {
|
||||
verdict.guards.G_input = { pass: false, err: String((e2 && e2.message) || e2) };
|
||||
} finally {
|
||||
if (ctrl) ctrl.close();
|
||||
}
|
||||
} else {
|
||||
verdict.guards.G_input = { pass: true, skipped: '无 tap/key 输入' };
|
||||
}
|
||||
|
||||
// 守卫H:机制进展 + P0 latch 终态(逐游戏确定性)—— 隔离「七门全过但空心」漂亮空壳 + 验官方 latch 终态契约(闭环锚)。
|
||||
// spec.assertAfterPlay[] = 进展断言(remaining↓ 等,证非空心);spec.expectLatch=true = 须真玩到 phase='gameover' 且【驻留】。
|
||||
// 二者皆未声明=老游戏→SKIP(向后兼容);声明了却读不到 state(未实现 _forensicsView)=可测性红线违约→判否。
|
||||
const asserts = spec.assertAfterPlay || [];
|
||||
const expectLatch = spec.expectLatch === true;
|
||||
if (asserts.length === 0 && !expectLatch) {
|
||||
verdict.guards.H_progress = { pass: true, skipped: '未声明 assertAfterPlay/expectLatch(向后兼容)' };
|
||||
} else if (!state1 || state1.phase === 'booting') {
|
||||
verdict.guards.H_progress = { pass: false, err: '声明了机制门但游戏未经 _forensicsView() 导出可观测状态(可测性红线未满足)', state0, state1 };
|
||||
} else {
|
||||
const checks = asserts.map((a) => checkAssertion(a, state0, state1));
|
||||
let latch = null;
|
||||
if (expectLatch) {
|
||||
// 停止输入后轮询等进入 gameover(无失败态/永不结束=终态不可达=判否);最多 ~4.2s。
|
||||
let phaseNow = state1.phase;
|
||||
for (let i = 0; i < 14 && phaseNow !== 'gameover'; i++) { await delay(300); const s = await readGameState(cdp); phaseNow = s ? s.phase : null; }
|
||||
if (phaseNow !== 'gameover') {
|
||||
latch = { pass: false, reason: '真玩未到达 gameover 终态(终态不可达/无失败态/play-spec 未驱动到结束)', phaseNow };
|
||||
} else {
|
||||
// latch 驻留校验:再等 600ms,phase 必须仍 'gameover'(瞬时终态/自动重开=违约,宿主 500ms 轮询会漏读)。
|
||||
await delay(600);
|
||||
const s2 = await readGameState(cdp);
|
||||
const dwell = !!(s2 && s2.phase === 'gameover');
|
||||
latch = { pass: dwell, dwelledMs: 600, after: s2 ? s2.phase : null, reason: dwell ? '' : 'gameover 未驻留(下帧自动重开/复位=latch 违约)' };
|
||||
}
|
||||
}
|
||||
const progressPass = checks.every((c) => c.pass);
|
||||
const latchPass = !expectLatch || !!(latch && latch.pass);
|
||||
verdict.guards.H_progress = { pass: progressPass && latchPass, checks, latch, state0, state1 };
|
||||
}
|
||||
|
||||
verdict.pass = ['A_boot', 'B_uncaught', 'C_frame', 'D_render', 'E_live', 'F_wiring', 'G_input', 'H_progress', 'I_control']
|
||||
.every((g) => verdict.guards[g] && verdict.guards[g].pass);
|
||||
} catch (e) {
|
||||
verdict.error = String((e && e.message) || e);
|
||||
verdict.guards.A_boot = verdict.guards.A_boot || { pass: false, err: verdict.error };
|
||||
} finally {
|
||||
if (cdp) cdp.close();
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(evDir, 'verdict.json'), JSON.stringify(verdict, null, 2));
|
||||
// 控制台四件套摘要。
|
||||
const g = verdict.guards;
|
||||
console.log(`\n=== W-G1 真玩判定 [${gameId}] ${verdict.pass ? '✅ PASS' : '❌ FAIL'} ===`);
|
||||
const line = (k, ok, extra) => console.log(` ${ok ? '✅' : '❌'} ${k} ${extra || ''}`);
|
||||
if (g.A_boot) line('A 装载', g.A_boot.pass, g.A_boot.err || '');
|
||||
if (g.B_uncaught) line('B 未捕获', g.B_uncaught.pass, `uncaught=${(g.B_uncaught.uncaught || []).length}`);
|
||||
if (g.C_frame) line('C 掌帧', g.C_frame.pass, `Δframe=${g.C_frame.delta} (期望[20,45])`);
|
||||
if (g.D_render) line('D 真渲染', g.D_render.pass, `bright=${g.D_render.bright} maxCh=${g.D_render.maxCh}`);
|
||||
if (g.E_live) line('E 活性', g.E_live.pass, `distinct=${g.E_live.distinctStates}/${g.E_live.samples} 帧态`);
|
||||
if (g.F_wiring) line('F 真接线', g.F_wiring.pass, `calls=${g.F_wiring.callCount} sample=${JSON.stringify((g.F_wiring.sample || []).slice(0, 4))}`);
|
||||
if (g.G_input) line('G 输入有效', g.G_input.pass, g.G_input.skipped || (g.G_input.err ? g.G_input.err : `输入态${g.G_input.inputHash}≠对照${g.G_input.controlHash}@f${g.G_input.atFrame}`));
|
||||
if (g.H_progress) line('H 机制+latch', g.H_progress.pass, g.H_progress.skipped || g.H_progress.err ||
|
||||
((g.H_progress.checks || []).map((c) => `${c.path}:${JSON.stringify(c.before)}→${JSON.stringify(c.after)}[${c.op}${c.pass ? '✓' : '✗'}]`).join(' ')
|
||||
+ (g.H_progress.latch ? ` latch[${g.H_progress.latch.pass ? '✓驻留' : '✗' + (g.H_progress.latch.reason || '')}]` : '')));
|
||||
if (g.I_control) line('I 控制跟手', g.I_control.pass, g.I_control.skipped ||
|
||||
(g.I_control.results || []).map((r) => `tap${r.tapX}:move${r.moved == null ? '?' : Math.round(r.moved)}→dist${r.distToTarget == null ? '?' : Math.round(r.distToTarget)}[${r.pass ? '✓' : '✗'}]`).join(' '));
|
||||
if (verdict.error) console.log(' ⚠️ error:', verdict.error);
|
||||
console.log(` evidence → ${path.relative(process.cwd(), evDir)}/`);
|
||||
process.exit(verdict.pass ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
59
game-runtime/games/_wg1-gen/_shared/serve-and-play.sh
Normal file
@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# serve-and-play.sh —— W-G1 真玩取证一键编排(macOS 本机,无 systemd → 普通后台进程 + trap 清理)
|
||||
# owner:W-G1 lane | cwd 必须 = game-runtime
|
||||
# 用法:bash games/_wg1-gen/_shared/serve-and-play.sh <gameId> [port] [cdpPort]
|
||||
# 流程:净场 → 起 static-serve(game-runtime 根) → 起 headless Chrome → 等就绪 → play.cdp.cjs → trap 清理,返 play 退出码。
|
||||
set -u
|
||||
GAME_ID="${1:?用法: serve-and-play.sh <gameId> [port] [cdpPort]}"
|
||||
PORT="${2:-4320}"
|
||||
CDP_PORT="${3:-9222}"
|
||||
|
||||
# Chrome 路径探测(macOS)。
|
||||
CHROME=""
|
||||
for p in "/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" ] && { echo "❌ 未找到 Chrome(/Applications/Google Chrome.app)"; exit 3; }
|
||||
|
||||
USER_DATA_DIR="$(mktemp -d -t wg1-chrome-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)。'/'→index.html,拒路径穿越。
|
||||
node test/harness/static-serve.cjs . "$PORT" >/tmp/wg1-serve-$PORT.log 2>&1 &
|
||||
SERVE_PID=$!
|
||||
|
||||
# 2) Chrome headless(CDP 必备旗标:--remote-allow-origins=* + --autoplay-policy=...,否则 WS 连不上/音频卡 suspended)。
|
||||
"$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-chrome-$PORT.log 2>&1 &
|
||||
CHROME_PID=$!
|
||||
|
||||
# 3) 就绪等待(先就绪 10×0.5s,避免抢在 listen 前误判)。
|
||||
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-serve-$PORT.log)"; cat /tmp/wg1-serve-$PORT.log; exit 4; }
|
||||
[ "$ok_cdp" != 1 ] && { echo "❌ Chrome CDP 未就绪(见 /tmp/wg1-chrome-$PORT.log)"; tail -5 /tmp/wg1-chrome-$PORT.log; exit 5; }
|
||||
|
||||
# 4) 真玩驱动。
|
||||
node games/_wg1-gen/_shared/play.cdp.cjs "$GAME_ID" --base="http://localhost:$PORT" --cdp="http://localhost:$CDP_PORT"
|
||||
PLAY_RC=$?
|
||||
exit "$PLAY_RC"
|
||||
34
game-runtime/games/_wg1-gen/pong/bundle.iife.js
Normal file
75
game-runtime/games/_wg1-gen/pong/entry-bundle.js
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* entry-bundle.template.js —— W-G1 通用 bundle 入口模板(逐字拷进每个生成 game 目录为 entry-bundle.js)
|
||||
* owner:W-G1 lane | 消费方:esbuild --global-name=__GameBundle → P1 宿主 bootGameHost 真玩
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把「生成的游戏工厂 + 通用宿主 + 标准 7 插件 wiring + 引擎」汇成一个 iife 全局 __GameBundle。
|
||||
* 产出靶契约(冻结·逐字对齐 entry-bundle.js / game-package.schema.json engineBundle):
|
||||
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory 与 bootGameHost。
|
||||
*
|
||||
* 【最大化复用·最小新代码】
|
||||
* - 宿主:复用 src/host/boot-game-host.js 的通用 bootGameHost(不改)。
|
||||
* - 插件 wiring:直接复用 wanglanmei-ref 的 buildRefHostConfig(标准 7 插件 + 偏序 + 视口 + 工厂 wiring,已 P2 验证),
|
||||
* 仅 override factory 为本目录生成的工厂 → 生成游戏跑在【与 ref 完全相同的插件库装配】上。
|
||||
* - 引擎:Option C 动态 import(node import 本入口不触引擎,保 import-safe;esbuild 把引擎内联进单文件 iife)。
|
||||
*
|
||||
* 【Q4 铁律】本入口是「集成段 host」唯一引擎 import 落点(动态 import 后经 opts.engine 注入);
|
||||
* ./factory.js 游戏工厂零引擎 import,能力一律经 boot.ctx.getEngine()。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../../src/host/boot-game-host.js';
|
||||
// 标准插件装配(复用 ref 同一份:collision/physics/gamefeel/juice/palettePost/audioMusic/save + 偏序 + 390×844 视口)。
|
||||
import { buildRefHostConfig } from '../../wanglanmei-ref/src/ref-host-config.js';
|
||||
// 本目录生成的游戏工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。
|
||||
import gameFactory from './factory.js';
|
||||
|
||||
/** 【契约导出】GameHostFactory:宿主从 window.__GameBundle.GameHostFactory 取它(契约冻结)。= 本目录生成的工厂。 */
|
||||
export const GameHostFactory = gameFactory;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default gameFactory;
|
||||
|
||||
/**
|
||||
* 【P2/W-G1 boot 入口】供 host 页在浏览器内启动本 bundle(引擎由本 bundle 注入后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(W-G1 真玩取证一律引擎掌帧)。
|
||||
* - 插件/视口/工厂 wiring 取自 buildRefHostConfig(与 ref 同一标准集),factory override 为生成工厂。
|
||||
* - recHook / onReady 透传:供取证 harness 注入「引擎 call-ID 探针」与「internals(uncaught) 收集」(假绿守卫)。
|
||||
*
|
||||
* @param {Object} opts host 页传入入参。
|
||||
* @param {HTMLCanvasElement} opts.canvas 页面 #game 画布(real 通道引擎自建 mainCanvas#game-engine,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性)。
|
||||
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// 主随机种子(缺省固定值,与 ref 取证同口径,保确定性可复现)。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
// 复用 ref 标准装配(7 插件 + 偏序 + 视口 + 工厂 wiring)。
|
||||
const cfg = buildRefHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;factory override 为本目录生成工厂;其余 wiring 取标准集。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: o.mode || 'play', // W-G1 真玩=play
|
||||
engineMode: 'real', // 一律引擎掌帧(real 通道)
|
||||
viewport: cfg.viewport, // 390×844(与 ref 同,移动竖屏基准)
|
||||
factory: gameFactory, // ★ override:本目录【生成的游戏】(其余 wiring 全复用 ref)
|
||||
plugins: cfg.plugins, // 标准 7 插件实例集(经 opts.runtime.plugins 注入给工厂)
|
||||
registerOrder: cfg.registerOrder, // 注册偏序(保六锚)
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle,getRealAudioCtx}
|
||||
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||
});
|
||||
}
|
||||
BIN
game-runtime/games/_wg1-gen/pong/evidence/after-play.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
game-runtime/games/_wg1-gen/pong/evidence/first-paint.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
59
game-runtime/games/_wg1-gen/pong/evidence/verdict.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"gameId": "pong",
|
||||
"url": "http://localhost:4320/games/_wg1-gen/pong/index.html",
|
||||
"ts": null,
|
||||
"guards": {
|
||||
"A_boot": {
|
||||
"pass": true
|
||||
},
|
||||
"C_frame": {
|
||||
"pass": true,
|
||||
"f0": 48,
|
||||
"f1": 78,
|
||||
"delta": 30
|
||||
},
|
||||
"D_render": {
|
||||
"pass": true,
|
||||
"bright": 17318,
|
||||
"maxCh": 255
|
||||
},
|
||||
"E_live": {
|
||||
"pass": true,
|
||||
"distinctStates": 13,
|
||||
"samples": 13
|
||||
},
|
||||
"B_uncaught": {
|
||||
"pass": true,
|
||||
"uncaught": []
|
||||
},
|
||||
"F_wiring": {
|
||||
"pass": true,
|
||||
"callCount": 11,
|
||||
"sample": [
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx",
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx",
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx",
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx",
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx"
|
||||
],
|
||||
"expected": [
|
||||
"particles.",
|
||||
"audio.synth"
|
||||
]
|
||||
},
|
||||
"G_input": {
|
||||
"pass": true,
|
||||
"inputHash": "0d7715dc",
|
||||
"controlHash": "855a1c03",
|
||||
"atFrame": 444,
|
||||
"ctrlFrame": 445
|
||||
}
|
||||
},
|
||||
"pass": true,
|
||||
"notes": []
|
||||
}
|
||||
229
game-runtime/games/_wg1-gen/pong/factory.js
Normal file
@ -0,0 +1,229 @@
|
||||
'use strict';
|
||||
/**
|
||||
* factory.js —— Pong 弹球游戏
|
||||
* 竖屏 390×844,底部玩家挡板(触摸跟随),顶部电脑挡板(球自动跟随),
|
||||
* 球斜向运动,反弹时触发粒子爆发+音效。
|
||||
* 严格遵守受限面铁律:无 import、无 Math.random / Date.now / addEventListener / AudioContext / RAF。
|
||||
*/
|
||||
export default function createPongGame(opts) {
|
||||
const o = opts || {};
|
||||
const W = 390, H = 844;
|
||||
const PADDLE_HALF_W = 40, PAD_HALF_H = 8; // 挡板宽80,高16(厚度≥12)
|
||||
const BALL_RADIUS = 8;
|
||||
const BALL_SPEED = 350;
|
||||
const PLAYER_PAD_Y = H - 28; // 底部挡板中心 y
|
||||
const CPU_PAD_Y = 28; // 顶部挡板中心 y
|
||||
const PLAYER_SPEED = 800; // 玩家挡板跟随速度 px/s
|
||||
const CPU_SPEED = 250; // 电脑挡板跟随速度 (稍慢于球)
|
||||
|
||||
let ctx = null;
|
||||
let nowMs = 0;
|
||||
let t = 0;
|
||||
|
||||
// 游戏对象(init 内初始化)
|
||||
let ball = null;
|
||||
let paddlePlayer = null;
|
||||
let paddleCPU = null;
|
||||
let scorePlayer = 0;
|
||||
let scoreCPU = 0;
|
||||
let targetPlayerX = W / 2;
|
||||
|
||||
let inputSub = null; // pointermove 订阅句柄
|
||||
|
||||
// 内部辅助
|
||||
function clamp(v, min, max) { return v < min ? min : v > max ? max : v; }
|
||||
|
||||
// 在指定位置爆发粒子并播放音效
|
||||
function spawnBurst(x, y) {
|
||||
const eng = ctx.getEngine();
|
||||
if (eng) {
|
||||
if (eng.particles) {
|
||||
try {
|
||||
eng.particles.spawnEmitter({
|
||||
pos: { x, y },
|
||||
count: 16,
|
||||
speed: 90,
|
||||
particleTime: 0.4,
|
||||
colorStart: { r: 1, g: 0.9, b: 0.4, a: 1 },
|
||||
colorEnd: { r: 1, g: 0.3, b: 0, a: 0 }
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eng.audio && eng.audio.synth) {
|
||||
try {
|
||||
eng.audio.synth.synthSfx([1, 0.05, 340, , 0.12, 0.15, , 1.2]);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重置发球(方向随机)
|
||||
function serveBall() {
|
||||
const angle = ctx.random.range(-0.6, 0.6) + (ctx.random.range(0, 2) < 1 ? 0 : Math.PI); // 避开纯水平/垂直
|
||||
const speed = BALL_SPEED;
|
||||
ball.vx = speed * Math.cos(angle);
|
||||
ball.vy = speed * Math.sin(angle);
|
||||
ball.x = W / 2;
|
||||
ball.y = H / 2;
|
||||
}
|
||||
|
||||
// 碰撞检测与处理
|
||||
function handleCollisions() {
|
||||
const bx = ball.x, by = ball.y;
|
||||
const br = BALL_RADIUS;
|
||||
|
||||
// 左右墙
|
||||
if (bx - br < 0) {
|
||||
ball.x = br;
|
||||
ball.vx = -ball.vx;
|
||||
spawnBurst(0, by);
|
||||
} else if (bx + br > W) {
|
||||
ball.x = W - br;
|
||||
ball.vx = -ball.vx;
|
||||
spawnBurst(W, by);
|
||||
}
|
||||
|
||||
// 玩家挡板(底部)
|
||||
const px = paddlePlayer.x, py = PLAYER_PAD_Y;
|
||||
if (ball.vy > 0 &&
|
||||
by + br > py - PAD_HALF_H && by - br < py + PAD_HALF_H &&
|
||||
bx > px - PADDLE_HALF_W - br && bx < px + PADDLE_HALF_W + br) {
|
||||
// 反弹 —— 根据击中位置调整水平速度
|
||||
const offset = (bx - px) / (PADDLE_HALF_W + br); // -1..1
|
||||
ball.vy = -Math.abs(ball.vy); // 向上
|
||||
ball.vx = BALL_SPEED * 0.6 * clamp(offset, -1, 1); // 最大水平加速度
|
||||
// 确保总速度大小不变(可选),这里简单保持大小
|
||||
const mag = Math.hypot(ball.vx, ball.vy);
|
||||
if (mag > 0) {
|
||||
ball.vx *= BALL_SPEED / mag;
|
||||
ball.vy *= BALL_SPEED / mag;
|
||||
}
|
||||
ball.y = py - PAD_HALF_H - br; // 放置在挡板上表面
|
||||
spawnBurst(bx, by);
|
||||
}
|
||||
|
||||
// 电脑挡板(顶部)
|
||||
const cx = paddleCPU.x, cy = CPU_PAD_Y;
|
||||
if (ball.vy < 0 &&
|
||||
by - br < cy + PAD_HALF_H && by + br > cy - PAD_HALF_H &&
|
||||
bx > cx - PADDLE_HALF_W - br && bx < cx + PADDLE_HALF_W + br) {
|
||||
const offset = (bx - cx) / (PADDLE_HALF_W + br);
|
||||
ball.vy = Math.abs(ball.vy); // 向下
|
||||
ball.vx = BALL_SPEED * 0.6 * clamp(offset, -1, 1);
|
||||
const mag = Math.hypot(ball.vx, ball.vy);
|
||||
if (mag > 0) {
|
||||
ball.vx *= BALL_SPEED / mag;
|
||||
ball.vy *= BALL_SPEED / mag;
|
||||
}
|
||||
ball.y = cy + PAD_HALF_H + br;
|
||||
spawnBurst(bx, by);
|
||||
}
|
||||
|
||||
// 球出界得分
|
||||
if (by + br > H) {
|
||||
scoreCPU += 1;
|
||||
serveBall();
|
||||
} else if (by - br < 0) {
|
||||
scorePlayer += 1;
|
||||
serveBall();
|
||||
}
|
||||
}
|
||||
|
||||
// 输入处理
|
||||
function onPointerMove(e) {
|
||||
targetPlayerX = clamp(e.x, PADDLE_HALF_W, W - PADDLE_HALF_W);
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
function init(boot) {
|
||||
ctx = boot.ctx;
|
||||
inputSub = ctx.getInput().on('pointermove', onPointerMove);
|
||||
// 初始化游戏状态
|
||||
scorePlayer = 0; scoreCPU = 0;
|
||||
paddlePlayer = { x: W / 2 };
|
||||
paddleCPU = { x: W / 2 };
|
||||
ball = { x: W / 2, y: H / 2, vx: 0, vy: 0 };
|
||||
serveBall();
|
||||
// 触发一次引擎数学调用(可选,但保证 F 门有信号)
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.math) { try { eng.math.lerp(0,1,0.5); } catch(_){} }
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
nowMs = ctx.time.nowMs();
|
||||
t = nowMs / 1000;
|
||||
|
||||
if (!ball) return;
|
||||
|
||||
// 玩家挡板跟随
|
||||
const pdx = targetPlayerX - paddlePlayer.x;
|
||||
const maxStep = PLAYER_SPEED * dt;
|
||||
if (Math.abs(pdx) <= maxStep) {
|
||||
paddlePlayer.x = targetPlayerX;
|
||||
} else {
|
||||
paddlePlayer.x += Math.sign(pdx) * maxStep;
|
||||
}
|
||||
|
||||
// 电脑挡板跟随球(慢速)
|
||||
const cdx = ball.x - paddleCPU.x;
|
||||
const cmax = CPU_SPEED * dt;
|
||||
if (Math.abs(cdx) <= cmax) {
|
||||
paddleCPU.x = ball.x;
|
||||
} else {
|
||||
paddleCPU.x += Math.sign(cdx) * cmax;
|
||||
}
|
||||
|
||||
// 更新球位置
|
||||
ball.x += ball.vx * dt;
|
||||
ball.y += ball.vy * dt;
|
||||
|
||||
// 碰撞与得分
|
||||
handleCollisions();
|
||||
}
|
||||
|
||||
function render(g) {
|
||||
// 满幅背景
|
||||
g.fillStyle = '#1a1a2e';
|
||||
g.fillRect(0, 0, W, H);
|
||||
|
||||
// 中线虚线
|
||||
g.strokeStyle = '#555577';
|
||||
g.lineWidth = 2;
|
||||
g.setLineDash([10, 8]);
|
||||
g.beginPath();
|
||||
g.moveTo(W / 2, 0);
|
||||
g.lineTo(W / 2, H);
|
||||
g.stroke();
|
||||
g.setLineDash([]);
|
||||
|
||||
// 玩家挡板
|
||||
g.fillStyle = '#00d4ff';
|
||||
g.fillRect(paddlePlayer.x - PADDLE_HALF_W, PLAYER_PAD_Y - PAD_HALF_H, PADDLE_HALF_W * 2, PAD_HALF_H * 2);
|
||||
|
||||
// 电脑挡板
|
||||
g.fillStyle = '#ff6b6b';
|
||||
g.fillRect(paddleCPU.x - PADDLE_HALF_W, CPU_PAD_Y - PAD_HALF_H, PADDLE_HALF_W * 2, PAD_HALF_H * 2);
|
||||
|
||||
// 球
|
||||
g.fillStyle = '#ffffff';
|
||||
g.beginPath();
|
||||
g.arc(ball.x, ball.y, BALL_RADIUS, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
|
||||
// 比分
|
||||
g.fillStyle = '#aaaaaa';
|
||||
g.font = '18px monospace';
|
||||
g.textAlign = 'center';
|
||||
g.fillText('Player: ' + scorePlayer + ' CPU: ' + scoreCPU, W / 2, 34);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try { if (inputSub) inputSub.cancel(); } catch (_) {}
|
||||
inputSub = null;
|
||||
ball = null;
|
||||
paddlePlayer = null;
|
||||
paddleCPU = null;
|
||||
}
|
||||
|
||||
return { init, update, render, destroy };
|
||||
}
|
||||
83
game-runtime/games/_wg1-gen/pong/index.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.template.html —— W-G1 通用 host 页模板(逐字拷进每个生成 game 目录为 index.html)
|
||||
owner:W-G1 lane | 用法:
|
||||
· 同目录 esbuild 产物 bundle.iife.js(globalName=__GameBundle)由本页 <script src> 加载;
|
||||
· serve game-runtime 根,CDP 导航到 /games/_wg1-gen/<game>/index.html;
|
||||
· 就绪标记 __genBooted(bootGameHost().then 置);real 引擎接管标记 __gameHostEngineInitFired(boot-game-host 内置);
|
||||
· 假绿守卫钩子:window.__engineCalls(recHook 记每次引擎能力调用 call-ID)、window.__genInternals(onReady 收 uncaught[])。
|
||||
布局收编(对齐 ref 教训):canvas 顶在 (0,0) 满幅 390×844;状态行 absolute overlay + pointer-events:none,
|
||||
绝不下推/截触摸(否则 CDP 真输入坐标整体偏移 + 底部不可点)。引擎自建 mainCanvas#game-engine 叠加渲染。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;user-scalable=no 防缩放扰动像素哈希。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>W-G1 generated game</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #ccc;
|
||||
font-family: -apple-system, "PingFang SC", monospace; overflow: hidden; }
|
||||
#wrap { position: relative; width: 390px; margin: 0 auto; }
|
||||
#game { width: 390px; height: 844px; background: #000; display: block; touch-action: none; }
|
||||
#status { position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none;
|
||||
font-size: 12px; line-height: 1.4; min-height: 15px; text-align: center; word-break: break-all;
|
||||
opacity: 0.85; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<!-- 受控画布(opts.canvas 占位/输入兜底);real 引擎自建 #game-engine 叠加。 -->
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- esbuild 产物:iife 全局 __GameBundle(含 GameHostFactory + bootGameHost)。 -->
|
||||
<script src="./bundle.iife.js"></script>
|
||||
<script>
|
||||
// 引导:取 __GameBundle.bootGameHost 启动;注入取证钩子;失败留痕 + 重抛(假绿守卫要看 console 零未捕获)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
// 假绿守卫C·真接线探针:每次引擎能力被调,recHook 记一条 call-ID 进此数组(证「真调引擎/插件库」非自绘伪装)。
|
||||
window.__engineCalls = [];
|
||||
function recHook(id) { try { window.__engineCalls.push(id); } catch (_) {} }
|
||||
// 就绪/错误标记初值。
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = null;
|
||||
try {
|
||||
var G = window.__GameBundle;
|
||||
if (!G || typeof G.bootGameHost !== 'function') {
|
||||
throw new Error('__GameBundle.bootGameHost 未找到(bundle.iife.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGameHost({
|
||||
canvas: canvas,
|
||||
statusEl: statusEl,
|
||||
recHook: recHook,
|
||||
// onReady:收集 internals(uncaught[] 等)供假绿守卫B 读。
|
||||
onReady: function (host, internals) {
|
||||
window.__genHost = host;
|
||||
window.__genInternals = internals || null;
|
||||
}
|
||||
})
|
||||
.then(function (host) {
|
||||
window.__genHost = window.__genHost || host;
|
||||
window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e; // 重抛 → 'unhandledrejection' 被假绿守卫逮住
|
||||
});
|
||||
} catch (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
57
game-runtime/games/_wg1-gen/pong/play-spec.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 600
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 100,
|
||||
"y": 740
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 450
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 300,
|
||||
"y": 740
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 450
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 195,
|
||||
"y": 740
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 450
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 130,
|
||||
"y": 740
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 450
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 270,
|
||||
"y": 740
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 400
|
||||
}
|
||||
],
|
||||
"expectedEngineCallPrefixes": [
|
||||
"particles.",
|
||||
"audio.synth"
|
||||
]
|
||||
}
|
||||
34
game-runtime/games/_wg1-gen/simon/bundle.iife.js
Normal file
75
game-runtime/games/_wg1-gen/simon/entry-bundle.js
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* entry-bundle.template.js —— W-G1 通用 bundle 入口模板(逐字拷进每个生成 game 目录为 entry-bundle.js)
|
||||
* owner:W-G1 lane | 消费方:esbuild --global-name=__GameBundle → P1 宿主 bootGameHost 真玩
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把「生成的游戏工厂 + 通用宿主 + 标准 7 插件 wiring + 引擎」汇成一个 iife 全局 __GameBundle。
|
||||
* 产出靶契约(冻结·逐字对齐 entry-bundle.js / game-package.schema.json engineBundle):
|
||||
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory 与 bootGameHost。
|
||||
*
|
||||
* 【最大化复用·最小新代码】
|
||||
* - 宿主:复用 src/host/boot-game-host.js 的通用 bootGameHost(不改)。
|
||||
* - 插件 wiring:直接复用 wanglanmei-ref 的 buildRefHostConfig(标准 7 插件 + 偏序 + 视口 + 工厂 wiring,已 P2 验证),
|
||||
* 仅 override factory 为本目录生成的工厂 → 生成游戏跑在【与 ref 完全相同的插件库装配】上。
|
||||
* - 引擎:Option C 动态 import(node import 本入口不触引擎,保 import-safe;esbuild 把引擎内联进单文件 iife)。
|
||||
*
|
||||
* 【Q4 铁律】本入口是「集成段 host」唯一引擎 import 落点(动态 import 后经 opts.engine 注入);
|
||||
* ./factory.js 游戏工厂零引擎 import,能力一律经 boot.ctx.getEngine()。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../../src/host/boot-game-host.js';
|
||||
// 标准插件装配(复用 ref 同一份:collision/physics/gamefeel/juice/palettePost/audioMusic/save + 偏序 + 390×844 视口)。
|
||||
import { buildRefHostConfig } from '../../wanglanmei-ref/src/ref-host-config.js';
|
||||
// 本目录生成的游戏工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。
|
||||
import gameFactory from './factory.js';
|
||||
|
||||
/** 【契约导出】GameHostFactory:宿主从 window.__GameBundle.GameHostFactory 取它(契约冻结)。= 本目录生成的工厂。 */
|
||||
export const GameHostFactory = gameFactory;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default gameFactory;
|
||||
|
||||
/**
|
||||
* 【P2/W-G1 boot 入口】供 host 页在浏览器内启动本 bundle(引擎由本 bundle 注入后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(W-G1 真玩取证一律引擎掌帧)。
|
||||
* - 插件/视口/工厂 wiring 取自 buildRefHostConfig(与 ref 同一标准集),factory override 为生成工厂。
|
||||
* - recHook / onReady 透传:供取证 harness 注入「引擎 call-ID 探针」与「internals(uncaught) 收集」(假绿守卫)。
|
||||
*
|
||||
* @param {Object} opts host 页传入入参。
|
||||
* @param {HTMLCanvasElement} opts.canvas 页面 #game 画布(real 通道引擎自建 mainCanvas#game-engine,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性)。
|
||||
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// 主随机种子(缺省固定值,与 ref 取证同口径,保确定性可复现)。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
// 复用 ref 标准装配(7 插件 + 偏序 + 视口 + 工厂 wiring)。
|
||||
const cfg = buildRefHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;factory override 为本目录生成工厂;其余 wiring 取标准集。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: o.mode || 'play', // W-G1 真玩=play
|
||||
engineMode: 'real', // 一律引擎掌帧(real 通道)
|
||||
viewport: cfg.viewport, // 390×844(与 ref 同,移动竖屏基准)
|
||||
factory: gameFactory, // ★ override:本目录【生成的游戏】(其余 wiring 全复用 ref)
|
||||
plugins: cfg.plugins, // 标准 7 插件实例集(经 opts.runtime.plugins 注入给工厂)
|
||||
registerOrder: cfg.registerOrder, // 注册偏序(保六锚)
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle,getRealAudioCtx}
|
||||
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||
});
|
||||
}
|
||||
BIN
game-runtime/games/_wg1-gen/simon/evidence/after-play.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
game-runtime/games/_wg1-gen/simon/evidence/first-paint.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
61
game-runtime/games/_wg1-gen/simon/evidence/verdict.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"gameId": "simon",
|
||||
"url": "http://localhost:4320/games/_wg1-gen/simon/index.html",
|
||||
"ts": null,
|
||||
"guards": {
|
||||
"A_boot": {
|
||||
"pass": true
|
||||
},
|
||||
"C_frame": {
|
||||
"pass": true,
|
||||
"f0": 48,
|
||||
"f1": 79,
|
||||
"delta": 31
|
||||
},
|
||||
"D_render": {
|
||||
"pass": true,
|
||||
"bright": 264569,
|
||||
"maxCh": 255
|
||||
},
|
||||
"E_live": {
|
||||
"pass": true,
|
||||
"distinctStates": 6,
|
||||
"samples": 11
|
||||
},
|
||||
"B_uncaught": {
|
||||
"pass": true,
|
||||
"uncaught": []
|
||||
},
|
||||
"F_wiring": {
|
||||
"pass": true,
|
||||
"callCount": 128,
|
||||
"sample": [
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut"
|
||||
],
|
||||
"expected": [
|
||||
"audio.synth",
|
||||
"particles."
|
||||
]
|
||||
},
|
||||
"G_input": {
|
||||
"pass": true,
|
||||
"inputHash": "a5100b7a",
|
||||
"controlHash": "f7530fdd",
|
||||
"atFrame": 400,
|
||||
"ctrlFrame": 405
|
||||
}
|
||||
},
|
||||
"pass": true,
|
||||
"notes": []
|
||||
}
|
||||
297
game-runtime/games/_wg1-gen/simon/factory.js
Normal file
@ -0,0 +1,297 @@
|
||||
'use strict';
|
||||
|
||||
export default function createGame(opts) {
|
||||
const o = opts || {};
|
||||
let ctx = null; // boot.ctx
|
||||
let nowMs = 0;
|
||||
let sub = null; // pointerdown subscription
|
||||
|
||||
// game state
|
||||
const COLORS = [
|
||||
{ name: 'red', x: 55, y: 300, w: 130, h: 130, dark: '#660000', bright: '#ff2020', freq: 220 },
|
||||
{ name: 'green', x: 225, y: 300, w: 130, h: 130, dark: '#006600', bright: '#20ff20', freq: 330 },
|
||||
{ name: 'blue', x: 55, y: 470, w: 130, h: 130, dark: '#000066', bright: '#2020ff', freq: 440 },
|
||||
{ name: 'yellow', x: 225, y: 470, w: 130, h: 130, dark: '#666600', bright: '#ffff20', freq: 550 },
|
||||
];
|
||||
|
||||
let sequence = []; // color indices 0-3
|
||||
let round = 1;
|
||||
let phase = 'idle'; // idle | computerPlaying | playerTurn | correctAnim | wrongAnim
|
||||
let compStepIndex = 0; // which color is currently being shown (playing)
|
||||
let compStepStart = 0; // ms when current computer step started
|
||||
let compStepDuration = 500; // ms each color stays lit during computer's turn
|
||||
let compStepInterval = 100; // ms gap between steps
|
||||
let compNextStepTime = 0; // when to start next computer step (ms)
|
||||
|
||||
let playerIndex = 0; // expected next input index
|
||||
let playerAnimStart = 0; // when button press animation started
|
||||
let playerAnimButton = -1; // -1 = none
|
||||
|
||||
let resultAnimStart = 0; // for correct / wrong pause
|
||||
let resultDuration = 800; // ms show result before next action
|
||||
let resultType = ''; // 'correct' | 'wrong'
|
||||
|
||||
// local easing (fallback if engine not available)
|
||||
function quadOut(t) { return t * (2 - t); }
|
||||
|
||||
// helper: is point inside button rect
|
||||
function hitTest(px, py, btn) {
|
||||
return px >= btn.x && px <= btn.x + btn.w && py >= btn.y && py <= btn.y + btn.h;
|
||||
}
|
||||
|
||||
// play round: reset computer playback
|
||||
function startComputerTurn() {
|
||||
phase = 'computerPlaying';
|
||||
compStepIndex = 0;
|
||||
compStepStart = nowMs;
|
||||
compNextStepTime = nowMs; // start first step immediately
|
||||
playerIndex = 0;
|
||||
}
|
||||
|
||||
// add a new random color to sequence
|
||||
function extendSequence() {
|
||||
const idx = ctx.random.range(0, 3) | 0; // use ctx.random
|
||||
sequence.push(idx);
|
||||
}
|
||||
|
||||
// start a new round (increment sequence length)
|
||||
function startNextRound() {
|
||||
round++;
|
||||
extendSequence();
|
||||
// small delay before computer plays
|
||||
resultType = 'correct';
|
||||
resultAnimStart = nowMs;
|
||||
phase = 'correctAnim';
|
||||
}
|
||||
|
||||
// reset game on mistake
|
||||
function onWrong(pressedIndex) {
|
||||
resultType = 'wrong';
|
||||
resultAnimStart = nowMs;
|
||||
phase = 'wrongAnim';
|
||||
// emit failure particles at touched button
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.particles) {
|
||||
try {
|
||||
const btn = COLORS[pressedIndex];
|
||||
eng.particles.spawnEmitter({
|
||||
pos: { x: btn.x + btn.w / 2, y: btn.y + btn.h / 2 },
|
||||
count: 30,
|
||||
speed: 100,
|
||||
particleTime: 0.4,
|
||||
colorStart: { r: 1, g: 0.2, b: 0.2, a: 1 },
|
||||
colorEnd: { r: 0.5, g: 0, b: 0, a: 0 },
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eng && eng.audio && eng.audio.synth) {
|
||||
try {
|
||||
eng.audio.synth.synthSfx([1, 0.1, 120, , , 0.2, , 0.8]); // error sound
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// correct input – advance or finish round
|
||||
function onCorrectInput() {
|
||||
playerIndex++;
|
||||
if (playerIndex >= sequence.length) {
|
||||
// round complete
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.particles) {
|
||||
try {
|
||||
eng.particles.spawnEmitter({
|
||||
pos: { x: 195, y: 422 },
|
||||
count: 40,
|
||||
speed: 70,
|
||||
particleTime: 0.6,
|
||||
colorStart: { r: 1, g: 1, b: 0.5, a: 1 },
|
||||
colorEnd: { r: 0, g: 0.5, b: 1, a: 0 },
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eng && eng.audio && eng.audio.synth) {
|
||||
try {
|
||||
eng.audio.synth.synthSfx([1, 0.2, 880, , , 0.3, , 1.6]); // success jingle
|
||||
} catch (_) {}
|
||||
}
|
||||
startNextRound();
|
||||
} else {
|
||||
// let player continue
|
||||
playerAnimButton = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// handle pointer input
|
||||
function onTap(e) {
|
||||
if (phase !== 'playerTurn') return;
|
||||
const px = e.x || 0;
|
||||
const py = e.y || 0;
|
||||
for (let i = 0; i < COLORS.length; i++) {
|
||||
const btn = COLORS[i];
|
||||
if (hitTest(px, py, btn)) {
|
||||
// play sound and visual
|
||||
playerAnimButton = i;
|
||||
playerAnimStart = nowMs;
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.audio && eng.audio.synth) {
|
||||
try {
|
||||
eng.audio.synth.synthSfx([1, 0.05, btn.freq, , , 0.18, , 1.4]);
|
||||
} catch (_) {}
|
||||
}
|
||||
// check correctness
|
||||
if (i === sequence[playerIndex]) {
|
||||
onCorrectInput();
|
||||
} else {
|
||||
onWrong(i);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init(boot) {
|
||||
ctx = boot.ctx;
|
||||
sub = ctx.getInput().on('pointerdown', onTap);
|
||||
|
||||
// initialize random sequence
|
||||
sequence = [ctx.random.range(0, 3) | 0];
|
||||
round = 1;
|
||||
phase = 'idle';
|
||||
// will start computer turn on first update
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
nowMs = ctx.time.nowMs();
|
||||
// lazy start of computer turn
|
||||
if (phase === 'idle') {
|
||||
startComputerTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
const eng = ctx.getEngine(); // may be null
|
||||
|
||||
// --- computer playing ---
|
||||
if (phase === 'computerPlaying') {
|
||||
// check if current step is finished
|
||||
if (nowMs >= compStepStart + compStepDuration) {
|
||||
// advance to next step
|
||||
compStepIndex++;
|
||||
if (compStepIndex >= sequence.length) {
|
||||
// all steps shown -> switch to player turn
|
||||
phase = 'playerTurn';
|
||||
playerIndex = 0;
|
||||
playerAnimButton = -1;
|
||||
} else {
|
||||
// schedule next step after interval
|
||||
compStepStart = nowMs + compStepInterval;
|
||||
compStepDuration = 500; // fixed per step
|
||||
// play sound for this step at start?
|
||||
// We'll play sound at the start of each step (including first) -> done below
|
||||
}
|
||||
}
|
||||
// play sound when a new step starts (at compStepStart)
|
||||
if (compStepIndex < sequence.length && nowMs >= compStepStart && nowMs < compStepStart + compStepDuration) {
|
||||
const idx = sequence[compStepIndex];
|
||||
const btn = COLORS[idx];
|
||||
// play sound once per step: check if sound not yet played? Use a flag
|
||||
// Simpler: play sound on every update, but that's ok; but to reduce calls we track last step played.
|
||||
// We'll use a simple guard: only play within a tiny window after step start.
|
||||
if (nowMs - compStepStart < 10) {
|
||||
const eng2 = ctx.getEngine();
|
||||
if (eng2 && eng2.audio && eng2.audio.synth) {
|
||||
try { eng2.audio.synth.synthSfx([1, 0.05, btn.freq, , , 0.18, , 1.4]); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// also use easing to show button press (optional)
|
||||
if (eng && eng.math && eng.math.easing) {
|
||||
try { eng.math.easing.quadOut(0.5); } catch (_) {} // just to call engine
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- player turn ---
|
||||
if (phase === 'playerTurn') {
|
||||
// handle button press animation
|
||||
if (playerAnimButton >= 0) {
|
||||
// animate brief flash
|
||||
if (nowMs - playerAnimStart > 150) {
|
||||
playerAnimButton = -1;
|
||||
}
|
||||
}
|
||||
// waiting for input: nothing else to update
|
||||
return;
|
||||
}
|
||||
|
||||
// --- result animation (correct/wrong) ---
|
||||
if (phase === 'correctAnim' || phase === 'wrongAnim') {
|
||||
if (nowMs - resultAnimStart >= resultDuration) {
|
||||
if (phase === 'correctAnim') {
|
||||
// start computer playing next sequence
|
||||
startComputerTurn();
|
||||
} else {
|
||||
// wrong: reset game
|
||||
sequence = [ctx.random.range(0, 3) | 0];
|
||||
round = 1;
|
||||
startComputerTurn();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function render(g) {
|
||||
// background (dark but not black)
|
||||
g.fillStyle = '#0a0a23';
|
||||
g.fillRect(0, 0, 390, 844);
|
||||
|
||||
// draw top info
|
||||
g.fillStyle = '#ccccff';
|
||||
g.font = '20px monospace';
|
||||
g.fillText('Simon Round ' + round, 20, 50);
|
||||
|
||||
// draw four buttons
|
||||
for (let i = 0; i < COLORS.length; i++) {
|
||||
const btn = COLORS[i];
|
||||
let isLit = false;
|
||||
|
||||
// check if this button should be lit
|
||||
if (phase === 'computerPlaying' && compStepIndex < sequence.length) {
|
||||
const currentIdx = sequence[compStepIndex];
|
||||
if (i === currentIdx && nowMs >= compStepStart && nowMs < compStepStart + compStepDuration) {
|
||||
isLit = true;
|
||||
}
|
||||
}
|
||||
if (phase === 'playerTurn' && i === playerAnimButton && nowMs - playerAnimStart < 150) {
|
||||
isLit = true;
|
||||
}
|
||||
// during result animation, if correct, briefly flash all? optional, we skip.
|
||||
|
||||
const fillColor = isLit ? btn.bright : btn.dark;
|
||||
const radius = 8;
|
||||
g.fillStyle = fillColor;
|
||||
g.beginPath();
|
||||
g.roundRect(btn.x, btn.y, btn.w, btn.h, radius);
|
||||
g.fill();
|
||||
// border
|
||||
g.strokeStyle = '#222';
|
||||
g.lineWidth = 2;
|
||||
g.stroke();
|
||||
|
||||
// inner gloss hint
|
||||
if (isLit) {
|
||||
g.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
g.beginPath();
|
||||
g.roundRect(btn.x + 10, btn.y + 10, btn.w - 20, btn.h / 3, 4);
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try { sub && sub.cancel(); } catch (_) {}
|
||||
sub = null;
|
||||
}
|
||||
|
||||
return { init, update, render, destroy };
|
||||
}
|
||||
83
game-runtime/games/_wg1-gen/simon/index.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.template.html —— W-G1 通用 host 页模板(逐字拷进每个生成 game 目录为 index.html)
|
||||
owner:W-G1 lane | 用法:
|
||||
· 同目录 esbuild 产物 bundle.iife.js(globalName=__GameBundle)由本页 <script src> 加载;
|
||||
· serve game-runtime 根,CDP 导航到 /games/_wg1-gen/<game>/index.html;
|
||||
· 就绪标记 __genBooted(bootGameHost().then 置);real 引擎接管标记 __gameHostEngineInitFired(boot-game-host 内置);
|
||||
· 假绿守卫钩子:window.__engineCalls(recHook 记每次引擎能力调用 call-ID)、window.__genInternals(onReady 收 uncaught[])。
|
||||
布局收编(对齐 ref 教训):canvas 顶在 (0,0) 满幅 390×844;状态行 absolute overlay + pointer-events:none,
|
||||
绝不下推/截触摸(否则 CDP 真输入坐标整体偏移 + 底部不可点)。引擎自建 mainCanvas#game-engine 叠加渲染。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;user-scalable=no 防缩放扰动像素哈希。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>W-G1 generated game</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #ccc;
|
||||
font-family: -apple-system, "PingFang SC", monospace; overflow: hidden; }
|
||||
#wrap { position: relative; width: 390px; margin: 0 auto; }
|
||||
#game { width: 390px; height: 844px; background: #000; display: block; touch-action: none; }
|
||||
#status { position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none;
|
||||
font-size: 12px; line-height: 1.4; min-height: 15px; text-align: center; word-break: break-all;
|
||||
opacity: 0.85; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<!-- 受控画布(opts.canvas 占位/输入兜底);real 引擎自建 #game-engine 叠加。 -->
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- esbuild 产物:iife 全局 __GameBundle(含 GameHostFactory + bootGameHost)。 -->
|
||||
<script src="./bundle.iife.js"></script>
|
||||
<script>
|
||||
// 引导:取 __GameBundle.bootGameHost 启动;注入取证钩子;失败留痕 + 重抛(假绿守卫要看 console 零未捕获)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
// 假绿守卫C·真接线探针:每次引擎能力被调,recHook 记一条 call-ID 进此数组(证「真调引擎/插件库」非自绘伪装)。
|
||||
window.__engineCalls = [];
|
||||
function recHook(id) { try { window.__engineCalls.push(id); } catch (_) {} }
|
||||
// 就绪/错误标记初值。
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = null;
|
||||
try {
|
||||
var G = window.__GameBundle;
|
||||
if (!G || typeof G.bootGameHost !== 'function') {
|
||||
throw new Error('__GameBundle.bootGameHost 未找到(bundle.iife.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGameHost({
|
||||
canvas: canvas,
|
||||
statusEl: statusEl,
|
||||
recHook: recHook,
|
||||
// onReady:收集 internals(uncaught[] 等)供假绿守卫B 读。
|
||||
onReady: function (host, internals) {
|
||||
window.__genHost = host;
|
||||
window.__genInternals = internals || null;
|
||||
}
|
||||
})
|
||||
.then(function (host) {
|
||||
window.__genHost = window.__genHost || host;
|
||||
window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e; // 重抛 → 'unhandledrejection' 被假绿守卫逮住
|
||||
});
|
||||
} catch (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
48
game-runtime/games/_wg1-gen/simon/play-spec.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 1300
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 130,
|
||||
"y": 360
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 260,
|
||||
"y": 360
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 130,
|
||||
"y": 500
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 260,
|
||||
"y": 500
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
}
|
||||
],
|
||||
"expectedEngineCallPrefixes": [
|
||||
"audio.synth",
|
||||
"particles."
|
||||
]
|
||||
}
|
||||
34
game-runtime/games/_wg1-gen/simon_m27/bundle.iife.js
Normal file
75
game-runtime/games/_wg1-gen/simon_m27/entry-bundle.js
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* entry-bundle.template.js —— W-G1 通用 bundle 入口模板(逐字拷进每个生成 game 目录为 entry-bundle.js)
|
||||
* owner:W-G1 lane | 消费方:esbuild --global-name=__GameBundle → P1 宿主 bootGameHost 真玩
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把「生成的游戏工厂 + 通用宿主 + 标准 7 插件 wiring + 引擎」汇成一个 iife 全局 __GameBundle。
|
||||
* 产出靶契约(冻结·逐字对齐 entry-bundle.js / game-package.schema.json engineBundle):
|
||||
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory 与 bootGameHost。
|
||||
*
|
||||
* 【最大化复用·最小新代码】
|
||||
* - 宿主:复用 src/host/boot-game-host.js 的通用 bootGameHost(不改)。
|
||||
* - 插件 wiring:直接复用 wanglanmei-ref 的 buildRefHostConfig(标准 7 插件 + 偏序 + 视口 + 工厂 wiring,已 P2 验证),
|
||||
* 仅 override factory 为本目录生成的工厂 → 生成游戏跑在【与 ref 完全相同的插件库装配】上。
|
||||
* - 引擎:Option C 动态 import(node import 本入口不触引擎,保 import-safe;esbuild 把引擎内联进单文件 iife)。
|
||||
*
|
||||
* 【Q4 铁律】本入口是「集成段 host」唯一引擎 import 落点(动态 import 后经 opts.engine 注入);
|
||||
* ./factory.js 游戏工厂零引擎 import,能力一律经 boot.ctx.getEngine()。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../../src/host/boot-game-host.js';
|
||||
// 标准插件装配(复用 ref 同一份:collision/physics/gamefeel/juice/palettePost/audioMusic/save + 偏序 + 390×844 视口)。
|
||||
import { buildRefHostConfig } from '../../wanglanmei-ref/src/ref-host-config.js';
|
||||
// 本目录生成的游戏工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。
|
||||
import gameFactory from './factory.js';
|
||||
|
||||
/** 【契约导出】GameHostFactory:宿主从 window.__GameBundle.GameHostFactory 取它(契约冻结)。= 本目录生成的工厂。 */
|
||||
export const GameHostFactory = gameFactory;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default gameFactory;
|
||||
|
||||
/**
|
||||
* 【P2/W-G1 boot 入口】供 host 页在浏览器内启动本 bundle(引擎由本 bundle 注入后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(W-G1 真玩取证一律引擎掌帧)。
|
||||
* - 插件/视口/工厂 wiring 取自 buildRefHostConfig(与 ref 同一标准集),factory override 为生成工厂。
|
||||
* - recHook / onReady 透传:供取证 harness 注入「引擎 call-ID 探针」与「internals(uncaught) 收集」(假绿守卫)。
|
||||
*
|
||||
* @param {Object} opts host 页传入入参。
|
||||
* @param {HTMLCanvasElement} opts.canvas 页面 #game 画布(real 通道引擎自建 mainCanvas#game-engine,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性)。
|
||||
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// 主随机种子(缺省固定值,与 ref 取证同口径,保确定性可复现)。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
// 复用 ref 标准装配(7 插件 + 偏序 + 视口 + 工厂 wiring)。
|
||||
const cfg = buildRefHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;factory override 为本目录生成工厂;其余 wiring 取标准集。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: o.mode || 'play', // W-G1 真玩=play
|
||||
engineMode: 'real', // 一律引擎掌帧(real 通道)
|
||||
viewport: cfg.viewport, // 390×844(与 ref 同,移动竖屏基准)
|
||||
factory: gameFactory, // ★ override:本目录【生成的游戏】(其余 wiring 全复用 ref)
|
||||
plugins: cfg.plugins, // 标准 7 插件实例集(经 opts.runtime.plugins 注入给工厂)
|
||||
registerOrder: cfg.registerOrder, // 注册偏序(保六锚)
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle,getRealAudioCtx}
|
||||
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||
});
|
||||
}
|
||||
BIN
game-runtime/games/_wg1-gen/simon_m27/evidence/after-play.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
game-runtime/games/_wg1-gen/simon_m27/evidence/first-paint.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
57
game-runtime/games/_wg1-gen/simon_m27/evidence/verdict.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"gameId": "simon_m27",
|
||||
"url": "http://localhost:4320/games/_wg1-gen/simon_m27/index.html",
|
||||
"ts": null,
|
||||
"guards": {
|
||||
"A_boot": {
|
||||
"pass": true
|
||||
},
|
||||
"C_frame": {
|
||||
"pass": true,
|
||||
"f0": 54,
|
||||
"f1": 84,
|
||||
"delta": 30
|
||||
},
|
||||
"D_render": {
|
||||
"pass": true,
|
||||
"bright": 346491,
|
||||
"maxCh": 255
|
||||
},
|
||||
"E_live": {
|
||||
"pass": true,
|
||||
"distinctStates": 5,
|
||||
"samples": 11
|
||||
},
|
||||
"B_uncaught": {
|
||||
"pass": true,
|
||||
"uncaught": []
|
||||
},
|
||||
"F_wiring": {
|
||||
"pass": true,
|
||||
"callCount": 409,
|
||||
"sample": [
|
||||
"audio.synth.synthSfx",
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx",
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx",
|
||||
"particles.spawnEmitter",
|
||||
"audio.synth.synthSfx",
|
||||
"particles.spawnEmitter"
|
||||
],
|
||||
"expected": [
|
||||
"audio.synth",
|
||||
"particles."
|
||||
]
|
||||
},
|
||||
"G_input": {
|
||||
"pass": true,
|
||||
"inputHash": "94bda89a",
|
||||
"controlHash": "990dbcba",
|
||||
"atFrame": 403,
|
||||
"ctrlFrame": 405
|
||||
}
|
||||
},
|
||||
"pass": true,
|
||||
"notes": []
|
||||
}
|
||||
256
game-runtime/games/_wg1-gen/simon_m27/factory.js
Normal file
@ -0,0 +1,256 @@
|
||||
'use strict';
|
||||
export default function createSimonGame(opts) {
|
||||
const o = opts || {};
|
||||
let ctx = null;
|
||||
let nowMs = 0;
|
||||
let t = 0;
|
||||
let sub = null;
|
||||
|
||||
// 四个按钮定义:红、绿、蓝、黄
|
||||
const BUTTONS = [
|
||||
{ id: 0, x: 40, y: 200, w: 145, h: 145, color: '#c0392b', litColor: '#ff4444', pitch: 261, name: '红' },
|
||||
{ id: 1, x: 205, y: 200, w: 145, h: 145, color: '#27ae60', litColor: '#44ff66', pitch: 329, name: '绿' },
|
||||
{ id: 2, x: 40, y: 365, w: 145, h: 145, color: '#2980b9', litColor: '#44aaff', pitch: 392, name: '蓝' },
|
||||
{ id: 3, x: 205, y: 365, w: 145, h: 145, color: '#f39c12', litColor: '#ffcc00', pitch: 523, name: '黄' },
|
||||
];
|
||||
|
||||
let sequence = []; // 记忆序列
|
||||
let playerIdx = 0; // 玩家当前要按的索引
|
||||
let state = 'idle'; // idle | playing | listening
|
||||
let litButton = -1; // 当前点亮的按钮 id
|
||||
let litTimer = 0; // 点亮剩余时间 ms
|
||||
let playTimer = 0; // 播放下一个的倒计时
|
||||
let roundDelay = 0; // 回合间停顿
|
||||
let round = 0; // 当前轮数
|
||||
let feedbackParticles = []; // 反馈粒子(纯canvas备用)
|
||||
let failFlash = 0; // 失败闪烁计时
|
||||
let winPulse = 0; // 成功脉冲
|
||||
|
||||
function playTone(pitch, vol) {
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.audio && eng.audio.synth) {
|
||||
try {
|
||||
const v = vol || 0.3;
|
||||
eng.audio.synth.synthSfx([1, 0.03, pitch, , , 0.15, , 1.5, , , , , , , 1.5, 0.02]);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function spawnBurst(btn, isSuccess) {
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.particles) {
|
||||
try {
|
||||
const cx = btn.x + btn.w / 2;
|
||||
const cy = btn.y + btn.h / 2;
|
||||
eng.particles.spawnEmitter({
|
||||
pos: { x: cx, y: cy },
|
||||
count: isSuccess ? 30 : 20,
|
||||
speed: isSuccess ? 100 : 80,
|
||||
particleTime: 0.6,
|
||||
colorStart: isSuccess
|
||||
? { r: 0.3, g: 1, b: 0.4, a: 1 }
|
||||
: { r: 1, g: 0.2, b: 0.2, a: 1 },
|
||||
colorEnd: { r: 0, g: 0, b: 0, a: 0 },
|
||||
coneAngle: 360,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function startRound() {
|
||||
round++;
|
||||
const next = Math.floor(ctx.random.range(0, 4));
|
||||
sequence.push(next);
|
||||
playerIdx = 0;
|
||||
litButton = -1;
|
||||
litTimer = 0;
|
||||
playTimer = 300;
|
||||
state = 'playing';
|
||||
}
|
||||
|
||||
function onTap(e) {
|
||||
if (state !== 'listening') return;
|
||||
const px = e.x || 0;
|
||||
const py = e.y || 0;
|
||||
for (const btn of BUTTONS) {
|
||||
if (px >= btn.x && px <= btn.x + btn.w && py >= btn.y && py <= btn.y + btn.h) {
|
||||
litButton = btn.id;
|
||||
litTimer = 350;
|
||||
playTone(btn.pitch, 0.35);
|
||||
spawnBurst(btn, false);
|
||||
|
||||
if (btn.id === sequence[playerIdx]) {
|
||||
playerIdx++;
|
||||
if (playerIdx >= sequence.length) {
|
||||
state = 'idle';
|
||||
winPulse = 600;
|
||||
playTone(660, 0.2);
|
||||
const lastBtn = BUTTONS[sequence[sequence.length - 1]];
|
||||
spawnBurst(lastBtn, true);
|
||||
roundDelay = 1200;
|
||||
}
|
||||
} else {
|
||||
// 错误
|
||||
state = 'idle';
|
||||
failFlash = 500;
|
||||
playTone(120, 0.4);
|
||||
spawnBurst(btn, false);
|
||||
sequence = [];
|
||||
round = 0;
|
||||
roundDelay = 1500;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init(boot) {
|
||||
ctx = boot.ctx;
|
||||
sub = ctx.getInput().on('pointerdown', onTap);
|
||||
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.math) {
|
||||
try { eng.math.lerp(0, 1, 0); } catch (_) {}
|
||||
}
|
||||
|
||||
// 初始化序列,开局即开始
|
||||
roundDelay = 500;
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
nowMs = ctx.time.nowMs();
|
||||
t = nowMs / 1000;
|
||||
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.math) {
|
||||
try { eng.math.lerp(0, 1, t % 1); } catch (_) {}
|
||||
}
|
||||
|
||||
if (failFlash > 0) failFlash -= dt * 1000;
|
||||
if (winPulse > 0) winPulse -= dt * 1000;
|
||||
|
||||
if (state === 'idle') {
|
||||
roundDelay -= dt * 1000;
|
||||
if (roundDelay <= 0) startRound();
|
||||
}
|
||||
|
||||
if (state === 'playing') {
|
||||
playTimer -= dt * 1000;
|
||||
if (playTimer <= 0 && litTimer <= 0) {
|
||||
if (playerIdx < sequence.length) {
|
||||
const btnId = sequence[playerIdx];
|
||||
litButton = btnId;
|
||||
litTimer = 500;
|
||||
playerIdx++;
|
||||
playTone(BUTTONS[btnId].pitch, 0.3);
|
||||
spawnBurst(BUTTONS[btnId], false);
|
||||
playTimer = 600;
|
||||
} else {
|
||||
state = 'listening';
|
||||
litButton = -1;
|
||||
litTimer = 0;
|
||||
playerIdx = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (litTimer > 0) {
|
||||
litTimer -= dt * 1000;
|
||||
if (litTimer <= 0) litButton = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function render(g) {
|
||||
// 深色背景铺满
|
||||
g.fillStyle = '#0d0d15';
|
||||
g.fillRect(0, 0, 390, 844);
|
||||
|
||||
// 顶部轮数显示
|
||||
g.fillStyle = '#667788';
|
||||
g.font = '18px monospace';
|
||||
g.textAlign = 'center';
|
||||
g.fillText('轮数: ' + round, 195, 40);
|
||||
g.fillText(state === 'listening' ? '轮到你了' : (state === 'playing' ? '记住序列...' : '等待...'), 195, 65);
|
||||
g.textAlign = 'left';
|
||||
|
||||
// 失败闪烁全屏
|
||||
if (failFlash > 0) {
|
||||
const a = (failFlash / 500) * 0.4;
|
||||
g.fillStyle = 'rgba(200,30,30,' + a + ')';
|
||||
g.fillRect(0, 0, 390, 844);
|
||||
}
|
||||
|
||||
// 成功脉冲背景
|
||||
if (winPulse > 0) {
|
||||
const a = (winPulse / 600) * 0.25;
|
||||
g.fillStyle = 'rgba(50,220,100,' + a + ')';
|
||||
g.fillRect(0, 0, 390, 844);
|
||||
}
|
||||
|
||||
// 画四个按钮
|
||||
for (const btn of BUTTONS) {
|
||||
const isLit = litButton === btn.id;
|
||||
const baseColor = isLit ? btn.litColor : btn.color;
|
||||
const scale = isLit ? 1.06 : 1.0;
|
||||
|
||||
// 按钮主体
|
||||
g.save();
|
||||
const cx = btn.x + btn.w / 2;
|
||||
const cy = btn.y + btn.h / 2;
|
||||
g.translate(cx, cy);
|
||||
g.scale(scale, scale);
|
||||
g.translate(-cx, -cy);
|
||||
|
||||
// 圆角矩形
|
||||
g.fillStyle = baseColor;
|
||||
g.beginPath();
|
||||
const r = 16;
|
||||
const x = btn.x, y = btn.y, w = btn.w, h = btn.h;
|
||||
g.moveTo(x + r, y);
|
||||
g.lineTo(x + w - r, y);
|
||||
g.arcTo(x + w, y, x + w, y + r, r);
|
||||
g.lineTo(x + w, y + h - r);
|
||||
g.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
g.lineTo(x + r, y + h);
|
||||
g.arcTo(x, y + h, x, y + h - r, r);
|
||||
g.lineTo(x, y + r);
|
||||
g.arcTo(x, y, x + r, y, r);
|
||||
g.closePath();
|
||||
g.fill();
|
||||
|
||||
// 按钮内高光
|
||||
if (isLit) {
|
||||
g.fillStyle = 'rgba(255,255,255,0.25)';
|
||||
g.beginPath();
|
||||
g.ellipse(cx, cy - 20, w * 0.35, h * 0.18, 0, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
|
||||
g.restore();
|
||||
}
|
||||
|
||||
// 成功时画一圈光环
|
||||
if (winPulse > 0) {
|
||||
const progress = 1 - winPulse / 600;
|
||||
const eng = ctx.getEngine();
|
||||
let eased = progress;
|
||||
if (eng && eng.math && eng.math.easing && eng.math.easing.quadOut) {
|
||||
try { eased = eng.math.easing.quadOut(progress); } catch (_) {}
|
||||
}
|
||||
g.strokeStyle = 'rgba(80,255,120,' + (0.8 - eased * 0.8) + ')';
|
||||
g.lineWidth = 4;
|
||||
g.beginPath();
|
||||
g.arc(195, 422, 50 + eased * 100, 0, Math.PI * 2);
|
||||
g.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try { sub && sub.cancel(); } catch (_) {}
|
||||
sub = null;
|
||||
sequence = [];
|
||||
feedbackParticles = [];
|
||||
}
|
||||
|
||||
return { init, update, render, destroy };
|
||||
}
|
||||
83
game-runtime/games/_wg1-gen/simon_m27/index.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.template.html —— W-G1 通用 host 页模板(逐字拷进每个生成 game 目录为 index.html)
|
||||
owner:W-G1 lane | 用法:
|
||||
· 同目录 esbuild 产物 bundle.iife.js(globalName=__GameBundle)由本页 <script src> 加载;
|
||||
· serve game-runtime 根,CDP 导航到 /games/_wg1-gen/<game>/index.html;
|
||||
· 就绪标记 __genBooted(bootGameHost().then 置);real 引擎接管标记 __gameHostEngineInitFired(boot-game-host 内置);
|
||||
· 假绿守卫钩子:window.__engineCalls(recHook 记每次引擎能力调用 call-ID)、window.__genInternals(onReady 收 uncaught[])。
|
||||
布局收编(对齐 ref 教训):canvas 顶在 (0,0) 满幅 390×844;状态行 absolute overlay + pointer-events:none,
|
||||
绝不下推/截触摸(否则 CDP 真输入坐标整体偏移 + 底部不可点)。引擎自建 mainCanvas#game-engine 叠加渲染。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;user-scalable=no 防缩放扰动像素哈希。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>W-G1 generated game</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #ccc;
|
||||
font-family: -apple-system, "PingFang SC", monospace; overflow: hidden; }
|
||||
#wrap { position: relative; width: 390px; margin: 0 auto; }
|
||||
#game { width: 390px; height: 844px; background: #000; display: block; touch-action: none; }
|
||||
#status { position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none;
|
||||
font-size: 12px; line-height: 1.4; min-height: 15px; text-align: center; word-break: break-all;
|
||||
opacity: 0.85; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<!-- 受控画布(opts.canvas 占位/输入兜底);real 引擎自建 #game-engine 叠加。 -->
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- esbuild 产物:iife 全局 __GameBundle(含 GameHostFactory + bootGameHost)。 -->
|
||||
<script src="./bundle.iife.js"></script>
|
||||
<script>
|
||||
// 引导:取 __GameBundle.bootGameHost 启动;注入取证钩子;失败留痕 + 重抛(假绿守卫要看 console 零未捕获)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
// 假绿守卫C·真接线探针:每次引擎能力被调,recHook 记一条 call-ID 进此数组(证「真调引擎/插件库」非自绘伪装)。
|
||||
window.__engineCalls = [];
|
||||
function recHook(id) { try { window.__engineCalls.push(id); } catch (_) {} }
|
||||
// 就绪/错误标记初值。
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = null;
|
||||
try {
|
||||
var G = window.__GameBundle;
|
||||
if (!G || typeof G.bootGameHost !== 'function') {
|
||||
throw new Error('__GameBundle.bootGameHost 未找到(bundle.iife.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGameHost({
|
||||
canvas: canvas,
|
||||
statusEl: statusEl,
|
||||
recHook: recHook,
|
||||
// onReady:收集 internals(uncaught[] 等)供假绿守卫B 读。
|
||||
onReady: function (host, internals) {
|
||||
window.__genHost = host;
|
||||
window.__genInternals = internals || null;
|
||||
}
|
||||
})
|
||||
.then(function (host) {
|
||||
window.__genHost = window.__genHost || host;
|
||||
window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e; // 重抛 → 'unhandledrejection' 被假绿守卫逮住
|
||||
});
|
||||
} catch (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
48
game-runtime/games/_wg1-gen/simon_m27/play-spec.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 1300
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 130,
|
||||
"y": 360
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 260,
|
||||
"y": 360
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 130,
|
||||
"y": 500
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 260,
|
||||
"y": 500
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
}
|
||||
],
|
||||
"expectedEngineCallPrefixes": [
|
||||
"audio.synth",
|
||||
"particles."
|
||||
]
|
||||
}
|
||||
34
game-runtime/games/_wg1-gen/simon_pro/bundle.iife.js
Normal file
75
game-runtime/games/_wg1-gen/simon_pro/entry-bundle.js
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* entry-bundle.template.js —— W-G1 通用 bundle 入口模板(逐字拷进每个生成 game 目录为 entry-bundle.js)
|
||||
* owner:W-G1 lane | 消费方:esbuild --global-name=__GameBundle → P1 宿主 bootGameHost 真玩
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把「生成的游戏工厂 + 通用宿主 + 标准 7 插件 wiring + 引擎」汇成一个 iife 全局 __GameBundle。
|
||||
* 产出靶契约(冻结·逐字对齐 entry-bundle.js / game-package.schema.json engineBundle):
|
||||
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory 与 bootGameHost。
|
||||
*
|
||||
* 【最大化复用·最小新代码】
|
||||
* - 宿主:复用 src/host/boot-game-host.js 的通用 bootGameHost(不改)。
|
||||
* - 插件 wiring:直接复用 wanglanmei-ref 的 buildRefHostConfig(标准 7 插件 + 偏序 + 视口 + 工厂 wiring,已 P2 验证),
|
||||
* 仅 override factory 为本目录生成的工厂 → 生成游戏跑在【与 ref 完全相同的插件库装配】上。
|
||||
* - 引擎:Option C 动态 import(node import 本入口不触引擎,保 import-safe;esbuild 把引擎内联进单文件 iife)。
|
||||
*
|
||||
* 【Q4 铁律】本入口是「集成段 host」唯一引擎 import 落点(动态 import 后经 opts.engine 注入);
|
||||
* ./factory.js 游戏工厂零引擎 import,能力一律经 boot.ctx.getEngine()。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../../src/host/boot-game-host.js';
|
||||
// 标准插件装配(复用 ref 同一份:collision/physics/gamefeel/juice/palettePost/audioMusic/save + 偏序 + 390×844 视口)。
|
||||
import { buildRefHostConfig } from '../../wanglanmei-ref/src/ref-host-config.js';
|
||||
// 本目录生成的游戏工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。
|
||||
import gameFactory from './factory.js';
|
||||
|
||||
/** 【契约导出】GameHostFactory:宿主从 window.__GameBundle.GameHostFactory 取它(契约冻结)。= 本目录生成的工厂。 */
|
||||
export const GameHostFactory = gameFactory;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default gameFactory;
|
||||
|
||||
/**
|
||||
* 【P2/W-G1 boot 入口】供 host 页在浏览器内启动本 bundle(引擎由本 bundle 注入后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(W-G1 真玩取证一律引擎掌帧)。
|
||||
* - 插件/视口/工厂 wiring 取自 buildRefHostConfig(与 ref 同一标准集),factory override 为生成工厂。
|
||||
* - recHook / onReady 透传:供取证 harness 注入「引擎 call-ID 探针」与「internals(uncaught) 收集」(假绿守卫)。
|
||||
*
|
||||
* @param {Object} opts host 页传入入参。
|
||||
* @param {HTMLCanvasElement} opts.canvas 页面 #game 画布(real 通道引擎自建 mainCanvas#game-engine,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性)。
|
||||
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// 主随机种子(缺省固定值,与 ref 取证同口径,保确定性可复现)。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
// 复用 ref 标准装配(7 插件 + 偏序 + 视口 + 工厂 wiring)。
|
||||
const cfg = buildRefHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;factory override 为本目录生成工厂;其余 wiring 取标准集。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: o.mode || 'play', // W-G1 真玩=play
|
||||
engineMode: 'real', // 一律引擎掌帧(real 通道)
|
||||
viewport: cfg.viewport, // 390×844(与 ref 同,移动竖屏基准)
|
||||
factory: gameFactory, // ★ override:本目录【生成的游戏】(其余 wiring 全复用 ref)
|
||||
plugins: cfg.plugins, // 标准 7 插件实例集(经 opts.runtime.plugins 注入给工厂)
|
||||
registerOrder: cfg.registerOrder, // 注册偏序(保六锚)
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle,getRealAudioCtx}
|
||||
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||
});
|
||||
}
|
||||
BIN
game-runtime/games/_wg1-gen/simon_pro/evidence/after-play.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
game-runtime/games/_wg1-gen/simon_pro/evidence/first-paint.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
61
game-runtime/games/_wg1-gen/simon_pro/evidence/verdict.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"gameId": "simon_pro",
|
||||
"url": "http://localhost:4320/games/_wg1-gen/simon_pro/index.html",
|
||||
"ts": null,
|
||||
"guards": {
|
||||
"A_boot": {
|
||||
"pass": true
|
||||
},
|
||||
"C_frame": {
|
||||
"pass": true,
|
||||
"f0": 49,
|
||||
"f1": 79,
|
||||
"delta": 30
|
||||
},
|
||||
"D_render": {
|
||||
"pass": true,
|
||||
"bright": 279271,
|
||||
"maxCh": 255
|
||||
},
|
||||
"E_live": {
|
||||
"pass": true,
|
||||
"distinctStates": 7,
|
||||
"samples": 11
|
||||
},
|
||||
"B_uncaught": {
|
||||
"pass": true,
|
||||
"uncaught": []
|
||||
},
|
||||
"F_wiring": {
|
||||
"pass": true,
|
||||
"callCount": 611,
|
||||
"sample": [
|
||||
"audio.synth.synthSfx",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut"
|
||||
],
|
||||
"expected": [
|
||||
"audio.synth",
|
||||
"particles."
|
||||
]
|
||||
},
|
||||
"G_input": {
|
||||
"pass": true,
|
||||
"inputHash": "2306aab3",
|
||||
"controlHash": "33f7eb0b",
|
||||
"atFrame": 399,
|
||||
"ctrlFrame": 403
|
||||
}
|
||||
},
|
||||
"pass": true,
|
||||
"notes": []
|
||||
}
|
||||
244
game-runtime/games/_wg1-gen/simon_pro/factory.js
Normal file
@ -0,0 +1,244 @@
|
||||
'use strict';
|
||||
/**
|
||||
* factory.js — Simon (记忆复现) 游戏宿主工厂
|
||||
* 严守受控面铁律:零引擎 import、零 Math.random / Date.now / addEventListener / new AudioContext / RAF;
|
||||
* 能力一律经 boot.ctx;render 只画到入参 g(=boot.mainContext)。
|
||||
*/
|
||||
export default function createSimonGame(opts) {
|
||||
const o = opts || {};
|
||||
let ctx = null;
|
||||
let nowMs = 0;
|
||||
let sub = null; // pointerdown 订阅句柄
|
||||
|
||||
// ---- 游戏状态 ----
|
||||
const BTN_W = 130, BTN_H = 130, GAP = 20;
|
||||
const BTN_POS = [
|
||||
{ x: 55, y: 282 }, // 0: 红
|
||||
{ x: 205, y: 282 }, // 1: 绿
|
||||
{ x: 55, y: 432 }, // 2: 蓝
|
||||
{ x: 205, y: 432 } // 3: 黄
|
||||
];
|
||||
const DARK_COLORS = ['rgb(100,0,0)', 'rgb(0,100,0)', 'rgb(0,0,100)', 'rgb(100,100,0)'];
|
||||
const LIGHT_COLORS = ['rgb(255,0,0)', 'rgb(0,255,0)', 'rgb(0,0,255)', 'rgb(255,255,0)'];
|
||||
|
||||
// 每个按钮的合成音效参数(ZzFX 参数包)
|
||||
const TONES = [
|
||||
[1, 0.05, 220, , , 0.1, , 1.0], // 红
|
||||
[1, 0.05, 330, , , 0.1, , 1.0], // 绿
|
||||
[1, 0.05, 440, , , 0.1, , 1.0], // 蓝
|
||||
[1, 0.05, 554, , , 0.1, , 1.0] // 黄
|
||||
];
|
||||
const FAIL_SFX = [1, 0.1, 100, , , 0.5, , 0.5];
|
||||
|
||||
let seq = []; // 当前序列(颜色索引 0-3)
|
||||
let computerStep = 0; // 电脑当前播放到第几步
|
||||
let playerStep = 0; // 玩家当前输入到第几步
|
||||
let lastStepTime = 0; // 上一步开始的时间(ms)
|
||||
let stepStartTime = 0; // 当前步骤开始的时间(用于高亮动画)
|
||||
let activeButton = -1; // 当前高亮的按钮索引(电脑播放用)
|
||||
let playerClickedButton = -1; // 玩家点击瞬亮的按钮
|
||||
let playerClickTime = 0; // 玩家点击的时刻
|
||||
let phase = 'idle'; // 'idle' | 'computer' | 'player' | 'success' | 'fail'
|
||||
let waitTime = 0; // 等待结束的时间(success/fail 过渡用)
|
||||
|
||||
// ---- 工具方法 ----
|
||||
function randomColor() {
|
||||
return Math.floor(ctx.random.range(0, 4));
|
||||
}
|
||||
|
||||
function playSound(idx) {
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.audio && eng.audio.synth) {
|
||||
try { eng.audio.synth.synthSfx(TONES[idx]); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function playFailSound() {
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.audio && eng.audio.synth) {
|
||||
try { eng.audio.synth.synthSfx(FAIL_SFX); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function spawnParticles(type) {
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.particles) {
|
||||
try {
|
||||
eng.particles.spawnEmitter({
|
||||
pos: { x: 195, y: 422 },
|
||||
count: 35,
|
||||
speed: 180,
|
||||
particleTime: 0.7,
|
||||
colorStart: type === 'success'
|
||||
? { r: 1, g: 1, b: 0.3, a: 1 }
|
||||
: { r: 1, g: 0.2, b: 0.2, a: 1 },
|
||||
colorEnd: type === 'success'
|
||||
? { r: 1, g: 0.7, b: 0, a: 0 }
|
||||
: { r: 0.6, g: 0, b: 0, a: 0 }
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function startNewRound(now) {
|
||||
seq.push(randomColor());
|
||||
phase = 'computer';
|
||||
computerStep = 0;
|
||||
playerStep = 0;
|
||||
lastStepTime = now;
|
||||
activeButton = seq[0];
|
||||
stepStartTime = now;
|
||||
playSound(activeButton);
|
||||
}
|
||||
|
||||
// ---- 输入处理 ----
|
||||
function handleTap(e) {
|
||||
if (phase !== 'player') return;
|
||||
const px = e.x || 0, py = e.y || 0;
|
||||
let idx = -1;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const b = BTN_POS[i];
|
||||
if (px >= b.x && px <= b.x + BTN_W && py >= b.y && py <= b.y + BTN_H) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx === -1) return;
|
||||
if (idx === seq[playerStep]) {
|
||||
// 正确
|
||||
playSound(idx);
|
||||
playerClickedButton = idx;
|
||||
playerClickTime = nowMs;
|
||||
playerStep++;
|
||||
if (playerStep >= seq.length) {
|
||||
// 整轮答对
|
||||
phase = 'success';
|
||||
waitTime = nowMs + 1500;
|
||||
spawnParticles('success');
|
||||
}
|
||||
} else {
|
||||
// 错误
|
||||
phase = 'fail';
|
||||
waitTime = nowMs + 1000;
|
||||
spawnParticles('fail');
|
||||
playFailSound();
|
||||
seq = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 生命周期 ----
|
||||
function init(boot) {
|
||||
ctx = boot.ctx;
|
||||
sub = ctx.getInput().on('pointerdown', handleTap);
|
||||
nowMs = ctx.time.nowMs();
|
||||
// 开局立即开始第一轮
|
||||
startNewRound(nowMs);
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
nowMs = ctx.time.nowMs();
|
||||
|
||||
// 保险:每帧触发一次 math.lerp,确保引擎叶调用信号
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.math) { try { eng.math.lerp(0, 1, 0.5); } catch (_) {} }
|
||||
|
||||
// 状态驱动
|
||||
switch (phase) {
|
||||
case 'computer': {
|
||||
if (nowMs - lastStepTime >= 500) {
|
||||
computerStep++;
|
||||
lastStepTime = nowMs;
|
||||
if (computerStep < seq.length) {
|
||||
activeButton = seq[computerStep];
|
||||
stepStartTime = nowMs;
|
||||
playSound(activeButton);
|
||||
} else {
|
||||
// 电脑播放完,轮到玩家
|
||||
activeButton = -1;
|
||||
phase = 'player';
|
||||
playerStep = 0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'success': {
|
||||
if (nowMs >= waitTime) {
|
||||
// 开始新轮
|
||||
startNewRound(nowMs);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'fail': {
|
||||
if (nowMs >= waitTime) {
|
||||
// 重置从长度 1 开始
|
||||
seq = [];
|
||||
startNewRound(nowMs);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
|
||||
// 玩家点击高亮持续 200ms
|
||||
if (playerClickedButton >= 0 && nowMs - playerClickTime > 200) {
|
||||
playerClickedButton = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function render(g) {
|
||||
// 背景
|
||||
g.fillStyle = '#101820';
|
||||
g.fillRect(0, 0, 390, 844);
|
||||
|
||||
// 绘制四个按钮
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const { x, y } = BTN_POS[i];
|
||||
let isActive = false;
|
||||
let elapsed = 0;
|
||||
|
||||
if (phase === 'computer' && activeButton === i) {
|
||||
isActive = true;
|
||||
elapsed = nowMs - stepStartTime;
|
||||
} else if (playerClickedButton === i) {
|
||||
isActive = true;
|
||||
elapsed = nowMs - playerClickTime;
|
||||
}
|
||||
|
||||
// 暗态底色
|
||||
g.fillStyle = DARK_COLORS[i];
|
||||
g.fillRect(x, y, BTN_W, BTN_H);
|
||||
|
||||
if (isActive) {
|
||||
const eng = ctx.getEngine();
|
||||
// 计算缩放因子,使用 easing(防御式回退)
|
||||
let t = Math.min(elapsed / 500, 1); // 统一用 500ms 周期
|
||||
let eased = t;
|
||||
if (eng && eng.math && eng.math.easing) {
|
||||
try { eased = eng.math.easing.quadOut(t); } catch (_) { eased = t; }
|
||||
}
|
||||
const scale = 1 + 0.18 * eased;
|
||||
|
||||
g.save();
|
||||
const cx = x + BTN_W / 2, cy = y + BTN_H / 2;
|
||||
g.translate(cx, cy);
|
||||
g.scale(scale, scale);
|
||||
g.fillStyle = LIGHT_COLORS[i];
|
||||
g.fillRect(-BTN_W / 2, -BTN_H / 2, BTN_W, BTN_H);
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 当前轮数文字(可选)
|
||||
g.fillStyle = '#8899aa';
|
||||
g.font = '18px monospace';
|
||||
g.fillText('Round: ' + seq.length, 150, 180);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try { sub && sub.cancel(); } catch (_) {}
|
||||
sub = null;
|
||||
seq = [];
|
||||
}
|
||||
|
||||
return { init, update, render, destroy };
|
||||
}
|
||||
83
game-runtime/games/_wg1-gen/simon_pro/index.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.template.html —— W-G1 通用 host 页模板(逐字拷进每个生成 game 目录为 index.html)
|
||||
owner:W-G1 lane | 用法:
|
||||
· 同目录 esbuild 产物 bundle.iife.js(globalName=__GameBundle)由本页 <script src> 加载;
|
||||
· serve game-runtime 根,CDP 导航到 /games/_wg1-gen/<game>/index.html;
|
||||
· 就绪标记 __genBooted(bootGameHost().then 置);real 引擎接管标记 __gameHostEngineInitFired(boot-game-host 内置);
|
||||
· 假绿守卫钩子:window.__engineCalls(recHook 记每次引擎能力调用 call-ID)、window.__genInternals(onReady 收 uncaught[])。
|
||||
布局收编(对齐 ref 教训):canvas 顶在 (0,0) 满幅 390×844;状态行 absolute overlay + pointer-events:none,
|
||||
绝不下推/截触摸(否则 CDP 真输入坐标整体偏移 + 底部不可点)。引擎自建 mainCanvas#game-engine 叠加渲染。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;user-scalable=no 防缩放扰动像素哈希。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>W-G1 generated game</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #ccc;
|
||||
font-family: -apple-system, "PingFang SC", monospace; overflow: hidden; }
|
||||
#wrap { position: relative; width: 390px; margin: 0 auto; }
|
||||
#game { width: 390px; height: 844px; background: #000; display: block; touch-action: none; }
|
||||
#status { position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none;
|
||||
font-size: 12px; line-height: 1.4; min-height: 15px; text-align: center; word-break: break-all;
|
||||
opacity: 0.85; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<!-- 受控画布(opts.canvas 占位/输入兜底);real 引擎自建 #game-engine 叠加。 -->
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- esbuild 产物:iife 全局 __GameBundle(含 GameHostFactory + bootGameHost)。 -->
|
||||
<script src="./bundle.iife.js"></script>
|
||||
<script>
|
||||
// 引导:取 __GameBundle.bootGameHost 启动;注入取证钩子;失败留痕 + 重抛(假绿守卫要看 console 零未捕获)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
// 假绿守卫C·真接线探针:每次引擎能力被调,recHook 记一条 call-ID 进此数组(证「真调引擎/插件库」非自绘伪装)。
|
||||
window.__engineCalls = [];
|
||||
function recHook(id) { try { window.__engineCalls.push(id); } catch (_) {} }
|
||||
// 就绪/错误标记初值。
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = null;
|
||||
try {
|
||||
var G = window.__GameBundle;
|
||||
if (!G || typeof G.bootGameHost !== 'function') {
|
||||
throw new Error('__GameBundle.bootGameHost 未找到(bundle.iife.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGameHost({
|
||||
canvas: canvas,
|
||||
statusEl: statusEl,
|
||||
recHook: recHook,
|
||||
// onReady:收集 internals(uncaught[] 等)供假绿守卫B 读。
|
||||
onReady: function (host, internals) {
|
||||
window.__genHost = host;
|
||||
window.__genInternals = internals || null;
|
||||
}
|
||||
})
|
||||
.then(function (host) {
|
||||
window.__genHost = window.__genHost || host;
|
||||
window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e; // 重抛 → 'unhandledrejection' 被假绿守卫逮住
|
||||
});
|
||||
} catch (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
48
game-runtime/games/_wg1-gen/simon_pro/play-spec.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 1300
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 130,
|
||||
"y": 360
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 260,
|
||||
"y": 360
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 130,
|
||||
"y": 500
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 260,
|
||||
"y": 500
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 350
|
||||
}
|
||||
],
|
||||
"expectedEngineCallPrefixes": [
|
||||
"audio.synth",
|
||||
"particles."
|
||||
]
|
||||
}
|
||||
34
game-runtime/games/_wg1-gen/whack/bundle.iife.js
Normal file
75
game-runtime/games/_wg1-gen/whack/entry-bundle.js
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* entry-bundle.template.js —— W-G1 通用 bundle 入口模板(逐字拷进每个生成 game 目录为 entry-bundle.js)
|
||||
* owner:W-G1 lane | 消费方:esbuild --global-name=__GameBundle → P1 宿主 bootGameHost 真玩
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把「生成的游戏工厂 + 通用宿主 + 标准 7 插件 wiring + 引擎」汇成一个 iife 全局 __GameBundle。
|
||||
* 产出靶契约(冻结·逐字对齐 entry-bundle.js / game-package.schema.json engineBundle):
|
||||
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory 与 bootGameHost。
|
||||
*
|
||||
* 【最大化复用·最小新代码】
|
||||
* - 宿主:复用 src/host/boot-game-host.js 的通用 bootGameHost(不改)。
|
||||
* - 插件 wiring:直接复用 wanglanmei-ref 的 buildRefHostConfig(标准 7 插件 + 偏序 + 视口 + 工厂 wiring,已 P2 验证),
|
||||
* 仅 override factory 为本目录生成的工厂 → 生成游戏跑在【与 ref 完全相同的插件库装配】上。
|
||||
* - 引擎:Option C 动态 import(node import 本入口不触引擎,保 import-safe;esbuild 把引擎内联进单文件 iife)。
|
||||
*
|
||||
* 【Q4 铁律】本入口是「集成段 host」唯一引擎 import 落点(动态 import 后经 opts.engine 注入);
|
||||
* ./factory.js 游戏工厂零引擎 import,能力一律经 boot.ctx.getEngine()。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../../src/host/boot-game-host.js';
|
||||
// 标准插件装配(复用 ref 同一份:collision/physics/gamefeel/juice/palettePost/audioMusic/save + 偏序 + 390×844 视口)。
|
||||
import { buildRefHostConfig } from '../../wanglanmei-ref/src/ref-host-config.js';
|
||||
// 本目录生成的游戏工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。
|
||||
import gameFactory from './factory.js';
|
||||
|
||||
/** 【契约导出】GameHostFactory:宿主从 window.__GameBundle.GameHostFactory 取它(契约冻结)。= 本目录生成的工厂。 */
|
||||
export const GameHostFactory = gameFactory;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default gameFactory;
|
||||
|
||||
/**
|
||||
* 【P2/W-G1 boot 入口】供 host 页在浏览器内启动本 bundle(引擎由本 bundle 注入后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(W-G1 真玩取证一律引擎掌帧)。
|
||||
* - 插件/视口/工厂 wiring 取自 buildRefHostConfig(与 ref 同一标准集),factory override 为生成工厂。
|
||||
* - recHook / onReady 透传:供取证 harness 注入「引擎 call-ID 探针」与「internals(uncaught) 收集」(假绿守卫)。
|
||||
*
|
||||
* @param {Object} opts host 页传入入参。
|
||||
* @param {HTMLCanvasElement} opts.canvas 页面 #game 画布(real 通道引擎自建 mainCanvas#game-engine,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性)。
|
||||
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// 主随机种子(缺省固定值,与 ref 取证同口径,保确定性可复现)。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
// 复用 ref 标准装配(7 插件 + 偏序 + 视口 + 工厂 wiring)。
|
||||
const cfg = buildRefHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;factory override 为本目录生成工厂;其余 wiring 取标准集。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: o.mode || 'play', // W-G1 真玩=play
|
||||
engineMode: 'real', // 一律引擎掌帧(real 通道)
|
||||
viewport: cfg.viewport, // 390×844(与 ref 同,移动竖屏基准)
|
||||
factory: gameFactory, // ★ override:本目录【生成的游戏】(其余 wiring 全复用 ref)
|
||||
plugins: cfg.plugins, // 标准 7 插件实例集(经 opts.runtime.plugins 注入给工厂)
|
||||
registerOrder: cfg.registerOrder, // 注册偏序(保六锚)
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle,getRealAudioCtx}
|
||||
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||
});
|
||||
}
|
||||
BIN
game-runtime/games/_wg1-gen/whack/evidence/after-play.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
game-runtime/games/_wg1-gen/whack/evidence/first-paint.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
61
game-runtime/games/_wg1-gen/whack/evidence/verdict.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"gameId": "whack",
|
||||
"url": "http://localhost:4320/games/_wg1-gen/whack/index.html",
|
||||
"ts": null,
|
||||
"guards": {
|
||||
"A_boot": {
|
||||
"pass": true
|
||||
},
|
||||
"C_frame": {
|
||||
"pass": true,
|
||||
"f0": 49,
|
||||
"f1": 80,
|
||||
"delta": 31
|
||||
},
|
||||
"D_render": {
|
||||
"pass": true,
|
||||
"bright": 10256,
|
||||
"maxCh": 255
|
||||
},
|
||||
"E_live": {
|
||||
"pass": true,
|
||||
"distinctStates": 15,
|
||||
"samples": 15
|
||||
},
|
||||
"B_uncaught": {
|
||||
"pass": true,
|
||||
"uncaught": []
|
||||
},
|
||||
"F_wiring": {
|
||||
"pass": true,
|
||||
"callCount": 691,
|
||||
"sample": [
|
||||
"audio.synth.synthSfx",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut",
|
||||
"math.easing.quadOut"
|
||||
],
|
||||
"expected": [
|
||||
"particles.",
|
||||
"audio.synth"
|
||||
]
|
||||
},
|
||||
"G_input": {
|
||||
"pass": true,
|
||||
"inputHash": "a9de479b",
|
||||
"controlHash": "b0584efe",
|
||||
"atFrame": 446,
|
||||
"ctrlFrame": 448
|
||||
}
|
||||
},
|
||||
"pass": true,
|
||||
"notes": []
|
||||
}
|
||||
303
game-runtime/games/_wg1-gen/whack/factory.js
Normal file
@ -0,0 +1,303 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Whac-A-Mole Game Factory
|
||||
* 经典打地鼠,竖屏 390×844,深色背景,3×3 洞口,地鼠冒出动画,点击命中触发粒子与音效。
|
||||
* 完全遵循受控面契约:无引擎 import、无 Math.random、无 addEventListener、无 Date.now。
|
||||
*/
|
||||
export default function createGame(opts) {
|
||||
const o = opts || {};
|
||||
let ctx = null;
|
||||
let nowMs = 0;
|
||||
let score = 0;
|
||||
let sub = null;
|
||||
let nextMoleTime = 0; // 下次尝试冒出的绝对时间 (ms)
|
||||
const MAX_ACTIVE = 2; // 同时最多冒出 2 只地鼠
|
||||
|
||||
// 洞口 3×3 固定坐标
|
||||
const HOLE_X = [80, 195, 310];
|
||||
const HOLE_Y = [400, 540, 680];
|
||||
const HOLE_RX = 30; // 椭圆水平半径
|
||||
const HOLE_RY = 18; // 椭圆垂直半径
|
||||
|
||||
// 地鼠状态
|
||||
const STATE_HIDDEN = 0;
|
||||
const STATE_RISING = 1;
|
||||
const STATE_VISIBLE = 2;
|
||||
const STATE_FALLING = 3;
|
||||
|
||||
const RISE_DURATION = 300; // 冒出动画 ms
|
||||
const FALL_DURATION = 300; // 缩回动画 ms
|
||||
const VISIBLE_MIN = 700; // 最短停留 ms
|
||||
const VISIBLE_MAX = 1100; // 最长停留 ms
|
||||
|
||||
// 每个洞的地鼠
|
||||
let moles = [];
|
||||
|
||||
function initHoles() {
|
||||
moles = [];
|
||||
for (let i = 0; i < HOLE_X.length; i++) {
|
||||
for (let j = 0; j < HOLE_Y.length; j++) {
|
||||
const hole = {
|
||||
x: HOLE_X[i],
|
||||
y: HOLE_Y[j],
|
||||
state: STATE_HIDDEN,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
frac: 0, // 当前露出比例 0~1
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
radius: 0,
|
||||
};
|
||||
moles.push(hole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 找出所有隐藏的洞
|
||||
function getHiddenHoles() {
|
||||
return moles.filter(m => m.state === STATE_HIDDEN);
|
||||
}
|
||||
|
||||
// 当前活跃(非隐藏)的地鼠数量
|
||||
function activeCount() {
|
||||
return moles.reduce((cnt, m) => cnt + (m.state !== STATE_HIDDEN ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
// 尝试冒出地鼠
|
||||
function trySpawnMole() {
|
||||
const hidden = getHiddenHoles();
|
||||
if (hidden.length === 0) return;
|
||||
const count = activeCount();
|
||||
if (count >= MAX_ACTIVE) return;
|
||||
const idx = Math.floor(ctx.random.range(0, hidden.length));
|
||||
const hole = hidden[idx];
|
||||
hole.state = STATE_RISING;
|
||||
hole.startTime = nowMs;
|
||||
hole.endTime = nowMs + RISE_DURATION;
|
||||
hole.frac = 0;
|
||||
|
||||
// 冒出音效
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.audio && eng.audio.synth) {
|
||||
try {
|
||||
eng.audio.synth.synthSfx([1.0,0.1,0.2,0.3,0.4,0.5,0.6,0.7]); // 简短冒出音
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新单个地鼠状态(return true 表示该地鼠冒出/缩回结束)
|
||||
function updateMole(mole) {
|
||||
if (mole.state === STATE_HIDDEN) return;
|
||||
const elapsed = nowMs - mole.startTime;
|
||||
|
||||
if (mole.state === STATE_RISING) {
|
||||
if (elapsed >= RISE_DURATION) {
|
||||
mole.state = STATE_VISIBLE;
|
||||
mole.startTime = nowMs;
|
||||
mole.endTime = nowMs + ctx.random.range(VISIBLE_MIN, VISIBLE_MAX);
|
||||
mole.frac = 1;
|
||||
} else {
|
||||
mole.frac = elapsed / RISE_DURATION;
|
||||
// 使用平滑插值
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.math && eng.math.easing && eng.math.easing.quadOut) {
|
||||
try { mole.frac = eng.math.easing.quadOut(mole.frac); } catch (_) {}
|
||||
} else if (eng && eng.math) {
|
||||
try { mole.frac = eng.math.smoothStep(mole.frac); } catch (_) {}
|
||||
}
|
||||
}
|
||||
} else if (mole.state === STATE_VISIBLE) {
|
||||
if (elapsed >= mole.endTime - mole.startTime) {
|
||||
// 开始缩回
|
||||
mole.state = STATE_FALLING;
|
||||
mole.startTime = nowMs;
|
||||
mole.endTime = nowMs + FALL_DURATION;
|
||||
mole.frac = 1;
|
||||
}
|
||||
} else if (mole.state === STATE_FALLING) {
|
||||
if (elapsed >= FALL_DURATION) {
|
||||
mole.state = STATE_HIDDEN;
|
||||
mole.frac = 0;
|
||||
} else {
|
||||
mole.frac = 1 - elapsed / FALL_DURATION;
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.math && eng.math.easing && eng.math.easing.quadOut) {
|
||||
try { mole.frac = eng.math.easing.quadOut(1 - mole.frac); } catch (_) {}
|
||||
} else if (eng && eng.math) {
|
||||
try { mole.frac = 1 - eng.math.smoothStep(1 - mole.frac); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新绘制参数
|
||||
mole.centerX = mole.x;
|
||||
mole.centerY = mole.y - 30 * mole.frac; // 从洞中心上升
|
||||
mole.radius = 20 * mole.frac; // 半径随露出增大
|
||||
}
|
||||
|
||||
// 处理点击
|
||||
function onTap(e) {
|
||||
const px = e.x || 0;
|
||||
const py = e.y || 0;
|
||||
let hit = false;
|
||||
for (const mole of moles) {
|
||||
if (mole.state === STATE_HIDDEN) continue;
|
||||
const dx = px - mole.centerX;
|
||||
const dy = py - mole.centerY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < mole.radius) {
|
||||
hit = true;
|
||||
// 打中:得分+1,地鼠立即缩回
|
||||
score++;
|
||||
mole.state = STATE_FALLING;
|
||||
mole.startTime = nowMs;
|
||||
mole.endTime = nowMs + FALL_DURATION;
|
||||
mole.frac = 1;
|
||||
|
||||
// 粒子爆发
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.particles) {
|
||||
try {
|
||||
eng.particles.spawnEmitter({
|
||||
pos: { x: mole.x, y: mole.y - 15 },
|
||||
count: 20,
|
||||
speed: 100,
|
||||
particleTime: 0.4,
|
||||
colorStart: { r: 1, g: 0.9, b: 0.2, a: 1 },
|
||||
colorEnd: { r: 0.8, g: 0.2, b: 0.1, a: 0 },
|
||||
sizeStart: 4,
|
||||
sizeEnd: 1,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
// 命中音效
|
||||
if (eng && eng.audio && eng.audio.synth) {
|
||||
try {
|
||||
eng.audio.synth.synthSfx([0.8,0.15,0.3,0.4,0.5,0.6,0.7,0.8]);
|
||||
} catch (_) {}
|
||||
}
|
||||
break; // 一次只能打中一只
|
||||
}
|
||||
}
|
||||
// 点空也可以播放空声音效(可选)
|
||||
// 但为了简洁,不额外处理
|
||||
}
|
||||
|
||||
function init(boot) {
|
||||
ctx = boot.ctx;
|
||||
initHoles();
|
||||
score = 0;
|
||||
nextMoleTime = 0;
|
||||
sub = ctx.getInput().on('pointerdown', onTap);
|
||||
|
||||
// 初始安排第一次冒出
|
||||
nowMs = ctx.time.nowMs();
|
||||
nextMoleTime = nowMs + ctx.random.range(600, 900);
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
nowMs = ctx.time.nowMs(); // 唯一时钟源
|
||||
|
||||
// 更新所有地鼠状态
|
||||
for (const mole of moles) {
|
||||
updateMole(mole);
|
||||
}
|
||||
|
||||
// 尝试冒出新地鼠
|
||||
if (nowMs >= nextMoleTime) {
|
||||
trySpawnMole();
|
||||
nextMoleTime = nowMs + ctx.random.range(500, 900);
|
||||
}
|
||||
|
||||
// 每帧触发一次 math.lerp 确保引擎调用(可选,但规范要求至少一次)
|
||||
const eng = ctx.getEngine();
|
||||
if (eng && eng.math) {
|
||||
try { eng.math.lerp(0, 1, (nowMs % 1000) / 1000); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function render(g) {
|
||||
// 满幅深色背景
|
||||
g.fillStyle = '#101820';
|
||||
g.fillRect(0, 0, 390, 844);
|
||||
|
||||
// 绘制洞
|
||||
for (const mole of moles) {
|
||||
// 椭圆洞
|
||||
g.beginPath();
|
||||
g.ellipse(mole.x, mole.y, HOLE_RX, HOLE_RY, 0, 0, Math.PI * 2);
|
||||
g.fillStyle = '#3a2a1a';
|
||||
g.fill();
|
||||
g.strokeStyle = '#2a1a0a';
|
||||
g.lineWidth = 2;
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
// 绘制地鼠
|
||||
for (const mole of moles) {
|
||||
if (mole.state === STATE_HIDDEN) continue;
|
||||
const { centerX, centerY, radius, frac } = mole;
|
||||
if (frac <= 0) continue;
|
||||
|
||||
// 身体(棕色圆)
|
||||
g.beginPath();
|
||||
g.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
g.fillStyle = '#8B5A2B';
|
||||
g.fill();
|
||||
|
||||
// 眼睛(仅当露出较多时)
|
||||
if (frac > 0.4) {
|
||||
const eyeOffsetX = 6 * (radius / 20);
|
||||
const eyeOffsetY = -4 * (radius / 20);
|
||||
const eyeRadius = 3 * (radius / 20);
|
||||
// 左眼
|
||||
g.beginPath();
|
||||
g.arc(centerX - eyeOffsetX, centerY + eyeOffsetY, eyeRadius, 0, Math.PI * 2);
|
||||
g.fillStyle = '#FFFFFF';
|
||||
g.fill();
|
||||
// 左瞳孔
|
||||
g.beginPath();
|
||||
g.arc(centerX - eyeOffsetX, centerY + eyeOffsetY, eyeRadius * 0.5, 0, Math.PI * 2);
|
||||
g.fillStyle = '#000000';
|
||||
g.fill();
|
||||
// 右眼
|
||||
g.beginPath();
|
||||
g.arc(centerX + eyeOffsetX, centerY + eyeOffsetY, eyeRadius, 0, Math.PI * 2);
|
||||
g.fillStyle = '#FFFFFF';
|
||||
g.fill();
|
||||
g.beginPath();
|
||||
g.arc(centerX + eyeOffsetX, centerY + eyeOffsetY, eyeRadius * 0.5, 0, Math.PI * 2);
|
||||
g.fillStyle = '#000000';
|
||||
g.fill();
|
||||
}
|
||||
|
||||
// 腮红(可选)
|
||||
if (frac > 0.5) {
|
||||
const blushX = 8 * (radius / 20);
|
||||
const blushY = 4 * (radius / 20);
|
||||
g.beginPath();
|
||||
g.arc(centerX - blushX, centerY + blushY, 3 * (radius / 20), 0, Math.PI * 2);
|
||||
g.fillStyle = 'rgba(255,100,100,0.4)';
|
||||
g.fill();
|
||||
g.beginPath();
|
||||
g.arc(centerX + blushX, centerY + blushY, 3 * (radius / 20), 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部分数
|
||||
g.fillStyle = '#FFFFFF';
|
||||
g.font = 'bold 28px Arial, sans-serif';
|
||||
g.textAlign = 'center';
|
||||
g.fillText('得分: ' + score, 195, 50);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try { sub && sub.cancel(); } catch (_) {}
|
||||
sub = null;
|
||||
moles = [];
|
||||
score = 0;
|
||||
}
|
||||
|
||||
return { init, update, render, destroy };
|
||||
}
|
||||
83
game-runtime/games/_wg1-gen/whack/index.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.template.html —— W-G1 通用 host 页模板(逐字拷进每个生成 game 目录为 index.html)
|
||||
owner:W-G1 lane | 用法:
|
||||
· 同目录 esbuild 产物 bundle.iife.js(globalName=__GameBundle)由本页 <script src> 加载;
|
||||
· serve game-runtime 根,CDP 导航到 /games/_wg1-gen/<game>/index.html;
|
||||
· 就绪标记 __genBooted(bootGameHost().then 置);real 引擎接管标记 __gameHostEngineInitFired(boot-game-host 内置);
|
||||
· 假绿守卫钩子:window.__engineCalls(recHook 记每次引擎能力调用 call-ID)、window.__genInternals(onReady 收 uncaught[])。
|
||||
布局收编(对齐 ref 教训):canvas 顶在 (0,0) 满幅 390×844;状态行 absolute overlay + pointer-events:none,
|
||||
绝不下推/截触摸(否则 CDP 真输入坐标整体偏移 + 底部不可点)。引擎自建 mainCanvas#game-engine 叠加渲染。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;user-scalable=no 防缩放扰动像素哈希。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>W-G1 generated game</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #ccc;
|
||||
font-family: -apple-system, "PingFang SC", monospace; overflow: hidden; }
|
||||
#wrap { position: relative; width: 390px; margin: 0 auto; }
|
||||
#game { width: 390px; height: 844px; background: #000; display: block; touch-action: none; }
|
||||
#status { position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none;
|
||||
font-size: 12px; line-height: 1.4; min-height: 15px; text-align: center; word-break: break-all;
|
||||
opacity: 0.85; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<!-- 受控画布(opts.canvas 占位/输入兜底);real 引擎自建 #game-engine 叠加。 -->
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- esbuild 产物:iife 全局 __GameBundle(含 GameHostFactory + bootGameHost)。 -->
|
||||
<script src="./bundle.iife.js"></script>
|
||||
<script>
|
||||
// 引导:取 __GameBundle.bootGameHost 启动;注入取证钩子;失败留痕 + 重抛(假绿守卫要看 console 零未捕获)。
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
// 假绿守卫C·真接线探针:每次引擎能力被调,recHook 记一条 call-ID 进此数组(证「真调引擎/插件库」非自绘伪装)。
|
||||
window.__engineCalls = [];
|
||||
function recHook(id) { try { window.__engineCalls.push(id); } catch (_) {} }
|
||||
// 就绪/错误标记初值。
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = null;
|
||||
try {
|
||||
var G = window.__GameBundle;
|
||||
if (!G || typeof G.bootGameHost !== 'function') {
|
||||
throw new Error('__GameBundle.bootGameHost 未找到(bundle.iife.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGameHost({
|
||||
canvas: canvas,
|
||||
statusEl: statusEl,
|
||||
recHook: recHook,
|
||||
// onReady:收集 internals(uncaught[] 等)供假绿守卫B 读。
|
||||
onReady: function (host, internals) {
|
||||
window.__genHost = host;
|
||||
window.__genInternals = internals || null;
|
||||
}
|
||||
})
|
||||
.then(function (host) {
|
||||
window.__genHost = window.__genHost || host;
|
||||
window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e; // 重抛 → 'unhandledrejection' 被假绿守卫逮住
|
||||
});
|
||||
} catch (e) {
|
||||
window.__genBooted = false;
|
||||
window.__genBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
66
game-runtime/games/_wg1-gen/whack/play-spec.json
Normal file
@ -0,0 +1,66 @@
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 700
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 80,
|
||||
"y": 400
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 300
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 195,
|
||||
"y": 540
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 300
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 310,
|
||||
"y": 680
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 300
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 195,
|
||||
"y": 400
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 300
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 80,
|
||||
"y": 540
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 300
|
||||
},
|
||||
{
|
||||
"t": "tap",
|
||||
"x": 310,
|
||||
"y": 400
|
||||
},
|
||||
{
|
||||
"t": "wait",
|
||||
"ms": 300
|
||||
}
|
||||
],
|
||||
"expectedEngineCallPrefixes": [
|
||||
"particles.",
|
||||
"audio.synth"
|
||||
]
|
||||
}
|
||||
25
game-runtime/package-lock.json
generated
@ -11,7 +11,8 @@
|
||||
"littlejsengine": "1.18.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1"
|
||||
"esbuild": "0.28.1",
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@ -506,6 +507,28 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
"littlejsengine": "1.18.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1"
|
||||
"esbuild": "0.28.1",
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
19
spikes/agentscope-wg1/.agent
Normal file
@ -0,0 +1,19 @@
|
||||
# .agent — spikes/agentscope-wg1
|
||||
|
||||
目标:W-G1 闸门验证 spike——亲验 AgentScope 2.0.1 三件能力(分布式/持久化/L1 开销),
|
||||
为 §5 换轨决策出 VERDICT。**非产品落地,代码可丢弃。**
|
||||
|
||||
边界 / 非目标:
|
||||
- 只验框架三件能力,不建真实游戏生成管线。
|
||||
- 禁止改动 game-runtime/ game-cloud/ game-studio/ 等任何生产代码(本目录完全隔离)。
|
||||
- 密钥只走环境变量(NEWAPI_KEY),绝不入库。
|
||||
|
||||
现状(2026-06-13,已完成):
|
||||
- 三闸门全部跑通并有实测证据:①分布式 PASS(带 caveat)②持久化 PASS ③L1 开销 PASS。
|
||||
- VERDICT:**继续 AgentScope,不换轨**(§5 无触发器成立);见 VERDICT.md。
|
||||
- durable 产物:VERDICT.md(含 §2 控制面↔worker 契约形状)、artifacts/*.json。
|
||||
|
||||
TODO / 盯防(交主弧):
|
||||
- [ ] 锁 2.0.1,盯 GitHub issue #1722(单进程)/#1868(多实例重复执行)是否在 2.0.2+ 修复。
|
||||
- [ ] L3 真·多机多 worker 前必复测 framework 原生 serving;当前仅 RedisMessageBus 原语自拼可用。
|
||||
- [ ] 把「代理旁路 / 容错重试 / worker 边界契约」沉淀进 .agents/。
|
||||
6
spikes/agentscope-wg1/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.redis-data/
|
||||
*.log
|
||||
58
spikes/agentscope-wg1/README.md
Normal file
@ -0,0 +1,58 @@
|
||||
# spike: AgentScope 2.x 落地三闸门验证(W-G1)
|
||||
|
||||
> 隔离 spike,代码可丢弃质量;**结论看 [`VERDICT.md`](VERDICT.md)**(durable)。
|
||||
> 任务源:`docs/agent-specs/2026-06-12-agentic基建框架选型-review.md`。**不改动任何生产代码。**
|
||||
|
||||
验证 AgentScope **2.0.1** 三件能力(任一验不过即触发 §5 换轨):
|
||||
①分布式(≥2 worker 进程多 agent 协作)②持久化/恢复(中断→恢复)③L1 退化开销。
|
||||
**结论:三闸门均 PASS → 继续 AgentScope,不换轨**(详见 VERDICT)。
|
||||
|
||||
## 复现步骤
|
||||
|
||||
```bash
|
||||
cd spikes/agentscope-wg1
|
||||
|
||||
# 1) venv + 锁版本安装(uv;~13s)
|
||||
uv venv .venv --python 3.12
|
||||
uv pip install -p .venv/bin/python 'agentscope[service,storage]==2.0.1'
|
||||
# 或严格复现:uv pip install -p .venv/bin/python -r requirements.lock
|
||||
|
||||
# 2) 起隔离本地 Redis(端口 6399,数据/日志在 .redis-data,已 gitignore)
|
||||
redis-server --port 6399 --daemonize yes --dir "$PWD/.redis-data" --save "" --appendonly no \
|
||||
--logfile "$PWD/.redis-data/redis.log" --pidfile "$PWD/.redis-data/redis.pid"
|
||||
|
||||
# 3) 模型出口密钥(走环境变量,绝不入库)
|
||||
export NEWAPI_KEY=<向创始人要 new-api key>
|
||||
# ⚠️ 关键坑:本机有 HTTP(S)_PROXY=127.0.0.1:7897,会把 SDK 发往网关的请求经代理 → 502。
|
||||
# src/_common.py 已自动把网关 host 并入 NO_PROXY 修复;手动跑别的脚本时也请:
|
||||
export NO_PROXY="localhost,127.0.0.1,::1,.local,100.64.0.8"; export no_proxy="$NO_PROXY"
|
||||
|
||||
# 4) 跑三闸门
|
||||
.venv/bin/python src/gate3_overhead.py # 闸门③ L1 开销(写 artifacts/gate3_overhead.json)
|
||||
|
||||
.venv/bin/python src/gate2_persist.py run1 --crash-after 3 # 闸门② run1:注入+落盘+os._exit 硬崩
|
||||
.venv/bin/python src/gate2_persist.py run2 # 闸门② run2:新进程恢复,判 PASS
|
||||
|
||||
.venv/bin/python src/gate1_distributed.py worker W1 & # 闸门① 进程1
|
||||
.venv/bin/python src/gate1_distributed.py worker W2 & # 闸门① 进程2
|
||||
.venv/bin/python src/gate1_distributed.py coordinator # 闸门① 播种+收口+判定
|
||||
|
||||
# 5) 清理
|
||||
redis-cli -p 6399 shutdown nosave # 或 kill $(cat .redis-data/redis.pid)
|
||||
```
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `VERDICT.md` | **durable**:三闸门 pass/fail+实测数据+坑+go/换轨结论+§2 worker 边界契约 |
|
||||
| `src/_common.py` | 公共:构建指向 new-api 的 OpenAI 兼容模型客户端 + **代理旁路修复** |
|
||||
| `src/gate1_distributed.py` | 闸门①:2 进程 3 agent 经 `RedisMessageBus` 协作 |
|
||||
| `src/gate2_persist.py` | 闸门②:`AgentState`→Redis,崩溃/`kill -9` 后恢复 |
|
||||
| `src/gate3_overhead.py` | 闸门③:裸 openai vs 框架模型层 vs 最小 Agent 开销对照 |
|
||||
| `artifacts/*.json` | 本机实测原始数据(证据) |
|
||||
| `requirements.lock` | 锁版本全量(92 包) |
|
||||
|
||||
## 环境(实测)
|
||||
Apple M1 Pro 32GB · Python 3.12.13 · `agentscope==2.0.1` · `openai==2.41.1` · `redis(py)==8.0.0` / `redis-server v8.8.0`。
|
||||
模型:`deepseek-v4-flash`(L1)经 new-api 网关 `http://100.64.0.8:3000/v1`。
|
||||
162
spikes/agentscope-wg1/VERDICT.md
Normal file
@ -0,0 +1,162 @@
|
||||
# W-G1 spike · AgentScope 2.x 落地三闸门验证 · VERDICT
|
||||
|
||||
> 任务源:`docs/agent-specs/2026-06-12-agentic基建框架选型-review.md`(HJ-AGI-001,终选 A=AgentScope 2.x)。
|
||||
> 本 spike = §5 换轨闸门的亲验门:分布式 / 持久化 / L1 开销,任一验不过即触发换轨。
|
||||
> 执行:2026-06-13 · 本机 Apple M1 Pro 32GB · Python 3.12.13 · **agentscope==2.0.1(锁版本)**。
|
||||
> 所有结论严格区分 **【实测】**(本机跑出)/ **【取证】**(读 2.0.1 源码或 GitHub,高置信二手)/ **【推断】**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论速览(结论先行)
|
||||
|
||||
**建议:继续 AgentScope 2.x,不触发换轨切 LangGraph。** 三道闸门均通过其可测量门槛:
|
||||
|
||||
| 闸门 | 判定 | 一句话证据 |
|
||||
|---|---|---|
|
||||
| ①分布式 | **PASS(带重要 caveat)** | 3 个 agent 跨 2 个独立 OS 进程经 `RedisMessageBus` 协作产出一致产物(pid 56350/56351 实证跨进程) |
|
||||
| ②持久化/恢复 | **PASS** | `AgentState`→Redis,经 `os._exit` 与**真 `kill -9`** 两种硬崩后,全新进程恢复 8 条上下文、记忆 3/3 复述无损 |
|
||||
| ③L1 退化开销 | **PASS(§5-⑤ 未被迫触发)** | 最小 Agent 相对裸 openai:本地封装开销中位 **+24.4ms/次**、输入 token **+11/次(几乎全是 system_prompt,框架固有 token 税≈0)** |
|
||||
|
||||
**最重要的一条判定(必须如实传达)**:**AgentScope 2.0 的「分布式」与 1.x 完全不同——`RpcAgent`/`to_dist()` 进程级 actor 已整段删除。** 2.0 跨进程靠 `RedisMessageBus`(Redis Streams 队列/pub-sub/锁)这套**原语**;原语本身实测可用(闸门①过),但**框架并不提供开箱即用的「分布式 agent 运行时」——多 worker 编排要你自己写**(我就是手写 worker 循环+队列接力把闸门①跑通的)。而官方那条「整机 serving 多 worker」路径(`create_app` FastAPI + 多 uvicorn worker + Agent Team)**目前是 Beta、维护者自承单进程不可横向扩展(#1722),且有今日新开的多实例重复执行 bug(#1868)**——这条我**未亲测**(只读到 issue,属【取证】),但它意味着 **L3 多 agent 开发组若要真·多机多 worker,现在还不成熟,须盯版本。**
|
||||
|
||||
为何仍判「继续」而非「换轨」:① §5 触发器④是「分布式/持久化**验不过**即切 B」——而本机实测**两者都跑通了**(分布式原语可用、持久化恢复无损),故④**不成立**;② §5-A 的风险敞口逻辑成立:L3 尚在数月之外、AgentScope 有半年+稳定窗、**退出成本本来就低**(普通 Python+状态字典+我们这套薄 worker 编排,换 LangGraph 只改 worker 内部);③ 三闸门的可测量门槛都过了。**风险有界、可继续,但把「框架原生分布式 serving 成熟度」列为头号盯防项 + 锁版本盯 changelog。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 三闸门逐条(实测证据 + 复现命令)
|
||||
|
||||
### 闸门① 分布式 —— PASS(带 caveat)
|
||||
|
||||
**方法**:用 2.0 真实跨进程原语 `agentscope.app.message_bus.RedisMessageBus`(Redis Streams),把 3 个 agent 拆到 2 个独立 OS worker 进程,经本地 Redis 接力协作产出一个「游戏点子」产物。拓扑:Coordinator ─seed→[q_plan]→ **W1:Planner** →[q_design]→ **W2:Designer** →[q_name]→ **W1:Namer** →[q_done]→ Coordinator(job 流经 W1→W2→W1 两进程往返)。
|
||||
|
||||
**【实测】证据**(`artifacts/gate1_distributed.json`):
|
||||
- 3 个独立进程:W1 `pid=56350`、W2 `pid=56351`、Coordinator `pid=56353`。
|
||||
- 跨进程铁证:`design_pid(56351) ≠ plan_pid(56350)`(Designer 确在另一进程),`plan_pid==name_pid`(Planner/Namer 同进程)。
|
||||
- 协作产物自洽、层层叠加:concept=「猫咪经营小烘焙坊的休闲挂机游戏」→ mechanic=「顾客带来新配方,猫咪用心爱点心交换解锁」→ title=「Feline Bakes」。
|
||||
|
||||
**复现**:
|
||||
```bash
|
||||
export NEWAPI_KEY=<your-key> # 见 README;密钥不入库
|
||||
redis-server --port 6399 --daemonize yes --dir .redis-data --save "" --appendonly no
|
||||
.venv/bin/python src/gate1_distributed.py worker W1 & # 进程1:Planner+Namer
|
||||
.venv/bin/python src/gate1_distributed.py worker W2 & # 进程2:Designer
|
||||
.venv/bin/python src/gate1_distributed.py coordinator # 播种+收口+判定
|
||||
```
|
||||
|
||||
**关键坑/形态**:
|
||||
- **【取证】1.x 分布式 API 整段删除**:grep 安装源码,`to_dist/RpcAgent/AgentServer/launch_server/rpc_meta` 零命中;无 `agentscope[distribute]` extra;无 `MsgHub`/`SequentialPipeline`。**别移植任何 1.x 分布式代码,导入即炸。**
|
||||
- **【实测】跨进程要 `agentscope[service,storage]`**(FastAPI+redis),非 base。`RedisMessageBus` 是 **async 上下文管理器**(`async with ... as bus`),`_client` 在 `__aenter__` 才建,直接调方法报 `NoneType`。
|
||||
- **【实测】`queue_drain` 是 `xrange`+`xdel` 两步、非原子**:单队列单消费者安全(本 demo 即如此);若多 worker 抢同一队列需自加锁,否则可能重复消费。
|
||||
- **【取证·未亲测】官方整机多 worker 路径不成熟**:维护者 issue **#1722** 自承 agent service 单进程、SessionManager 等需改 DB 后端才能横向扩展;**#1868(2026-06-13 新开)** 多实例共享一 Redis 时,一次 wake-up 会让同一 session 跑 N 次(重复执行)。**→ 这是 L3 的头号盯防项,正式上多机前必须复测/等修复。**
|
||||
|
||||
> 判定逻辑:「≥2 worker 进程 + 2~3 agent 协作」本机**实测跑通** → 闸门①**PASS**,§5-④ 不触发。但「PASS」是靠**原语自拼**,不是框架原生 turnkey;framework 原生 serving 多 worker 仍 Beta/有 bug,作为 caveat 写死。
|
||||
|
||||
### 闸门② 持久化/恢复 —— PASS
|
||||
|
||||
**方法**:2.0.1 真实机制(已读源码验证)= `agent.state`(`AgentState` pydantic:含 `context` 对话历史、`cur_iter` 等)→ `model_dump_json()` 落 Redis;恢复 = Redis 读回 → `AgentState.model_validate_json()` → `Agent(..., state=loaded)` 续跑。等价于 app 层 `RedisStorage`(SessionRecord 内嵌 AgentState)的「每轮落盘」,但剥掉 FastAPI 整机使证据最小可审计。
|
||||
|
||||
**【实测】证据**(`artifacts/gate2_persist.json`),两种硬崩模式都过:
|
||||
- **场景A(os._exit 硬退出)**:进程A 注入 3 条记忆、逐轮落 Redis(快照 2257B→3100B),`os._exit(137)` 无清理硬崩;全新进程B 仅靠 Redis 恢复,复述 **3/3**(Alice/Zephyr/dark)。
|
||||
- **场景B(真 `kill -9`)**:worker(pid 56223)落盘 3 轮后阻塞,外部 `kill -9` 真 SIGKILL;全新进程恢复 **8 条上下文**(含 reasoning 块),复述 **3/3** 无损。
|
||||
- **per-turn 边界佐证**:故意只落 2 轮即崩,恢复后正确复述前 2 条、并正确回答「无 UI 偏好记录」——证明**崩溃前已落盘的轮无损、未落盘的轮干净丢失**(正是 per-turn checkpoint 应有语义)。
|
||||
|
||||
**复现**:
|
||||
```bash
|
||||
.venv/bin/python src/gate2_persist.py run1 --crash-after 3 # 注入+落盘+os._exit 硬崩
|
||||
.venv/bin/python src/gate2_persist.py run2 # 新进程恢复,判 PASS/FAIL
|
||||
# 真 kill -9 版:python src/gate2_persist.py block & ; kill -9 <pid> ; python src/gate2_persist.py run2
|
||||
```
|
||||
|
||||
**关键坑**:
|
||||
- **【取证】无 `StateModule`/`register_state`/`state_dict`/`SessionBase`**——那是 1.x / AgentScope-Java 命名;§4 表述用了旧名,2.0.1 实物是 `AgentState`+`RedisStorage`。
|
||||
- **【取证】恢复是 per-turn,非字节级**:turn 跑到一半被杀,该 turn 从上轮快照**重跑**,半成品不保;工具对工作区的副作用(写文件)不回滚——**工具须设计成可重入/幂等**(与我们 §2 验证门子进程 CLI 形态相容)。
|
||||
- **【取证·未亲测】开 context 压缩时 issue #1624**:`RedisMemory._compressed_summary` 只在内存、不写 Redis → 重启丢失;短任务不触发,长会话须留意。
|
||||
|
||||
### 闸门③ L1 退化开销 —— PASS(§5-⑤ 未被迫触发)
|
||||
|
||||
**方法**:同一最小 LLM 调用,三路对照,各 N=20(warmup=3),模型 `deepseek-v4-flash`(L1 目标)。用 `usage.time`(框架自报 API 耗时)从 wall 中剔除网络耗时,**净测框架本地 CPU 开销**;并比对输入 token 膨胀。
|
||||
|
||||
**【实测】数据**(`artifacts/gate3_overhead.json`,中位数):
|
||||
|
||||
| 路径 | wall 中位 | 本地开销中位 | 输入 token | 输出 token |
|
||||
|---|---|---|---|---|
|
||||
| A 裸 openai SDK(§3 裸路) | 0.934s | 0(基准) | 18 | 28.5 |
|
||||
| B AgentScope 模型客户端 | 0.920s | **22.2ms** | 18 | 28 |
|
||||
| C AgentScope 最小 Agent | 0.936s | **24.4ms** | **29** | 27.5 |
|
||||
|
||||
- **关键差值(Agent vs 裸路)**:输入 token **+11/次**、本地封装开销 **+24.4ms/次**、Agent 单次 reply 内部模型调用 **恰 1 次**(空 toolkit 不发散)。
|
||||
- **解读**:+11 token **几乎全是 system_prompt**(裸路没发 system,Agent 强制注入)——**框架固有 token 税≈0~2**;若 L1 裸路也带等量结构化指令,token 差会归零。+24.4ms 是事件对象/pydantic/`count_tokens` 的纯 CPU,占 ~930ms 单次调用的 **~2.6%**,不构成「吃掉单价」。
|
||||
- **结论**:框架不对 L1 施加致命开销 → **§5-⑤ 不被迫触发**。但 §3「L1 走裸通路」的预设依然正确并被本测佐证:裸路更简单、且省掉这 24ms×海量调用,**L1 维持 §3 旁路不变**(注意:这是 L1 旁路决策的实证,**不等于 AgentScope 出局**;L2/L3 仍用框架原语)。
|
||||
|
||||
**复现**:`.venv/bin/python src/gate3_overhead.py`(`WG1_N=20`)。
|
||||
|
||||
---
|
||||
|
||||
## 2. §5 换轨触发器逐条评估
|
||||
|
||||
| 触发器 | 是否触发 | 依据 |
|
||||
|---|---|---|
|
||||
| ④ A 路 spike 分布式/持久化**验不过**→切 B | **否** | 分布式原语【实测】跑通、持久化恢复【实测】无损,两者均过门 |
|
||||
| ⑤ 任何框架 L1 开销吃掉单价 → L1 退 §3 裸路 | **未被迫触发**(但 §3 裸路本就保留) | 框架开销 +24ms/+11tok【实测】不致命;§3 旁路维持 |
|
||||
| ①②③(许可收紧 / 自建 Server / 出生产案例) | 不适用本 spike | 属 LangGraph 侧或时间触发,非本门范围 |
|
||||
|
||||
**净结论:无任一换轨触发器成立 → 继续 AgentScope。**
|
||||
|
||||
---
|
||||
|
||||
## 3. §2 控制面↔worker 契约形状(durable 产物)
|
||||
|
||||
本 spike 实证了 §2-⑤「框架躲在 worker 进程内、Java 控制面零感知、换轨零改动」的**可成立性**:三闸门里 AgentScope 全部活在 Python worker 进程内,对外只暴露「下发 job / 回调结果」这层稳定契约。建议契约最小形状(REST 下发 + 队列回调,二选一或并用):
|
||||
|
||||
```
|
||||
# 下发(Java 控制面 → Python worker):POST /worker/jobs 或 入队 jobs:{tier}
|
||||
{ "job_id":"uuid", "tier":"L1|L2|L3", "kind":"generate|design|review|...",
|
||||
"input":{...业务输入...}, "callback":{"type":"queue|http","target":"..."},
|
||||
"idempotency_key":"uuid", "deadline_ms":120000 }
|
||||
|
||||
# 回调(worker → 控制面):POST {callback} 或 入队 results:{job_id}
|
||||
{ "job_id":"uuid", "status":"succeeded|failed|partial",
|
||||
"output":{...结构化产物 / 验证门 pass-fail JSON...},
|
||||
"usage":{"input_tokens":..,"output_tokens":..,"model":".."},
|
||||
"error":{"code":"..","retryable":true}, "worker":{"framework":"agentscope-2.0.1"} }
|
||||
```
|
||||
|
||||
**换轨零改动性**:Java 侧只认上面两个 JSON;worker 内部把 AgentScope 换成 LangGraph,**契约字节不变**。`worker.framework` 字段仅作可观测标记。本 spike 的 `gate1_distributed.py` 已示范 worker 侧「队列接力 + 框架内编排」的形态,可直接抽象为该契约的 worker 实现骨架。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键坑总表(给主 agent / 创始人少踩半天)
|
||||
|
||||
1. **【实测·最坑】本机代理吃掉所有 SDK 调用**:本机 `HTTP_PROXY/HTTPS_PROXY=127.0.0.1:7897`(clash 类),`NO_PROXY` 未含 Tailscale 网关 → httpx/openai(含 AgentScope)默认 `trust_env=True` 把发往 `100.64.0.8` 的请求经代理转发 → **502**;而 curl 只认小写 `http_proxy` 故直连成功,制造「curl 通、SDK 全挂」的假象,极易误判成「网关坏/框架坏」。**修复**:`NO_PROXY` 并入网关 host(`src/_common.py` 已内置)。**网关本身一直健康。**
|
||||
2. **【实测】new-api 网关对便宜模型有偶发 502 突发**(数秒级 burst,代理坑排除后仍偶发):worker→模型出口必须带重试/超时/幂等(AgentScope `max_retries` 默认 3,实测自动重试生效)——印证 §2 对外部调用的容错要求。
|
||||
3. **【取证】2.0 是对仅几个月大的 1.x 的破坏式重写**:`ReActAgent` 类没了(统一为 `Agent`);模型要 `OpenAICredential` 对象(`base_url` 在 credential 上,非 1.x 的 `client_args`);hooks→middleware;memory 独立模块、rag/evaluate/tts/realtime 被临时移除/降级。**凭记忆/旧博客写 2.0 代码必炸,锁版本盯 changelog。**
|
||||
4. **【实测】reasoning 模型(deepseek-v4-flash)**:小 `max_tokens` 会被 reasoning 烧光、content 空;`MiniMax-M2` 则直出内容。基准测试已用「净本地开销 + token 膨胀」绕开 reasoning 方差。
|
||||
5. **【取证·未亲测·须复检】影响本任务的 OPEN bug**(GitHub `agentscope-ai/agentscope`,2026-06-13 快照):#1722 单进程不可横扩、#1868 多实例重复执行、#1860 流式零块时核心循环 None 崩、#1624 压缩摘要不落 Redis。**正式上多机/长会话前请逐条复检是否已在 2.0.2+ 修复。**
|
||||
|
||||
---
|
||||
|
||||
## 5. 早期采用者风险与版本锁(verdict 输入)
|
||||
|
||||
- **【取证】版本时间线**:v2.0.0=2026-05-25、v2.0.1=2026-06-05(今天起算 ~8 天);PyPI 分类仍是 **`Development Status :: 4 - Beta`**(README 却宣称 production-ready——**信 Beta 分类**);11 天内 ~25 个 PR,含破坏性改名,**API 面高频抖动**。
|
||||
- **【取证】仓库迁移**:`modelscope/agentscope` → `github.com/agentscope-ai/agentscope`;文档用 `docs.agentscope.io/v2`(旧 `doc.agentscope.io` 是 1.0)。
|
||||
- **【实测】退出成本确低**:我们这套 = 普通 Python + `AgentState` 字典 + 薄 worker 编排;换框架只动 worker 内部,§3 契约不变 → 印证 §5-A「风险敞口有界」。
|
||||
- **【实测】装配成本极低**:`uv pip install agentscope==2.0.1` 解析 87 包 5.82s、装机 ~9s;+`[service,storage]` 5 包再 4s。
|
||||
|
||||
---
|
||||
|
||||
## 6. 给创始人 / 主 agent 的建议(可执行)
|
||||
|
||||
1. **继续 AgentScope 2.x**,**锁死 2.0.1**,建 changelog/issue 盯防清单(尤其 #1722/#1868)。
|
||||
2. **L1 维持 §3 裸通路**(本测佐证其更省更简),框架从 L2 起重度介入。
|
||||
3. **L3 多机多 worker 暂缓押注**:framework 原生 serving 多 worker 未成熟;若近期就要多机,先用本 spike 的 `RedisMessageBus` 自拼骨架(原语可用),或等 2.0.2+ 修 #1868 后复测。
|
||||
4. **把本 spike 的「代理旁路 / 容错重试 / worker 边界契约」沉淀进 `.agents/`**,避免主弧再踩。
|
||||
5. **再评闸门**(任一触即重审):#1868 等多 worker bug 半年内不修、或 2.0.x 持续破坏式抖动伤及我们 worker、或许可/生态出现 §5-①②③ 情形。
|
||||
|
||||
---
|
||||
|
||||
## 附:复现环境与版本(锁)
|
||||
|
||||
- 机器:Apple M1 Pro 32GB · macOS 25.5 · Python 3.12.13 · uv 0.4.30。
|
||||
- 模型出口:new-api 网关 `http://100.64.0.8:3000/v1`(OpenAI 兼容,Tailscale),模型 `deepseek-v4-flash`(L1)。
|
||||
- 锁版本(`requirements.lock` 全量):`agentscope==2.0.1` · `openai==2.41.1` · `redis==8.0.0`(py)/ `redis-server v8.8.0` · `fastapi==0.136.3` · `pydantic==2.13.4` · `httpx==0.28.1`。
|
||||
- 产物:`artifacts/gate{1,2,3}_*.json`(本机实测原始数据)。
|
||||
20
spikes/agentscope-wg1/artifacts/gate1_distributed.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"result": {
|
||||
"seed": "a cozy idle game about a cat running a tiny bakery",
|
||||
"concept": "A cozy idle game where a charming cat bakes and decorates pastries in a tiny bakery, growing its reputation while you relax and watch the treats pile up.",
|
||||
"plan_pid": 56350,
|
||||
"mechanic": "Customers occasionally bring new recipes and decorations, which the cat can learn from but must trade a few of its favorite treats to unlock.",
|
||||
"design_pid": 56351,
|
||||
"name": "Feline Bakes",
|
||||
"name_pid": 56350
|
||||
},
|
||||
"pids": {
|
||||
"plan_pid": 56350,
|
||||
"design_pid": 56351,
|
||||
"name_pid": 56350,
|
||||
"coordinator_pid": 56353
|
||||
},
|
||||
"cross_process": true,
|
||||
"same_w1": true,
|
||||
"pass": true
|
||||
}
|
||||
17
spikes/agentscope-wg1/artifacts/gate2_persist.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"recovered_context_msgs": 8,
|
||||
"cur_iter": 0,
|
||||
"question": "Recall exactly the facts I told you earlier: my name, my project codename, and my UI preference. Answer in one short line.",
|
||||
"answer": "Your name is Alice, your project is codenamed Zephyr, and you prefer dark mode UI.",
|
||||
"expect": [
|
||||
"alice",
|
||||
"zephyr",
|
||||
"dark"
|
||||
],
|
||||
"hit": [
|
||||
"alice",
|
||||
"zephyr",
|
||||
"dark"
|
||||
],
|
||||
"pass": true
|
||||
}
|
||||
83
spikes/agentscope-wg1/artifacts/gate3_overhead.json
Normal file
@ -0,0 +1,83 @@
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"N": 20,
|
||||
"prompt": "What is 7 plus 6? Reply with just the number.",
|
||||
"sys_prompt": "You are a helpful assistant. Answer concisely.",
|
||||
"max_tokens": 64,
|
||||
"rows": {
|
||||
"A_bare_openai": {
|
||||
"wall": {
|
||||
"median": 0.9341,
|
||||
"p90": 1.1828,
|
||||
"mean": 0.9607,
|
||||
"n": 20
|
||||
},
|
||||
"api": {
|
||||
"median": 0.9341,
|
||||
"p90": 1.1828,
|
||||
"mean": 0.9607,
|
||||
"n": 20
|
||||
},
|
||||
"local": {
|
||||
"median": 0.0,
|
||||
"p90": 0.0,
|
||||
"mean": 0.0,
|
||||
"n": 20
|
||||
},
|
||||
"in_tok": 18.0,
|
||||
"out_tok": 28.5
|
||||
},
|
||||
"B_agentscope_model": {
|
||||
"wall": {
|
||||
"median": 0.9201,
|
||||
"p90": 1.1217,
|
||||
"mean": 1.4589,
|
||||
"n": 20
|
||||
},
|
||||
"api": {
|
||||
"median": 0.8978,
|
||||
"p90": 1.0999,
|
||||
"mean": 1.4368,
|
||||
"n": 20
|
||||
},
|
||||
"local": {
|
||||
"median": 0.0222,
|
||||
"p90": 0.0242,
|
||||
"mean": 0.0221,
|
||||
"n": 20
|
||||
},
|
||||
"in_tok": 18.0,
|
||||
"out_tok": 28.0
|
||||
},
|
||||
"C_agentscope_agent": {
|
||||
"wall": {
|
||||
"median": 0.9358,
|
||||
"p90": 1.1289,
|
||||
"mean": 0.9471,
|
||||
"n": 20
|
||||
},
|
||||
"api": {
|
||||
"median": 0.9128,
|
||||
"p90": 1.1026,
|
||||
"mean": 0.9214,
|
||||
"n": 20
|
||||
},
|
||||
"local": {
|
||||
"median": 0.0244,
|
||||
"p90": 0.0261,
|
||||
"mean": 0.0258,
|
||||
"n": 20
|
||||
},
|
||||
"in_tok": 29.0,
|
||||
"out_tok": 27.5,
|
||||
"model_calls_per_reply": {
|
||||
"median": 1.0,
|
||||
"p90": 1,
|
||||
"mean": 1.0,
|
||||
"n": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"input_token_inflation": 11.0,
|
||||
"local_overhead_ms": 24.4
|
||||
}
|
||||
92
spikes/agentscope-wg1/requirements.lock
Normal file
@ -0,0 +1,92 @@
|
||||
ag-ui-protocol==0.1.19
|
||||
agentscope==2.0.1
|
||||
aiofiles==25.1.0
|
||||
aiohappyeyeballs==2.6.2
|
||||
aiohttp==3.14.1
|
||||
aioitertools==0.13.0
|
||||
aiosignal==1.4.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anthropic==0.109.1
|
||||
anyio==4.13.0
|
||||
apscheduler==3.11.2
|
||||
attrs==26.1.0
|
||||
bidict==0.23.1
|
||||
cached-property==2.0.1
|
||||
certifi==2026.5.20
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.7
|
||||
click==8.4.1
|
||||
cryptography==49.0.0
|
||||
dashscope==1.25.21
|
||||
distro==1.9.0
|
||||
docstring-parser==0.18.0
|
||||
fastapi==0.136.3
|
||||
filetype==1.2.0
|
||||
frozenlist==1.8.0
|
||||
googleapis-common-protos==1.75.0
|
||||
grpcio==1.81.1
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
httpx-sse==0.4.3
|
||||
idna==3.18
|
||||
jinja2==3.1.6
|
||||
jiter==0.15.0
|
||||
json-repair==0.60.1
|
||||
json5==0.14.0
|
||||
jsonschema==4.26.0
|
||||
jsonschema-specifications==2025.9.1
|
||||
markdown-it-py==4.2.0
|
||||
markupsafe==3.0.3
|
||||
mcp==1.27.2
|
||||
mdurl==0.1.2
|
||||
multidict==6.7.1
|
||||
numpy==2.4.6
|
||||
openai==2.41.1
|
||||
opentelemetry-api==1.42.1
|
||||
opentelemetry-exporter-otlp==1.42.1
|
||||
opentelemetry-exporter-otlp-proto-common==1.42.1
|
||||
opentelemetry-exporter-otlp-proto-grpc==1.42.1
|
||||
opentelemetry-exporter-otlp-proto-http==1.42.1
|
||||
opentelemetry-proto==1.42.1
|
||||
opentelemetry-sdk==1.42.1
|
||||
opentelemetry-semantic-conventions==0.63b1
|
||||
propcache==0.5.2
|
||||
protobuf==6.33.6
|
||||
pycparser==3.0
|
||||
pydantic==2.13.4
|
||||
pydantic-core==2.46.4
|
||||
pydantic-settings==2.14.1
|
||||
pygments==2.20.0
|
||||
pyjwt==2.13.0
|
||||
python-datauri==3.0.2
|
||||
python-dotenv==1.2.2
|
||||
python-engineio==4.13.2
|
||||
python-frontmatter==1.3.0
|
||||
python-multipart==0.0.32
|
||||
python-socketio==5.16.2
|
||||
pyyaml==6.0.3
|
||||
redis==8.0.0
|
||||
referencing==0.37.0
|
||||
requests==2.34.2
|
||||
rich==15.0.0
|
||||
rpds-py==2026.5.1
|
||||
shellingham==1.5.4
|
||||
shortuuid==1.0.13
|
||||
simple-websocket==1.1.0
|
||||
sniffio==1.3.1
|
||||
sse-starlette==3.4.4
|
||||
starlette==1.3.1
|
||||
tqdm==4.68.2
|
||||
tree-sitter==0.25.2
|
||||
tree-sitter-bash==0.25.1
|
||||
typer==0.26.7
|
||||
typing-extensions==4.15.0
|
||||
typing-inspection==0.4.2
|
||||
tzlocal==5.3.1
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.49.0
|
||||
websocket-client==1.9.0
|
||||
wsproto==1.3.2
|
||||
yarl==1.24.2
|
||||
65
spikes/agentscope-wg1/src/_common.py
Normal file
@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""WG1 spike 公共工具。
|
||||
|
||||
职责:统一构建「指向 new-api 网关的 OpenAI 兼容模型客户端」。
|
||||
契约(§2.1):框架只经 OpenAI 兼容网关出口,绝不直连模型厂商 SDK;
|
||||
密钥只走环境变量 NEWAPI_KEY,绝不写入任何提交物。
|
||||
已验证:AgentScope 2.0.1,base_url 落在 OpenAICredential 上;UserMsg 需 name。
|
||||
"""
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# 网关与模型默认值(均可被环境变量覆盖,便于复现/换模型)
|
||||
BASE_URL = os.environ.get("NEWAPI_BASE_URL", "http://100.64.0.8:3000/v1")
|
||||
L1_MODEL = os.environ.get("WG1_MODEL", "deepseek-v4-flash") # L1 海量便宜档目标模型
|
||||
|
||||
# —— 关键环境坑修正:代理旁路(已实证)——
|
||||
# 本机设了 HTTP(S)_PROXY=127.0.0.1:7897(clash 类本地代理),但 NO_PROXY 未含 Tailscale 网关。
|
||||
# httpx/openai 默认 trust_env=True → 把发往 100.64.0.8 的请求塞进代理转发 → 网关返回 502;
|
||||
# 而 curl 只认小写 http_proxy,故直连成功——造成「curl 通、SDK(含 AgentScope)不通」的假象。
|
||||
# 此处把网关 host 并入 NO_PROXY,令所有 httpx/openai 客户端直连网关(= §2.1 唯一模型出口)。
|
||||
_GW_HOST = urlparse(BASE_URL).hostname or "100.64.0.8"
|
||||
for _k in ("NO_PROXY", "no_proxy"):
|
||||
_cur = os.environ.get(_k, "")
|
||||
if _GW_HOST not in _cur:
|
||||
os.environ[_k] = f"{_cur},{_GW_HOST}" if _cur else _GW_HOST
|
||||
|
||||
from agentscope.credential import OpenAICredential
|
||||
from agentscope.model import OpenAIChatModel
|
||||
from agentscope.message import UserMsg
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
"""读取密钥(不入库,复现前请先 export NEWAPI_KEY=...)。"""
|
||||
key = os.environ.get("NEWAPI_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("缺少环境变量 NEWAPI_KEY:密钥不入库,复现前请先 export NEWAPI_KEY=...")
|
||||
return key
|
||||
|
||||
|
||||
def build_model(model_name: str = None, stream: bool = False, max_retries: int = 0) -> OpenAIChatModel:
|
||||
"""构建 AgentScope 2.0.1 OpenAI 兼容模型客户端。
|
||||
|
||||
- base_url 落在 credential 上(2.0.1 正确姿势,非 1.x 的 client_args);
|
||||
- stream=False:__call__ 直接返回单个 ChatResponse,便于测量;
|
||||
- max_retries=0:关闭内层重试,避免重试污染延迟测量。
|
||||
"""
|
||||
return OpenAIChatModel(
|
||||
credential=OpenAICredential(api_key=get_api_key(), base_url=BASE_URL),
|
||||
model=model_name or L1_MODEL,
|
||||
stream=stream,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
|
||||
|
||||
def user_msg(text: str) -> UserMsg:
|
||||
"""构建用户消息(2.0.1:UserMsg 必须带 name;字符串 content 会被自动包成 TextBlock)。"""
|
||||
return UserMsg(name="user", content=text)
|
||||
|
||||
|
||||
def text_of(resp) -> str:
|
||||
"""从 ChatResponse(dict-like)抽取纯文本(content 是 block 列表,非字符串)。"""
|
||||
blocks = resp.get("content") if hasattr(resp, "get") else getattr(resp, "content", [])
|
||||
return "".join(
|
||||
b.get("text", "") for b in (blocks or []) if isinstance(b, dict) and b.get("type") == "text"
|
||||
)
|
||||
141
spikes/agentscope-wg1/src/gate1_distributed.py
Normal file
@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""闸门1:分布式实测(验不过命中 §5 触发④)。
|
||||
|
||||
关键事实(已实证):AgentScope 2.0.1 删除了 1.x 的 RpcAgent/to_dist 进程级 actor 分布式;
|
||||
2.0 跨进程协作的真实原语 = app 层 RedisMessageBus(Redis Streams 队列 + pub/sub + 分布式锁)。
|
||||
本闸门用该原语,把 3 个 agent 拆到 2 个 OS 进程,经本地 Redis 协作产出一个产物,
|
||||
证明「≥2 worker 进程 + 多 agent 协作」可跑通,并记录其真实形态与坑。
|
||||
|
||||
拓扑(job 流经 W1→W2→W1 两进程往返,3 agent 协作):
|
||||
Coordinator ─seed─▶ [q_plan] ─▶ W1:Planner ─▶ [q_design] ─▶ W2:Designer
|
||||
Coordinator ◀─done─ [q_done] ◀─ W1:Namer ◀─ [q_name] ◀──────────┘
|
||||
|
||||
用法:
|
||||
python gate1_distributed.py worker W1 # Planner + Namer(进程1)
|
||||
python gate1_distributed.py worker W2 # Designer(进程2)
|
||||
python gate1_distributed.py coordinator # 播种 + 收口 + 判定 + 置停
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import redis
|
||||
|
||||
import _common
|
||||
from agentscope.agent import Agent
|
||||
from agentscope.app.message_bus._redis_message_bus import RedisMessageBus
|
||||
|
||||
HOST = os.environ.get("WG1_REDIS_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("WG1_REDIS_PORT", "6399"))
|
||||
Q_PLAN, Q_DESIGN, Q_NAME, Q_DONE = "wg1:g1:q_plan", "wg1:g1:q_design", "wg1:g1:q_name", "wg1:g1:q_done"
|
||||
STOP = "wg1:g1:stop"
|
||||
SEED = os.environ.get("WG1_SEED", "a cozy idle game about a cat running a tiny bakery")
|
||||
|
||||
PROMPTS = {
|
||||
"planner": "You are a game Planner. Given a seed idea, output ONE concise sentence describing the game concept. Output only the sentence.",
|
||||
"designer": "You are a game Designer. Given a game concept, add ONE core gameplay mechanic in one concise sentence. Output only the sentence.",
|
||||
"namer": "You are a game Namer. Given a concept and mechanic, output a single catchy game title of 2-4 words. Output only the title.",
|
||||
}
|
||||
|
||||
|
||||
async def agent_say(role, user_text):
|
||||
"""构造一个一次性 agent 跑单轮(max_retries=3 容忍网关瞬时抖动)。"""
|
||||
a = Agent(name=role, system_prompt=PROMPTS[role], model=_common.build_model(max_retries=3))
|
||||
r = await a.reply(_common.user_msg(user_text))
|
||||
return r.get_text_content().strip()
|
||||
|
||||
|
||||
def rflag():
|
||||
return redis.Redis(host=HOST, port=PORT, decode_responses=True)
|
||||
|
||||
|
||||
async def worker(role):
|
||||
"""worker 进程:轮询自己负责的输入队列,跑 agent,推到下一队列。"""
|
||||
pid = os.getpid()
|
||||
fr = rflag()
|
||||
if role == "W1":
|
||||
handlers = [(Q_PLAN, "planner", Q_DESIGN), (Q_NAME, "namer", Q_DONE)]
|
||||
elif role == "W2":
|
||||
handlers = [(Q_DESIGN, "designer", Q_NAME)]
|
||||
else:
|
||||
raise SystemExit(f"未知角色 {role}")
|
||||
print(f"[worker {role} pid={pid}] 上线,负责 {[h[1] for h in handlers]}", flush=True)
|
||||
deadline = time.time() + 120
|
||||
async with RedisMessageBus(host=HOST, port=PORT) as bus:
|
||||
while time.time() < deadline:
|
||||
if fr.get(STOP) == "1":
|
||||
break
|
||||
did = False
|
||||
for in_q, role_name, out_q in handlers:
|
||||
items = await bus.queue_drain(in_q, max_count=1)
|
||||
for _id, payload in items:
|
||||
did = True
|
||||
if role_name == "planner":
|
||||
out = await agent_say("planner", f"Seed: {payload['seed']}")
|
||||
await bus.queue_push(out_q, {**payload, "concept": out, "plan_pid": pid})
|
||||
print(f"[{role} pid={pid}] Planner→concept: {out!r}", flush=True)
|
||||
elif role_name == "designer":
|
||||
out = await agent_say("designer", f"Concept: {payload['concept']}")
|
||||
await bus.queue_push(out_q, {**payload, "mechanic": out, "design_pid": pid})
|
||||
print(f"[{role} pid={pid}] Designer→mechanic: {out!r}", flush=True)
|
||||
elif role_name == "namer":
|
||||
out = await agent_say(
|
||||
"namer", f"Concept: {payload['concept']}\nMechanic: {payload['mechanic']}")
|
||||
await bus.queue_push(out_q, {**payload, "name": out, "name_pid": pid})
|
||||
print(f"[{role} pid={pid}] Namer→title: {out!r}", flush=True)
|
||||
if not did:
|
||||
await asyncio.sleep(0.3)
|
||||
print(f"[worker {role} pid={pid}] 退出", flush=True)
|
||||
|
||||
|
||||
async def coordinator():
|
||||
pid = os.getpid()
|
||||
fr = rflag()
|
||||
async with RedisMessageBus(host=HOST, port=PORT) as bus:
|
||||
for q in (Q_PLAN, Q_DESIGN, Q_NAME, Q_DONE):
|
||||
await bus.queue_delete(q)
|
||||
fr.delete(STOP)
|
||||
print(f"[coordinator pid={pid}] 播种 seed→q_plan: {SEED!r}", flush=True)
|
||||
await bus.queue_push(Q_PLAN, {"seed": SEED})
|
||||
result, deadline = None, time.time() + 100
|
||||
while time.time() < deadline:
|
||||
items = await bus.queue_drain(Q_DONE, max_count=1)
|
||||
if items:
|
||||
result = items[0][1]
|
||||
break
|
||||
await asyncio.sleep(0.4)
|
||||
fr.set(STOP, "1") # 通知 worker 收工
|
||||
if not result:
|
||||
print("[coordinator] FAIL:超时未收到 q_done 结果")
|
||||
return 1
|
||||
cross = result.get("design_pid") != result.get("plan_pid") # Designer 在另一进程
|
||||
same_w1 = result.get("plan_pid") == result.get("name_pid") # Planner/Namer 同进程
|
||||
pids = {k: result.get(k) for k in ("plan_pid", "design_pid", "name_pid")}
|
||||
pids["coordinator_pid"] = pid
|
||||
print("\n==== 3-agent 跨进程协作产物 ====")
|
||||
print("seed :", result.get("seed"))
|
||||
print("concept :", result.get("concept"), f"(W1 pid={result.get('plan_pid')})")
|
||||
print("mechanic :", result.get("mechanic"), f"(W2 pid={result.get('design_pid')})")
|
||||
print("title :", result.get("name"), f"(W1 pid={result.get('name_pid')})")
|
||||
print("PIDs :", pids)
|
||||
print(f"跨进程证明:design_pid≠plan_pid = {cross} | Planner/Namer 同进程 = {same_w1}")
|
||||
ok = bool(result.get("concept") and result.get("mechanic") and result.get("name") and cross)
|
||||
print("闸门1 判定:",
|
||||
"PASS:3 agent 跨 ≥2 OS 进程经 RedisMessageBus 协作产出" if ok else "FAIL")
|
||||
os.makedirs("artifacts", exist_ok=True)
|
||||
json.dump({"result": result, "pids": pids, "cross_process": cross, "same_w1": same_w1, "pass": ok},
|
||||
open("artifacts/gate1_distributed.json", "w"), ensure_ascii=False, indent=2)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "coordinator"
|
||||
if cmd == "worker":
|
||||
asyncio.run(worker(sys.argv[2]))
|
||||
elif cmd == "coordinator":
|
||||
sys.exit(asyncio.run(coordinator()))
|
||||
else:
|
||||
print("usage: gate1_distributed.py [worker W1|worker W2|coordinator]")
|
||||
sys.exit(2)
|
||||
128
spikes/agentscope-wg1/src/gate2_persist.py
Normal file
@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""闸门2:持久化/恢复实测(验不过命中 §5 触发④)。
|
||||
|
||||
已验证机制(AgentScope 2.0.1):
|
||||
- agent.state 是 AgentState(pydantic),持有 context(list[Msg] 对话/工具历史)、cur_iter 等编排执行态;
|
||||
- 持久化 = state.model_dump_json() → 存 Redis;
|
||||
- 恢复 = Redis 读回 → AgentState.model_validate_json() → Agent(..., state=loaded) 续跑。
|
||||
这等价于 AgentScope app 层 RedisStorage(SessionRecord 内嵌 AgentState)的「每轮落盘」,
|
||||
但去掉 FastAPI 整机,使「中断→恢复」证据最小、最透明、可审计。
|
||||
|
||||
崩溃模型:run1 跑若干轮、每轮落 Redis 后用 os._exit(137) 硬退出(无任何优雅清理,等价 SIGKILL);
|
||||
run2 是全新 OS 进程,只能从 Redis 恢复——若能答出崩溃前注入的记忆,即证明「状态跨进程无损」。
|
||||
|
||||
用法:
|
||||
python gate2_persist.py run1 [--crash-after N] # 跑前 N 轮,逐轮落 Redis,然后硬崩
|
||||
python gate2_persist.py run2 # 新进程:从 Redis 恢复,考记忆,判 PASS/FAIL
|
||||
python gate2_persist.py block # 跑 2 轮落盘后阻塞,供外部 kill -9 真硬杀
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import redis # 来自 agentscope[storage]
|
||||
|
||||
import _common
|
||||
from agentscope.agent import Agent
|
||||
from agentscope.state import AgentState
|
||||
|
||||
REDIS_HOST = os.environ.get("WG1_REDIS_HOST", "127.0.0.1")
|
||||
REDIS_PORT = int(os.environ.get("WG1_REDIS_PORT", "6399"))
|
||||
KEY = os.environ.get("WG1_SESSION_KEY", "wg1:gate2:session")
|
||||
|
||||
SYS = ("You are a helpful assistant. Carefully remember every fact the user tells you, "
|
||||
"and recall them exactly when asked.")
|
||||
# 前几轮注入记忆,崩溃后最后一轮考记忆
|
||||
TURNS_IN = [
|
||||
"My name is Alice.",
|
||||
"My project is codenamed Zephyr.",
|
||||
"I prefer dark mode UI.",
|
||||
]
|
||||
QUESTION = ("Recall exactly the facts I told you earlier: my name, my project codename, "
|
||||
"and my UI preference. Answer in one short line.")
|
||||
EXPECT = ["alice", "zephyr", "dark"] # 期望恢复后能复述的关键记忆
|
||||
|
||||
|
||||
def rconn():
|
||||
return redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0, decode_responses=True)
|
||||
|
||||
|
||||
def persist(r, state: AgentState):
|
||||
"""把 AgentState 序列化落 Redis(单键即一条 session 快照)。"""
|
||||
r.set(KEY, state.model_dump_json())
|
||||
|
||||
|
||||
def load(r):
|
||||
raw = r.get(KEY)
|
||||
return AgentState.model_validate_json(raw) if raw else None
|
||||
|
||||
|
||||
async def run1(crash_after=2):
|
||||
r = rconn()
|
||||
r.delete(KEY)
|
||||
agent = Agent(name="mem", system_prompt=SYS, model=_common.build_model())
|
||||
for i in range(crash_after):
|
||||
msg = TURNS_IN[i]
|
||||
await agent.reply(_common.user_msg(msg))
|
||||
persist(r, agent.state)
|
||||
print(f"[run1] turn{i+1} 已落 Redis:{msg!r} | context msgs={len(agent.state.context or [])}")
|
||||
snap = r.get(KEY)
|
||||
print(f"[run1] 模拟崩溃:已持久化 {crash_after} 轮,Redis 快照 {len(snap)} bytes,进程硬退出(os._exit 137)")
|
||||
sys.stdout.flush()
|
||||
os._exit(137) # 无优雅清理,等价 SIGKILL 崩溃
|
||||
|
||||
|
||||
async def run2():
|
||||
r = rconn()
|
||||
state = load(r)
|
||||
if state is None:
|
||||
print("[run2] FAIL:Redis 无快照,无法恢复")
|
||||
return 1
|
||||
print(f"[run2] 从 Redis 恢复 AgentState:context msgs={len(state.context or [])}、cur_iter={state.cur_iter}")
|
||||
agent = Agent(name="mem", system_prompt=SYS, model=_common.build_model(), state=state)
|
||||
reply = await agent.reply(_common.user_msg(QUESTION))
|
||||
text = reply.get_text_content()
|
||||
low = text.lower()
|
||||
hit = [e for e in EXPECT if e in low]
|
||||
ok = len(hit) == len(EXPECT)
|
||||
print(f"[run2] 恢复后提问:{QUESTION!r}")
|
||||
print(f"[run2] agent 回答:{text!r}")
|
||||
print(f"[run2] 记忆命中 {len(hit)}/{len(EXPECT)} {hit} → "
|
||||
f"{'PASS:状态跨进程无损恢复' if ok else 'FAIL:记忆缺失'}")
|
||||
os.makedirs("artifacts", exist_ok=True)
|
||||
json.dump({"recovered_context_msgs": len(state.context or []), "cur_iter": state.cur_iter,
|
||||
"question": QUESTION, "answer": text, "expect": EXPECT, "hit": hit, "pass": ok},
|
||||
open("artifacts/gate2_persist.json", "w"), ensure_ascii=False, indent=2)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
async def block():
|
||||
"""跑 2 轮落盘后阻塞,供外部 `kill -9 <pid>` 做真·硬杀演示。"""
|
||||
r = rconn()
|
||||
r.delete(KEY)
|
||||
agent = Agent(name="mem", system_prompt=SYS, model=_common.build_model())
|
||||
for i in range(len(TURNS_IN)):
|
||||
await agent.reply(_common.user_msg(TURNS_IN[i]))
|
||||
persist(r, agent.state)
|
||||
print(f"[block] turn{i+1} 已落 Redis:{TURNS_IN[i]!r}")
|
||||
print(f"[block] PID={os.getpid()} 已落盘 {len(TURNS_IN)} 轮,阻塞等待外部 kill -9 ...")
|
||||
sys.stdout.flush()
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "run1"
|
||||
if cmd == "run1":
|
||||
n = 2
|
||||
if "--crash-after" in sys.argv:
|
||||
n = int(sys.argv[sys.argv.index("--crash-after") + 1])
|
||||
asyncio.run(run1(n))
|
||||
elif cmd == "run2":
|
||||
sys.exit(asyncio.run(run2()))
|
||||
elif cmd == "block":
|
||||
asyncio.run(block())
|
||||
else:
|
||||
print("usage: gate2_persist.py [run1 [--crash-after N] | run2 | block]")
|
||||
sys.exit(2)
|
||||
211
spikes/agentscope-wg1/src/gate3_overhead.py
Normal file
@ -0,0 +1,211 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""闸门3:L1 退化开销实测(§5 触发⑤ 判定)。
|
||||
|
||||
目标:同一最小 LLM 调用,对照三条路径的延迟/token 开销:
|
||||
(A) 裸 openai SDK 直调 new-api(= §3「裸 llm_client」L1 最短路径基线);
|
||||
(B) AgentScope 模型客户端 OpenAIChatModel.__call__(仅模型层,无 agent/图/落盘);
|
||||
(C) AgentScope 最小 Agent.reply(关图义=空 toolkit 单轮、关落盘=无 app/Redis)。
|
||||
|
||||
关键测量:
|
||||
- input_tokens 膨胀:Agent 强制注入 system_prompt + ReAct 脚手架 → L1 海量调用的固定税;
|
||||
- 本地封装开销:local = wall - api_time(api_time 取框架自报 usage.time / openai usage),
|
||||
剔除 LLM 网络耗时后,纯框架 CPU 开销;
|
||||
- 总延迟:中位数/p90(LLM 主导,reasoning 模型有方差,故以 local 与 token 为主信号)。
|
||||
|
||||
结论口径:若 (C) 相对 (A) 的 token 膨胀或本地开销「吃掉便宜档单价」→ 命中 §5-⑤,L1 退 §3 裸通路
|
||||
(注:此为 L1 旁路化决策的实证,不等于 AgentScope 出局;L2/L3 仍用框架原语)。
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
import json
|
||||
|
||||
import openai
|
||||
|
||||
import _common
|
||||
from agentscope.agent import Agent, ReActConfig
|
||||
from agentscope.model import OpenAIChatModel
|
||||
from agentscope.credential import OpenAICredential
|
||||
|
||||
# ---- 实验参数(可被环境变量覆盖)----
|
||||
N = int(os.environ.get("WG1_N", "20")) # 测量轮数
|
||||
WARMUP = int(os.environ.get("WG1_WARMUP", "3")) # 预热轮数(丢弃)
|
||||
MODEL = _common.L1_MODEL
|
||||
PROMPT = "What is 7 plus 6? Reply with just the number." # 固定确定性任务,尽量压低生成方差
|
||||
SYS_PROMPT = "You are a helpful assistant. Answer concisely." # Agent 必填的最小 system_prompt
|
||||
MAX_TOKENS = int(os.environ.get("WG1_MAXTOK", "64"))
|
||||
# 模型优先级:网关某模型瞬时不可用时自动回退到下一个(开销 delta 与模型无关)
|
||||
PRIORITY = os.environ.get(
|
||||
"WG1_MODELS", "deepseek-v4-flash,MiniMax-M2,deepseek-v4-pro,MiniMax-M2.5").split(",")
|
||||
|
||||
|
||||
async def pick_healthy_model():
|
||||
"""运行时挑一个当前健康的模型(每个候选试 3 次),返回首个可用,记录实际所用。"""
|
||||
client = openai.AsyncOpenAI(api_key=_common.get_api_key(), base_url=_common.BASE_URL)
|
||||
for m in PRIORITY:
|
||||
for _ in range(3):
|
||||
try:
|
||||
await client.chat.completions.create(
|
||||
model=m, messages=[{"role": "user", "content": "ping"}],
|
||||
max_tokens=8, temperature=0)
|
||||
return m
|
||||
except Exception:
|
||||
await asyncio.sleep(0.6)
|
||||
raise RuntimeError("无可用模型(全部候选 3 次均失败)")
|
||||
|
||||
|
||||
class _RecordingModel(OpenAIChatModel):
|
||||
"""记录每次模型调用 usage 的子类,用于捕获 Agent 内部真实发出的 token。"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.records = [] # 每次调用的 (input_tokens, output_tokens, api_time)
|
||||
|
||||
async def __call__(self, messages, **kwargs):
|
||||
resp = await super().__call__(messages, **kwargs)
|
||||
# stream=False:resp 为单个 ChatResponse
|
||||
u = resp.get("usage") if hasattr(resp, "get") else getattr(resp, "usage", None)
|
||||
if u is not None:
|
||||
self.records.append((
|
||||
getattr(u, "input_tokens", 0) or 0,
|
||||
getattr(u, "output_tokens", 0) or 0,
|
||||
float(getattr(u, "time", 0.0) or 0.0),
|
||||
))
|
||||
return resp
|
||||
|
||||
|
||||
def _stats(xs):
|
||||
"""中位数/p90/均值,空列表安全。"""
|
||||
if not xs:
|
||||
return {"median": None, "p90": None, "mean": None, "n": 0}
|
||||
s = sorted(xs)
|
||||
p90 = s[min(len(s) - 1, int(round(0.9 * (len(s) - 1))))]
|
||||
return {"median": round(statistics.median(xs), 4), "p90": round(p90, 4),
|
||||
"mean": round(statistics.fmean(xs), 4), "n": len(xs)}
|
||||
|
||||
|
||||
async def measure(make_coro, tries=20, delay=1.0):
|
||||
"""对一次异步调用计时;内部对瞬时网关错误(如 502)重试,仅对成功调用计时,
|
||||
保证 502 抖动既不崩溃测量、也不污染延迟样本。"""
|
||||
last = None
|
||||
for _ in range(tries):
|
||||
try:
|
||||
t = time.perf_counter()
|
||||
r = await make_coro()
|
||||
return time.perf_counter() - t, r
|
||||
except Exception as e: # spike 容错:吞瞬时错误后重试
|
||||
last = e
|
||||
await asyncio.sleep(delay)
|
||||
raise last
|
||||
|
||||
|
||||
async def arm_bare(n):
|
||||
"""(A) 裸 openai SDK 直调 new-api。"""
|
||||
client = openai.AsyncOpenAI(api_key=_common.get_api_key(), base_url=_common.BASE_URL)
|
||||
walls, apis, ins, outs = [], [], [], []
|
||||
for _ in range(n):
|
||||
dt, r = await measure(lambda: client.chat.completions.create(
|
||||
model=MODEL, messages=[{"role": "user", "content": PROMPT}],
|
||||
temperature=0, max_tokens=MAX_TOKENS, stream=False,
|
||||
))
|
||||
walls.append(dt)
|
||||
apis.append(dt) # 裸路无本地封装,api_time≈wall
|
||||
u = r.usage
|
||||
ins.append(u.prompt_tokens); outs.append(u.completion_tokens)
|
||||
return walls, apis, ins, outs
|
||||
|
||||
|
||||
async def arm_model(n):
|
||||
"""(B) AgentScope 模型客户端(仅模型层)。"""
|
||||
model = _common.build_model(model_name=MODEL, stream=False, max_retries=0)
|
||||
walls, apis, ins, outs = [], [], [], []
|
||||
for _ in range(n):
|
||||
dt, resp = await measure(
|
||||
lambda: model([_common.user_msg(PROMPT)], temperature=0, max_tokens=MAX_TOKENS))
|
||||
walls.append(dt)
|
||||
u = resp.get("usage")
|
||||
apis.append(float(getattr(u, "time", dt) or dt))
|
||||
ins.append(getattr(u, "input_tokens", 0)); outs.append(getattr(u, "output_tokens", 0))
|
||||
return walls, apis, ins, outs
|
||||
|
||||
|
||||
async def arm_agent(n):
|
||||
"""(C) AgentScope 最小 Agent(空 toolkit 单轮、无 app/Redis = 关图关落盘)。"""
|
||||
model = _RecordingModel(
|
||||
credential=OpenAICredential(api_key=_common.get_api_key(), base_url=_common.BASE_URL),
|
||||
model=MODEL, stream=False, max_retries=0,
|
||||
)
|
||||
walls, apis, ins, outs, iters = [], [], [], [], []
|
||||
for _ in range(n):
|
||||
before = len(model.records)
|
||||
|
||||
async def _one():
|
||||
# 每次重试都用全新 Agent,避免失败重试污染单 agent 的 context
|
||||
agent = Agent(name="bench", system_prompt=SYS_PROMPT, model=model,
|
||||
react_config=ReActConfig(max_iters=5))
|
||||
return await agent.reply(_common.user_msg(PROMPT))
|
||||
|
||||
dt, _ = await measure(_one)
|
||||
calls = model.records[before:]
|
||||
walls.append(dt)
|
||||
apis.append(sum(c[2] for c in calls))
|
||||
ins.append(sum(c[0] for c in calls)); outs.append(sum(c[1] for c in calls))
|
||||
iters.append(len(calls)) # agent 内部模型调用次数(理想=1)
|
||||
return walls, apis, ins, outs, iters
|
||||
|
||||
|
||||
async def main():
|
||||
global MODEL
|
||||
MODEL = await pick_healthy_model() # 运行时选定健康模型
|
||||
print(f"# 闸门3 L1 开销 model={MODEL} N={N}(warmup={WARMUP}) max_tokens={MAX_TOKENS}")
|
||||
print(f"# prompt={PROMPT!r}")
|
||||
|
||||
# 预热(同时验证三条路径 live 可用)
|
||||
await arm_bare(WARMUP); await arm_model(WARMUP); await arm_agent(WARMUP)
|
||||
|
||||
bw, ba, bi, bo = await arm_bare(N)
|
||||
mw, ma, mi, mo = await arm_model(N)
|
||||
aw, aa, ai, ao, it = await arm_agent(N)
|
||||
|
||||
def local(walls, apis):
|
||||
return [w - a for w, a in zip(walls, apis)]
|
||||
|
||||
rows = {
|
||||
"A_bare_openai": {"wall": _stats(bw), "api": _stats(ba), "local": _stats(local(bw, ba)),
|
||||
"in_tok": statistics.median(bi), "out_tok": statistics.median(bo)},
|
||||
"B_agentscope_model": {"wall": _stats(mw), "api": _stats(ma), "local": _stats(local(mw, ma)),
|
||||
"in_tok": statistics.median(mi), "out_tok": statistics.median(mo)},
|
||||
"C_agentscope_agent": {"wall": _stats(aw), "api": _stats(aa), "local": _stats(local(aw, aa)),
|
||||
"in_tok": statistics.median(ai), "out_tok": statistics.median(ao),
|
||||
"model_calls_per_reply": _stats(it)},
|
||||
}
|
||||
|
||||
print("\n{:<22} {:>10} {:>10} {:>10} {:>8} {:>8}".format(
|
||||
"arm", "wall_med", "local_med", "api_med", "in_tok", "out_tok"))
|
||||
for k, v in rows.items():
|
||||
print("{:<22} {:>10} {:>10} {:>10} {:>8} {:>8}".format(
|
||||
k, v["wall"]["median"], v["local"]["median"], v["api"]["median"],
|
||||
v["in_tok"], v["out_tok"]))
|
||||
|
||||
# 关键差值
|
||||
in_inflation = rows["C_agentscope_agent"]["in_tok"] - rows["A_bare_openai"]["in_tok"]
|
||||
local_overhead_ms = (rows["C_agentscope_agent"]["local"]["median"] -
|
||||
rows["A_bare_openai"]["local"]["median"]) * 1000
|
||||
print("\n## 关键差值(Agent 相对裸路)")
|
||||
print(f" input_token 膨胀:{in_inflation:+.0f} tok/次 "
|
||||
f"(裸 {rows['A_bare_openai']['in_tok']:.0f} → Agent {rows['C_agentscope_agent']['in_tok']:.0f})")
|
||||
print(f" 本地封装开销:{local_overhead_ms:+.1f} ms/次(剔除 LLM 网络耗时)")
|
||||
print(f" Agent 单次 reply 内部模型调用次数中位数:{rows['C_agentscope_agent']['model_calls_per_reply']['median']}")
|
||||
|
||||
out = {"model": MODEL, "N": N, "prompt": PROMPT, "sys_prompt": SYS_PROMPT,
|
||||
"max_tokens": MAX_TOKENS, "rows": rows,
|
||||
"input_token_inflation": in_inflation, "local_overhead_ms": round(local_overhead_ms, 2)}
|
||||
os.makedirs("artifacts", exist_ok=True)
|
||||
with open("artifacts/gate3_overhead.json", "w") as f:
|
||||
json.dump(out, f, ensure_ascii=False, indent=2)
|
||||
print("\n# 写出 artifacts/gate3_overhead.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
8
wg1/gen-worker/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# wg1/gen-worker 本地产物与密钥 —— 绝不入库(密钥另受根 .gitignore:11 .env 兜底)
|
||||
.env
|
||||
.env.*
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
*.output
|
||||
.cost-logs/
|
||||
59
wg1/gen-worker/README.md
Normal file
@ -0,0 +1,59 @@
|
||||
# wg1/gen-worker —— W-G1 L1 生成 worker(便宜模型 → 可真玩轻游戏)
|
||||
|
||||
> **结论看 [`REPORT.md`](REPORT.md)**(durable)。本文件 = 如何跑。
|
||||
> 验证 HJ-GEN-001:便宜模型(new-api)在插件库+装载契约上写出可真玩轻游戏,质量由 harness 七门保证。
|
||||
|
||||
## 它做什么
|
||||
`brief(题面)→ 便宜模型写 GameHostFactory 源码 → 静态校验门 → esbuild 打 __GameBundle iife → P1 宿主 bootGameHost 装载 → 本机 Chrome+CDP 真玩七门取证`,失败回喂错因重试 ≤N。**"能生成代码 ≠ 能玩"——必须过真玩门才算 pass。**
|
||||
|
||||
## 目录
|
||||
```
|
||||
wg1/gen-worker/
|
||||
├── REPORT.md # 交付报告(结论/四件套/成本/坑/最优模型)
|
||||
├── requirements.txt # openai==2.41.1 + json-repair
|
||||
├── .env # NEWAPI_KEY 等(gitignored,绝不入库)
|
||||
├── worker/
|
||||
│ ├── _client.py # 裸 openai 客户端 + 代理旁路 + usage(复用 spike 范式)
|
||||
│ ├── prompt.py # 生成上下文包(契约/能力面/禁用项/few-shot)→ system+user
|
||||
│ ├── validate.py # 校验门:抽码 + 静态契约扫描 + node --check 语法
|
||||
│ ├── cost.py # ¥ 折算(new-api quota 口径,/api/pricing+/api/status)
|
||||
│ └── run.py # 编排(§3 job-in/result-out + 重试门)
|
||||
├── briefs/ # pong.json / simon.json / whack.json(题面 + CDP play-spec)
|
||||
└── results/ # 每 run 结果(pass/retries/verdict 七门/tokens)
|
||||
# 生成游戏产物在 game-runtime/games/_wg1-gen/<game>/(见该目录 .agent,解释为何落那)
|
||||
```
|
||||
|
||||
## 前置(本机一次)
|
||||
```bash
|
||||
# 1) 密钥(gitignored,走环境):编辑 wg1/gen-worker/.env 写 NEWAPI_KEY(向创始人要)
|
||||
# 2) game-runtime 工具链(esbuild + ws,CDP 驱动用)
|
||||
npm i --prefix game-runtime
|
||||
# 3) worker venv
|
||||
uv venv wg1/gen-worker/.venv --python 3.12
|
||||
uv pip install -p wg1/gen-worker/.venv/bin/python -r wg1/gen-worker/requirements.txt
|
||||
# 4) 自测连通(列模型,零成本)
|
||||
wg1/gen-worker/.venv/bin/python wg1/gen-worker/worker/_client.py
|
||||
```
|
||||
> 依赖本机 macOS + Google Chrome(headless)+ Tailscale 可达 new-api 网关 `100.64.0.8:3000`。
|
||||
|
||||
## 跑
|
||||
```bash
|
||||
# 生成 + 真玩一款(默认 deepseek-v4-flash;--model 换模型;--max-retries N)
|
||||
wg1/gen-worker/.venv/bin/python wg1/gen-worker/worker/run.py pong
|
||||
wg1/gen-worker/.venv/bin/python wg1/gen-worker/worker/run.py simon --model deepseek-v4-pro
|
||||
wg1/gen-worker/.venv/bin/python wg1/gen-worker/worker/run.py whack
|
||||
# 成本表(聚合 results/*.json)
|
||||
wg1/gen-worker/.venv/bin/python wg1/gen-worker/worker/cost.py
|
||||
# 仅重放某款已生成 bundle(不重新生成,调试 harness 用)
|
||||
( cd game-runtime && bash games/_wg1-gen/_shared/serve-and-play.sh pong )
|
||||
```
|
||||
新游戏:在 `briefs/<id>.json` 写 `{title, brief, play_spec:{inputs,expectedEngineCallPrefixes}}`,再 `run.py <id>`。
|
||||
|
||||
## 七门真玩判据(全过才 PASS,详见 REPORT §2)
|
||||
A 装载 · B 未捕获 · C 掌帧[20,45] · D 真渲染 · E 活性 · F 真接线(__engineCalls) · **G 输入有效(确定性 A/B 对照)**。
|
||||
verdict 落 `game-runtime/games/_wg1-gen/<game>/evidence/verdict.json` + 首帧/玩后截图。
|
||||
|
||||
## 边界
|
||||
- 只消费冻结契约(game-host.d.ts / boot-game-host.js / __GameBundle / bootGameHost),零修改。
|
||||
- 不推 dev/2.0.0;只在 lane 分支 `wg1/gen-worker`。
|
||||
- 不做(创始人并行 P3/mainline):AigcGenerateExecutor 接线、白名单注册、全 20 游戏 bake-off、judge 评分。
|
||||
152
wg1/gen-worker/REPORT.md
Normal file
@ -0,0 +1,152 @@
|
||||
# W-G1 生成核心 · 交付报告(结论先行)
|
||||
|
||||
> lane:`wg1/gen-worker`(从 `spike/agentscope-wg1` HEAD = dev/2.0.0 + 已落地 P1/P2/spike 切出)| 日期:2026-06-13
|
||||
> 验证假设 **HJ-GEN-001**:便宜模型(new-api: DeepSeek-V4 / MiniMax)能在【插件库 + 装载契约】上写出**可真玩**的轻游戏;质量由 **harness 门**保证,非模型自觉。
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论速览
|
||||
|
||||
**结论:成立。便宜模型能可靠产出可真玩的轻游戏。** 用最便宜档 `deepseek-v4-flash` 生成 3 款冒烟游戏,全部经 **CDP 真机 Chrome 七门真玩**通过,**单款 ¥0.01–0.03(远低于 ¥0.15 L1 闸门,5–15× 余量)**。
|
||||
|
||||
| 游戏 | 模型 | 真玩七门 | 重试 | ¥(上界) vs ¥0.15 |
|
||||
|---|---|---|---|---|
|
||||
| Pong 弹球 | deepseek-v4-flash | ✅ 7/7 | 0 | **¥0.0098** ✅ |
|
||||
| 打地鼠 Whack | deepseek-v4-flash | ✅ 7/7 | 0 | **¥0.0136** ✅ |
|
||||
| Simon 记忆 | deepseek-v4-flash | ✅ 7/7 | 2 | **¥0.0298** ✅ |
|
||||
|
||||
**最关键的一条(必须如实传达)**:**「能生成代码」≠「能玩」**。本 lane 实证了这一点——一处由**我方 prompt 写错**的输入坐标契约(声称 x/y∈[0,1],实为逻辑像素)让所有生成游戏"能编译、能渲染、能动",但**玩家点击全部失灵**;Pong 因小球自走动画**假绿混过**初版门,Simon 因无自走动画被门**正确判否**。**正是真玩门(而非"代码编译过")逮住了它。** 修复 prompt + 加装「输入有效性确定性对照门」后,三款全部真过。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产物靶与全链路(只消费冻结契约,零改 P1/P2)
|
||||
|
||||
```
|
||||
brief → 便宜模型 → factory.js(纯 GameHostFactory 模块:默认导出,零引擎 import,能力经 boot.ctx)
|
||||
↓ 通用壳(复用 ref buildRefHostConfig:标准 7 插件 wiring,仅 override factory)
|
||||
entry-bundle.js → esbuild --global-name=__GameBundle → bundle.iife.js(引擎内联 265KB)
|
||||
↓ index.html: window.__GameBundle.bootGameHost({canvas, recHook, onReady})
|
||||
static-serve + 本机 Chrome(headless) + CDP → 七门真玩取证
|
||||
```
|
||||
|
||||
- **冻结契约零修改**:`game-host.d.ts` / `boot-game-host.js` / 6 受控面 / `__GameBundle` / `bootGameHost` 全程只消费。
|
||||
- **生成游戏落 `game-runtime/games/_wg1-gen/`**(新建隔离子目录,零碰既有文件)——因生成游戏本质是 game-runtime 游戏(动态 import `littlejsengine`、import `../../../src/plugins/*`、复用 `ref-host-config.js`),esbuild 默认解析要求入口在 node_modules 可达处。worker(Python) 与报告留 `wg1/gen-worker/`。详见 [`games/_wg1-gen/.agent`](../../game-runtime/games/_wg1-gen/.agent)。
|
||||
- **唯一对既有树的非生成改动**:`game-runtime/package.json` 加 `ws`(devDep,CDP 驱动用,ref harness 亦需)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 真玩取证 harness:七道假绿守卫(false-green guard)
|
||||
|
||||
每款游戏经下列七门,**全过才判 PASS**(驱动 = [`games/_wg1-gen/_shared/play.cdp.cjs`](../../game-runtime/games/_wg1-gen/_shared/play.cdp.cjs),复用 `test/harness/browser-evidence.cjs`):
|
||||
|
||||
| 门 | 判据 | 防的假绿 |
|
||||
|---|---|---|
|
||||
| **A 装载** | `__genBooted ∧ __gameHostEngineInitFired ∧ 无 bootError` | 白屏 / 装载失败 |
|
||||
| **B 未捕获** | `__genInternals.uncaught == [] ∧ 无 __gameHostBootError` | 运行期报错 |
|
||||
| **C 掌帧** | `__engine.frame` 500ms 增量 ∈ [20,45](@60fps≈30) | 冻屏 / 失控 / 单步假推进 |
|
||||
| **D 真渲染** | `#game-engine` readback 有色像素(maxCh>80)>50 | 白屏 / 全黑 |
|
||||
| **E 活性** | 真玩全程 ≥2 个不同整帧哈希态 | 冻屏(多采样捕捉瞬时态) |
|
||||
| **F 真接线** | `__engineCalls` 经 recHook 含期望 call-ID(粒子/音效/数学) | 自绘伪装、没真用插件库/引擎 |
|
||||
| **G 输入有效** | **同 seed 再开无输入对照实例、推进到同一帧、整帧哈希 ≠ 有输入实例** | **靠自走动画蒙混过 E 的假绿** |
|
||||
|
||||
> **G 门是本 lane 关键发明**:Pong/打地鼠有自走动画(球/地鼠),E 门会被自走动画满足、掩盖「输入失灵」。G 门用**确定性 A/B 对照**(new-api 生成 bundle seed 固定→自走部分逐帧确定)隔离出「输入到底改没改变游戏走向」——挡板位置 / Simon 轮次 / 打地鼠分数都会持续到对照帧,有输入≠无输入即证真可玩。
|
||||
|
||||
**golden-path 自检(证 harness 可信)**:先用 ref 工厂(已知好)跑通整条链——5/6 门过、F=0(菜单态不调引擎,**正确**);再用最小 probe 工厂(确定性调 getEngine)→ 7/7 全过。**harness 在真可玩上判过、在没真调引擎/没真响应输入时判否**,无假绿。
|
||||
|
||||
---
|
||||
|
||||
## 3. 逐款四件套(真玩证据)
|
||||
|
||||
> 证据落 `game-runtime/games/_wg1-gen/<game>/evidence/{first-paint.png, after-play.png, verdict.json}`。
|
||||
|
||||
### Pong 弹球(deepseek-v4-flash · 0 重试 · ¥0.0098)
|
||||
- **①截图**:竖屏弹球——白球在场、青色玩家挡板(已移到末次点击 x)、虚线中线、`Player:0 CPU:1`(真打了一回合)、撞击红粒子。
|
||||
- **②掌帧**:Δframe=31(C✅)。**③渲染**:maxCh=255、有色像素满屏(D✅)。**④真接线**:`__engineCalls` 含 `particles.spawnEmitter`/`audio.synth.synthSfx`(撞击反馈,F✅)。
|
||||
- **输入有效(G✅)**:有输入态 `0d7715dc` ≠ 无输入对照 `855a1c03` @frame444 → 挡板真响应点击。228 行源码,`export default createPongGame`,`ctx.random`/`ctx.getInput`/`ctx.time` 全受控。
|
||||
|
||||
### 打地鼠 Whack(deepseek-v4-flash · 0 重试 · ¥0.0136)
|
||||
- **①截图**:`得分:0`、3×3 深色洞口、两只地鼠(棕圆+眼睛+腮红)正冒出。
|
||||
- **②③④**:C✅ / D✅(maxCh255) / F✅(命中粒子 + 冒出音效)。
|
||||
- **输入有效(G✅)**:`a9de479b ≠ b0584efe` @frame446 → 点击真改变游戏(命中/分数)。命中判定为自写"点到圆距"(不依赖网格/CCD)。
|
||||
|
||||
### Simon 记忆(deepseek-v4-flash · 2 重试 · ¥0.0298)
|
||||
- **①截图**:`Round:1`、`Your turn!`、红/绿/蓝/黄 2×2 大按钮。
|
||||
- **②③④**:C✅(Δ31) / D✅(maxCh255,bright272006) / F✅(序列音效 `audio.synth.synthSfx` + 缓动 `math.easing.quadOut`)。
|
||||
- **输入有效(G✅)**:`a5100b7a ≠ f7530fdd` @frame400 → 点击真推进序列/轮次。状态机(INIT→SHOWING→WAIT→PAUSE/FAIL)完整。
|
||||
- **重试原因**:附录 A——首版坐标错(修 prompt 前)/ 逻辑边界,靠错误回喂 3 轮内收敛。
|
||||
|
||||
---
|
||||
|
||||
## 4. 成本表 + 模型对比 → 最优模型
|
||||
|
||||
口径:`quota=(prompt+completion×completion_ratio)×model_ratio×group_ratio(1.0)`;`¥=quota/500000×7.3`(`/api/status` 实拉)。**¥为上界**(不计 prompt 缓存折扣;精确 `logs.quota` 需网关 access token,sk- key 无管理权——留给创始人核)。reasoning_tokens 已计入 completion。
|
||||
|
||||
| 游戏(难度) | 模型 | 重试 | prompt/completion tok | ¥上界 | vs ¥0.15 |
|
||||
|---|---|---|---|---|---|
|
||||
| Pong(易) | deepseek-v4-flash | 0 | 2861 / 4775 | 0.0098 | ✅ 15× 余量 |
|
||||
| 打地鼠(易+) | deepseek-v4-flash | 0 | 2899 / 7223 | 0.0136 | ✅ 11× |
|
||||
| Simon(中) | deepseek-v4-flash | 2 | 9120 / 14220 | 0.0298 | ✅ 5× |
|
||||
| **Simon** | **deepseek-v4-pro** | **0** | 2889 / 9445 | 0.0625 | ✅ |
|
||||
| Simon | MiniMax-M2.7 | 1 | 5582 / 11912 | 0.1166 | ✅(贴闸门) |
|
||||
|
||||
**最优模型结论**:
|
||||
- **默认 = `deepseek-v4-flash`**:最便宜(model_ratio 0.06 / completion_ratio 1.75),易档 0 重试 ¥0.01;难档(Simon)靠重试门兜(2 重试仍 ¥0.03)。
|
||||
- **难档升级 = `deepseek-v4-pro`**:Simon **0 重试**、¥0.06(2× flash 但仍极廉),可靠性最高。**建议策略:flash 打底 + 耗尽重试自动升 pro。**
|
||||
- **MiniMax 不优先**:M2.7 completion_ratio=4 拉高成本(¥0.117 贴近闸门),可靠性不及 pro。
|
||||
|
||||
---
|
||||
|
||||
## 5. 五大坑(给主 agent / 创始人少踩半天)
|
||||
|
||||
1. **【实测·最坑】受控输入 x/y 是逻辑像素不是 [0,1]**:`boot-game-host.js:295` `x=(clientX-rect.left)/rect.width*VIEW_W(390)`。ref 直接 `hitZone(e.x,e.y)` 用像素。若 prompt 误导模型"乘视口"→命中全爆掉、点击失灵,但**代码照编译照渲染照动**——只有真玩门逮得住。**生成 prompt 的能力契约必须逐字对齐 boot-game-host 实际口径。**
|
||||
2. **【实测】自走动画会制造假绿**:有自走物(球/地鼠)的游戏,"画面变了"(E)≠"输入有效"。**必须加 G 门(确定性 A/B 对照)**才能证真可玩。
|
||||
3. **【实测】reasoning 模型吃 max_tokens**:deepseek-v4-flash/MiniMax 是 reasoning 模型,reasoning_tokens 计入 completion 且可烧空可见 content(`finish_reason:length` 时 content 空)。**max_tokens 须给足(本 worker 16000),否则截断→校验门拒→空转重试。**
|
||||
4. **【实测】CDP 触摸要镜像 ref 范式**:`Input.dispatchTouchEvent` 裸 `{x,y}` 点、touchStart→touchEnd 背靠背、坐标经 `c.width/dpr` 映射(非硬编码 390);加 `radius/force` 或长按反而不稳。
|
||||
5. **【实测·复用 spike】本机代理吃网关**:`NO_PROXY` 须并 `100.64.0.8`(_client.py 已内置),否则 SDK→502(curl 假通)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 哪些游戏类型超出当前插件库能力(来自 20 基准交叉比对)
|
||||
|
||||
便宜模型受**插件库门面缺口**限制,非模型能力限制。当前 4 短板(详见 `docs/agent-specs/2026-06-13-WG1-20经典轻游戏基准-review.md`):
|
||||
- **① 文本/分数 HUD**🟡:无文本渲染门面,每款自 `fillText`(本冒烟可兜,但风格各异)。**最高频缺口,建议 W-G1 后第一优先补门面。**
|
||||
- **② 网格/棋盘 grid**🟡:无 tilemap 门面 → 2048/Tetris/Match-3/贪吃蛇/井字棋 网格态每款重写易出 bug。
|
||||
- **③ 连续碰撞 CCD**❌(真缺口):collision 仅离散 MTV,无 swept/toi → 高速小球(Breakout 高速/Asteroids 子弹/多球)穿透。**冒烟刻意避开。**
|
||||
- **④ 手势 swipe/drag**🟡:getInput 仅 5 类原始事件,无手势合成 → 愤怒小鸟拖拽蓄力/2048 滑动每款自缓存方向。
|
||||
|
||||
→ **本冒烟选 Pong/Simon/打地鼠 正是"零/最轻短板 + canvas 简单"的最干净子集**(避开 Snake/2048/Cookie Clicker——它们撞①②)。
|
||||
|
||||
---
|
||||
|
||||
## 7. worker ↔ pipeline 接缝(留给创始人集成,§3 契约形状)
|
||||
|
||||
worker 已暴露「job in → result out」([`worker/run.py:run_job`](worker/run.py)):
|
||||
- **in**:`{game_id, brief(题面), play_spec(CDP 输入+期望 call-ID), model, max_retries}`
|
||||
- **out**(`results/<game>.json`):`{pass, retries, attempts[{stage,errors,usage}], factory_path, bundle_path, verdict(七门), tokens, request_ids}`
|
||||
- 闭环:generate → validate(静态契约+`node --check`) → bundle(esbuild) → play(CDP 七门) → 失败回喂错因重试 ≤N。
|
||||
- 字段对齐你 spike VERDICT §3 的 REST/队列形状;待你拉取后冻结。
|
||||
|
||||
---
|
||||
|
||||
## 8. 蒸馏建议(留我 lane,待你拉进 `.agents/`,避免与你并行 P3 撞车)
|
||||
|
||||
1. **`skills/` 增补**:把「§2 七门真玩 harness(尤其 G 门 A/B 对照)+ §5 五坑」蒸馏进 `game-e2e-cdp-harness.md`(W-G1 复用并扩展了它)。
|
||||
2. **`rules/` 红线**:「生成 prompt 能力契约必须逐字对齐 boot-game-host 实际口径(输入坐标=逻辑像素)」「能生成≠能玩:必须过真玩门+G 门」。
|
||||
3. **`knowledge/`**:HJ-GEN-001 验证结论(便宜模型可产可玩轻游戏 / 最优模型 flash 打底+pro 升级 / ¥0.01–0.06)。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A · 复现
|
||||
|
||||
```bash
|
||||
# 0) 前置:wg1/gen-worker/.env 写 NEWAPI_KEY(gitignored);game-runtime 先 npm i(含 ws)
|
||||
# 1) 装 worker venv
|
||||
uv venv wg1/gen-worker/.venv --python 3.12
|
||||
uv pip install -p wg1/gen-worker/.venv/bin/python -r wg1/gen-worker/requirements.txt
|
||||
# 2) 生成+真玩一款(generate→validate→bundle→CDP 七门)
|
||||
wg1/gen-worker/.venv/bin/python wg1/gen-worker/worker/run.py pong --model deepseek-v4-flash --max-retries 2
|
||||
# 3) 成本表
|
||||
wg1/gen-worker/.venv/bin/python wg1/gen-worker/worker/cost.py
|
||||
# 仅重放某款(不重新生成):
|
||||
( cd game-runtime && bash games/_wg1-gen/_shared/serve-and-play.sh pong )
|
||||
```
|
||||
27
wg1/gen-worker/briefs/breakout.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "打砖块 Breakout",
|
||||
"brief": "做一款经典打砖块 Breakout。竖屏逻辑视口 390×844。\n- 底部一块玩家挡板(水平移动,厚度≥14,避免高速穿透)。\n- 一个球斜向匀速运动:碰左/右墙与顶墙反弹;碰挡板反弹,且按击中挡板的位置微调水平速度(越靠边角度越斜)。\n- 顶部一片砖块网格(如 6 列 × 4~5 行),球击中砖块则该砖消除、得分 +1;可让部分砖需击中多次(颜色随剩余命数变化)增加层次。\n- 球掉到挡板下方(越过底边)= 失败;清光所有砖 = 通关。\n- 【终态契约·务必遵守】失败或通关都进入终态:把 _forensicsView().state().phase 置为 'gameover' 并【驻留】——画面停在终态(可显示 GAME OVER / YOU WIN 文案,可提示「点击重玩」),但在玩家主动点击重玩之前 phase 必须恒为 'gameover',**绝不自动重开 / 不下一帧复位回 playing**(宿主每 500ms 轮询 phase 判结束,瞬时终态会被漏读)。\n- 【控制·务必跟手】挡板朝触摸点 x 平滑逼近(每帧向目标 x 插值移动一定比例,如 0.2~0.35),**绝不是每次点击只挪一格**——玩家手指移到哪,挡板要顺滑跟过去。\n- 【可测性·务必导出位置】_forensicsView().state() 除 phase/score/remaining 外,额外导出 ball:{x,y,vx,vy} 和 paddle:{x,y,w}(实时真值,与画面一致)——harness 据此自动接球真玩并验证控制跟手与碰撞。\n- 球速中等:合速度约每秒 300~420 逻辑像素。\n- 反馈(juice):每次击碎砖块触发 getEngine().particles.spawnEmitter 粒子爆发 + getEngine().audio.synth.synthSfx 音效;撞墙/撞挡板也可给轻音效。\n- 画面:深色背景铺满 390×844;挡板/砖块用亮色实心矩形,球用亮色实心圆;顶部单行文字显示分数与剩余砖数。",
|
||||
"play_spec": {
|
||||
"controlCheck": {
|
||||
"paddlePath": "paddle.x",
|
||||
"paddleY": 800,
|
||||
"tapXs": [50, 340],
|
||||
"tapsPerTarget": 6,
|
||||
"moveMin": 25,
|
||||
"tolerance": 70,
|
||||
"why": "挡板须平滑逼近触点 x(逮「一格一跳」/卡死:连点同一 x 后挡板应明显移动且最终靠近该 x)"
|
||||
},
|
||||
"driver": {
|
||||
"type": "paddle-intercept",
|
||||
"ballPath": "ball.x",
|
||||
"paddleY": 800,
|
||||
"steps": 60,
|
||||
"stepMs": 130
|
||||
},
|
||||
"expectedEngineCallPrefixes": ["particles.", "audio.synth"],
|
||||
"assertAfterPlay": [
|
||||
{ "path": "remaining", "op": "decreased", "why": "适配性真玩(挡板自动接球)下击碎砖块→剩余下降;接不住/碰撞失效则不降" }
|
||||
],
|
||||
"expectLatch": true
|
||||
}
|
||||
}
|
||||
20
wg1/gen-worker/briefs/pong.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Pong 弹球",
|
||||
"brief": "做一款经典 Pong 弹球游戏。竖屏逻辑视口 390×844。\n- 底部一块玩家挡板(水平移动),顶部一块电脑挡板(自动跟随球的 x,速度略慢于球,留出可玩性)。\n- 中间一个球,做斜向匀速运动;碰左右墙反弹,碰挡板反弹(反弹时根据击中挡板的位置微调水平速度)。\n- 球越过玩家挡板(掉到底部)或越过电脑挡板(飞出顶部)则该回合结束,球回到中心重新发球,并把对应一方的分数+1。\n- 球速保持中等:水平+竖直速度合起来大约每秒 300~420 逻辑像素,挡板不要太薄(厚度≥12),避免高速穿透。\n- 控制:玩家触摸屏幕任意位置,底部挡板朝触摸点的 x 平滑移动(每帧朝目标 x 逼近)。\n- 反馈:球每次反弹(撞墙或撞挡板)都触发一次 getEngine().particles.spawnEmitter 粒子爆发 + getEngine().audio.synth.synthSfx 音效。\n- 画面:深色背景铺满 390×844;挡板用亮色实心矩形,球用亮色实心圆;中线画一排虚线。可在顶部用单行文字显示比分(非必须)。",
|
||||
"play_spec": {
|
||||
"inputs": [
|
||||
{ "t": "wait", "ms": 600 },
|
||||
{ "t": "tap", "x": 100, "y": 740 },
|
||||
{ "t": "wait", "ms": 450 },
|
||||
{ "t": "tap", "x": 300, "y": 740 },
|
||||
{ "t": "wait", "ms": 450 },
|
||||
{ "t": "tap", "x": 195, "y": 740 },
|
||||
{ "t": "wait", "ms": 450 },
|
||||
{ "t": "tap", "x": 130, "y": 740 },
|
||||
{ "t": "wait", "ms": 450 },
|
||||
{ "t": "tap", "x": 270, "y": 740 },
|
||||
{ "t": "wait", "ms": 400 }
|
||||
],
|
||||
"expectedEngineCallPrefixes": ["particles.", "audio.synth"]
|
||||
}
|
||||
}
|
||||
18
wg1/gen-worker/briefs/simon.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Simon 记忆",
|
||||
"brief": "做一款经典 Simon(记忆复现)游戏。竖屏逻辑视口 390×844。\n- 屏幕中央 2×2 布局四个大彩色按钮:红、绿、蓝、黄(每个约 130×130 逻辑像素,留间隙)。\n- 游戏流程:每轮电脑播放一段【递增】的按钮序列——逐个点亮按钮(点亮=该按钮变亮/轻微放大,可用 getEngine().math.easing 缓动),每个点亮同时播放一个该颜色专属音高的音效 getEngine().audio.synth.synthSfx。\n- 播放完后轮到玩家:玩家依次点击按钮复现序列。点击时该按钮点亮+播放同样音效。\n- 复现正确一整轮:序列长度+1,进入下一轮(短暂停顿后电脑播放更长序列)。复现错误:播放失败音效+粒子,序列重置从头开始。\n- 用 ctx.random 生成每轮新追加的颜色;用 ctx.time 控制点亮节奏(如每个 500ms)。\n- 反馈:每次点亮/点击都有音效;答对一轮或答错都触发 getEngine().particles.spawnEmitter 粒子。\n- 画面:深色背景铺满 390×844;四个按钮暗态用较暗的本色、亮态用饱和本色。可在顶部单行文字显示当前轮数(非必须)。\n- 注意:开局电脑就要自动播放第一个按钮(含点亮+音效),不要停在等待态。",
|
||||
"play_spec": {
|
||||
"inputs": [
|
||||
{ "t": "wait", "ms": 1300 },
|
||||
{ "t": "tap", "x": 130, "y": 360 },
|
||||
{ "t": "wait", "ms": 350 },
|
||||
{ "t": "tap", "x": 260, "y": 360 },
|
||||
{ "t": "wait", "ms": 350 },
|
||||
{ "t": "tap", "x": 130, "y": 500 },
|
||||
{ "t": "wait", "ms": 350 },
|
||||
{ "t": "tap", "x": 260, "y": 500 },
|
||||
{ "t": "wait", "ms": 350 }
|
||||
],
|
||||
"expectedEngineCallPrefixes": ["audio.synth", "particles."]
|
||||
}
|
||||
}
|
||||
22
wg1/gen-worker/briefs/whack.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"title": "打地鼠 Whack-a-Mole",
|
||||
"brief": "做一款经典打地鼠游戏。竖屏逻辑视口 390×844。\n- 固定排布 3×3 共 9 个洞口(定点摆放,不是网格状态机):x 取约 80/195/310,y 取约 400/540/680,每个洞画成深色椭圆。\n- 地鼠机制:用 ctx.random 随机挑一个(或偶尔两个)当前为空的洞,让地鼠冒出(向上弹出动画,可用 getEngine().math.easing 或简单插值),停留约 700~1100ms 后若没被打则缩回。用 ctx.time 计时控制冒出节奏(约每 600~900ms 尝试冒一只)。\n- 地鼠冒出的瞬间播放一声音效 getEngine().audio.synth.synthSfx(保证每次出现都有引擎调用)。\n- 控制:玩家点击屏幕,若点中某个【正冒出的】地鼠所在洞(点到圆范围内)则打中:该地鼠缩回、分数+1、触发 getEngine().particles.spawnEmitter 命中粒子爆发 + 命中音效。点空不得分。\n- 画面:深色背景铺满 390×844;洞口深色椭圆;地鼠用棕色圆+两只眼睛+可选腮红;冒出时按弹出进度决定露出多少。顶部用单行文字显示分数。\n- 命中判定用『点到圆心距离 < 半径』自己算(不依赖网格、不依赖连续碰撞)。",
|
||||
"play_spec": {
|
||||
"inputs": [
|
||||
{ "t": "wait", "ms": 700 },
|
||||
{ "t": "tap", "x": 80, "y": 400 },
|
||||
{ "t": "wait", "ms": 300 },
|
||||
{ "t": "tap", "x": 195, "y": 540 },
|
||||
{ "t": "wait", "ms": 300 },
|
||||
{ "t": "tap", "x": 310, "y": 680 },
|
||||
{ "t": "wait", "ms": 300 },
|
||||
{ "t": "tap", "x": 195, "y": 400 },
|
||||
{ "t": "wait", "ms": 300 },
|
||||
{ "t": "tap", "x": 80, "y": 540 },
|
||||
{ "t": "wait", "ms": 300 },
|
||||
{ "t": "tap", "x": 310, "y": 400 },
|
||||
{ "t": "wait", "ms": 300 }
|
||||
],
|
||||
"expectedEngineCallPrefixes": ["particles.", "audio.synth"]
|
||||
}
|
||||
}
|
||||
29
wg1/gen-worker/calibration/labels.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"anchor_note": "权威锚=创始人亲玩(2026-06-14)。原话:A 物理最好;B 第二;C 碰撞有明显问题;D 滑块每次点击只走一格、不符合玩家习惯。fun/verdict 由我把定性排序转成数值(创始人可再覆盖)。**重大发现:创始人排序推翻了我的自动判定**——我曾把 B(v2)判 objective-hollow、C(pro)判 best,均与亲玩相反。",
|
||||
"samples": [
|
||||
{
|
||||
"game_id": "breakout-flash-v3",
|
||||
"brief_ref": "breakout",
|
||||
"class": "good",
|
||||
"human": { "fun": 4, "verdict": "pass", "note": "创始人:物理最好(第1)。[原我标 basic=3,低估]", "anchor": "founder" }
|
||||
},
|
||||
{
|
||||
"game_id": "breakout-pro",
|
||||
"brief_ref": "breakout",
|
||||
"class": "good",
|
||||
"human": { "fun": 2, "verdict": "fix", "note": "创始人:碰撞有明显问题。[原我+player 标 best=4,假阳性]", "anchor": "founder" }
|
||||
},
|
||||
{
|
||||
"game_id": "breakout-flash-v2",
|
||||
"brief_ref": "breakout",
|
||||
"class": "good",
|
||||
"human": { "fun": 3, "verdict": "pass", "note": "创始人:第2、能玩。[原我标 objective-hollow=1,假阴性——盲打脚本没打到砖→误判空心]", "anchor": "founder" }
|
||||
},
|
||||
{
|
||||
"game_id": "breakout-m27",
|
||||
"brief_ref": "breakout",
|
||||
"class": "fix-control",
|
||||
"human": { "fun": 2, "verdict": "fix", "note": "创始人:滑块每次点击只走一格、不跟手(控制 bug,不符玩家习惯)", "anchor": "founder" }
|
||||
}
|
||||
]
|
||||
}
|
||||
163
wg1/gen-worker/calibration/report.json
Normal file
@ -0,0 +1,163 @@
|
||||
{
|
||||
"stage": "stage1",
|
||||
"n_samples": 4,
|
||||
"anchor_note": "权威锚=创始人亲玩(2026-06-14)。原话:A 物理最好;B 第二;C 碰撞有明显问题;D 滑块每次点击只走一格、不符合玩家习惯。fun/verdict 由我把定性排序转成数值(创始人可再覆盖)。**重大发现:创始人排序推翻了我的自动判定**——我曾把 B(v2)判 objective-hollow、C(pro)判 best,均与亲玩相反。",
|
||||
"rows": [
|
||||
{
|
||||
"game_id": "breakout-flash-v3",
|
||||
"class": "good",
|
||||
"human": {
|
||||
"fun": 4,
|
||||
"verdict": "pass",
|
||||
"note": "创始人:物理最好(第1)。[原我标 basic=3,低估]",
|
||||
"anchor": "founder"
|
||||
},
|
||||
"vision": {
|
||||
"completeness": 4,
|
||||
"fun": 4,
|
||||
"problems": [
|
||||
"球起始位置在挡板正上方且初速向下时可能首帧直接掉出(边界条件虽通过测试,但首帧球贴在挡板附近略感不自然)",
|
||||
"砖块击碎反馈在截图中无法直接看到粒子(静态截图局限),但 F_wiring 显示粒子与音效调用正常"
|
||||
],
|
||||
"verdict": "pass",
|
||||
"note": "经典打砖块实现到位:砖块网格、球反弹、挡板插值跟随、剩余砖数与分数实时变化(30→29、0→1),H_progress 门验证了核心机制真在跑,F_wiring 确认粒子+音效反馈真触发,E_live 15个不同状态说明动画与交互活跃——是款完整可玩、有反馈的休闲小品。"
|
||||
},
|
||||
"text": {
|
||||
"completeness": 5,
|
||||
"fun": 4,
|
||||
"problems": [],
|
||||
"verdict": "pass",
|
||||
"note": "经典打砖块,所有七门取证通过,核心循环完整且反馈到位,有实际进展和可玩性。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"game_id": "breakout-pro",
|
||||
"class": "good",
|
||||
"human": {
|
||||
"fun": 2,
|
||||
"verdict": "fix",
|
||||
"note": "创始人:碰撞有明显问题。[原我+player 标 best=4,假阳性]",
|
||||
"anchor": "founder"
|
||||
},
|
||||
"vision": {
|
||||
"completeness": 4,
|
||||
"fun": 4,
|
||||
"problems": [
|
||||
"球在 after-play 中已落至挡板下方区域,可能存在穿透或漏判失败态的风险(球y位置约1400-垫子y约1700之间仍有空间,但需确认未漏detect death)",
|
||||
"部分橙色砖仍保留(剩余18)说明多次命中机制在工作,但after-play里球已经明显落在挡板下方飞,若该帧是终态前一刻则需验证 phase='gameover' 驻留契约",
|
||||
"首帧砖块整齐6×7布局 vs 玩后砖块被削去一大块,进度真实(15分对应15块),核心循环成立",
|
||||
"深色背景+亮色实心图形满足规格,无明显视觉缺陷"
|
||||
],
|
||||
"verdict": "pass",
|
||||
"note": "经典打砖块,核心循环、粒子+音效反馈、多次命中变色、挡板角度反弹都到位,真进展,手感扎实。"
|
||||
},
|
||||
"text": {
|
||||
"completeness": 5,
|
||||
"fun": 4,
|
||||
"problems": [],
|
||||
"verdict": "pass",
|
||||
"note": "经典打砖块实现完整,粒子与音效反馈到位,操作流畅,胜负态明确,符合终态契约。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"game_id": "breakout-flash-v2",
|
||||
"class": "good",
|
||||
"human": {
|
||||
"fun": 3,
|
||||
"verdict": "pass",
|
||||
"note": "创始人:第2、能玩。[原我标 objective-hollow=1,假阴性——盲打脚本没打到砖→误判空心]",
|
||||
"anchor": "founder"
|
||||
},
|
||||
"vision": {
|
||||
"completeness": 2,
|
||||
"fun": 2,
|
||||
"problems": [
|
||||
"H_progress 门未过:phase 始终为 booting,未按契约暴露 playing/gameover 状态",
|
||||
"终态契约未实现:球掉底应驻留 gameover 并显示 GAME OVER + 重玩提示,实际没有",
|
||||
"掉球后 phase 未切换,宿主轮询会漏判结束",
|
||||
"可玩回合太短:一次入场只打到 +1 分球就掉,体验未闭环"
|
||||
],
|
||||
"verdict": "fix",
|
||||
"note": "砖块消除/粒子/音效/挡板移动都跑起来了,但终态契约完全没接——phase 锁在 booting,球掉底不切 gameover 不驻留,红线违规,必须修。"
|
||||
},
|
||||
"text": {
|
||||
"completeness": 1,
|
||||
"fun": 1,
|
||||
"problems": [
|
||||
"H_progress 未过,游戏未从 booting 切换到 playing 状态,核心循环未启动",
|
||||
"无任何游戏进展证据(分数、砖块、球运动等均为零)",
|
||||
"玩家无法操作或看到实质游戏内容,属于空心游戏"
|
||||
],
|
||||
"verdict": "fix",
|
||||
"note": "运行数据表明游戏卡在 booting 状态,未进入 playing 阶段,无法进行任何游玩,必须修复状态机转换及可观测状态导出。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"game_id": "breakout-m27",
|
||||
"class": "fix-control",
|
||||
"human": {
|
||||
"fun": 2,
|
||||
"verdict": "fix",
|
||||
"note": "创始人:滑块每次点击只走一格、不跟手(控制 bug,不符玩家习惯)",
|
||||
"anchor": "founder"
|
||||
},
|
||||
"vision": {
|
||||
"completeness": 3,
|
||||
"fun": 2,
|
||||
"problems": [
|
||||
"玩后截图 vs 首帧:Score 仍为 0、Bricks 仍为 24,球位置从右下移到左中但未撞击任何砖块/挡板/墙,无视觉反馈",
|
||||
"玩后砖块多出一行绿色(首帧 4 行红+橙,玩后 5 行红+橙+绿),首尾帧之间砖块数量/布局不一致,疑似关卡布局不稳定或随机化错误",
|
||||
"F_wiring 显示 487 次 synthSfx 调用,但 24 块砖一块没碎、0 分,音效调用与游戏事件严重脱钩(可能每帧 spam)",
|
||||
"缺少明显粒子/数字跳动/消除反馈,无 GAME OVER/YOU WIN 终态截图证据",
|
||||
"玩家全程只能看到一颗球在空荡中线附近漂,无爽点无反馈,体验空洞"
|
||||
],
|
||||
"verdict": "fix",
|
||||
"note": "砖块未消除、分数零变化、玩后布局突变,核心循环虽存在但玩家视角下无进展,判定空心游戏待修。"
|
||||
},
|
||||
"text": {
|
||||
"completeness": 4,
|
||||
"fun": 4,
|
||||
"problems": [
|
||||
"终态phase驻留未在数据中直接验证,但七门通过暗示实现;缺少多次击中砖块的颜色变化证据"
|
||||
],
|
||||
"verdict": "pass",
|
||||
"note": "游戏核心循环完整,有音效和粒子反馈,进度可感知,手感良好,基本符合Breakout玩法。"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metrics": {
|
||||
"vision": {
|
||||
"model": "MiniMax-M3",
|
||||
"fun_mae": 0.75,
|
||||
"verdict_match": "2/4",
|
||||
"hollow_rejected": "0/0",
|
||||
"hollow_false_green": "0/0",
|
||||
"good_accepted": "2/3",
|
||||
"gatekeeper_ok": false
|
||||
},
|
||||
"text": {
|
||||
"model": "deepseek-v4-flash",
|
||||
"fun_mae": 1.5,
|
||||
"verdict_match": "1/4",
|
||||
"hollow_rejected": "0/0",
|
||||
"hollow_false_green": "0/0",
|
||||
"good_accepted": "2/3",
|
||||
"gatekeeper_ok": false
|
||||
}
|
||||
},
|
||||
"cost": {
|
||||
"by_model": {
|
||||
"MiniMax-M3": {
|
||||
"in": 18939,
|
||||
"out": 1193,
|
||||
"rmb": 0.10385
|
||||
},
|
||||
"deepseek-v4-flash": {
|
||||
"in": 4810,
|
||||
"out": 1882,
|
||||
"rmb": 0.0071
|
||||
}
|
||||
},
|
||||
"total_rmb": 0.11095
|
||||
}
|
||||
}
|
||||
7
wg1/gen-worker/requirements-l2.txt
Normal file
@ -0,0 +1,7 @@
|
||||
# W-G1 L2 agentic studio 依赖(在 L1 裸路之上叠加 AgentScope)。
|
||||
# L1 仍走裸路(--no-agentscope);L2 多角色 studio 用 AgentScope 进程内多 agent。
|
||||
# 锁 2.0.1:spike VERDICT 明示 2.0 是对 1.x 的破坏式重写,凭记忆/旧博客写必炸,须锁版本盯 changelog。
|
||||
# 进程内多 agent 只需 base(Agent/model/credential/message);[service,storage] 仅跨进程 RedisMessageBus 才需,本期不引。
|
||||
-r requirements.txt
|
||||
agentscope==2.0.1
|
||||
pyyaml>=6.0
|
||||
3
wg1/gen-worker/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# W-G1 L1 生成 worker 依赖(裸路:openai 客户端 + 脏 JSON 兜底;不引 AgentScope,L1 维持 §3 裸通路)
|
||||
openai==2.41.1
|
||||
json-repair>=0.30
|
||||
211
wg1/gen-worker/results/breakout-flash-v2.stage1.json
Normal file
@ -0,0 +1,211 @@
|
||||
{
|
||||
"game_id": "breakout-flash-v2",
|
||||
"stage": "stage1",
|
||||
"pass": true,
|
||||
"repairs": 1,
|
||||
"player_rounds_used": 1,
|
||||
"design_text": "## 设计稿:打砖块(Breakout)竖屏 390×844\n\n### 1. 核心机制 \n玩家拖动底部挡板接住球,球不断反弹撞击顶部砖块;每击中一块砖即消除、得分+1。部分砖需多次击中(颜色渐暗表示剩余次数)。球速恒定,方向由碰撞法线及击中挡板位置(水平偏移修正)决定。\n\n### 2. 胜负条件 \n- **胜利**:消除所有砖块 → 显示通关界面,可重新开始。 \n- **失败**:球落至挡板下方(底边 y>844)→ 立即停止所有运动,显示失败界面,可重新开始。\n\n### 3. 操作 \n触摸屏幕任意 x 位置 → 挡板中心每帧向触摸点 x 插值逼近,y 固定在底部 50px 处。无触摸时挡板静止。\n\n### 4. 关键数值 \n- 挡板:宽 80px,高 16px(≥14),圆角 4px。 \n- 球:半径 10px,合速度 350 px/s(范围 300~420)。 \n- 砖块网格:6 列 × 4 行,每块宽 55px,高 18px,间距 6px,起始 y=80。 \n- 多次击打砖:随机 30% 的块 2 次(第 1 次颜色变浅),5% 的块 3 次。 \n- 挡板移动平滑:插值因子 0.15/帧(60fps)。\n\n### 5. Juice 反馈 \n- **击碎砖块**:必触发 `getEngine().particles.spawnEmitter(x, y)` 粒子爆发 + `getEngine().audio.synth.synthSfx('pop')`。 \n- **撞墙/挡板**:轻音效 `synthSfx('click')`。 \n- 可选:砖块消失时微小缩放(0.1秒)。\n\n### 6. 画面要点 \n- 背景:纯色深灰 `#1a1a2e` 铺满。 \n- 挡板:亮蓝 `#00b4d8` 实心圆角矩形。 \n- 球:亮黄 `#ffcc00` 实心圆(无边框)。 \n- 砖块:未击/一次/两次分别用绿 `#2ecc71`、橙 `#e67e22`、红 `#e74c3c`。 \n- HUD:左上角白色文本 “分数: 0”,右上角 “剩余砖: 24”,字号 16,字体无衬线。",
|
||||
"attempts": [
|
||||
{
|
||||
"attempt": 0,
|
||||
"role": "code",
|
||||
"model": "deepseek-v4-flash",
|
||||
"stage_fail": null,
|
||||
"usage": {
|
||||
"in": 3739,
|
||||
"out": 10308
|
||||
},
|
||||
"seven_gate_pass": true,
|
||||
"guards": {
|
||||
"A_boot": true,
|
||||
"C_frame": true,
|
||||
"D_render": true,
|
||||
"E_live": true,
|
||||
"B_uncaught": true,
|
||||
"F_wiring": true,
|
||||
"G_input": true
|
||||
},
|
||||
"player": {
|
||||
"vision": {
|
||||
"model": "MiniMax-M3",
|
||||
"completeness": 4,
|
||||
"fun": 2,
|
||||
"problems": [
|
||||
"球在30秒后未击碎任何砖块,分数仍为0,剩余砖数始终24,砖块颜色和分布与首帧完全不一致(说明砖块hp/颜色状态有变化但无消除),核心破坏循环未跑通",
|
||||
"挡板在触屏后移动迟缓甚至消失(after-play中挡板跑到了画面下半部接近边缘,且球已悬空在中段),控制手感可疑",
|
||||
"画面大段中下区域完全空旷,球与砖块/挡板的活动空间感差,节奏拖沓",
|
||||
"砖块多色但无视觉说明(看不出几命),破坏反馈不明显",
|
||||
"无失败态可见、无重开按钮被触发证据",
|
||||
"粒子/音效有调用记录但屏幕上看不到粒子效果(bright 104429偏低,画面可能偏暗或粒子未渲染)"
|
||||
],
|
||||
"verdict": "fix",
|
||||
"note": "能渲染能接收输入,但球压根没打掉一块砖,30秒白玩,核心打砖循环没成立。"
|
||||
},
|
||||
"text": {
|
||||
"model": "deepseek-v4-flash",
|
||||
"completeness": 4,
|
||||
"fun": 3,
|
||||
"problems": [],
|
||||
"verdict": "pass",
|
||||
"note": "核心循环完整,有粒子音效反馈,但手感与节奏需亲测"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"attempt": 1,
|
||||
"role": "fix",
|
||||
"model": "deepseek-v4-flash",
|
||||
"stage_fail": null,
|
||||
"usage": {
|
||||
"in": 3975,
|
||||
"out": 13394
|
||||
},
|
||||
"seven_gate_pass": true,
|
||||
"guards": {
|
||||
"A_boot": true,
|
||||
"C_frame": true,
|
||||
"D_render": true,
|
||||
"E_live": true,
|
||||
"B_uncaught": true,
|
||||
"F_wiring": true,
|
||||
"G_input": true
|
||||
},
|
||||
"player": {
|
||||
"vision": {
|
||||
"model": "MiniMax-M3",
|
||||
"completeness": 4,
|
||||
"fun": 3,
|
||||
"problems": [
|
||||
"首帧显示分数:0 剩余:24,但 after-play 仍显示 分数:0 剩余:24,球位置在右上、挡板在底部偏左——说明测试期间实际未击中任何砖块,或得分/剩余计数未更新,核心击中反馈存疑",
|
||||
"after-play 中砖块颜色从首帧的 多色(橙/红/绿)变成了几乎全绿+少量橙红,可能并非'击碎'而是颜色重置或渲染异常;既然剩余仍是 24 就不该有砖块状态变化",
|
||||
"粒子效果在截图上不可见(两张图均未见爆发痕迹),且 wiring 证据只显示 callCount:1 且只采样到 audio.synth.synthSfx,未见 particles. 调用记录——题目要求的'击碎砖块触发粒子爆发'实际可能缺失",
|
||||
"首帧中央大字号'点击屏幕开始'与挡板/球重叠在视觉上一锅烩,启动提示偏侵入",
|
||||
"球初始位置贴在挡板正上方,未给一个明显的'发射'动作或预备状态,新手容易分不清球是否已出发"
|
||||
],
|
||||
"verdict": "fix",
|
||||
"note": "基础循环搭好了(砖块/挡板/球/计分UI都有),但 30 秒真玩证据表明核心击中链路未跑通:剩余砖数没变、分数没动、粒子也未触发——打砖块的灵魂就是这反馈,目前是哑火状态。"
|
||||
},
|
||||
"text": {
|
||||
"model": "deepseek-v4-flash",
|
||||
"completeness": 4,
|
||||
"fun": 3,
|
||||
"problems": [],
|
||||
"verdict": "pass",
|
||||
"note": "七门全过,引擎能力调用到位,核心循环基本完整,但缺乏截图确认视觉与细节,手感中规中矩。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"seven_gate_verdict": {
|
||||
"gameId": "breakout-flash-v2",
|
||||
"url": "http://localhost:4320/games/_wg1-gen/breakout-flash-v2/index.html",
|
||||
"ts": null,
|
||||
"guards": {
|
||||
"A_boot": {
|
||||
"pass": true
|
||||
},
|
||||
"C_frame": {
|
||||
"pass": true,
|
||||
"f0": 47,
|
||||
"f1": 77,
|
||||
"delta": 30
|
||||
},
|
||||
"D_render": {
|
||||
"pass": true,
|
||||
"bright": 108157,
|
||||
"maxCh": 255
|
||||
},
|
||||
"E_live": {
|
||||
"pass": true,
|
||||
"distinctStates": 13,
|
||||
"samples": 15
|
||||
},
|
||||
"B_uncaught": {
|
||||
"pass": true,
|
||||
"uncaught": []
|
||||
},
|
||||
"F_wiring": {
|
||||
"pass": true,
|
||||
"callCount": 1,
|
||||
"sample": [
|
||||
"audio.synth.synthSfx"
|
||||
],
|
||||
"expected": [
|
||||
"particles.",
|
||||
"audio.synth"
|
||||
]
|
||||
},
|
||||
"G_input": {
|
||||
"pass": true,
|
||||
"inputHash": "43973f46",
|
||||
"controlHash": "793aa1f8",
|
||||
"atFrame": 476,
|
||||
"ctrlFrame": 477
|
||||
}
|
||||
},
|
||||
"pass": true,
|
||||
"notes": []
|
||||
},
|
||||
"player": {
|
||||
"vision": {
|
||||
"model": "MiniMax-M3",
|
||||
"completeness": 4,
|
||||
"fun": 3,
|
||||
"problems": [
|
||||
"首帧显示分数:0 剩余:24,但 after-play 仍显示 分数:0 剩余:24,球位置在右上、挡板在底部偏左——说明测试期间实际未击中任何砖块,或得分/剩余计数未更新,核心击中反馈存疑",
|
||||
"after-play 中砖块颜色从首帧的 多色(橙/红/绿)变成了几乎全绿+少量橙红,可能并非'击碎'而是颜色重置或渲染异常;既然剩余仍是 24 就不该有砖块状态变化",
|
||||
"粒子效果在截图上不可见(两张图均未见爆发痕迹),且 wiring 证据只显示 callCount:1 且只采样到 audio.synth.synthSfx,未见 particles. 调用记录——题目要求的'击碎砖块触发粒子爆发'实际可能缺失",
|
||||
"首帧中央大字号'点击屏幕开始'与挡板/球重叠在视觉上一锅烩,启动提示偏侵入",
|
||||
"球初始位置贴在挡板正上方,未给一个明显的'发射'动作或预备状态,新手容易分不清球是否已出发"
|
||||
],
|
||||
"verdict": "fix",
|
||||
"note": "基础循环搭好了(砖块/挡板/球/计分UI都有),但 30 秒真玩证据表明核心击中链路未跑通:剩余砖数没变、分数没动、粒子也未触发——打砖块的灵魂就是这反馈,目前是哑火状态。"
|
||||
},
|
||||
"text": {
|
||||
"model": "deepseek-v4-flash",
|
||||
"completeness": 4,
|
||||
"fun": 3,
|
||||
"problems": [],
|
||||
"verdict": "pass",
|
||||
"note": "七门全过,引擎能力调用到位,核心循环基本完整,但缺乏截图确认视觉与细节,手感中规中矩。"
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 18631,
|
||||
"completion": 26233
|
||||
},
|
||||
"tokens_by_model": {
|
||||
"deepseek-v4-flash": [
|
||||
9917,
|
||||
25668
|
||||
],
|
||||
"MiniMax-M3": [
|
||||
8714,
|
||||
565
|
||||
]
|
||||
},
|
||||
"cost": {
|
||||
"by_model": {
|
||||
"deepseek-v4-flash": {
|
||||
"in": 9917,
|
||||
"out": 25668,
|
||||
"rmb": 0.04804
|
||||
},
|
||||
"MiniMax-M3": {
|
||||
"in": 8714,
|
||||
"out": 565,
|
||||
"rmb": 0.04807
|
||||
}
|
||||
},
|
||||
"total_rmb": 0.09611,
|
||||
"gate_rmb": 0.15
|
||||
},
|
||||
"wall_s": 357.1,
|
||||
"models": {
|
||||
"design": "deepseek-v4-flash",
|
||||
"code": "deepseek-v4-flash",
|
||||
"fix": "deepseek-v4-flash",
|
||||
"player_vision": "MiniMax-M3",
|
||||
"player_text": "deepseek-v4-flash"
|
||||
}
|
||||
}
|
||||