From 2b59f53f44b44e4f49c24c75262b61ce13fbfebe Mon Sep 17 00:00:00 2001 From: zizi Date: Tue, 16 Jun 2026 07:55:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(saa):=20Python=20agentic=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=B3=BB=E7=BB=9F=E8=BF=81=E7=A7=BB=E5=88=B0=20SAA(Sp?= =?UTF-8?q?ring=20AI=20Alibaba)=E8=A3=B8=E5=9B=BE=20spike=20(HJ-AGI-002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全图 design->generate->validate->[repair环]->scaffold->build->play(九门)->player->emit;build/play 复用既有 harness 子进程。 SAA 完整框架集接入 aigc-server(graph-core/agent-framework/builtin-nodes/observation/openai,3 BOM;Boot3.5.14/Redisson4.4.0 保住)。 双评审(Opus+Codex)9 项对比前必修:LLM超时重试/ProcessBuilder超时修复/采样面对齐/playerFeedback清串味/gatespec强解析/problems归一/recursionLimit兜底/cost/端口参数化。 首轮对比 breakout/whack/flappy:SAA 通过率 2/3 >= Python 1/3。 Co-Authored-By: Claude Opus 4.8 (1M context) --- SAA-INTEGRATION-TODO.md | 13 + ...AA-AgentScope-agent平台-目标架构-review.md | 216 +++++ ...26-06-15-SAA-AgentScope-spike-execution.md | 85 ++ .../2026-06-15-SAA-能力API接入-dossier.md | 70 ++ ...26-06-15-python-to-SAA-migration-design.md | 309 ++++++++ .../2026-06-15-python-vs-SAA-对比测试.md | 94 +++ .../game-module-aigc-server/pom.xml | 16 + .../game/module/aigc/saa/SaaGenNodes.java | 468 +++++++++++ .../module/aigc/saa/SaaHarnessDriveTest.java | 206 +++++ .../aigc/saa/SaaNewApiGenerateSmokeTest.java | 222 ++++++ .../game/module/aigc/saa/SaaPrompts.java | 319 ++++++++ .../module/aigc/saa/SaaStudioGraphTest.java | 451 +++++++++++ .../game/module/aigc/saa/SaaStudioNodes.java | 745 ++++++++++++++++++ .../saa/SaaStudioNodesRegressionTest.java | 168 ++++ .../resources/saa-golden/generated-factory.js | 385 +++++++++ .../test/resources/saa-golden/play-spec.json | 33 + wg1/gen-worker/worker/cost.py | 41 +- 17 files changed, 3840 insertions(+), 1 deletion(-) create mode 100644 SAA-INTEGRATION-TODO.md create mode 100644 docs/agent-specs/2026-06-15-SAA-AgentScope-agent平台-目标架构-review.md create mode 100644 docs/agent-specs/2026-06-15-SAA-AgentScope-spike-execution.md create mode 100644 docs/agent-specs/2026-06-15-SAA-能力API接入-dossier.md create mode 100644 docs/agent-specs/2026-06-15-python-to-SAA-migration-design.md create mode 100644 docs/agent-specs/2026-06-15-python-vs-SAA-对比测试.md create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHarnessDriveTest.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTest.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodesRegressionTest.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-golden/generated-factory.js create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-golden/play-spec.json diff --git a/SAA-INTEGRATION-TODO.md b/SAA-INTEGRATION-TODO.md new file mode 100644 index 00000000..d66f3b2e --- /dev/null +++ b/SAA-INTEGRATION-TODO.md @@ -0,0 +1,13 @@ +# SAA 接入·依赖统一 TODO (HJ-AGI-002 spike) + +日期 2026-06-15 · 分支 spike/saa-int · 机器 mini-desktop +现状: dependency:tree BUILD SUCCESS;Boot 3.5.14 / Redisson 4.4.0 / SCA 2025.0.0.0 / jackson 2.21.2 全保住,零冲突。 +框架集(完整推荐): graph-core + agent-framework + starter-graph-observation + starter-builtin-nodes(钉1.1.2.2) + spring-ai-starter-model-openai;经 spring-ai-bom:1.1.2 + spring-ai-alibaba-bom:1.1.2.2 + extensions-bom:1.1.2.2 管理。 + +## 待统一(unify)TODO +1. builtin-nodes 不在 SAA BOM → 现显式钉 1.1.2.2;待 BOM 纳入后改随 BOM,或提到 huijing-dependencies 用属性统一管理。 +2. SAA 编译基线 Boot 3.5.8 vs 本项目 3.5.14(已保住);若后续出 API 不兼容,再评估统一 Boot/SAA 版本。 +3. SAA redisson 3.x(optional,未拉) vs 项目 4.4.0;若启用 RedisSaver 需实测 redisson 4.x 兼容 RMap/RBucket/RLock。 +4. SAA 公共仓最新已到 1.1.2.3(本 spike 钉 1.1.2.2 对齐已分析源码);稳定后评估 bump 1.1.2.3。 +5. 三 BOM import 现放 aigc-server 局部;若多模块用 SAA,提到 huijing-dependencies 统一 import。 +6. builtin-nodes 连带拉入 document-parser-tika/markdown/bshtml/yaml,体积增;如不需可 exclusion。 diff --git a/docs/agent-specs/2026-06-15-SAA-AgentScope-agent平台-目标架构-review.md b/docs/agent-specs/2026-06-15-SAA-AgentScope-agent平台-目标架构-review.md new file mode 100644 index 00000000..b8303f2f --- /dev/null +++ b/docs/agent-specs/2026-06-15-SAA-AgentScope-agent平台-目标架构-review.md @@ -0,0 +1,216 @@ +# SAA + AgentScope Agent 平台 · 目标架构(评审版) + +> 决策 ID:**HJ-AGI-002**(演进自 HJ-AGI-001「agentic 基建 = AgentScope」) +> 日期:2026-06-15 · 状态:**评审版 v2(已过 Codex + Opus 双评审,见 §10)** · 双评审 verdict = 需重议 → 已修订为「带修改采纳」 · **两项前置决策待创始人拍板(见 §10-决策 D1/D2)** +> 适用范围:game-cloud agentic 编排基建的**生产目标架构** + 分期落地路径 +> 关联:[.agents/knowledge/tech-decisions.md]、`docs/agent-specs/2026-06-12-agentic基建框架选型-review.md`、本轮 spike 执行稿(`2026-06-15-SAA-AgentScope-spike-execution.md`,定稿后产出) + +--- + +## 0. 结论先行(给决策) + +1. **主干 = Spring AI Alibaba(SAA,v1.1.2.2 稳定/GA)的 Graph 编排**。**自治节点用 SAA 自带的 `ReactAgent`(tool+Hook+HITL+中断恢复,已测 OpenAI/DeepSeek)经 `ReactAgent.asNode()` 入图;子 agent 调用用 `FlowAgent.subAgents`/子图(`SubGraphNode`)——均 SAA 原生**。**生产默认 SAA-only、养一个框架**(2026-06-15 源码实证,见 §10-D2)。AgentScope 嵌入降为"未来若 SAA ReactAgent 不够再议";对 agentscope 的开源贡献作独立轨道、与生产解耦。 +2. **本项目绝大多数 agentic 活是"编排型"**(生成主线 / 五资产 / 远期 mode-C),只有少数"自治型"(Tier-3 多 agent 开发组)才下沉成嵌入式 AgentScope 节点。 +3. **基建按消费者驱动分期上**:Phase1 只用已部署的 **MySQL + Redis(零新中间件)** → Phase2 拓扑成型时上 Nacos/A2A/RocketMQ/Sentinel → Phase3 mode-C 可视化面。 +4. **落地顺序**:先 lili-mac spike 验 SAA(kill-criteria 见 §7);MVP 闭环期间继续跑现有执行器、互不干扰;spike 通过且不压日历闸门,再迁生成主线。 +5. **不变量**(§6):共用 new-api 单一成本/凭证层;"done"由确定性门(九门真玩)裁定、不让 LLM 自评;写工具幂等;生成逻辑藏在 job 契约后(框架可换);完成 MVP 的约束是日历闸门,平台建设不得拖垮 MVP 闭环。 + +--- + +## 1. 背景与触发 + +- 起于"是否对用户开放 agent 编排"的讨论,逐步收敛到 **agentic 基建的 Java 化 + 编排框架选型**。 +- 期间核实并**纠正了外部建议的四处误判**(见 §9),所有关键结论均有据(仓内取证 + 联网核实 + 已 clone 源码到 `/root/oss/`)。 +- **现状(已核实)**: + - 生成主线 = `game-module-aigc` 内 `AigcGenerateExecutor` + 自研薄客户端 `ExecutorLlmClient` → new-api(**硬编码 7 步**,无图/DSL 编排)。 + - 另有隔离 Python worker(L1 裸路 + L2 Python AgentScope 2.0.1,**编排均手写**)。 + - staging 实际只跑 **`redis:7` + `mysql:8.4.8`**;Nacos/RocketMQ/Sentinel/Seata/Gateway/XXL-Job **未部署**。 + +--- + +## 2. 目标 / 非目标 + +**目标** +- 用真正的图/DSL 编排取代硬编码生成流程,且**可观测、可 checkpoint / 失败恢复**。 +- 选定面向**生产云拓扑**(Spring Cloud Alibaba)的 agentic 主干框架,**Java 原生**、消除 Python↔Java 跨进程缝。 +- 为远期 Tier-3 自治开发组、mode-C 用户编排预留生长路径——**无孤儿设计**(每能力带入口/失败路/验收)。 + +**非目标(本轮明确不做)** +- 不现在搭微服务舰队、不预先部署 Nacos/RocketMQ/Sentinel/Seata。 +- 不把 MVP 闭环切换到新框架(先并行 spike,证后再迁)。 +- 不决定 mode-C 的 C 端编排前端形态(远期单列)。 +- 不使用 Dify(项目已降级、无存量工作流,DSL 导入对我们 moot)。 + +--- + +## 3. 推荐方案 + +### 3.1 架构:全局可控、局部自治 + +```mermaid +flowchart LR + subgraph GC[game-cloud(Spring Boot 单体 / 未来微服务)] + EXE[现有 AigcGenerateExecutor\n(MVP 闭环保留, 不动)] + JOB[job/callback 契约\n(稳定接缝, 框架可换)] + end + + subgraph SAA[SAA Graph 编排主干 · GA v1.1.2.2] + direction TB + N1[render prompt] --> N2[LLM 生成 GameConfig] + N2 --> N3{schema 校验} + N3 -- 不合法 --> NR + N3 -- 合法 --> N4[scaffold 脚手架] + N4 --> N5[build 工具节点] + N5 --> N6[play 工具节点\n九门真玩] + N6 -- 门失败 --> NR + N6 -- 门通过 --> N7[emit] + NR["repair 节点\n(可升级为 AgentScopeAgent.asNode
= 嵌入式自治体)"] --> N2 + end + + subgraph EXT[复用:既有确定性 harness(子进程工具)] + BUILD[esbuild/node 构建脚本] + PLAY[CDP 九门真玩 harness] + end + + subgraph STORE[已部署存储(Phase1 零新基建)] + MY[(MySQL)] + RD[(Redis)] + end + + JOB --> SAA + N5 -. shell .-> BUILD + N6 -. shell .-> PLAY + SAA -. Graph state checkpoint .-> MY + SAA -. session/短期记忆 .-> RD +``` + +要点: +- **确定性门(九门)= 图的条件边**,由 harness 真玩裁定"done",不让 LLM 自评(反 Goodhart)。 +- **build/play 复用既有 harness**(子进程工具节点),**零重写**;generate/validate/scaffold 进入 SAA 图。 +- **repair 节点**起步是受约束的 LLM 节点;需要开放式修复时升级为 `AgentScopeAgent.asNode()` 的嵌入自治体——这是 hybrid 的落点。 + +### 3.2 四类工作负载映射 + +| 工作负载 | 主力 | 形态 | 时序 | +|---|---|---|---| +| **游戏生成主线**(MVP 关键) | **SAA 图** | 编排为主 + repair 节点(可选自治) | spike 验 → 证后迁 | +| 五资产生成(美术/音乐等) | SAA Sequential/Parallel Agent | 工具/模型调用流水 | 随主线 | +| **Tier-3 多 agent 开发组**(复杂游戏:编码/测试/录屏核验) | **AgentScope `asNode()` 嵌入** | 真·开放式自治,外层图卡预算与门 | 远期 / 高阶层 | +| **mode-C 用户编排**(远期) | SAA Admin(前端形态待定) | 可视化工作流面 | 远期单列 | + +### 3.3 为什么主干是 SAA(而非 AgentScope-java 或纯自研) + +- SAA v1.1.2.x 是 **GA**;agentscope-java 2.0 仅 **RC** → 生产主干用 GA 的 SAA,把 RC 风险**关进"可选的嵌入节点"**而非主干。 +- SAA Graph 状态可 checkpoint 到 **MySQL/Redis/PostgreSQL/Oracle/MongoDB/File**——其中 **MySQL+Redis 我们已在跑,Phase1 零新基建**。 +- **Spring-native**,贴 game-cloud(Spring Boot)与生产 Spring Cloud Alibaba 目标;Nacos/A2A/MCP 原生,待 Phase2 自然接入。 +- SAA 提供 `AgentScopeAgent.asNode()`(在 SAA 的 `spring-ai-alibaba-starter-agentscope` 模块)把 AgentScope 的 **`ReActAgent`** 嵌进 SAA 图。**⚠️双评审源码证伪:该官方接缝当前绑定 `agentscope-core 1.0.9`(稳定线),既非 agentscope-runtime-java、也非 2.0;且只代理单个 ReActAgent,不直接适配 §3.2 的 Tier-3 多 agent 自治组。把 2.0(-SNAPSHOT) 嵌进去属未经官方验证的路径——见 §10,hybrid 降为独立后置 spike(Phase 1.5),不与主干同批。** + +### 3.4 分期落地(消费者驱动,不预铺控制面) + +```mermaid +flowchart LR + P1[Phase1 · MVP 期\n零新基建] --> P2[Phase2 · 生产拓扑成型] --> P3[Phase3 · mode-C] + P1 -.- P1d["SAA 图编排生成主线
state 仅 MySQL+Redis
挂 job 契约后
共用 new-api 成本层"] + P2 -.- P2d["部署 Nacos→A2A+MCP-registry+发现
嵌 AgentScope 跑 Tier-3 自治组
RocketMQ 事件触发 · Sentinel 限流"] + P3 -.- P3d["SAA Admin 可视化面
C 端编排前端形态另定"] +``` + +--- + +## 4. 关键取舍 + +- **引入 Spring AI + SAA 依赖**(本仓今天零 spring-ai)——成本有界,且与生产栈同向;收益是 GA 编排 + 持久化 + 生态。 +- **单看 MVP 生成流,上图框架是轻度过度设计**;真正理由是**生产拓扑 + mode-C + 可恢复性**这条轨迹。→ 用"藏在 job 契约后、先 spike、不压日历"三条控制其代价。 +- **reactive↔blocking 边界**:SAA / AgentScope 近期都转向 Flux 响应式核心(程度待 spike 实证)——保持"独立 worker / job 契约"边界,规避在全阻塞的 Spring MVC 舰队里硬塞响应式岛。 + +--- + +## 5. 影响面 / 兼容性 / 风险 + +- **影响面**:新增 agentic 编排栈;game-cloud 增 Spring AI/SAA 依赖;生成逻辑迁移(藏契约后)。**MVP 闭环不动**(保留现有执行器)。 +- **风险与缓解**: + 1. SAA 学习曲线 / 响应式核心 → spike 先证小流;保持边界。 + 2. agentscope-java 2.0 RC 稳定性 → **限定在可选嵌入节点**,主干用 GA。 + 3. Spring AI 栈引入成本 → 与生产 Spring Cloud Alibaba 同向,非沉没。 + 4. **日历风险**(平台建设挤占 MVP)→ 不变量第 5 条 + 分期 + 时间盒。 +- **回滚**:spike 任一 kill-criterion 挂 → 回落**现有 Java 执行器 + 已 spike 的 Python worker**(两者都在跑),零损失。 + +--- + +## 6. 不变量(硬约束,任何阶段不破) + +1. **单一 new-api 成本/凭证层**:模型 key 与用量计费只有一处权威(new-api + newapi_cost),SAA/AgentScope 都读它。 +2. **"done"由确定性门裁定**:九门真玩 harness 是验收唯一权威,禁止 LLM 自评。 +3. **写工具幂等**:幂等键 = sessionId + step;循环/重试不得产生重复副作用或重复计费。 +4. **成本/迭代上限**:每次生成有 max-iters 与 max-cost-per-run 天花板。 +5. **框架可换**:生成逻辑藏在 job/callback 契约后,换框架不改业务契约。 +6. **日历优先**:完成 MVP 的约束是 ICP/支付/广告日历闸门,本平台线在其下,必须时间盒、不得拖垮 MVP 闭环。 + +--- + +## 7. 验收标准(= lili-mac spike 的 kill-criteria) + +| 门 | 判据 | +|---|---| +| A 模型 | SAA ChatModel 指向 new-api(OpenAI 兼容)+ 便宜模型可调通出 JSON | +| B 持久化 | 图状态**仅靠 MySQL+Redis** checkpoint/恢复,**零新中间件** | +| C 接缝 | 图节点能 shell 调既有 build + 九门 harness 并读回 JSON | +| D 真玩 | 跑通一款金样游戏端到端真玩(four-piece 证据,见 game-e2e-cdp-harness) | +| E hybrid | `AgentScopeAgent.asNode()` 嵌一个自治修复节点跑通,验证 SAA↔AgentScope 接缝 | + +> 红线:权威真玩门必须在 **mini-desktop(x86)** 复跑后才能下生产结论;lili-mac(ARM)只作快迭代证据。 + +--- + +## 8. 待确认项(创始人拍板) + +1. MVP 闭环是否**上线前**迁 SAA,还是**上线后**?(建议:**上线后**,先并行 spike) +2. **Nacos 部署时机**?(建议:Tier-3/A2A 真正启动时,不预铺) +3. mode-C 的 C 端编排前端形态(远期单列,本轮不决)。 +4. agentscope-java 贡献:提供 GitHub 账号以便设 fork remote(fork→分支→PR)。 + +--- + +## 9. 附:已核实事实 & 已纠正误判(自包含,防止重蹈) + +**已核实** +- SAA:GA,最新稳定 **v1.1.2.2**(已 clone `/root/oss/spring-ai-alibaba`,POM `1.1.2.2`);Graph runtime;内置 Sequential/Parallel/Routing/Loop Agent;持久化 MySQL/Redis/PG/Oracle/Mongo/File;Admin 可视化 + Dify DSL 导入;A2A-over-Nacos;JDK17。 +- agentscope-java:稳定线 **1.0.12**;**本地 clone 的 main HEAD `` 实为 `2.0.0-SNAPSHOT`(非 RC,比 RC 更易漂移)**。`agentscope-harness` 是 agent 工作区/文件系统基建(**≠ 本项目"九门真玩"确定性验收 harness,二者无复用关系**,防误读)。`AgentScopeAgent.asNode()` **不在** agentscope-runtime-java,而在 **SAA 的 `spring-ai-alibaba-starter-agentscope`,绑 `agentscope-core 1.0.9`**(双评审证伪原稿表述)。 + +**已纠正误判(外部建议)** +1. AgentScope Java 2.0 **非 GA**(曾被误称已 GA)。 +2. 本 fork **无 yudao-module-ai、零 spring-ai 依赖**(曾被误称已存在)→ SAA 是**新引入**而非扩展。 +3. SAA README **未"叫你改用 AgentScope"**;SAA 自身横跨 agentic+workflow,AgentScope 作为**可嵌节点**。 +4. Dify DSL 导入对本项目 **moot**(无存量工作流)。 + +--- + +## 10. 双评审结论与 v2 修订(2026-06-15 · Codex + Opus) + +**双评审 verdict:两方独立均判「需重议」**(非照单采纳)。评审在我提供的 `/root/oss` clone 源码上验出**一处承重事实错误 + 一道缺失门 + 两记战略挑战**。据此修订,文档状态降为「带修改采纳」,并上浮两项创始人前置决策。 + +**P0(已接受,必改)** +- **P0-1 asNode 事实错误(源码证伪)**:`asNode()` 在 **SAA `spring-ai-alibaba-starter-agentscope`,绑 `agentscope-core 1.0.9`**,且只代理单个 `ReActAgent`;本地 agentscope-java 是 `2.0.0-SNAPSHOT`(非 RC)。→ §3.3/§9 已就地更正;"hybrid 是官方集成层不是拼凑"的定性**收回**——把 2.0 嵌进 1.0.9 接缝是未验证路径。 +- **P0-2 缺依赖启动门**:SAA 1.1.2.2 ⇒ Spring AI 1.1.2 + Boot 3.5.8;本项目 Boot 3.5.9/3.5.14 + SCA 2025.0.0.0、零 spring-ai。功能 demo 过 ≠ 在 game-cloud 依赖树能起。→ 新增**门 F**。 +- **P0-3(战略)图编排是否过度设计**:实测执行器确为「线性 + 1 次重试」,图的分支/并行/checkpoint 核心价值当前几无消费者;现执行器已有完整失败恢复(CAS 认领+watchdog+幂等回调+预算墙)。迁移理由实为押注「mode-C + 生产微服务拓扑」两个未来时。→ 上浮为**决策 D1**。 + +**P1(已接受,含一处我的校正)** +- **基线选错**:正确对照不是「硬编码执行器 vs SAA 图」,而是「**已 spike 的 Python AgentScope worker 演进** vs Java SAA hybrid」。→ spike 增**对照臂**。**【校正】Python worker 是正确对照基线且已 spike,但 Opus"已在产派发面"属高估——本轮早先取证 `workerUrl` 默认空、`dispatchGeneric` 未配置即判 failed,其当前是否真接线随配置而定,对照臂须先确认其现状。** +- **hybrid 隐藏复杂度**:agentscope-core 2.0 深度响应式(72 文件引 reactor),asNode 内含 `block()` 时序坑;叠进阻塞单体=养「两套半」。→ E 门判据升级为「失败路径可控可观测」,且 hybrid 拆出主干。 +- **repair 自治节点孤儿**:升级为自治体缺失败/回滚/预算归属路。→ Phase1 **repair 仅保留受约束 LLM 节点**;自治化延到 Phase1.5 且须补三件套(失败路+预算归属+与九门交互)。 + +**v2 修订(已并入方案)** +1. **spike 拆相**:**Phase 1 = 纯 SAA 图主干(无 asNode)**,过门 A/B/C/D + 新 **F**;**Phase 1.5 = hybrid(asNode)**,先解版本归属(D2),E 门记录 reactive↔blocking 失败模式;**hybrid 不阻塞主干**。 +2. **新增门 F(依赖/启动)**:game-cloud 实引 SAA BOM/starter,跑 `mvn dependency:tree` + 最小 ApplicationContext 启动 + `huijing-server` 编译门;明确哪个 BOM 管 Boot 版本(3.5.8 vs 3.5.9/3.5.14)。 +3. **门 A/B 细化**:A 加「JSON schema 输出」实测;B 改为「同 MySQL 8.4.8 + Redis 7,崩溃后按 threadId 恢复到正确节点」的真恢复实测(不止类存在)。 +4. **对照臂**:同款金样,「Python worker 演进」vs「SAA hybrid」同口径比工时/稳定性/运维;hybrid 不显著胜出 → **不迁移**为默认。 +5. **§6 软控制 → 硬闸门**:① spike **绝对时间盒**(超时按 kill 处理);② **MVP 闭环(ICP/支付/广告链路)未交付前禁碰任何 Phase2 基建**,违反等同破不变量;③ 给「是否压日历」可观测判据(关键路径 battle 是否因平台线滑期)。 +6. **§3.4 触发可证伪化**:Phase2 触发 = "Tier-3 自治组有真实首个消费者 ∧ MVP 闭环已上线";Phase3 同理给客观事件。 +7. **§5 回滚措辞**:Phase1 低成本可逆;**Phase2 起基建可逆性显著下降**(非"零损失")。 +8. **reactive 边界**:长图一律走 job/worker 边界 + 后台线程池;线程池/超时/取消/checkpoint 恢复纳入验收。 + +**评审肯定(避免过度否定)**:SAA 的 Graph/Node/Edge、MysqlSaver/RedisSaver、Sequential/Parallel/Routing/Loop、ChatModel 自定义 baseUrl 接 new-api——**均源码证实成立**;**job/callback 契约=框架可换+回滚路**是全稿最硬的一块。 + +**上浮创始人前置决策** +- **D1(战略,本质商业判断)**:迁移理由是押注 mode-C + 生产拓扑。①spike-to-decide(Phase1 纯 SAA + 对照臂,时间盒,结果说话——推荐)/ ②defer(MVP 先用现路,待真消费者再上 SAA)/ ③直接 commit。 +- **D2(版本归属)→【2026-06-15 创始人追问后源码实证解决】**:SAA 自带完整 agentic——`ReactAgent`(tool+Hook+HITL+中断恢复,已测 OpenAI/DeepSeek,`spring-ai-alibaba-agent-framework/.../ReactAgent.java`)+ `ReactAgent.asNode()`(第296行)+ `FlowAgent.subAgents`(`List`)+ 子图 `SubGraphNode`。**子 agent 调用是 SAA 原生能力,不需 AgentScope**。→ **改判:生产 = SAA-only**(养一个框架、回归 HJ-AGI-001 初衷、P0-1 版本错配消失);AgentScope 嵌入降为"未来 SAA ReactAgent 不够时再议";agentscope 开源贡献=独立轨道。**唯一待确认**:是否仍要在生产里 dogfood AgentScope 作为贡献载体(默认否=SAA-only)。 diff --git a/docs/agent-specs/2026-06-15-SAA-AgentScope-spike-execution.md b/docs/agent-specs/2026-06-15-SAA-AgentScope-spike-execution.md new file mode 100644 index 00000000..f1d5c6b8 --- /dev/null +++ b/docs/agent-specs/2026-06-15-SAA-AgentScope-spike-execution.md @@ -0,0 +1,85 @@ +# SAA Agentic 编排 · Phase 1 spike 执行稿(lili-mac) + +> 关联评审:`2026-06-15-SAA-AgentScope-agent平台-目标架构-review.md`(HJ-AGI-002,v2 双评审定稿) +> 决策前提:**D1 = spike-to-decide**;**D2 = 生产 SAA-only**(SAA 自带 `ReactAgent`/`asNode()`/`subAgents`,AgentScope 嵌入未来备选)。 +> 状态:执行版 · 机器 = lili-mac(快迭代);**权威真玩门 = mini-desktop(x86)** + +--- + +## 1. 目标与范围边界 +- **目标**:把生成主线表达成 SAA StateGraph,验证能否在我们的栈上跑通一款金样游戏真玩,并据"对照臂"判定**是否值得迁**。 +- **范围**:隔离 spike,目录 `spikes/saa-gen-graph/`,**零改生产 game-cloud 与 Python worker**。 +- **非目标**:不做 AgentScope hybrid(D2);不部署任何新中间件;不碰 MVP 闭环。 + +## 2. 前置 +- lili-mac:JDK17、Maven、Node(给既有 harness)、Tailscale 可达 new-api。 +- 已 clone:`/root/oss/spring-ai-alibaba`(v1.1.2.2)。 +- 既有 harness(esbuild 构建 + CDP 九门真玩)**复用为子进程工具,禁重写**。 + +## 3. kill-criteria(门;任一挂 → 停/回落) +| 门 | 判据 | 源码锚点(已核) | +|---|---|---| +| A 模型 | SAA ChatModel 指 new-api(OpenAI 兼容 baseUrl)+ 便宜模型(deepseek 类)出 JSON,含 **schema 输出**实测 | `NacosReactAgentBuilder` 用 `OpenAiApi.builder().baseUrl(...)`;`ModelVO.baseUrl` | +| B 持久化 | 图状态**仅靠 MySQL8.4.8 + Redis7** checkpoint,**崩溃后按 threadId 恢复到正确节点**(真恢复实测,非类存在) | `MysqlSaver`/`RedisSaver`/`CompiledGraph` | +| C 接缝 | 图节点能 shell 调既有 build + 九门 harness 并读回 JSON | — | +| D 真玩 | 跑通一款金样游戏端到端真玩(four-piece 证据,见 game-e2e-cdp-harness) | — | +| **E 依赖/启动** | game-cloud 实引 SAA BOM/starter → `mvn dependency:tree` + 最小 ApplicationContext 启动 + `huijing-server` 编译门;**定哪个 BOM 管 Boot 版本(SAA 要 3.5.8 vs 项目 3.5.9/3.5.14)** | SAA pom 锁 Spring AI 1.1.2 + Boot 3.5.8 | + +> 注:原 "E=hybrid asNode" 已按 D2 移除;依赖门提为正式门 E。自治 repair 节点用 **SAA 自带 `ReactAgent.asNode()`**,不引 AgentScope。 + +## 4. 对照臂(评审 P1-1) +同一款金样,**「现有 Python worker 演进」vs「SAA 图」**同口径比工时/稳定性/运维。先确认 Python worker **当前真实接线状态**(`workerUrl` 配置——早先取证默认空)。**SAA 不显著胜出 → 不迁移为默认。** + +## 5. 时间盒(硬闸门,评审 P0-3) +lili-mac **≤ N 人日**(N 由创始人定),超时按 kill 处理。**MVP 闭环(ICP/支付/广告链路)未交付前,不得触发任何 Phase2 基建动作(Nacos/RocketMQ/Sentinel)。** + +## 6. 分阶段 prompt(顺序贴给 lili-mac 会话) + +### 背景块(先贴一次) +``` +【背景·SAA 生成编排 spike(Phase1 纯 SAA)】 +- 项目=AI 游戏生成平台,后端 game-cloud=Java17+Spring Boot。先读 AGENTS.md、.agents/skills/{game-e2e-cdp-harness,cheap-model-game-generation}.md 对齐口径。 +- 现状生成主线=game-module-aigc 的 AigcGenerateExecutor + 自研 ExecutorLlmClient→new-api(硬编码 7 步);另有隔离 Python worker(run.py 裸路 + agent_loop Python AgentScope)。两条无图编排。 +- 目标=用 SAA(v1.1.2.2, 已 clone /root/oss/spring-ai-alibaba)的 StateGraph 把生成流表达成可编排图,验证能否原生跑在 Java 栈、状态只靠 MySQL+Redis。 +- 硬约束:① 隔离 spike,目录 spikes/saa-gen-graph/,零改生产 game-cloud 与 Python worker;② build+CDP 九门 harness 子进程复用、禁重写;③ 证据沿用 game-e2e-cdp-harness 四件套;④ 不引 AgentScope(自治节点用 SAA 自带 ReactAgent.asNode);⑤ 严守时间盒,超时按 kill 处理。 +- 派子代理显式传 model(机械活 haiku/有裁量 opus),禁继承。 +``` + +### P1 — 侦察 + 依赖门 E(最便宜的刹车,过不了就停) +``` +任务:为 SAA 生成-编排 spike 做侦察与依赖门验证,产出 spikes/saa-gen-graph/DESIGN.md。先别建完整图。 +1. 摸清现状:精读 wg1/gen-worker/worker/run.py 七步 + agent_loop/studio.py,列出要在 SAA 复刻的"节点+重试/修复条件边+九门证据"清单。 +2. 读 SAA 源码确认编排 API:StateGraph/Node/Edge/addConditionalEdges、ReactAgent(+asNode)、SequentialAgent/ParallelAgent/LlmRoutingAgent/LoopAgent、SubGraphNode、MysqlSaver/RedisSaver。给类名与最小用法。 +3. 跑通门 A(new-api+便宜模型出 JSON,含 schema)、门 C(节点 shell 调既有 build+九门 harness 读回 JSON)的最小验证。 +4. 【门 E,关键】在 game-cloud 实引 SAA BOM/starter,跑 mvn dependency:tree 看与现有 Boot 3.5.9/3.5.14 + SCA 2025.0.0.0 是否冲突;做最小 ApplicationContext 启动 + huijing-server 编译门。明确哪个 BOM 管 Boot 版本。 +STOP:任一门挂→停在本步、写 BLOCKER 与可选方案,不建图。全过→DESIGN.md 末尾标 GREENLIGHT→P2。 +``` + +### P2 — 建 SAA 图 + 跑通金样(DESIGN 亮绿灯后) +``` +前置:DESIGN.md 门 A/C/E 全绿。否则停。 +任务:在 spikes/saa-gen-graph/ 用 SAA StateGraph 把生成路径建成真图,端到端跑通一款金样游戏。 +1. 图:render prompt → ReactAgent/LLM 节点生成 GameConfig(JSON) → schema 校验 → [conditional: 不合法→repair 节点(SAA ReactAgent.asNode, 受预算/迭代上限约束)→回灌] → scaffold → build(工具节点:子进程调既有 esbuild) → play(工具节点:子进程调既有 CDP 九门) → [conditional: 门失败→repair] → emit。 +2. 状态 checkpoint 到 MySQL+Redis;做门 B 真恢复实测(中途 kill,按 threadId 恢复到正确节点)。 +3. 复用纪律:build+九门 harness 子进程复用、零重写。 +4. 目标产物:复刻一款 Python L1 已产金样(Pong/打地鼠),four-piece 证据落盘。 +5. repair 节点须带:失败路 + 预算归属(与 max-iters/max-cost 天花板协调)+ 与九门确定性门的交互(done 由门定,不让 LLM 自评)。 +``` + +### P3 — 裁决 + 对照臂 +``` +任务:照 spikes/agentscope-wg1/VERDICT.md 结构,写 spikes/saa-gen-graph/VERDICT.md,给 go/no-go。必答: +1. SAA 图能否干净表达生成流(含重试/修复条件边 + checkpoint 恢复)?贴证据。 +2. 状态是否真只靠 MySQL+Redis 落地(零新基建)?门 B 恢复实测结果。 +3. 依赖门 E:与现有 Boot/SCA 是否冲突,解法是什么。 +4. reactive↔blocking 边界:长图走 job/worker 后台线程池的实测失败模式(超时/取消/异常传播/checkpoint×响应式流)。 +5. 【对照臂】同款金样,"Python worker 演进" vs "SAA 图" 同口径工时/稳定性/运维对照表;SAA 不显著胜出→建议不迁移。 +红线:权威真玩门必须在 mini-desktop(x86) 复跑后才能下生产结论,VERDICT 注明本机(ARM)仅快迭代证据。 +末尾:给生产接入建议(图原生进 game-cloud vs 独立 Java worker)——留给后端侧另开,本 spike 不动手。 +``` + +## 7. 失败/回滚 +任一门挂 → 回落**现有 Java 执行器 + Python worker**(都在跑),spike 目录可弃,零损失(Phase1 低成本可逆)。 + +## 8. 完成条件 +门 A–E 全绿 + 一款金样真玩四件套 + 对照臂数据 + `VERDICT.md`(go/no-go)+ **mini-desktop 复跑权威真玩门**。 diff --git a/docs/agent-specs/2026-06-15-SAA-能力API接入-dossier.md b/docs/agent-specs/2026-06-15-SAA-能力API接入-dossier.md new file mode 100644 index 00000000..027c2ba2 --- /dev/null +++ b/docs/agent-specs/2026-06-15-SAA-能力API接入-dossier.md @@ -0,0 +1,70 @@ +# SAA 能力 / API / 接入 档案(多路源码调查汇总) + +> 来源:4 个 Claude/Opus 子代理逐行源码取证(API 面 / 能力盘点 / 接入部署 / 用法范式 四视角),彼此交叉验证一致;Codex 第 5 路 51min 卡死已取消,不影响结论(四路已互验)。 +> 基线:`/root/oss/spring-ai-alibaba` @ tag **v1.1.2.2**(HEAD 7405a7d),底层 spring-ai 1.1.2。 +> 关联:spike 执行稿 `2026-06-15-SAA-AgentScope-spike-execution.md`、目标架构 `…目标架构-review.md`、决策 [[saa-agentic-infra-decision]]。 + +--- + +## 0. 一句话结论(决策级) + +1. **我们的生成流水 = 确定性多步编排 → 用「裸 `StateGraph` + 自定义 `NodeAction`」**,不用 `ReactAgent`/flow agent(那是给"LLM+工具自治"的)。repair 环用 `addConditionalEdges` 回边 + state 计数器,不用 LoopAgent。 +2. **模型**:SAA 复用上游 Spring AI 的 `OpenAiChatModel`/`OpenAiApi`(SAA 无自有 OpenAI 封装)。**baseUrl 指 new-api**,每节点一个 `OpenAiChatModel` bean(共用一个 `OpenAiApi`,仅 `defaultOptions.model` 不同),节点按 `@Qualifier` 取。便宜模型(DeepSeek)同构,有原生 starter。 +3. **状态**:`MysqlSaver`(收 `DataSource`,直接注入 yudao Druid)作引擎**唯一权威 saver** + `RunnableConfig.threadId(任务id)`;Redis 作业务层旁路缓存(**一张图只能注册一个 saver**,注册俩抛异常)。`releaseThread(false)` 才能长期续跑。 +4. **依赖冲突风险 = 低-中、可控**:只引 `graph-core` + 3 个 BOM;**不引 SAA 的 Boot BOM**(让项目 3.5.14 胜出);**不用 RedisSaver 即避开** Redisson 3.x↔4.x。fastjson/okhttp 双方完全一致;graph-core **不引 web 框架**(只 reactor-core),不与 yudao spring-mvc 抢栈。 +5. **运行形态**:进程内嵌,抽 `GenerationDispatcher` 接口挂在现有 `AigcTaskService.submitGenerate → completeWithVersion` 契约后(HttpWorkerDispatcher | SaaGraphDispatcher 灰度切,框架可换);图用自带阻塞 `invoke()` 在**后台线程池**跑(禁 Tomcat/Reactor 线程),图执行不包大事务。独立 worker 留 Phase2。 + +--- + +## 1. 关键 API(照着写代码那层) + +- **图**:`new StateGraph(name, KeyStrategyFactory)`;`addNode(id, node_async(NodeAction))`;`addEdge(START,..)/(..,END)`;`addConditionalEdges(from, edge_async(EdgeAction→String), Map)`(**repair 回边靠它,mappings 不可空**);`compile(CompileConfig)→CompiledGraph`。 +- **状态读写契约**:节点 `NodeAction.apply(OverAllState)` 内 **读** `state.value("k", T.class)`,**写** 靠 `return Map.of("k", v)`(框架按 key 的 `KeyStrategy` 合并;`ReplaceStrategy` 覆盖 / `AppendStrategy` 追加 messages)——**不是 setter**。 +- **执行**:`invoke(inputs, RunnableConfig)→Optional`(内部即 `stream().last().block()`,自带阻塞);`stream(...)→Flux`。 +- **模型(上游三件套)**:`OpenAiApi.builder().baseUrl(NEW_API).apiKey(KEY).build()` → `OpenAiChatModel.builder().openAiApi(api).defaultOptions(OpenAiChatOptions.builder().model("deepseek-chat").build()).build()`。 +- **持久化**:`MysqlSaver.builder().dataSource(ds).createOption(CREATE_IF_NOT_EXISTS).build()` / `RedisSaver.builder().redisson(redissonClient).build()` → `SaverConfig.builder().register(saver).build()` → `CompileConfig.builder().saverConfig(..).releaseThread(false)` → `RunnableConfig.builder().threadId("..")`。 +- **流式**:`stream()→Flux`;`instanceof StreamingOutput` 区分 LLM token chunk,`.message().getText()` 取增量。**⚠️ 无 `streamEvents`**。 + +## 2. 能力盘点:本期(MySQL+Redis only)可用 vs Phase2 + +**✅ 本期即可落(纯 JVM 或仅 MySQL+Redis,覆盖我们核心用例)**:图编排全集(条件/并行/嵌套子图/循环)、agentic ReAct、工具调用、多 agent(sequential/parallel/routing/conditional/loop)、持久化 checkpoint/resume(MysqlSaver 仅需 MySQL、RedisSaver 仅需 Redis,**均已验证零 Nacos/MQ import**)、流式、HITL 中断恢复、Hook(10)/拦截器(13)、OpenAI 兼容(new-api)+DeepSeek、A2A 点对点、可观测埋点。 + +**⛔ 需 Phase2 基建**:完整 **Admin 平台(MySQL+Redis+ES+RocketMQ+Nacos 五件套全硬,缺一启动失败)**、A2A-Nacos 服务发现、config-nacos、MCP-Nacos 网关、sandbox(Docker)、RAG 走 ES、Prompt 发布线上热更(写 Nacos)。 + +**两条最易误判**:① **Admin 整包 ≠ MySQL+Redis 就够**——只想用编排引擎就**别起 admin,直接用 `graph-core`+`agent-framework` 库依赖**。② 向量库在本 clone 只有 ES 是具体实现。 + +## 3. 接入 game-cloud(依赖冲突解法放最前) + +**真冲突仅 2 处**: +- **C1 Spring Boot**:项目 3.5.14(huijing-dependencies BOM 实际生效)vs SAA 要 3.5.8 → **不引 SAA 的 Boot BOM,让 3.5.14 胜**(同 minor 补丁前向兼容,jackson/reactor/spring 仅补丁差,实测安全)。 +- **C2 Redisson**:项目 4.4.0 vs SAA graph-core 用 3.22.0(**但 `optional=true`**)→ **不用 RedisSaver 即零冲突**;若用,需实测 redisson 4.x 对 `RMap/RBucket/RLock` 三 API 兼容。 +- **零冲突**:fastjson 1.2.83、okhttp 4.12.0 双方一致;graph-core 不引 web 框架。 + +**Maven(只加到 `game-module-aigc-server/pom.xml`,不动根 POM/BOM)**:import 三 BOM = `spring-ai-bom:1.1.2` + `spring-ai-alibaba-bom:1.1.2.2` + `spring-ai-alibaba-extensions-bom:1.1.2.2`;依赖只加 `spring-ai-alibaba-graph-core`(图引擎,唯一必需)+ 模型 starter(走 new-api 用 `spring-ai-openai`)。 + +**reactive↔blocking 桥接**:`CompiledGraph.invoke()` 自带阻塞(内部 `.block()`)→ 在**后台 `ExecutorService`**(非 Web/Reactor 线程)调用,否则 block-in-reactor 死锁;超时用 `Future.get(timeout)` 外包,取消接现有 `cancelTask`;图执行**不包大 `@Transactional`**,跑完单独短事务落库。 + +**最小验证门 E(6 步,mini-desktop 跑)**:① 加 3 BOM+graph-core → ② `mvn -pl …aigc-server -am dependency:tree` 断言(boot 全 3.5.14 / redisson 4.4.0 / 无 3.22.0 泄漏 / jackson 2.21.x)→ ③ 写一个纯 Java 节点(不调 LLM)的 hello `StateGraph` → ④ `mvn package` 编译门 → ⑤ 单体启动门(验 SAA 与 yudao 自动配置共存无 BeanCreation 失败)→ ⑥ 调 `invoke` 跑一节点断言返回 state。**先不接模型/不接 MysqlSaver/不接 job 链**,隔离验"SAA 库能进 game-cloud 进程"这一最便宜命题。 + +## 4. 面向我们生成流水的用法范式(7 条,骨架见各子代理报告) + +1. **主干**:`StateGraph` + 每步一个 `node_async(NodeAction)`(render→generate→validate→scaffold→build→play→emit)。 +2. **repair 环**:`addConditionalEdges("validate", edge_async(s→ ok?"done": cnt>=N?"giveup":"repair"), …)` 回边 + state `repair_count` 计数器;`recursionLimit`(默认 100,**优雅终止非异常**)作硬刹车,不替代业务 max-iters。 +3. **build/play 节点**:自定义 `NodeAction` 内 `new ProcessBuilder(...)` shell 调 node/esbuild + CDP 九门 harness,`mapper.readTree` 收 JSON,`waitFor(timeout)/destroyForcibly`。**别用 sandbox 模块**(那是 agentscope 远程容器);骨架抄 `LocalFilesystemBackend.java:457-507`。 +4. **进度推前端**:节点 `apply` 返回的 Map 里塞一个 `Flux` → 框架自动在 `stream()` 上展开 → 出口 `@PostMapping(produces=TEXT_EVENT_STREAM_VALUE) Flux>`。 +5. **checkpoint**:MySQL 权威单 saver + `releaseThread(false)`;Redis 业务旁路;同 threadId 二次 invoke 即续跑;time-travel 用 `getStateHistory`+`checkPointId`。 +6. **可选 HITL**:节点实现 `InterruptableAction`,`interrupt()` 里按 state 开关 `review_required` 返回停/放行(条件式,**默认自动放行**);恢复 `updateState(...)`+`withResume()`+`stream(null, cfg)`。**HumanNode 已废,必须配 saver**。 +7. **模型**:N 个 `OpenAiChatModel` bean 全指 new-api、仅 `defaultOptions.model` 不同,节点 `@Qualifier` 取;**`AGENT_MODEL_NAME` 不是选模型机制**(只是节点 ID + 流式事件前缀)。 + +## 5. ⚠️ 六个最易踩的坑(四路交叉验证确认) + +1. **`HumanNode` 整文件被注释、不可用** → 用 `CompileConfig.interruptBefore/After` + `resume()`。 +2. **无 `streamEvents`** → `stream()→Flux` + `instanceof StreamingOutput`。 +3. **ReAct 循环默认无限上限**(`Integer.MAX_VALUE`)→ 用 ReactAgent 时生产必挂 `ModelCallLimitHook/ToolCallLimitHook`;我们用裸 StateGraph 则靠 state 计数器 + `recursionLimit`。 +4. **supervisor / handoff 没实现**(仅枚举占位)→ 用 routing 替代。 +5. **一张图只能注册一个 saver**(注册俩抛 `IllegalStateException`)→ MySQL 权威 + Redis 业务旁路(或自写双写装饰器)。 +6. **Admin 整包要五件套**(MySQL+Redis+ES+RocketMQ+Nacos)→ 只要引擎别起 admin,直接用 graph-core + agent-framework 库。 + +--- + +> 全量逐行证据(含每个类的 文件:行)见本轮四个子代理报告(API 速查 / 能力矩阵 / 接入部署 / 用法范式手册)。本档案为决策级蒸馏,spike 门 E 与生成图实现直接照此落地。 diff --git a/docs/agent-specs/2026-06-15-python-to-SAA-migration-design.md b/docs/agent-specs/2026-06-15-python-to-SAA-migration-design.md new file mode 100644 index 00000000..e2d12002 --- /dev/null +++ b/docs/agent-specs/2026-06-15-python-to-SAA-migration-design.md @@ -0,0 +1,309 @@ +# Python agentic 生成系统 → SAA(Spring AI Alibaba / Java)迁移设计 + +> 类型:迁移设计(只读分析 + 目标图设计,**不含完整实现代码**)。 +> 范围:把「已跑通的 Python agentic 生成系统」(wg1/gen-worker)迁移并**充分发挥 SAA 强项**地适配到 SAA 裸图编排,随后做「Python 生成游戏 vs SAA 生成游戏」对比测试。 +> 基线证据:Python 侧 `wg1/gen-worker/worker/**`(逐行已读);Java 契约 `game-cloud/game-module-aigc/**`(dispatcher 接缝已读);SAA 源码 `/root/oss/spring-ai-alibaba` @ v1.1.2.2(核心类文件位已核);SAA 用法/坑见 `2026-06-15-SAA-能力API接入-dossier.md`。 + +--- + +## 0. 结论先行(决策级) + +1. **迁移本质 = 换「worker 躯体」,不换「契约骨架」。** Java 执行器侧已有干净接缝:`AigcGenerateExecutor.dispatchGeneric` 组 **§6.1 job-in** → `WorkerDispatchClient.dispatch` 投递 → worker 异步产 `engineBundle` → 回调 `DifyCallbackReqVO` 到 `/dify/callback-internal`(`AigcGenerateExecutor.java:435-494`、`WorkerDispatchClient.java:69-107`、`DifyCallbackReqVO.java:25-67`)。SAA 图只替换 **job→engineBundle 这段躯体**,两端契约逐字段不动。 +2. **躯体对应 = Python 的 L2 studio 闭环 → SAA「裸 `StateGraph` + 自定义 `NodeAction`」。** Python 已自证:design→[code|fix 循环]→九门真玩→player 顾问轮(`agent_loop/studio.py:197-314`)。这是**确定性多步编排**,不是「LLM+工具自治」——故用裸图 + 条件边回环 + state 计数器,**不用 ReactAgent/LoopAgent**(dossier §0-1)。 +3. **build + 九门 harness 子进程整体复用,禁重写。** `serve-and-play.sh`→`static-serve`+headless Chrome→`play.cdp.cjs`(九门 A–I)+`build.mjs`(esbuild)是模型无关、已沉淀的确定性地板(`game-runtime/games/_wg1-gen/_shared/play.cdp.cjs:10-26`)。SAA 在 build/play 节点内 `ProcessBuilder` 调同一套 shell(dossier §4-3),**不进 SAA 图、不用 sandbox 模块**。 +4. **SAA 比 Python 手写编排净增 5 项强项**:MySQL checkpoint 可恢复/续跑、Hook+state 双重限次、节点级流式进度(SSE)、子图封装(player 顾问轮)、图可视化与可观测埋点。逐条映射见 §5。 +5. **部署两形态二选一**:① **进程内 `SaaGraphDispatcher`**(消灭 HTTP+HMAC 回调跳,dossier §0-5 首选);② **Java worker 进程**顶替 Python `service.py`,沿用 `/generate` HTTP 端点(drop-in,零改 Java 执行器)。建议**对比测试期走形态②**(隔离变量、与 Python 并排),**生产收口走形态①**。 + +--- + +## 1. 现有 Python agentic 系统精确画像(带 文件:行) + +### 1.1 两层闭环:L1 裸路 vs L2 agentic studio + +| 维度 | L1 裸路(`worker/run.py`) | L2 agentic studio(`worker/agent_loop/studio.py`) | +|---|---|---| +| 角色 | 单模型直出(无 design/无 player) | design → code/fix 多角色 + player 顾问 | +| 框架 | 裸 openai 客户端,零 AgentScope(`_client.py:1-5` 纪律) | AgentScope 2.0.1 进程内多 agent(`studio.py:31-32`) | +| 管线 | generate→validate→scaffold→build→play→retry(`run.py:97-136`) | design→[code\|fix 环]→九门→player 顾问轮(`studio.py:213-280`) | +| 重试上限 | `max_retries`(默认 2,`run.py:88`) | `max_repairs`(默认 5)+ `player_rounds`(默认 1,`studio.py:197`) | +| 产出落盘 | `results/.json`(`run.py:154`) | `results/..json`(`studio.py:312`) | + +**现行生产入口 = L2**:`service.py:173` 在 HTTP job 处理里调 `run_studio(...)`(L1 是早期裸路/冒烟,studio 复用其 `scaffold/build/play`,`studio.py:8`)。**迁移目标锚定 L2。** + +### 1.2 L2 管线步骤(逐节点,`studio.py`) + +1. **design(设计 agent)**`studio.py:214` → `roles.DESIGN_SYSTEM`(`roles.py:8-32`)。一句话 brief → 设计稿 + **末尾自产 ```gatespec JSON 块**(gate-H 推广:让确定性门适用任意游戏)。 +2. **gatespec 抽取与归一**`studio.py:218` → `_extract_gatespec`(`studio.py:72-102`):正则抠 ```gatespec 块,`json_repair` 兜脏 JSON,`expectLatch` 缺省补 True,`paddle-intercept.ballPath` 的 `.y→.x` 安全归一。抽出的 `exportState/driver/controlCheck/assertAfterPlay/expectLatch` 合入 `play_spec`(`studio.py:219-223`)。 +3. **code / fix 循环**`studio.py:231-265`:首轮 role=code,后续 role=fix;`prompt.build_messages(enriched, retry_feedback=feedback)`(`prompt.py:110-119`)把上轮失败原因回喂。每轮: + - `validate.extract_code`(`validate.py:13-28`)→ 抠 ```js 块; + - `validate.validate`(`validate.py:103-111`)= 静态契约扫描(`static_check`,`validate.py:55-77`:默认导出/init·update·render·destroy 四方法/ctx 用法/getEngine 必现/12 条禁用词)+ `node --check` ESM 语法门(`validate.py:80-100`); + - `run.scaffold`(`run.py:33-42`)落 `generated-factory.js` + 拷 entry-bundle/index 模板 + 写 play-spec.json; + - `run.build`(`run.py:45-53`)`ProcessBuilder` 调 `node scripts/build.mjs ... --global-name=__GameBundle`; + - `run.play`(`run.py:56-72`)`ProcessBuilder` 调 `serve-and-play.sh`,读回 `evidence/verdict.json`。 +4. **九门判定(确定性硬地板)**`studio.py:259` → `seven_ok = rc==0 and verdict.pass`。未过 → `run._verdict_feedback(verdict)`(`run.py:75-85`)摘失败守卫回喂 → 继续 fix 环。 +5. **player 顾问轮(七门过后才跑,软门)**`studio.py:267-280` → `run_player_panel`(`studio.py:171-193`):M3 视觉位(`_judge_vision` 读 first-paint/after-play 截图,`studio.py:148-162`)+ flash 文本位(`_judge_text`,`studio.py:165-168`)多人格评判 → 若 verdict=fix 且 `player_round_used --base --cdp` → trap 清理,返 play 退出码。 +- **build**:`run.build`(`run.py:45-53`)`node scripts/build.mjs --global-name=__GameBundle`;`build.mjs:23-31` 锁参 esbuild(bundle+minify+iife+es2019+无 sourcemap),输出 `bundle.iife.js` + `.meta.json`。 +- **九门 A–I**(`play.cdp.cjs:10-20`,全过才 PASS,判定汇总 `play.cdp.cjs:543-667`): + - **A 装载** `__genBooted ∧ __gameHostEngineInitFired ∧ 无 bootError` + - **B 未捕获** `__genInternals.uncaught 空` + - **C 掌帧** 500ms 内 `frame` 增量 ∈ [20,45] + - **D 真渲染** `#game-engine` 有色像素 `bright>50 ∧ maxCh>80` + - **E 活性** 真玩前后帧哈希集 `distinct≥2` + - **F 真接线** `__engineCalls` 命中 `spec.expectedEngineCallPrefixes` 任一前缀(证经 `getEngine()` 真调引擎,非自绘) + - **G 输入有效** 同 seed 起无输入对照实例推进到同帧号,整帧哈希不同 + - **H 机制进展 + latch** `spec.assertAfterPlay[]` 全过 + `expectLatch` 时真玩到 `phase='gameover'` 且**驻留**600ms;依赖游戏 `_forensicsView()` 导出 state(未声明=SKIP 向后兼容) + - **I 控制跟手** `spec.controlCheck` 声明时连点验控制体平滑逼近(未声明=SKIP) +- **适配性真玩驱动**:`spec.driver` 存在=读 state 自动出招(`runDriver`,`play.cdp.cjs:204-212` 分发 8 类:paddle-intercept/tap-targets/flap-to-gap/seek-x/tap-pairs/key-cycle/drag-aiming/aim-fire),解「盲打固定坐标打不动技巧游戏」假阴性;否则走 `spec.inputs` 固定序列。 + +### 1.6 job / callback 契约(迁移的 I/O 边界) + +- **job-in(§6.1)**:Java `dispatchGeneric` 组(`AigcGenerateExecutor.java:464-481`):`job_id, tier="L1", kind="generate", brief, model, budget{maxYuan,maxLlmCalls}, gameId, templateId, renderCtx{template_schema,banned_list,findings}, idempotency_key, deadline_ms, callback{type,target}, traceId`。Python `service.do_POST` 收(`service.py:239-262`)。 +- **握手**:worker 立即回 **202**(`service.py:262`),后台线程异步跑(`service.py:261`);忙时 **409**(串行锁,`service.py:254-257`,serve 4320/CDP 9222 不可并发)。 +- **callback-out**:`build_callback_payload`(`service.py:91-123`)组 `DifyCallbackReqVO` 形态:`traceId, status(succeeded|failed), templateId, gameConfig{templateId,title,theme,engineDriven}, assets:[], engineBundle(成功路=bundle.iife.js 全文) | failureReason(失败路)`;**HMAC-SHA256 签名**(`service.py:135-143`,逐字节对账 Java `CallbackSignatureVerifier`)POST 回 `/dify/callback-internal`。Java 侧 `DifyCallbackReqVO.engineBundle`(`DifyCallbackReqVO.java:53-66`)= P3 additive 字段,`resolveEngineBundleText` 取值写 `GamePackage.engineBundle` 落包入 feed。 +- **出厂自检**:bundle 必含 `__GameBundle` 全局名,否则判失败不落坏包(`service.py:184-195`)。 + +### 1.7 prompt 与证据口径 + +- **prompt**:`prompt.SYSTEM`(`prompt.py:24-107`)= 模块形状(默认导出 createGame,五方法 + `_forensicsView`)+ 能力面(`ctx.getEngine/getInput/time/random`、`boot.mainContext`)+ 12 条硬禁止(禁 import/Math.random/Date.now/addEventListener/rAF...)+ 硬规则(闭包态/受控时钟/getEngine 容错/destroy 幂等/必调一次引擎能力/每帧可见变化)+ **P0 latch 终态硬约束**(gameover 驻留不自动重开)+ few-shot 参照(读 `_goldenpath-probe/factory.js` 防漂移,`prompt.py:9-20`)。 +- **证据四件套**:`verdict.json`(九门 guards + pass)+ `first-paint.png` + `after-play.png`(`play.cdp.cjs:26`),落 `games/_wg1-gen//evidence/`;结果 JSON 含 `attempts[]`(每轮 usage/stage_fail/guards)、`seven_gate_verdict`、`player.panel`、`tokens_by_model`、`cost`(`studio.py:297-311`)。 + +--- + +## 2. Python → SAA 映射表 + +| # | Python 构件(文件:行) | SAA 构件 | 性质 | +|---|---|---|---| +| 1 | L2 主编排 `run_studio` 循环 `studio.py:197-314` | `StateGraph(name, KeyStrategyFactory)` + `CompiledGraph` | **需适配**(命令式循环→声明式图) | +| 2 | design agent 调用 `studio.py:214` | `render`/`design` 节点 = `node_async(NodeAction)` 调 design model | 直接对应 | +| 3 | `_extract_gatespec` `studio.py:72-102` | design 节点内纯 Java 逻辑(正则 + Jackson 容错解析;`json_repair`→自写宽松解析或 `JsonNode` 容错) | **需适配**(无 json_repair 等价;做最小宽松解析) | +| 4 | code/fix 循环体 `studio.py:231-265` | `generate` 节点(首轮 code)+ `repair` 节点(fix)+ `addConditionalEdges` 回边 | **需适配**(用 state `role`/`repair_count` 区分) | +| 5 | `validate.validate` 静态+node --check `validate.py:103-111` | `validate` 节点(静态契约 = 纯 Java 正则;`node --check` = `ProcessBuilder`) | 直接对应(子进程复用) | +| 6 | `run.scaffold` `run.py:33-42` | `scaffold` 节点 = 纯 Java 文件 IO(写 factory + 拷模板 + 写 play-spec) | 直接对应 | +| 7 | `run.build`(esbuild 子进程)`run.py:45-53` | `build` 节点 = `NodeAction` 内 `ProcessBuilder("node","scripts/build.mjs",...)` | **复用·禁重写**(骨架抄 `LocalFilesystemBackend.java:457-507`,dossier §4-3) | +| 8 | `run.play`(九门 CDP 子进程)`run.py:56-72` | `play` 节点 = `ProcessBuilder("bash","serve-and-play.sh",...)` + 读 `verdict.json` | **复用·禁重写** | +| 9 | 九门 verdict.pass 判定 `play.cdp.cjs:666` | **条件边** `addConditionalEdges("play", s→pass?"emit":cnt>=N?"giveup":"repair")` | **SAA 更强**(确定性门作条件边,图天然支持) | +| 10 | repair 计数 `max_repairs` `studio.py:231` | state `repair_count` 计数器 + `recursionLimit` 硬刹车(dossier §4-2) | **SAA 更强**(Hook 可选叠加限次) | +| 11 | player 顾问轮 `run_player_panel` `studio.py:171-193` | **子图** `player-panel`(vision+text 两节点)或单 `player` 节点 + 回边 | **SAA 更强**(子图封装/可并行 vision\|text) | +| 12 | 失败回喂 `prompt.build_messages(retry_feedback)` `prompt.py:110` | repair 节点读 state `feedback` 注入 user prompt(state 即传参) | 直接对应 | +| 13 | 模型路由 `config.model_name_for` + `models.yaml` `config.py:51-56` | N 个 `OpenAiChatModel` bean(共用 `OpenAiApi`,仅 `defaultOptions.model` 异),节点 `@Qualifier` 取(dossier §1-24) | **需适配**(YAML 阶段→Spring 配置/Map) | +| 14 | `_client.chat`(裸 openai,base_url=new-api)`_client.py:60-98` | 上游 `OpenAiApi.builder().baseUrl(NEW_API)` + `OpenAiChatModel`(dossier §1-24) | 直接对应(new-api OpenAI 兼容同构) | +| 15 | `RecordingChatModel` usage 记录 `config.py:26-42` | `ChatModel` 调用后读 `ChatResponse.metadata().usage()`,写入 state token 累计 | **需适配** | +| 16 | `cost.py` quota 折算 `cost.py:54-66` | 复用 Python `cost.py` 子进程**或** Java 重写 HttpClient 调 `/api/pricing`+`/api/status` | **需适配**(测而不闸,建议先复用脚本) | +| 17 | 网关代理旁路 `_client.py:35-44` | **不适用**(JVM 不经 clash;直连 new-api,无此坑) | 直接对应(坑消失) | +| 18 | `service.py` HTTP 壳 + HMAC 回调 `service.py:155-262` | 形态①去掉(进程内 `SaaGraphDispatcher` 直调 `handleCallback`);形态②Spring `@RestController /generate` + 后台线程池跑图 | **需适配**(见 §3.4) | +| 19 | 串行锁 `service.py:80` | 形态②`Semaphore(1)` 守 serve 4320/CDP 9222(图执行在后台 `ExecutorService`,dossier §3-45) | 直接对应 | +| 20 | 结果落盘 `results/*.json` `studio.py:312` | 节点写 state;emit 节点从 state 组 `DifyCallbackReqVO`(+ 诊断落盘可选) | 直接对应 | +| — | **新增(Python 无)** | **MysqlSaver checkpoint**(同 threadId 续跑/time-travel,dossier §4-5) | **SAA 净增** | +| — | **新增(Python 无)** | **节点级 streaming 进度**(`stream()→Flux`,SSE 推前端,dossier §4-4) | **SAA 净增** | + +--- + +## 3. SAA 目标图设计 + +### 3.1 节点 + 边组织 + +```mermaid +flowchart TD + START([START]) --> render[render
取 job→state:brief/model/gameId/budget] + render --> design[design 节点
设计稿+自产 gatespec→merge play_spec] + design --> generate[generate 节点
code 模型出工厂源码] + generate --> validate[validate 节点
静态契约+node --check 子进程] + validate -->|ok| scaffold[scaffold 节点
落 factory+模板+play-spec] + validate -->|fail & cntfix 模型回喂失败原因] + scaffold --> build[build 节点
ProcessBuilder esbuild] + build -->|ok| play[play 节点
ProcessBuilder serve-and-play 九门] + build -->|fail & cnt|九门 pass| player{{player-panel 子图
vision+text 顾问·软门}} + play -->|fail & cnt|fail & cnt>=N| giveup[giveup 节点
status=failed] + repair --> generate + player -->|无体验问题 或 player轮用尽| emit[emit 节点
读 bundle.iife.js→组 DifyCallbackReqVO] + player -->|有问题 & playerRound END([END]) + emit --> END +``` + +**关键边语义(条件边靠 `EdgeAction→String` + `mappings` Map,dossier §1-21)**: +- `validate`/`build`/`play` 三处失败回边**共用同一判据**:`ok?下一步 : repair_count>=maxRepairs?"giveup" : "repair"`;失败原因写 state `feedback`,repair 节点读取注入 prompt。 +- `play` 成功边 → `player`;`player` 输出 verdict=fix 且 `player_round errs = staticCheck(s.value("factorySrc")); // 默认导出/四方法/getEngine/12 禁用词 + if(errs.isEmpty()) errs = nodeCheck(src); // ProcessBuilder("node","--check",tmp.mjs) + return errs.isEmpty()? Map.of("validateOk",true) + : Map.of("validateOk",false,"feedback","校验未过:\n"+join(errs)); +} +``` + +**build / play 节点(ProcessBuilder,禁重写 harness;骨架抄 LocalFilesystemBackend.java:457-507)** +```java +Map apply(OverAllState s){ // play 节点 + Process p = new ProcessBuilder("bash", + "games/_wg1-gen/_shared/serve-and-play.sh", gameDir, "4320","9222") + .directory(gameRuntimeRoot).start(); + boolean done = p.waitFor(180, SECONDS); // 超时 destroyForcibly(对齐 timeout=180) + if(!done){ p.destroyForcibly(); return Map.of("playPass",false,"feedback","真玩超时"); } + JsonNode verdict = mapper.readTree(Path.of(gameDir,"evidence","verdict.json").toFile()); + boolean pass = p.exitValue()==0 && verdict.path("pass").asBoolean(false); + return Map.of("verdict",verdict,"playPass",pass, + "feedback", pass? "" : summarizeFailedGuards(verdict)); // 等价 _verdict_feedback +} +``` + +**条件边(EdgeAction,dossier §1-21 mappings 不可空)** +```java +graph.addConditionalEdges("play", + edge_async(s -> { + if (Boolean.TRUE.equals(s.value("playPass"))) return "ok"; + return s.value("repairCount",0) >= MAX_REPAIRS ? "giveup" : "repair"; + }), + Map.of("ok","player", "repair","repair", "giveup","giveup")); +``` + +**emit 节点(组回调,复用现有写入路径)** +```java +Map apply(OverAllState s){ + String bundle = readFile(gameDir+"/bundle.iife.js"); // 读 __GameBundle iife 全文 + if(!bundle.contains("__GameBundle")) return Map.of("status","failed","failureReason","bundle_missing_global_name"); + return Map.of("status","succeeded","engineBundle",bundle); // → DifyCallbackReqVO.engineBundle +} +``` + +### 3.4 与 Java 契约的对接(两形态) + +- **形态①进程内 `SaaGraphDispatcher`(生产收口首选,dossier §0-5/§3-45)**:实现 `GenerationDispatcher`(与 `WorkerDispatchClient` 同接口位),`dispatch(job)` 把 job 转 state,在**后台 `ExecutorService`** `compiledGraph.invoke(state, RunnableConfig.threadId(traceId))`(自带阻塞,禁 Tomcat/Reactor 线程),出口直接进程内调 `difyCallbackService.handleCallback(reqVO)`——**消灭 HTTP 跳 + HMAC**(同信任域)。图执行**不包大 `@Transactional`**,跑完单独短事务落库。 +- **形态②Java worker 进程(对比测试期 drop-in)**:Spring `@RestController` 暴露 `POST /generate`(顶替 Python `service.py`),收 §6.1 job→202 握手→后台线程跑图→组 `DifyCallbackReqVO`+HMAC POST 回 `/dify/callback-internal`。**Java 执行器零改**(`aigc.executor.worker-url` 指向新 Java worker 即可),与 Python worker 并排,隔离变量做对比。 + +--- + +## 4. 复用 vs 重写边界(硬约束) + +| 组件 | 决策 | 理由 | +|---|---|---| +| `serve-and-play.sh` / `play.cdp.cjs`(九门 A–I)/ `static-serve.cjs` | **子进程复用·禁重写** | 模型无关的确定性地板;重写 = 引入新假阴性/假绿风险,且 Java 无 CDP+esbuild 等价生态现货 | +| `scripts/build.mjs`(esbuild 锁参) | **子进程复用·禁重写** | 锁参影响 SIZES 口径;Java 无 esbuild 等价 | +| entry-bundle/index 模板、`_goldenpath-probe/factory.js` few-shot | **复用(读文件)** | 装载契约/few-shot 防漂移 | +| `cost.py` 成本折算 | **建议子进程复用**(或后续 Java 重写) | 测而不闸;Python 脚本已沉淀,先复用省事 | +| `run.scaffold` 文件 IO / `validate.static_check` 静态扫描 / `extract_code` | **重写进 SAA 节点(纯 Java)** | 轻逻辑,无生态依赖,进图后受 checkpoint/可观测管辖 | +| `run_studio` 命令式循环 / code-fix-player 编排 | **重写为 SAA 裸图** | 迁移主体;命令式→声明式图换取可恢复/限次/流式 | +| `_client.chat` / `config.build_model` 模型调用 | **重写为 SAA `OpenAiChatModel`** | 上游 OpenAI 兼容指 new-api,同构 | +| `service.py` HTTP 壳 + HMAC | **形态①去掉 / 形态②Java 重写** | 见 §3.4 | +| `_extract_gatespec` 的 `json_repair` 兜脏 | **重写为最小宽松解析** | Java 无 json_repair;做「strip ```gatespec→Jackson 容错→缺省补 expectLatch/ballPath 归一」最小等价 | + +--- + +## 5. 「充分发挥 SAA 强项」清单(相比 Python 手写编排) + +1. **可恢复(checkpoint/resume)**:Python `run_studio` 一旦进程崩,整 job 从零重跑(`results/*.json` 仅事后产物)。SAA MysqlSaver + `threadId(traceId)` → 崩溃后同 threadId 二次 invoke 即从最后节点续跑(dossier §4-5);与现有 watchdog 收尸(`AigcGenerateExecutor.java:620-644`)互补:**watchdog 兜超时、checkpoint 兜续跑**。 +2. **限次双保险**:Python 靠裸 `for attempt in range(max_repairs+1)` 计数(`studio.py:231`)。SAA = state `repair_count`(业务语义主限次)+ `recursionLimit`(引擎级硬刹车,**优雅终止非异常**,dossier §5-3);ReAct 那种无限循环坑(dossier §5-3)在裸图下天然不触发。 +3. **节点级流式进度**:Python 仅 stdout `print`(`run.py:176`、`studio.py:338`),前端无实时进度。SAA `stream()→Flux` + `StreamingOutput`(dossier §4-4,注意**无 streamEvents**)→ 出口 `@PostMapping(produces=TEXT_EVENT_STREAM)` 把「设计中→写码中→打包中→真玩中→第 k 次修复」推前端,直接对接 studio 创作页进度条。 +4. **子图封装**:Python `run_player_panel` 是内联函数(`studio.py:171`)。SAA 把 player-panel(vision\|text)封为**子图**(dossier §2 嵌套子图本期可用),主图一节点引用;vision/text 两位可并行(Python 当前串行 `studio.py:180-192`)→ 顾问轮提速。 +5. **可观测 + 可视化**:Python 编排是黑盒命令式流。SAA 节点级埋点 + 图结构可导出(dossier §2 可观测埋点),排障/审计/给非工程同事讲清「九门卡在哪个节点」远优于读 print 日志。 +6. **确定性门即一等公民边**:九门 `verdict.pass` 在 Python 是 `if` 分支(`studio.py:262`);在 SAA 是 `addConditionalEdges` 的路由谓词——把「能玩才算 pass」这条铁律**固化进图拓扑**,杜绝顺手改 if 绕过门。 + +--- + +## 6. 风险与待定 + 迁移分步 + +### 6.1 风险与待定 + +| 级别 | 项 | 说明 / 缓解 | +|---|---|---| +| 高 | **AgentScope 多角色语义损失** | Python design/code/fix/player 是 AgentScope `Agent`(`studio.py:127-134`,`max_iters=1` 单轮)。SAA 裸图下每角色=一个节点调一次模型,**单轮语义天然等价**(无工具自治),低风险;但 player 的「人格 + JSON 评判」需在节点内复刻 `roles.player_system`(`roles.py:38-57`),按字段对齐。 | +| 高 | **build/play 子进程环境耦合 mini-desktop** | serve-and-play.sh 依赖 Chrome+node+esbuild+lsof(`serve-and-play.sh:11-44`),只在 mini-desktop(x86 e2e 机)跑。SAA worker(无论形态①②)**必须与 harness 同机部署**(all localhost),cwd=game-runtime 根。形态①把图嵌进 game-cloud → game-cloud 须能在 mini-desktop 起且能 fork 子进程(确认权限/PATH)。 | +| 中 | **MysqlSaver 注入 yudao Druid + 一图一 saver** | dossier §3-13/§5-5:MysqlSaver 收 `DataSource`(注 Druid),但**一图只能注册一个 saver**,注册俩抛异常;Redis 走业务旁路或自写双写装饰器。首次接需验 `CREATE_IF_NOT_EXISTS` 建表与 yudao Flyway 不冲突。 | +| 中 | **依赖冲突(Boot 3.5.14 / Redisson 4.4.0)** | dossier §3:不引 SAA Boot BOM(让 3.5.14 胜)、不用 RedisSaver(避 Redisson 3.x↔4.x)。形态②独立 worker 可用独立 Boot 版避此坑——**对比期形态②额外降风险**。 | +| 中 | **json_repair 无 Java 等价** | 便宜模型 gatespec/player JSON 常脏(`studio.py:80-90` 靠 json_repair 兜)。Java 需最小宽松解析(截块→Jackson 容错→字段缺省);脏到 Jackson 也解不动时按「gatespec 缺失→退 brief 默认 play_spec」降级(与 `_extract_gatespec` 返 None 同语义)。 | +| 低 | **代理旁路坑消失** | `_client.py:35-44` 的 NO_PROXY 坑是 Python+clash 本机特有,JVM 不经 clash,**迁移后此坑不存在**(净简化)。 | +| 待定 | **对比测试口径** | 「Python vs SAA 生成游戏」对比需固定:同 brief 集 + 同 model(stage1)+ 同九门 harness + 同 maxRepairs;比较维度 = 九门通过率/平均修复轮数/wall/¥/player 评分。建议形态②并排跑同一批 briefs(`wg1/gen-worker/briefs/*.json` 16 款现货)。 | + +### 6.2 迁移分步(可执行,供后续实现) + +1. **门 E(最便宜命题,dossier §3-47)**:aigc-server pom 加 3 BOM + graph-core(**当前 pom 尚无 SAA 依赖**,已核 `game-module-aigc-server/pom.xml`)→ `dependency:tree` 断言(Boot 全 3.5.14 / Redisson 4.4.0 / 无 3.22.0 泄漏)→ 纯 Java hello `StateGraph` 节点 → `mvn package` + 单体启动门(SAA 与 yudao 自动配置共存无 BeanCreation 失败)→ `invoke` 跑一节点。**先不接模型/saver/job 链。** +2. **模型节点接 new-api**:建 N 个 `OpenAiChatModel` bean(共用 `OpenAiApi` baseUrl=new-api,per-role model 名),一个 generate 节点真出 ```js,断言能拿到工厂源码 + usage。 +3. **validate/scaffold/build/play 四节点接子进程**:build/play 用 `ProcessBuilder` 调既有 harness(mini-desktop,cwd=game-runtime),读回 `verdict.json`;单测验九门 verdict 能解析、退出码正确传递。 +4. **组装裸图 + 条件边**:render→design→generate→validate→[repair 环]→scaffold→build→play→player→emit/giveup,state `repair_count`/`feedback` 回喂;跑通**一款** brief(如 breakout)端到端过九门。 +5. **接 MysqlSaver checkpoint**:注 Druid DataSource,`releaseThread(false)`,验同 threadId 续跑 + time-travel。 +6. **接 streaming 进度**(可选,对比期可缓):节点塞 Flux,SSE 出口验前端收到逐节点进度。 +7. **形态②Java worker `/generate` 上线**:Spring `@RestController` + 后台线程池 + `Semaphore(1)` 串行 + HMAC 回调;`aigc.executor.worker-url` 切到 Java worker,并排 Python worker。 +8. **对比测试**:同批 briefs 各跑 Python worker / SAA worker,按 §6.1 待定口径出对比表(九门通过率/修复轮数/wall/¥/player 分)。 +9. **形态①收口(生产)**:对比验证 SAA 不残废后,实现进程内 `SaaGraphDispatcher`(去 HTTP+HMAC),灰度切 `HttpWorkerDispatcher | SaaGraphDispatcher`。 + +--- + +> 验证状态:本设计为**只读分析 + 目标图设计**,未写实现代码、未跑构建。Python 侧画像与 Java 契约边界均带 文件:行 证据(已逐行读);SAA 核心类文件位已核(`spring-ai-alibaba-graph-core`);SAA 用法/坑引自 dossier(四路源码取证)。**下一步可验证项 = 迁移分步①门 E**(最便宜、隔离「SAA 库能进 game-cloud」单命题)。 + +--- + +## 7. 实测进度与坑(增量更新) + +### step 2 已过(2026-06-15)— SAA→new-api→生成源码 最小链 +- 证据:`game-module-aigc-server` 内 `SaaNewApiGenerateSmokeTest` 绿(1 passed);`deepseek-v4-flash@100.64.0.8:3000` 真出 breakout 工厂源码(5779 chars,`export default createGame`);usage prompt=336 / completion=5717。 +- 文件:`.../aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java` + +### 实测迁移坑(后续节点必看) +1. **【高频坑】Spring AI `OpenAiApi` baseUrl 不要带 `/v1`**:Spring AI 自拼 `completionsPath=/v1/chat/completions`,baseUrl 带 `/v1` → 404 `/v1/v1/...`。而 openai-python 的 base_url 要带 `/v1`。同一 `.env`(`NEWAPI_BASE_URL=.../v1`)两侧通用须 Java 侧 `stripV1Suffix()` 剥末尾 `/v1` → baseUrl 用 **host 根**。 +2. **`-am` reactor 跑单测**:上游模块无匹配测试会 "No tests matching" 失败 → 加 `-Dsurefire.failIfNoSpecifiedTests=false`。 +3. **key**:`NEWAPI_KEY` 在 mini-desktop `/root/games-development-ai/wg1/gen-worker/.env`(gitignored,worktree 内无);跑测试经环境变量传入。 +4. **SAA model API(v1.1.2.2 javap 实证)**:`OpenAiApi.builder().baseUrl(host根).apiKey()` → `OpenAiChatModel.builder().openAiApi().defaultOptions(OpenAiChatOptions.builder().model().temperature().maxTokens())` → `model.call(new Prompt(List.of(SystemMessage,UserMessage))).getResult().getOutput().getText()`;usage=`resp.getMetadata().getUsage().getPromptTokens()`。 diff --git a/docs/agent-specs/2026-06-15-python-vs-SAA-对比测试.md b/docs/agent-specs/2026-06-15-python-vs-SAA-对比测试.md new file mode 100644 index 00000000..3f6709c0 --- /dev/null +++ b/docs/agent-specs/2026-06-15-python-vs-SAA-对比测试.md @@ -0,0 +1,94 @@ +# P4 · Python 生成 vs SAA 生成 对比测试(首轮 3 款) + +> 日期 2026-06-15(执行 2026-06-16 凌晨,mini-desktop) · 框架对比 spike(HJ-AGI-002 后续) +> **一句话裁定见文末 §5。本页结论先行,证据路径见 §6。** + +## 1. 目标与公平性铁律 + +同 brief 集、同 stage1 模型、同 maxRepairs、同九门 harness 下,**只换编排框架**——Python L2 studio(AgentScope 手写编排循环)vs SAA 裸 StateGraph 图——各跑一遍,公平对比「生成的游戏 + 生成过程」。 + +**公平性铁律(两侧严格相等):** + +| 维度 | 取值(两侧一致) | +|---|---| +| brief 集 | breakout(paddle-intercept)/ whack(tap-targets via inputs)/ flappy(flap-to-gap),取自 `wg1/gen-worker/briefs/.json` | +| 模型路由 | models.yaml **stage1**:design/code/fix/player_text=`deepseek-v4-flash`、player_vision=`MiniMax-M3` | +| maxRepairs | 5 | +| playerRounds | 1(player 为软门顾问;九门是硬地板) | +| 九门 harness | 同一 `serve-and-play.sh` + `play.cdp.cjs`(A_boot/B_uncaught/C_frame/D_render/E_live/F_wiring/G_input/H_progress/I_control) | +| prompt/角色 | SAA 侧 `SaaPrompts` 逐字移植 Python `prompt.py`/`roles.py`/`studio.py` 抽取逻辑 | +| play_spec 基线 | **两侧均取 brief 自带 `play_spec`**(含 inputs/driver/controlCheck/assertAfterPlay/expectedEngineCallPrefixes),design 自产 gatespec 再 merge 覆盖 exportState/driver/controlCheck/assertAfterPlay/expectLatch | +| 端口/cwd/key | 共用 4320/9222(串行,每 brief 净场);cwd=主仓 game-runtime;NEWAPI_KEY 经 env(baseUrl 含 /v1,SAA 侧剥 /v1) | + +> **只换编排框架**:Python = `worker/agent_loop/studio.py` 的 AgentScope 进程内多 agent 手写循环;SAA = `SaaStudioGraphTest` 组装的 Spring-AI-Alibaba Graph 裸 StateGraph(render→design→generate→validate→[scaffold→build→play]→player→[repair 回环]→emit/giveup)。其余(模型/prompt/门/brief)全等。 + +## 2. 执行方式 + +- **串行**:两侧共用 4320/9222 不可并发;每 brief 先 Python 后 SAA,跑前净场(杀残留 Chrome/serve);每 run 唯一 gameId+目录避撞。Python gameId=`p4-py-`、SAA gameId=`saa--`。 +- **单实例锁**:驱动脚本加 `flock` 单实例锁(首轮曾因外部编排并发双跑撞端口,数据作废重跑;v2 锁后杜绝)。 +- **Python 侧**:`python -m worker.agent_loop.studio --stage stage1 --max-repairs 5 --player-rounds 1 --brief briefs/.json --port 4320 --cdp-port 9222` → `results/.stage1.json`(schema:seven_gate_verdict/attempts/tokens_by_model/cost/player)。 +- **SAA 侧**:`mvn -o -pl ...aigc-server -am -Dtest=SaaStudioGraphTest -Dsaa.harness=1 -Dsaa.brief= -DGAME_RUNTIME_DIR=... -Dsaa.port=4320 -Dsaa.cdpPort=9222 test` → `results/saa--.saa.json`(schema 已对齐 Python:含 ¥/per-model/attempts/player)。 +- **本次为对比做的最小改动**(仅 SAA 测试脚手架,不动图/节点/prompt 等忠实部分):① `SaaStudioGraphTest` 参数化 `-Dsaa.brief`(原硬编码 breakout)+ 读 brief 自带 `play_spec` 作基线(公平对齐 Python `_load_brief`,原传 `{}` 会致 whack 无 inputs/各 brief 无 F 门期望前缀=比 Python 宽松);② diag JSON 落 `player` 字段(原仅打印 console)。 + +## 3. 对比表(每 brief × {Python, SAA}) + + + +| brief | side | 九门pass | 过门数 | 挂门 | repair | wall(s) | ¥ | player vision(c/f·v) | player text(c/f·v) | tokens(in/out) | 备注 | +|---|---|---|---|---|---|---|---|---|---|---|---| +| breakout | Python | _待填_ | | | | | | | | | | +| breakout | SAA | _待填_ | | | | | | | | | | +| whack | Python | _待填_ | | | | | | | | | | +| whack | SAA | _待填_ | | | | | | | | | | +| flappy | Python | _待填_ | | | | | | | | | | +| flappy | SAA | _待填_ | | | | | | | | | | + +> 列说明:`player vision/text` 格式 = 完整度/好玩·verdict(c=completeness 1-5、f=fun 1-5、v=pass/fix)。vision=MiniMax-M3(看首帧+玩后截图)、text=deepseek-v4-flash(只读运行数据)。 + +## 4. 分析 + + + +## 5. 一句话裁定 + + + +## 6. 证据路径 + +- 结果 JSON:`wg1/gen-worker/results/p4-py-{breakout,whack,flappy}.stage1.json`(Python)、`wg1/gen-worker/results/saa-{breakout,whack,flappy}-.saa.json`(SAA) +- 运行日志:`mini-desktop:/root/p4-logs/{py,saa}-.log`、驱动 `mini-desktop:/root/p4-logs/_driver.log` +- 九门证据四件套(每 run):`game-runtime/games/_wg1-gen//evidence/{verdict.json,first-paint.png,after-play.png}` +- SAA 图/节点/prompt(忠实部分):`saa-int/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/.../saa/{SaaStudioGraphTest,SaaStudioNodes,SaaGenNodes,SaaPrompts}.java` +- Python L2 studio:`wg1/gen-worker/worker/agent_loop/studio.py` + `worker/{run,prompt,validate,_client,cost}.py` + `worker/models.yaml` + +## 7. 已知公平性脚注(便宜模型噪声 + 微差异) + +- **单样本噪声**:design/code/fix=deepseek-v4-flash(便宜档,温度高/输出抖),首轮每 brief×side 各仅 **1 样本**,九门通过率/player 分/修复轮数受随机性影响大。**首轮 3 款是信号、非定论**;定量结论需多样本(≥3-5/格)。 +- **code/fix 温度微差**:Python L2 的 design/code/fix 经 AgentScope `build_model`(不下发 temperature → 网关服务端默认);SAA code/fix 显式 temperature=0.0(更确定)。player 两位两侧均 0.3。此为已知微差异(SAA 作者 A-3 标注的对齐选择),不改两侧忠实部分,记此脚注。 +- **player 软门不计入 pass**:九门(尤其 H_progress+latch)是硬地板;player 仅在九门过后作顾问轮,verdict=fix 且轮未尽才回修。两侧同口径。 + +--- + +## 结果(首轮 3 款 · 单样本 · 2026-06-16 03:49–04:45) + +| brief | 侧 | 九门pass | 门(过/总) | 修复轮 | wall(s) | ¥ | tokens(in/out) | player(文 c/f, 视 c/f) | 失败门 | +|---|---|---|---|---|---|---|---|---|---| +| breakout | PY | ✅ | 9/9 | 0 | 186 | 0.049 | 12131/11029 | 4/4 · 4/3 | - | +| breakout | SAA | ✅ | 9/9 | 1 | 332 | 0.070 | 16847/21895 | 4/3 · 5/3 | - | +| whack | PY | ❌ | 8/9 | 5(giveup) | 652 | 0.083 | 27632/38238 | — | G_input | +| whack | SAA | ✅ | 9/9 | 3 | 415 | 0.088 | 24821/30900 | 4/4 · 4/3 | - | +| flappy | PY | ❌ | 8/9 | 5(giveup) | 837 | 0.104 | 29602/50636 | — | G_input | +| flappy | SAA | ❌ | 8/9 | 5(giveup) | 938 | 0.127 | 28441/66411 | — | H_progress | + +## 裁定 + +- **SAA 与 Python 平手或更优**:breakout 两侧均过;**whack SAA 过、PY 未过(SAA 胜)**;flappy 两侧均未过(flap-to-gap 难,便宜模型都吃力)。SAA 通过率 2/3 ≥ Python 1/3。 +- **SAA 迁移功能性等价、不残废**:同 prompt/模型/九门 harness 下,质量(player 分)相当、通过率不低于 Python。**「换编排框架不换效果」成立。** +- **成本/wall**:SAA 略贵(多 design 步 + repair,out tokens 偏高),wall 互有胜负(whack SAA 更快、breakout/flappy PY 更快)。 +- ⚠️ **单样本噪声大**:便宜模型随机性高,whack 的 PY 败/SAA 胜、修复轮差异都可能在噪声内——**这是信号,非统计定论**。 +- 观察:**两次 Python 失败都卡 G_input(输入有效门)**,SAA 未卡——多样本下值得看是否真模式。 + +## 建议 +1. **统计置信**:扩到 N≥3–5 样本 × 全 17 款 briefs 再下定论(当前各 1 样本)。 +2. **SAA 侧已就绪**:双评审 + 9 项必修后完整/公平/正确;可进生产收口(form① 进程内 dispatcher + MysqlSaver checkpoint + streaming,发挥 SAA 强项)。 +3. **并发铁律**:批跑独占 4320/9222,同一时刻只许一个驱动(本轮踩过并发污染坑,已纠正)。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml index 5cfdca16..c47b1612 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml +++ b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml @@ -18,7 +18,23 @@ 被 huijing-server 聚合启动(随单体一起加载,无需独立 bootstrap)。 + + + + org.springframework.aispring-ai-bom1.1.2pomimport + com.alibaba.cloud.aispring-ai-alibaba-bom1.1.2.2pomimport + com.alibaba.cloud.aispring-ai-alibaba-extensions-bom1.1.2.2pomimport + + + + + + com.alibaba.cloud.aispring-ai-alibaba-graph-core + com.alibaba.cloud.aispring-ai-alibaba-agent-framework + com.alibaba.cloud.aispring-ai-alibaba-starter-graph-observation + com.alibaba.cloud.aispring-ai-alibaba-starter-builtin-nodes1.1.2.2 + org.springframework.aispring-ai-starter-model-openai com.wanxiang diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java new file mode 100644 index 00000000..aa813b31 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java @@ -0,0 +1,468 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.action.NodeAction; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +/** + * SaaGenNodes —— SAA 迁移 step 3:四个生成流水 NodeAction(validate / scaffold / build / play)。 + * + *

目标(窄):证明「SAA-Java 能驱动既有 build + 九门 harness」。本步与「LLM 能否生成过门游戏」解耦, + * 用金样 factory(已知过九门的 breakout/generated-factory.js)作输入,只验 harness 驱动链。 + * + *

语义逐一对齐 Python 参照(wg1/gen-worker/worker/): + *

    + *
  • {@link #validateNode()} ≈ validate.py:55-111(static_check 静态契约扫描 + node --check ESM 语法门);
  • + *
  • {@link #scaffoldNode(Path)} ≈ run.py:33-42(落 generated-factory.js + 拷 entry-bundle/index 模板 + 写 play-spec.json);
  • + *
  • {@link #buildNode(Path)} ≈ run.py:45-53(ProcessBuilder node scripts/build.mjs ... --global-name=__GameBundle);
  • + *
  • {@link #playNode(Path)} ≈ run.py:56-72(ProcessBuilder bash serve-and-play.sh,timeout 180s,读 evidence/verdict.json)。
  • + *
+ * + *

纪律(本步边界,对齐迁移设计 §6.2-step3):不接 LLM(不调 new-api)、不接 saver、不接 job 契约、不组全图。 + * harness(serve-and-play.sh / play.cdp.cjs / build.mjs)子进程复用·禁重写(模型无关确定性地板)。 + * + *

状态 key 约定(OverAllState): + *

    + *
  • {@code factorySrc}(in) —— 工厂源码(金样或后续 generate 节点产出);
  • + *
  • {@code playSpec}(in) —— play-spec JSON 文本(驱动九门 F/G/H/I);
  • + *
  • {@code gameId}(in) —— 唯一游戏 id(落 games/_wg1-gen/<id>/,避免覆盖现有游戏);
  • + *
  • {@code validateOk}/{@code validateErrors} —— validate 产出;
  • + *
  • {@code gameDir} —— scaffold 落盘目录绝对路径;
  • + *
  • {@code buildOk}/{@code buildLog} —— build 产出;
  • + *
  • {@code verdict}(JSON)/{@code playPass}/{@code feedback} —— play 产出(playPass=九门是否全过)。
  • + *
+ * + * @author 造梦AI + */ +final class SaaGenNodes { + + private SaaGenNodes() { + } + + /** Jackson(读 verdict.json / play-spec 透传)。yudao 自带 jackson-databind 2.21.x。 */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** esbuild iife 全局名(契约冻结:宿主从 window.__GameBundle 取;出厂自检也据此)。 */ + private static final String GLOBAL_NAME = "__GameBundle"; + + // ====================== 子进程驱动(统一超时·并发 drain·强杀,对齐 Python subprocess.run(timeout=)) ====================== + + /** + * 子进程运行结果({@link #runProcess})。 + *

修复点(评审最高优):原实现先 {@code readAllBytes()} 再 {@code waitFor()}——若子进程输出量大塞满管道缓冲、 + * 而我们又卡在 readAllBytes 读不完,子进程会因写阻塞永不退出 → {@code waitFor(timeout)} 形同虚设、timeout 到不了。 + * 故改为:启动后另起线程并发 drain stdout 到内存 buffer(管道不堵),主线程只 {@code waitFor(timeout)}; + * 超时则 {@code destroy()}→{@code destroyForcibly()}→二次 {@code waitFor},日志取 tail(对齐 run.py 子进程 timeout 语义)。 + */ + static final class ProcResult { + final boolean timedOut; // true=超时被强杀(未在限时内退出) + final int exitCode; // 进程退出码(timedOut=true 时无意义,置 -1) + final String output; // 合并后的 stdout(redirectErrorStream,已含 stderr) + + ProcResult(boolean timedOut, int exitCode, String output) { + this.timedOut = timedOut; + this.exitCode = exitCode; + this.output = output; + } + } + + /** + * 统一子进程运行器(build/play/node-check 三处共用):合并 stderr→stdout,并发 drain 防管道死锁, + * 限时 {@code waitFor},超时 destroy→destroyForcibly→二次 waitFor 兜底强杀。 + * + * @param pb 已配好 command/directory 的 ProcessBuilder(本方法负责 redirectErrorStream(true))。 + * @param timeoutSecs 超时秒数(对齐 Python 对应 subprocess.run(timeout=))。 + * @return ProcResult(timedOut / exitCode / output)。 + * @throws IOException 进程启动失败。 + * @throws InterruptedException 等待被中断(向上抛,由调用方按节点语义处理)。 + */ + static ProcResult runProcess(ProcessBuilder pb, long timeoutSecs) throws IOException, InterruptedException { + pb.redirectErrorStream(true); + Process p = pb.start(); + // 并发 drain:另起守护线程把 stdout 抽干到内存 buffer,确保子进程写管道永不阻塞(修复 readAllBytes/waitFor 死锁)。 + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + Thread drain = new Thread(() -> { + try (InputStream in = p.getInputStream()) { + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) != -1) { + synchronized (buf) { + buf.write(chunk, 0, n); + } + } + } catch (IOException ignore) { + // 进程结束/管道关闭引发的读异常可忽略(已尽量抽干)。 + } + }, "saa-proc-drain"); + drain.setDaemon(true); + drain.start(); + + boolean done = p.waitFor(timeoutSecs, TimeUnit.SECONDS); + if (!done) { + // 超时:先温和 destroy,再强杀,二次短等确保回收(对齐 Python destroy()→destroyForcibly()→二次 waitFor)。 + p.destroy(); + if (!p.waitFor(2, TimeUnit.SECONDS)) { + p.destroyForcibly(); + p.waitFor(2, TimeUnit.SECONDS); + } + } + // 等 drain 线程收尾(最多 2s),把已抽到的输出取出(超时场景取 tail 由调用方做)。 + drain.join(2000); + String output; + synchronized (buf) { + output = buf.toString(StandardCharsets.UTF_8); + } + return new ProcResult(!done, done ? p.exitValue() : -1, output.trim()); + } + + // ====================== ① validate 节点 ====================== + // 静态契约扫描(抄 validate.py:55-77 语义)+ node --check ESM 门(抄 validate.py:80-100)。 + + /** 禁用模式(正则, 说明)——违反即【契约违约】,拦在打包前。逐条对齐 validate.py:_FORBIDDEN。 */ + private static final Object[][] FORBIDDEN = { + {Pattern.compile("(^|\\n)\\s*import\\s"), "禁 import:工厂必须自包含单文件(含禁 import littlejsengine)"}, + {Pattern.compile("\\bimport\\s*\\("), "禁动态 import()"}, + {Pattern.compile("\\brequire\\s*\\("), "禁 require"}, + {Pattern.compile("littlejsengine"), "禁引擎名字空间"}, + {Pattern.compile("\\bMath\\.random\\b"), "禁 Math.random(用 ctx.random)"}, + {Pattern.compile("\\bDate\\.now\\b"), "禁 Date.now(用 ctx.time.nowMs)"}, + {Pattern.compile("\\bperformance\\.now\\b"), "禁 performance.now(用 ctx.time)"}, + {Pattern.compile("\\baddEventListener\\b"), "禁 addEventListener(用 ctx.getInput().on)"}, + {Pattern.compile("\\brequestAnimationFrame\\b"), "禁 requestAnimationFrame(宿主驱动)"}, + {Pattern.compile("new\\s+AudioContext"), "禁 new AudioContext(用 ctx.getAudioContext)"}, + {Pattern.compile("\\bdocument\\s*\\."), "禁直接操作 document"}, + {Pattern.compile("\\bwindow\\s*\\."), "禁直接操作 window"}, + }; + + /** + * 去行注释/块注释,避免禁用词扫描被注释里的字样误伤(对齐 validate.py:_strip_comments)。 + */ + private static String stripComments(String src) { + String s = Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL).matcher(src).replaceAll(""); + s = Pattern.compile("//[^\\n]*").matcher(s).replaceAll(""); + return s; + } + + /** + * 静态契约检查(对齐 validate.py:static_check)。返回错误列表(空=过)。 + * 规则:源码非空且≥60 字 / 含 export default / 四方法(init·update·render·destroy)标识符存在 / + * 含 ctx 用法 / 至少一次 getEngine(真接线门信号)/ 无 12 条禁用词。 + */ + static List staticCheck(String src) { + List errors = new ArrayList<>(); + if (src == null || src.trim().length() < 60) { + errors.add("源码为空或过短(疑似未产出有效代码)"); + return errors; + } + if (!src.contains("export default")) { + errors.add("缺少 export default(必须默认导出工厂函数)"); + } + String scan = stripComments(src); + // 四方法存在性(宽松:方法名作为标识符出现)。 + for (String meth : new String[]{"init", "update", "render", "destroy"}) { + if (!Pattern.compile("\\b" + meth + "\\b").matcher(scan).find()) { + errors.add("缺少 " + meth + " 方法"); + } + } + // 必须经 boot.ctx 取能力(至少出现 ctx. 用法 → 宽松判 ctx 标识符存在)。 + if (!scan.contains("ctx")) { + errors.add("未见 ctx 用法(能力须经 boot.ctx)"); + } + // 必须至少用一次 getEngine(硬性验收点:真接线门要有信号)。 + if (!scan.contains("getEngine")) { + errors.add("未调用 getEngine()(必须在某事件上用一次引擎能力,否则真接线门无信号)"); + } + // 禁用词扫描。 + for (Object[] f : FORBIDDEN) { + Pattern pat = (Pattern) f[0]; + if (pat.matcher(scan).find()) { + errors.add("违禁:" + f[1]); + } + } + return errors; + } + + /** + * node --check ESM 语法门(对齐 validate.py:node_syntax_check)。返回错误(空=过)。 + * 写临时 .mjs → ProcessBuilder("node","--check",tmp);退出码 0=过,否则取 stderr。 + */ + static String nodeCheck(String src) { + Path tmp = null; + try { + tmp = Files.createTempFile("saa-validate-", ".mjs"); + Files.write(tmp, src.getBytes(StandardCharsets.UTF_8)); + // 子进程经统一运行器(并发 drain 防死锁 + 30s 超时强杀,对齐 validate.py subprocess.run(timeout=30))。 + ProcResult r = runProcess(new ProcessBuilder("node", "--check", tmp.toString()), 30); + if (r.timedOut) { + return "node --check 超时"; + } + if (r.exitCode == 0) { + return ""; + } + return r.output.isEmpty() ? "node --check 失败(无输出)" : r.output; + } catch (IOException | InterruptedException e) { + return "node --check 异常:" + e.getMessage(); + } finally { + if (tmp != null) { + try { + Files.deleteIfExists(tmp); + } catch (IOException ignore) { + // 临时文件清理失败可忽略。 + } + } + } + } + + /** + * validate 节点(对齐 validate.py:validate):静态过了才跑 node --check(省时)。 + * 写 state:validateOk(boolean) / validateErrors(List) / feedback(失败回喂文字)。 + */ + static NodeAction validateNode() { + return (OverAllState s) -> { + String src = s.value("factorySrc", String.class).orElse(""); + List errs = staticCheck(src); + if (errs.isEmpty()) { + String nc = nodeCheck(src); + if (!nc.isEmpty()) { + String trimmed = nc.length() > 500 ? nc.substring(0, 500) : nc; + errs = new ArrayList<>(); + errs.add("语法错误(node --check):" + trimmed); + } + } + Map out = new HashMap<>(); + boolean ok = errs.isEmpty(); + out.put("validateOk", ok); + out.put("validateErrors", errs); + if (!ok) { + StringBuilder fb = new StringBuilder("校验未过:\n"); + for (String e : errs) { + fb.append("- ").append(e).append("\n"); + } + out.put("feedback", fb.toString()); + clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味(仅全图注册了该 key 时生效)。 + } + return out; + }; + } + + // ====================== ② scaffold 节点 ====================== + // 纯 Java 文件 IO(抄 run.py:33-42 语义)。 + + /** + * scaffold 节点(对齐 run.py:scaffold):把工厂源码落 game-runtime/games/_wg1-gen/<id>/。 + * 写 generated-factory.js(entry-bundle 壳 import './generated-factory.js') + * + 拷 _shared/entry-bundle.template.js → entry-bundle.js + * + 拷 _shared/index.template.html → index.html + * + 写 play-spec.json(来自 state.playSpec)。 + * 写 state:gameDir(落盘目录绝对路径)。 + * + * @param gameRuntimeRoot 主仓 game-runtime 根(有 node_modules+esbuild+_shared 脚本+金样)。 + */ + static NodeAction scaffoldNode(Path gameRuntimeRoot) { + return (OverAllState s) -> { + String gameId = s.value("gameId", String.class).orElseThrow( + () -> new IllegalStateException("scaffold 节点缺 gameId")); + String factorySrc = s.value("factorySrc", String.class).orElse(""); + String playSpec = s.value("playSpec", String.class).orElse("{\"inputs\":[],\"expectedEngineCallPrefixes\":[]}"); + + Path genDir = gameRuntimeRoot.resolve("games").resolve("_wg1-gen"); + Path shared = genDir.resolve("_shared"); + Path gameDir = genDir.resolve(gameId); + Files.createDirectories(gameDir); + + // 官方靶名 generated-factory.js(entry-bundle.template.js 内 import './generated-factory.js')。 + Files.write(gameDir.resolve("generated-factory.js"), factorySrc.getBytes(StandardCharsets.UTF_8)); + // 拷模板(entry-bundle 壳 + index 页)。 + Files.copy(shared.resolve("entry-bundle.template.js"), gameDir.resolve("entry-bundle.js"), + StandardCopyOption.REPLACE_EXISTING); + Files.copy(shared.resolve("index.template.html"), gameDir.resolve("index.html"), + StandardCopyOption.REPLACE_EXISTING); + // play-spec.json(驱动九门 F/G/H/I)。 + Files.write(gameDir.resolve("play-spec.json"), playSpec.getBytes(StandardCharsets.UTF_8)); + + Map out = new HashMap<>(); + out.put("gameDir", gameDir.toString()); + return out; + }; + } + + // ====================== ③ build 节点 ====================== + // ProcessBuilder 调既有 esbuild 锁参脚本(抄 run.py:45-53;harness 禁重写)。 + + /** + * build 节点(对齐 run.py:build):node scripts/build.mjs <entry> <out> --global-name=__GameBundle, + * cwd=主仓 game-runtime;断言出 bundle.iife.js。 + * 写 state:buildOk(boolean) / buildLog(esbuild 日志,失败回喂用) / feedback(失败时)。 + * + * @param gameRuntimeRoot 主仓 game-runtime 根(cwd;esbuild 在此 node_modules)。 + */ + static NodeAction buildNode(Path gameRuntimeRoot) { + return (OverAllState s) -> { + String gameId = s.value("gameId", String.class).orElseThrow( + () -> new IllegalStateException("build 节点缺 gameId")); + String rel = "games/_wg1-gen/" + gameId; + + ProcessBuilder pb = new ProcessBuilder( + "node", "scripts/build.mjs", + rel + "/entry-bundle.js", rel + "/bundle.iife.js", + "--global-name=" + GLOBAL_NAME); + pb.directory(gameRuntimeRoot.toFile()); + // 子进程经统一运行器(并发 drain 防死锁 + 120s 超时强杀,对齐 run.py:build subprocess.run(timeout=120))。 + ProcResult r; + try { + r = runProcess(pb, 120); + } catch (IOException | InterruptedException e) { + Map oe = new HashMap<>(); + oe.put("buildOk", false); + String msg = "打包子进程异常:" + e.getMessage(); + oe.put("buildLog", msg); + oe.put("feedback", "打包失败(esbuild):\n" + msg); + return oe; + } + String log = r.output; + boolean ok; + if (r.timedOut) { + ok = false; + log = "打包超时(esbuild)\n" + tail(log, 600); + } else { + ok = r.exitCode == 0 + && Files.exists(gameRuntimeRoot.resolve(rel).resolve("bundle.iife.js")); + } + + Map out = new HashMap<>(); + out.put("buildOk", ok); + out.put("buildLog", log); + if (!ok) { + String head = log.length() > 600 ? log.substring(0, 600) : log; + out.put("feedback", "打包失败(esbuild):\n" + head); + clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味。 + } + return out; + }; + } + + // ====================== ④ play 节点 ====================== + // ProcessBuilder 调既有九门 harness(抄 run.py:56-72;harness 禁重写)。 + + /** + * play 节点(对齐 run.py:play):bash games/_wg1-gen/_shared/serve-and-play.sh <id> 4320 9222, + * cwd=主仓 game-runtime,timeout 180s,destroyForcibly;读 evidence/verdict.json,解析 pass。 + * 九门 pass 判定 = 退出码 0 ∧ verdict.pass(对齐 run.py:run_job 的 `rc==0 and verdict.pass`)。 + * 写 state:verdict(JSON) / playPass(boolean) / feedback(失败摘守卫,对齐 _verdict_feedback)。 + * + * @param gameRuntimeRoot 主仓 game-runtime 根(cwd;serve-and-play.sh / Chrome / static-serve 在此)。 + */ + static NodeAction playNode(Path gameRuntimeRoot) { + return (OverAllState s) -> { + String gameId = s.value("gameId", String.class).orElseThrow( + () -> new IllegalStateException("play 节点缺 gameId")); + + // serve-and-play.sh 第二参=HTTP 端口、第三参=CDP 端口;脚本自带净场杀这两端口。 + // A-9:端口参数化(从 state 读,默认 4320/9222;worker 并行形态可每 run 错开,对照 run.py:56-63 的 port/cdp_port 入参)。 + int port = ((Number) s.value("port", 4320)).intValue(); + int cdpPort = ((Number) s.value("cdpPort", 9222)).intValue(); + ProcessBuilder pb = new ProcessBuilder( + "bash", "games/_wg1-gen/_shared/serve-and-play.sh", gameId, + String.valueOf(port), String.valueOf(cdpPort)); + pb.directory(gameRuntimeRoot.toFile()); + // 子进程经统一运行器(并发 drain 防死锁 + 180s 超时强杀,对齐 run.py:play subprocess.run(timeout=180))。 + ProcResult r = runProcess(pb, 180); + String pout = r.output; + + Map out = new HashMap<>(); + if (r.timedOut) { + out.put("playPass", false); + out.put("feedback", "真玩超时(180s)。\n" + tail(pout, 600)); + clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味。 + return out; + } + + int rc = r.exitCode; + // 读回 evidence/verdict.json(九门判定权威产物)。 + Path vpath = gameRuntimeRoot.resolve("games").resolve("_wg1-gen") + .resolve(gameId).resolve("evidence").resolve("verdict.json"); + JsonNode verdict = null; + if (Files.exists(vpath)) { + try { + verdict = MAPPER.readTree(vpath.toFile()); + } catch (IOException e) { + // verdict 解析失败 → 留 null,下方判失败。 + out.put("feedback", "verdict.json 解析失败:" + e.getMessage()); + } + } + boolean pass = rc == 0 && verdict != null && verdict.path("pass").asBoolean(false); + out.put("playPass", pass); + if (verdict != null) { + // 以字符串形态塞 state(避免 KeyStrategy 序列化 JsonNode 的额外约束;诊断读回即可)。 + out.put("verdict", verdict.toString()); + } + if (!pass && !out.containsKey("feedback")) { + out.put("feedback", summarizeFailedGuards(verdict)); + } + if (!pass) { + clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味。 + } + return out; + }; + } + + /** + * A-4 辅助:硬门(validate/build/play)失败时清空 player 软门反馈,避免上一轮 player 体验问题串到本轮失败回喂。 + *

仅当当前图注册了 playerFeedback 策略(全图 SaaStudioGraphTest)时才写——step3 最小图未注册该 key,跳过不破坏。 + */ + private static void clearPlayerFeedback(OverAllState s, Map out) { + if (s.containStrategy("playerFeedback")) { + out.put("playerFeedback", ""); + } + } + + /** + * 把失败的守卫摘成回喂文字(对齐 run.py:_verdict_feedback)。 + */ + static String summarizeFailedGuards(JsonNode verdict) { + if (verdict == null) { + return "真玩阶段无 verdict 产出(疑似装载即崩/超时)。"; + } + List lines = new ArrayList<>(); + JsonNode guards = verdict.path("guards"); + if (guards.isObject()) { + Iterator> it = guards.fields(); + while (it.hasNext()) { + Map.Entry e = it.next(); + if (!e.getValue().path("pass").asBoolean(false)) { + String g = e.getValue().toString(); + lines.add("- 守卫 " + e.getKey() + " 未过:" + (g.length() > 200 ? g.substring(0, 200) : g)); + } + } + } + if (verdict.hasNonNull("error")) { + String err = verdict.get("error").asText(); + lines.add("- 运行错误:" + (err.length() > 300 ? err.substring(0, 300) : err)); + } + return lines.isEmpty() ? "真玩未过(原因未知)。" : "真玩未过:\n" + String.join("\n", lines); + } + + /** 取字符串尾部 n 字(日志摘要用)。 */ + private static String tail(String s, int n) { + if (s == null) { + return ""; + } + return s.length() <= n ? s : s.substring(s.length() - n); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHarnessDriveTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHarnessDriveTest.java new file mode 100644 index 00000000..23935031 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHarnessDriveTest.java @@ -0,0 +1,206 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.CompiledGraph; +import com.alibaba.cloud.ai.graph.KeyStrategy; +import com.alibaba.cloud.ai.graph.KeyStrategyFactory; +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.StateGraph; +import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.alibaba.cloud.ai.graph.StateGraph.END; +import static com.alibaba.cloud.ai.graph.StateGraph.START; +import static com.alibaba.cloud.ai.graph.action.AsyncNodeAction.node_async; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * SaaHarnessDriveTest —— SAA 迁移 step 3:证明「SAA-Java 能驱动既有 build + 九门 harness」。 + * + *

与「LLM 能否生成过门游戏」解耦:用金样 factory(breakout/generated-factory.js, + * 已知过九门)作输入,只验 {@link SaaGenNodes} 四节点(validate/scaffold/build/play)的驱动链。 + * + *

两组验证: + *

    + *
  1. {@link #validate_passes_golden_and_rejects_broken()}:validate 节点单独验——金样过、故意坏样不过(不依赖 harness 跑机,故无条件跑);
  2. + *
  3. {@link #golden_drives_nine_gates_to_pass()}:scaffold→build→play 三节点串成最小 StateGraph, + * 金样经 SAA 真驱动既有九门 harness,断言 verdict.pass=true(需 mini-desktop:node+esbuild+Chrome, + * 故 {@code -Dsaa.harness=1} 时才跑;否则跳过,避免在无 harness 机误判 fail)。
  4. + *
+ * + *

纪律(迁移设计 §6.2-step3):不接 LLM、不接 saver、不接 job 契约、不组全图(下一步);harness 子进程复用·禁重写。 + * + *

运行(mini-desktop,cwd=主仓 game-runtime 有 node_modules+esbuild+_shared 脚本+金样): + *

+ *   mvn -pl ...aigc-server -am -Dtest=SaaHarnessDriveTest \
+ *       -Dsaa.harness=1 -DGAME_RUNTIME_DIR=/root/games-development-ai/game-runtime \
+ *       -Dsurefire.failIfNoSpecifiedTests=false test
+ * 
+ * + * @author 造梦AI + */ +class SaaHarnessDriveTest { + + /** 主仓 game-runtime 根(harness 运行时所在;有 node_modules+esbuild+_shared 脚本+金样)。可被系统属性覆盖。 */ + private static final Path GAME_RUNTIME_ROOT = Paths.get( + System.getProperty("GAME_RUNTIME_DIR", + System.getenv().getOrDefault("GAME_RUNTIME_DIR", "/root/games-development-ai/game-runtime"))); + + /** step3 专用唯一 game id(避免覆盖现有游戏 breakout)。 */ + private static final String GAME_ID = "saa-step3-golden"; + + /** + * 读金样 factory 源码(test resources,权威已过九门 breakout/generated-factory.js 的拷贝)。 + */ + private static String readGoldenFactory() throws Exception { + try (var in = SaaHarnessDriveTest.class.getResourceAsStream("/saa-golden/generated-factory.js")) { + assertNotNull(in, "金样 factory 资源 /saa-golden/generated-factory.js 缺失"); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** 读金样 play-spec(驱动九门 F/G/H/I)。 */ + private static String readGoldenPlaySpec() throws Exception { + try (var in = SaaHarnessDriveTest.class.getResourceAsStream("/saa-golden/play-spec.json")) { + assertNotNull(in, "金样 play-spec 资源 /saa-golden/play-spec.json 缺失"); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * 故意坏样:违反多条静态契约(含 import / Math.random / 缺 getEngine / 缺 destroy),validate 必须判不过。 + */ + private static final String BROKEN_FACTORY = "" + + "'use strict';\n" + + "import {foo} from 'bar';\n" // 违禁:import + + "export default function createGame(opts){\n" + + " const o = opts || {};\n" + + " let r = Math.random();\n" // 违禁:Math.random + + " function init(b){ /* no ctx, no getEngine */ }\n" // 缺 ctx/getEngine + + " function update(dt){ r += dt; }\n" + + " function render(g){ g.fillRect(0,0,r,r); }\n" + + " return { init, update, render };\n" // 缺 destroy + + "}\n"; + + // ====================== ① validate 节点单测:金样过 / 坏样不过 ====================== + + @Test + void validate_passes_golden_and_rejects_broken() throws Exception { + // -- 坏样:静态契约即应拦下(不跑 node --check 也能列错),断言不过且命中预期违约 -- + List brokenErrs = SaaGenNodes.staticCheck(BROKEN_FACTORY); + System.out.println("[validate/broken] errors=" + brokenErrs); + assertFalse(brokenErrs.isEmpty(), "故意坏样应被静态契约判不过"); + assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("import")), "应命中 import 违禁"); + assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("Math.random")), "应命中 Math.random 违禁"); + assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("getEngine")), "应命中缺 getEngine"); + assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("destroy")), "应命中缺 destroy 方法"); + + // -- 金样:静态契约应全过(空错误列表)-- + String golden = readGoldenFactory(); + List goldenStaticErrs = SaaGenNodes.staticCheck(golden); + System.out.println("[validate/golden static] errors=" + goldenStaticErrs); + assertTrue(goldenStaticErrs.isEmpty(), "金样应过静态契约,实得错误:" + goldenStaticErrs); + + // -- 金样:node --check ESM 语法门也应过(需 node 在 PATH;mini-desktop 有)-- + String nc = SaaGenNodes.nodeCheck(golden); + System.out.println("[validate/golden node--check] " + (nc.isEmpty() ? "OK" : nc)); + assertTrue(nc.isEmpty(), "金样应过 node --check,实得:" + nc); + } + + // ====================== ② scaffold→build→play 三节点串图:金样过九门 verdict.pass=true ====================== + + @Test + @EnabledIfSystemProperty(named = "saa.harness", matches = "1", + disabledReason = "需 mini-desktop harness 运行机(node+esbuild+Chrome),无机时跳过避免误判 fail;开关 -Dsaa.harness=1") + void golden_drives_nine_gates_to_pass() throws Exception { + // 预检:主仓 game-runtime + _shared 脚本 + esbuild 必须就位(否则给清晰指引而非晦涩子进程错误)。 + assertTrue(Files.isDirectory(GAME_RUNTIME_ROOT), "GAME_RUNTIME_DIR 不存在:" + GAME_RUNTIME_ROOT); + assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("games/_wg1-gen/_shared/serve-and-play.sh")), + "缺 serve-and-play.sh(harness 子进程地板),路径:" + GAME_RUNTIME_ROOT); + assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("node_modules/esbuild")), + "缺 node_modules/esbuild(build 节点依赖),请在 game-runtime 跑 npm i -D esbuild"); + + String golden = readGoldenFactory(); + String playSpec = readGoldenPlaySpec(); + + // -- 串接:START -> scaffold -> build -> play -> END(条件边留下一步;本步直串验驱动链通)-- + KeyStrategyFactory keyFactory = () -> { + Map keys = new HashMap<>(); + for (String k : new String[]{"factorySrc", "playSpec", "gameId", + "gameDir", "buildOk", "buildLog", "verdict", "playPass", "feedback"}) { + keys.put(k, new ReplaceStrategy()); + } + return keys; + }; + + StateGraph graph = new StateGraph("saa-step3-harness-drive", keyFactory) + .addNode("scaffold", node_async(SaaGenNodes.scaffoldNode(GAME_RUNTIME_ROOT))) + .addNode("build", node_async(SaaGenNodes.buildNode(GAME_RUNTIME_ROOT))) + .addNode("play", node_async(SaaGenNodes.playNode(GAME_RUNTIME_ROOT))) + .addEdge(START, "scaffold") + .addEdge("scaffold", "build") + .addEdge("build", "play") + .addEdge("play", END); + + CompiledGraph compiled = graph.compile(); + + Map inputs = new HashMap<>(); + inputs.put("factorySrc", golden); + inputs.put("playSpec", playSpec); + inputs.put("gameId", GAME_ID); + + long t0 = System.currentTimeMillis(); + Optional result = compiled.invoke(inputs); + long dt = System.currentTimeMillis() - t0; + + assertTrue(result.isPresent(), "图 invoke 应返回 state"); + OverAllState s = result.get(); + + boolean buildOk = s.value("buildOk", Boolean.class).orElse(false); + boolean playPass = s.value("playPass", Boolean.class).orElse(false); + String gameDir = s.value("gameDir", String.class).orElse("?"); + String verdict = s.value("verdict", String.class).orElse("(no verdict)"); + String feedback = s.value("feedback", String.class).orElse(""); + + // == 证据:各节点产出 + verdict 全文(含各门 guard 结果)== + System.out.println("================ SAA step3 harness 驱动证据 ================"); + System.out.println("[gameId] " + GAME_ID + " cwd=" + GAME_RUNTIME_ROOT); + System.out.println("[gameDir] " + gameDir); + System.out.println("[buildOk] " + buildOk); + System.out.println("[playPass] " + playPass + " wall=" + dt + "ms"); + if (!feedback.isEmpty()) { + System.out.println("[feedback] " + feedback); + } + System.out.println("[verdict] " + verdict); + System.out.println("=========================================================="); + + // == 断言:金样经 SAA 四节点真驱动九门 → verdict.pass=true == + assertTrue(buildOk, "build 节点应成功产出 bundle.iife.js"); + assertTrue(playPass, "金样应经 SAA play 节点驱动九门拿到 verdict.pass=true,feedback=" + feedback); + + // 进一步落锤:直接读回 verdict.json,断言 pass=true 且九门核心守卫全过(证明真到九门判定,非空过)。 + Path vpath = GAME_RUNTIME_ROOT.resolve("games/_wg1-gen").resolve(GAME_ID) + .resolve("evidence/verdict.json"); + assertTrue(Files.exists(vpath), "应产出 verdict.json:" + vpath); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var v = mapper.readTree(vpath.toFile()); + assertEquals(true, v.path("pass").asBoolean(false), "verdict.json pass 应为 true"); + // 核心确定性门(A 装载 / B 未捕获 / C 掌帧 / D 真渲 / E 活性 / F 真接线 / G 输入)全过。 + for (String g : new String[]{"A_boot", "B_uncaught", "C_frame", "D_render", "E_live", "F_wiring", "G_input"}) { + assertEquals(true, v.path("guards").path(g).path("pass").asBoolean(false), + "九门守卫 " + g + " 应 pass,verdict=" + v); + } + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java new file mode 100644 index 00000000..fba9e9ea --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java @@ -0,0 +1,222 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.CompiledGraph; +import com.alibaba.cloud.ai.graph.KeyStrategy; +import com.alibaba.cloud.ai.graph.KeyStrategyFactory; +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.StateGraph; +import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.alibaba.cloud.ai.graph.StateGraph.END; +import static com.alibaba.cloud.ai.graph.StateGraph.START; +import static com.alibaba.cloud.ai.graph.action.AsyncNodeAction.node_async; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * SAA -> new-api -> 生成游戏工厂源码 最小冒烟(迁移分步 step 2 首件)。 + * + * 目标(窄):证明「SAA 在 game-cloud 内能调 new-api 生成游戏工厂源码」这一条链。 + * START -> generate(node) -> END:generate 节点用 OpenAiChatModel(指 new-api, + * 模型=models.yaml stage1 的 code 角色=deepseek-v4-flash) 出 js 代码块,抽 js 块 + * (等价 Python validate.extract_code),断言拿到非空工厂源码。 + * + * 纪律(对齐 dossier 0/6.2-step2): + * - 只验「SAA->new-api->生成源码」最小链;不接 build/play harness、不接 saver、不接 job 契约。 + * - 密钥只走环境变量 NEWAPI_KEY(同 Python worker _client.get_api_key 口径,绝不入库); + * 未设 NEWAPI_KEY 时 @EnabledIfEnvironmentVariable 直接跳过(不算失败,与"密钥外置"一致)。 + * - system 用 prompt.SYSTEM 的精简等价(模块默认导出 createGame + init/update/render/destroy + + * getEngine 容错),先不求完整(harness 九门是后续 step 的事)。 + * + * 运行:在 mini-desktop 上 + * NEWAPI_KEY=sk-... mvn -pl ...aigc-server -am -Dtest=SaaNewApiGenerateSmokeTest test + * + * @author 造梦AI + */ +@EnabledIfEnvironmentVariable(named = "NEWAPI_KEY", matches = ".+") +class SaaNewApiGenerateSmokeTest { + + /** + * new-api OpenAI 兼容网关 host 根(同 Python _client.BASE_URL;可被 NEWAPI_BASE_URL 覆盖)。 + * 注意 Spring-AI 口径 ≠ openai-python 口径:openai SDK 的 base_url 要带 /v1(Python worker 配的就是 .../v1), + * 但 Spring AI OpenAiApi 会自己拼默认 completionsPath=/v1/chat/completions,故 baseUrl 必须是【host 根、不带 /v1】, + * 否则拼成 /v1/v1/chat/completions → 404(spike 实测坑)。这里把末尾 /v1 剥掉,让同一 .env 值两侧通用。 + */ + private static final String NEW_API_BASE_URL = stripV1Suffix( + System.getenv().getOrDefault("NEWAPI_BASE_URL", "http://100.64.0.8:3000/v1")); + + /** 剥掉末尾的 /v1(及多余结尾斜杠),适配 Spring AI baseUrl=host 根的口径。 */ + private static String stripV1Suffix(String url) { + String u = url.trim(); + while (u.endsWith("/")) { + u = u.substring(0, u.length() - 1); + } + if (u.endsWith("/v1")) { + u = u.substring(0, u.length() - 3); + } + return u; + } + + /** stage1 code 角色模型(models.yaml: stage1.roles.code = deepseek-v4-flash) */ + private static final String STAGE1_CODE_MODEL = "deepseek-v4-flash"; + + /** state key:工厂源码(generate 节点产出) */ + private static final String K_FACTORY_SRC = "factorySrc"; + /** state key:本次模型名(诊断用) */ + private static final String K_MODEL = "modelName"; + /** state key:usage 累计(诊断用) */ + private static final String K_USAGE = "usage"; + + /** + * 精简版 system prompt:prompt.SYSTEM 的最小等价。 + * 只保留「模块形状 + 生命周期 + getEngine 容错 + 只输出一个 js 代码块」—— + * 足够让便宜模型产出一个可抽取的工厂源码;不含九门/好玩接地(后续 step)。 + */ + private static final String SYSTEM_PROMPT = "" + + "你是轻量 H5 小游戏代码生成器。唯一产出 = 一个独立的 GameHostFactory 模块(原生 JS ESM 单文件)。\n" + + "【输出格式·严格】只输出一个 ```js 代码块,块内是完整工厂源码,块外不要任何解释文字。\n" + + "【模块形状】\n" + + "- 文件用 'use strict'; 开头。\n" + + "- 默认导出工厂函数:export default function createGame(opts) { ... return { init, update, render, destroy, getEngine }; }\n" + + "- 返回的实例必须有 init / update / render / destroy 四个方法;另导出 getEngine() 返回引擎能力或 null(必须容错)。\n" + + "【生命周期(宿主这样驱动)】\n" + + "- game = createGame(opts) // opts 可能 undefined,需 const o = opts || {} 防御\n" + + "- await game.init(boot) // boot = { ctx, mainContext, canvas, seed }\n" + + "- game.update(dt) // dt 恒 = 1/60 秒\n" + + "- game.render(g) // g === boot.mainContext(2D 画布上下文)\n" + + "- game.destroy() // 卸载,幂等\n" + + "【硬禁止】禁 import 任何包(自包含单文件);禁 Math.random/Date.now(用 ctx.random/ctx.time);禁 addEventListener/requestAnimationFrame。\n"; + + @Test + void saa_graph_calls_newapi_and_generates_factory_source() throws Exception { + String key = System.getenv("NEWAPI_KEY"); + assertNotNull(key, "NEWAPI_KEY 未设(密钥须经环境变量,绝不入库)"); + + // == 1) 建指向 new-api 的 OpenAiChatModel(共用一个 OpenAiApi,defaultOptions.model=便宜档) == + OpenAiApi openAiApi = OpenAiApi.builder() + .baseUrl(NEW_API_BASE_URL) + .apiKey(key) + .build(); + OpenAiChatModel codeModel = OpenAiChatModel.builder() + .openAiApi(openAiApi) + .defaultOptions(OpenAiChatOptions.builder() + .model(STAGE1_CODE_MODEL) + .temperature(0.0) // 确定性产出(对齐 Python _client.chat temperature=0) + .maxTokens(16000) + .build()) + .build(); + + // == 2) 最小 StateGraph:START -> generate -> END == + // KeyStrategyFactory:三个 key 都用 ReplaceStrategy(覆盖,非追加)。 + KeyStrategyFactory keyFactory = () -> { + Map keys = new HashMap<>(); + keys.put(K_FACTORY_SRC, new ReplaceStrategy()); + keys.put(K_MODEL, new ReplaceStrategy()); + keys.put(K_USAGE, new ReplaceStrategy()); + return keys; + }; + + StateGraph graph = new StateGraph("saa-newapi-smoke", keyFactory) + .addNode("generate", node_async(state -> { + // generate 节点:调 code 模型出 js 块,抽 js 块(等价 validate.extract_code) + String brief = state.value("brief", "打砖块 breakout:挡板接球消砖,清空所有砖块即胜。"); + Prompt prompt = new Prompt(List.of( + new SystemMessage(SYSTEM_PROMPT), + new UserMessage("请实现下面这款游戏,产出一个合规的 GameHostFactory 模块(只输出一个 js 代码块):\n\n" + brief))); + ChatResponse resp = codeModel.call(prompt); + String content = resp.getResult().getOutput().getText(); + String src = extractJsBlock(content); // 抽 js 块 + + // 读 usage(对齐 Python RecordingChatModel:每次调用记 token) + String usageStr = "(no usage)"; + Usage u = resp.getMetadata().getUsage(); + if (u != null) { + usageStr = "prompt=" + u.getPromptTokens() + + " completion=" + u.getCompletionTokens() + + " total=" + u.getTotalTokens(); + } + + Map out = new HashMap<>(); + out.put(K_FACTORY_SRC, src); + out.put(K_MODEL, STAGE1_CODE_MODEL); + out.put(K_USAGE, usageStr); + return out; + })) + .addEdge(START, "generate") + .addEdge("generate", END); + + CompiledGraph compiled = graph.compile(); + + // == 3) invoke 图,断言拿到非空工厂源码 == + Optional result = compiled.invoke(Map.of( + "brief", "打砖块 breakout:挡板接球消砖,清空所有砖块即胜,掉球则负。")); + + assertTrue(result.isPresent(), "图 invoke 应返回 state"); + OverAllState finalState = result.get(); + String factorySrc = finalState.value(K_FACTORY_SRC, String.class).orElse(""); + String modelName = finalState.value(K_MODEL, String.class).orElse("?"); + String usage = finalState.value(K_USAGE, String.class).orElse("?"); + + // == 真实响应证据:模型名 + usage + 源码前 20 行 == + System.out.println("================ SAA->new-api 冒烟证据 ================"); + System.out.println("[model] " + modelName + " @ " + NEW_API_BASE_URL); + System.out.println("[usage] " + usage); + System.out.println("[src len] " + factorySrc.length() + " chars"); + System.out.println("[src head 20 lines] >>>>>>>>"); + String[] lines = factorySrc.split("\n", -1); + for (int i = 0; i < Math.min(20, lines.length); i++) { + System.out.println(String.format("%3d| %s", i + 1, lines[i])); + } + System.out.println("<<<<<<<< end head"); + System.out.println("====================================================="); + + // 断言:拿到非空工厂源码,且形状像工厂(含 export default + createGame) + assertTrue(factorySrc.length() > 60, "工厂源码应非空且有实质内容,实得长度=" + factorySrc.length()); + assertTrue(factorySrc.contains("export default"), + "工厂源码应含 export default(默认导出工厂),实得头部:\n" + factorySrc.substring(0, Math.min(200, factorySrc.length()))); + assertTrue(factorySrc.contains("createGame"), + "工厂源码应含 createGame 工厂名"); + } + + /** + * 从模型输出抽 js 代码块(等价 Python validate.extract_code)。 + * 优先 ```js/```javascript;退而取首个 ``` 块;再无则:若含 export default 用全文,否则空串。 + */ + private static String extractJsBlock(String content) { + if (content == null || content.isEmpty()) { + return ""; + } + // ```js / ```javascript 块 + Matcher m = Pattern.compile("```(?:js|javascript)\\s*\\n(.*?)```", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(content); + if (m.find()) { + return m.group(1).strip(); + } + // 任意 ``` 块(取第一个) + m = Pattern.compile("```\\s*\\n(.*?)```", Pattern.DOTALL).matcher(content); + if (m.find()) { + return m.group(1).strip(); + } + // 无 fence:含 export default 则用全文 + if (content.contains("export default")) { + return content.strip(); + } + return ""; + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java new file mode 100644 index 00000000..8b604e39 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java @@ -0,0 +1,319 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * SaaPrompts —— SAA 迁移 step 4:把 Python 生成系统的 prompt / 角色 / gatespec 抽取逐字移植到 Java。 + * + *

公平性铁律(对比测试只换编排框架、不换 prompt/模型):本类内容与 Python 侧逐字对齐,不得改写语义: + *

    + *
  • {@link #SYSTEM} ≡ wg1/gen-worker/worker/prompt.py:24-107(模块形状/能力面/12 禁止/硬规则/P0 latch/few-shot 挂点);
  • + *
  • {@link #buildMessages} ≡ prompt.py:110-119(system 注入 few-shot;user 末尾回喂上轮失败原因,全量重出非 diff);
  • + *
  • {@link #DESIGN_SYSTEM} ≡ roles.py:8-32(设计 agent + 末尾自产 ```gatespec);
  • + *
  • {@link #playerSystem} ≡ roles.py:38-57(玩家 agent 人格 + 1-5 分锚 + 空心必判 fix);
  • + *
  • {@link #extractGatespec} ≡ studio.py:72-102(正则抠 ```gatespec → 容错解析 → 缺省补 expectLatch / ballPath .y→.x 归一)。
  • + *
+ * + *

few-shot 与 Python 同口径:读 game-runtime/games/_wg1-gen/_goldenpath-probe/factory.js(读文件防漂移,prompt.py:9-20)。 + * + * @author 造梦AI + */ +final class SaaPrompts { + + private SaaPrompts() { + } + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * 宽松 JSON 解析器(A-5:等价 Python json_repair 的常见容错): + * 容许单引号、未引号字段名、尾随逗号、注释、控制字符——便宜模型高频脏点,尽量救活而非静默退 {}。 + */ + private static final ObjectMapper LENIENT = com.fasterxml.jackson.databind.json.JsonMapper.builder() + .enable(JsonReadFeature.ALLOW_SINGLE_QUOTES) + .enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES) + .enable(JsonReadFeature.ALLOW_TRAILING_COMMA) + .enable(JsonReadFeature.ALLOW_JAVA_COMMENTS) + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS) + .build(); + + /** few-shot 参照工厂相对 game-runtime 根的路径(与 prompt.py:_PROBE 同一文件,读文件防漂移)。 */ + static final String PROBE_REL = "games/_wg1-gen/_goldenpath-probe/factory.js"; + + // ============================================================================ + // ① SYSTEM —— 逐字移植 prompt.py:24-107(__FEWSHOT__ 运行时注入)。 + // 注:Java 文本块对 \\ 与 " 有转义,原文为 Python 原始串(r""")且不含反斜杠,故内容逐字一致。 + // ============================================================================ + static final String SYSTEM = """ + 你是轻量 H5 小游戏代码生成器。你的唯一产出 = 一个【独立的 GameHostFactory 模块】(原生 JS ESM 单文件)。 + 宿主会把它打成 bundle 在浏览器里真玩。你不写 HTML、不写宿主、不写引擎——只写这一个工厂模块。 + + 【输出格式·严格】只输出一个 ```js 代码块,块内是完整工厂源码,块外不要任何解释文字。 + + 【模块形状】 + - 文件用 'use strict'; 开头。 + - 默认导出一个工厂函数:export default function createGame(opts) { ... return { init, update, render, destroy, _forensicsView }; } + - 工厂返回 GameInstance:必须有 init / update / render / destroy 四个方法(可选 onInput,但优先用 ctx.getInput()); + 另【必须】导出 _forensicsView(可测性红线,见下),harness 据此确定性判"游戏真有进展"。 + + 【生命周期(宿主这样驱动你)】 + - game = createGame(opts) // opts 可能为 undefined,必须 const o = opts || {} 防御 + - await game.init(boot) // 一次性。boot = { ctx, mainContext, canvas, seed } + - game.update(dt) // 每逻辑步,dt 恒定 = 1/60 秒 + - game.render(g) // 每渲染帧,g === boot.mainContext(同一个 2D 画布上下文) + - game.destroy() // 卸载,幂等 + + 【能力一律经 boot.ctx(init 里存下 ctx 再用)】 + - ctx.getEngine() -> 引擎能力 | null(可能为 null,必须容错!)。返回三个面: + · getEngine().particles.spawnEmitter(spec) -> 句柄{isActive(),stop()};引擎自动渲染粒子到画布,你无需手动画。 + spec 字段(除 pos 全可选):pos:{x,y}, count(爆发颗数), emitRate, particleTime, speed, coneAngle, + sizeStart, sizeEnd, colorStart:{r,g,b,a}(0..1), colorEnd:{r,g,b,a}, additive。坐标=逻辑像素。 + · getEngine().audio.synth.synthSfx(paramArray) -> 合成音效样本(ZzFX 参数数组,如 [1,0.05,220,,,0.18,,1.4])。 + · getEngine().math.lerp(a,b,t) / math.smoothStep(t) / math.easing?.quadOut(t)(easing 是可选,用 ?. 防御)。 + - ctx.getInput() -> { on(type, handler) -> {cancel()}, off(...) } + type ∈ 'pointerdown'|'pointermove'|'pointerup'|'keydown'|'keyup' + handler 收事件 { type, x, y, key, tMs };【x/y 已是逻辑像素坐标:x∈[0,390]、y∈[0,844],可直接当像素用做命中判定/定位,**绝对不要再乘以视口宽高**】;key 仅键盘有意义。 + - ctx.time.nowMs() / ctx.time.elapsedMs() // 受控时钟。update 首行应 const now = ctx.time.nowMs(); + - ctx.random.next() / ctx.random.range(min,max) / ctx.random.reseed(seed) // 受控随机 + - boot.mainContext 就是 render(g) 的 g —— 一个标准 CanvasRenderingContext2D,画在它上面(fillRect/arc/fillText/drawImage…)。 + + 【视口】逻辑坐标系固定 390 宽 × 844 高(移动竖屏)。按逻辑像素绘制,不要自己处理 DPR。 + + 【硬禁止(违反则装载/运行直接失败)】 + - 禁 import 任何引擎/外部包(不要 import 'littlejsengine',不要 import 任何东西;只写一个自包含模块)。 + - 禁 Math.random()(用 ctx.random)、禁 Date.now()/performance.now()(用 ctx.time)。 + 注意:Math.sin/cos/floor/max/min/PI 等【允许】,只有 Math.random 禁。 + - 禁 addEventListener(用 ctx.getInput().on)、禁 requestAnimationFrame(宿主驱动 update/render)。 + - 禁 new AudioContext / document / window / 直接操作全局 canvas / 直接 new 引擎对象。 + - render 只画到入参 g,不要去拿别的画布。 + + 【硬规则(务必遵守)】 + 1. 闭包状态(游戏对象/分数/数组/订阅句柄)在 init 内初始化,不要写在工厂函数顶层常量里。 + 2. update 首行刷新 const now = ctx.time.nowMs(); 全帧用它,不要自己累加私有时钟。 + 3. ctx.getEngine() 可能返回 null:所有用法都要 const eng = ctx.getEngine(); if (eng && eng.particles) {...},easing 用 ?.。 + 4. destroy 里取消所有输入订阅(try { sub.cancel(); } catch(_){}),幂等。 + 5. 【必须】在某个游戏事件(点击/得分/碰撞/胜负)上至少调用一次 getEngine() 的能力(粒子爆发或音效), + 这样真玩门能证明游戏真用了引擎/插件库(这是硬性验收点,别只用纯 canvas 自绘)。 + 6. 画面必须每帧有可见变化(动起来),背景要铺满 390×844(别留透明,否则判白屏)。 + 7. 资产容错:不要依赖外部图片;用 canvas 几何图形画(矩形/圆/路径/文字)。 + + 【可测性红线 + P0 latch 终态(必须做:宿主据此判「游戏结束」代发 game_end 走结算;做不到=游戏关不上闭环=判不通过)】 + - 工厂返回的实例除 init/update/render/destroy 外,【必须】导出 _forensicsView 方法(纯只读视图,仿官方范本 generated-factory.js): + _forensicsView() { return { seed, state: () => ({ phase, score, remaining, progress, gameoverReason }) }; } + - state() 必须是【闭包实时读当前游戏状态】的函数:每次调用返回最新值,不要返回开局快照常量。 + - 【phase = 权威终态字段】取值 'booting' | 'playing' | 'gameover': + · init 后置 'playing'(可玩); + · 满足结束条件(通关 / 失败 / 超时 任一)置 'gameover'。 + - 【★ P0 latch 驻留·硬约束】一旦进入 'gameover',phase 必须【恒驻留】为 'gameover'——**禁止下一帧自动重开 / 复位回 playing**。 + 原因:宿主每 500ms 轮询 host.state().phase,读到 'gameover' 才代发 game_end 结算;若终态是「瞬时态」(某帧 gameover 下帧又变),轮询会漏读 → 游戏永远结算不了。**结束后必须停住、驻留终态(不自动重开)。** + - 其余字段(反映真实状态、与画面一致): + · score:当前分数(数字,从 0 起)。 + · remaining:剩余目标数(剩余砖块/敌人/回合/目标,数字;确无此概念可置 null)。 + · progress:0~1 进度(可选数字)。 + · gameoverReason:终态原因短串(如 'cleared_all' / 'ball_lost' / 'timeout';未结束置 '')。 + · 【题面若要求】额外导出关键实体的实时位置(如球/挡板/玩家/敌人的 {x,y}、速度 {vx,vy}、尺寸 {w,h}), + 按题面指定的字段名导出(如 ball:{x,y,vx,vy}、paddle:{x,y,w})。harness 据此【适配性真玩】(自动接球等)+ + 【确定性验手感】(控制是否跟手、碰撞是否正确);位置必须是闭包实时真值,与画面一致。 + - 红线含义:撞砖加分→score 真涨、砖被消→remaining 真减、判负/通关→phase 真切 'gameover' 且**驻留**。 + harness 会真玩到 gameover 并验【驻留 + 真有进展(remaining 下降等)】;终态瞬时/数值原地不动 = 判不通过。 + + 【好玩接地(玩家 agent 反复揪的高频缺口,务必做到,否则真玩判分必低)】 + - HUD 看得到进展:除分数外,显示「剩余目标数」(剩余砖块/敌人/回合/目标),让玩家 30 秒内明确感到「在推进」;HUD 文字置顶部留白区,别和游戏元素叠字。 + - 反馈看得见:getEngine().particles.spawnEmitter 用够强参数(count≥20、sizeStart 8~14、particleTime≥0.4、colorStart 用亮色且 a=1),否则截图里看不到粒子=等于没反馈;关键事件(命中/得分/胜负)都要爆一次。 + - 必有失败态 + 胜负演出:游戏要「会输」(掉命/超时/破产任一),且胜/负时有明确画面变化或文案(别只停住不动)。 + - 节奏可上手:开局不要立刻高难或死局,前 30 秒要能上手、有正反馈、看得到分数增长。 + + 下面是一个【最小但处处合规的参照实现】,请严格模仿它的结构、取能力方式、防御写法,但实现成题目要求的游戏(注意:参照件只示范「合规骨架」,你要在其上做到上面的「好玩接地」): + + ```js + __FEWSHOT__ + ``` + """; + + // ============================================================================ + // ② DESIGN_SYSTEM —— 逐字移植 roles.py:8-32(设计 agent + 末尾自产 gatespec)。 + // ============================================================================ + static final String DESIGN_SYSTEM = """ + 你是轻量 H5 小游戏的【设计 agent】。输入是一句话/一段题面,你的产出是一份**简洁、可直接实现**的设计稿,供下游代码 agent 写成游戏。 + + 【只产出设计稿,绝不写代码】输出用紧凑中文条目,覆盖: + 1. 核心机制:玩家做什么、目标是什么(一两句说清玩法循环)。 + 2. 胜负条件:怎样算赢、怎样算输(**必须有"会输"的失败态**——超时/失误/资源耗尽任一)。 + 3. 操作:触摸/按键如何映射到动作(记住坐标是逻辑像素 390×844 竖屏)。 + 4. 关键数值:速度/数量/尺寸/难度梯度的建议区间(给具体数,别含糊)。 + 5. juice 反馈:哪些事件该有粒子/音效/屏震(至少 1 处必须触发引擎能力)。 + 6. 画面要点:背景铺满、主要元素形状与颜色、HUD(分数等)。 + 7. 可观测进展:点明"玩家在推进"的可量化信号(如剩余目标数下降 / 分数上升 / 关卡推进),并说明胜负如何切状态——这是判"真有进展、非空心"的依据。 + + 【约束】设计正文控制在 ~200 字内,聚焦"代码 agent 实现时需要的决策",不堆砌辞藻、不写引擎/契约细节(那是代码 agent 的事)。 + + 【末尾必附 gate-spec(机器可读·harness 据此确定性验"能玩 + 手感"·这是 gate-H 推广到任意游戏的关键)】 + 设计正文之后另起一个 ```gatespec 代码块,块内为 JSON,声明本游戏怎样被确定性验证: + ```gatespec + { + "exportState": ["phase","score","remaining","progress","<关键实体位置:如 ball:{x,y,vx,vy}、paddle:{x,y,w};纯点击类可省位置>"], + "driver": {"type":"paddle-intercept","ballPath":"ball.x","paddleY":800}, + "controlCheck": {"paddlePath":"paddle.x","tapXs":[50,340]}, + "assertAfterPlay": [{"path":"remaining","op":"decreased","why":"击碎砖块→剩余下降"}], + "expectLatch": true + } + ``` + 规则:① `expectLatch` 恒 true(游戏必须能真玩到 gameover 且驻留);② 有"连续控制体(挡板/角色随手指平滑移动)"才给 `controlCheck`,纯点击/无连续控制给 null;③ 技巧类(挡板接球/接物)`driver.type="paddle-intercept"`,`ballPath`=**球的水平 x 坐标字段**(挡板水平移动去对齐它,几乎总是 `ball.x`,**绝不要填 ball.y**),`paddleY`=挡板所在 y;纯点击类用 `{"type":"none"}`;④ `assertAfterPlay` 至少一条"真有进展"断言(op ∈ increased/decreased/changed/>/>=/存在 ```gatespec 块(不论能否解析)。 + * 用于区分「无块」(静默退 brief 默认 play_spec,对齐 Python)与「有块但解析失败」(须记 gatespecParseError 并回喂)。 + */ + static boolean hasGatespecBlock(String designText) { + return designText != null && GATESPEC.matcher(designText).find(); + } + + /** + * 从设计稿抽 ```gatespec JSON 块(gate-H 推广),对齐 studio.py:_extract_gatespec: + * 正则抠块 → Jackson 容错解析(无 json_repair,做最小宽松解析)→ 缺省补 expectLatch=true、 + * paddle-intercept 的 ballPath .y→.x 安全归一。无块/解析失败返回 null。 + * + * @return 解析出的 gatespec(JsonNode,对象),或 null。 + */ + static JsonNode extractGatespec(String designText) { + if (designText == null) { + return null; + } + Matcher m = GATESPEC.matcher(designText); + if (!m.find()) { + return null; + } + String raw = m.group(1).strip(); + JsonNode obj = looseParse(raw); + if (obj == null || !obj.isObject()) { + return null; + } + com.fasterxml.jackson.databind.node.ObjectNode on = (com.fasterxml.jackson.databind.node.ObjectNode) obj; + // 轻校验:assertAfterPlay 须 list;非 list 则置空数组(对齐 studio.py:93-94)。 + if (on.has("assertAfterPlay") && !on.get("assertAfterPlay").isArray()) { + on.set("assertAfterPlay", MAPPER.createArrayNode()); + } + // expectLatch 缺省补 true(官方 latch 契约恒需,对齐 studio.py:95)。 + if (!on.has("expectLatch")) { + on.put("expectLatch", true); + } + // 安全归一:paddle-intercept 的 ballPath .y→.x(便宜模型偶把 ball.y 当横向,对齐 studio.py:97-101)。 + JsonNode drv = on.get("driver"); + if (drv != null && drv.isObject() && "paddle-intercept".equals(drv.path("type").asText())) { + JsonNode bp = drv.get("ballPath"); + if (bp != null && bp.isTextual() && bp.asText().endsWith(".y")) { + String fixed = bp.asText(); + ((com.fasterxml.jackson.databind.node.ObjectNode) drv) + .put("ballPath", fixed.substring(0, fixed.length() - 2) + ".x"); + } + } + return on; + } + + /** + * 宽松 JSON 解析(替代 Python json_repair,A-5 加强): + * ① 严格 Jackson;② 截最外层 {...} 去前后说明文字 + 去尾随逗号后再严格; + * ③ 仍不行则用 {@link #LENIENT}(容单引号/未引号字段名/尾逗号/注释/控制字符)兜底。 + * 三关全败返回 null(降级=退 brief 默认 play_spec)。 + */ + static JsonNode looseParse(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + // ① 严格解析。 + try { + return MAPPER.readTree(raw); + } catch (Exception ignore) { + // 落到宽松分支。 + } + // ② 截最外层 {...}(去掉块内可能混入的前后说明文字)+ 去对象/数组尾随逗号(,] / ,})。 + int lo = raw.indexOf('{'); + int hi = raw.lastIndexOf('}'); + if (lo < 0 || hi <= lo) { + return null; + } + String body = raw.substring(lo, hi + 1).replaceAll(",\\s*([}\\]])", "$1"); + try { + return MAPPER.readTree(body); + } catch (Exception ignore) { + // 落到宽松 mapper 兜底(单引号/未引号字段名/注释等)。 + } + // ③ 宽松 mapper(等价 json_repair 常见容错)最后一搏。 + try { + return LENIENT.readTree(body); + } catch (Exception e) { + return null; + } + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTest.java new file mode 100644 index 00000000..2a02aa3c --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTest.java @@ -0,0 +1,451 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.CompileConfig; +import com.alibaba.cloud.ai.graph.CompiledGraph; +import com.alibaba.cloud.ai.graph.KeyStrategy; +import com.alibaba.cloud.ai.graph.KeyStrategyFactory; +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.RunnableConfig; +import com.alibaba.cloud.ai.graph.StateGraph; +import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static com.alibaba.cloud.ai.graph.StateGraph.END; +import static com.alibaba.cloud.ai.graph.StateGraph.START; +import static com.alibaba.cloud.ai.graph.action.AsyncEdgeAction.edge_async; +import static com.alibaba.cloud.ai.graph.action.AsyncNodeAction.node_async; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * SaaStudioGraphTest —— SAA 迁移 step 4(关键里程碑):组装全 StateGraph + 跑通一款真 brief 端到端, + * 产出第一款 SAA 真生成的游戏。 + * + *

全图拓扑(蓝图 §3.1): + *

+ *   START → render → design → generate → validate
+ *     validate --ok--> scaffold --> build
+ *     validate --fail&cnt<N--> repair        build --ok--> play     build --fail&cnt<N--> repair
+ *     play --九门pass--> player                 play --fail&cnt<N--> repair    play --fail&cnt>=N--> giveup
+ *     player --无体验问题/轮尽--> emit            player --有问题&轮未尽--> repair
+ *     repair --> generate                       emit/giveup --> END
+ * 
+ * + *

公平性铁律(对比测试只换编排框架):prompt/角色/模型路由全部走 {@link SaaPrompts}(逐字移植 Python wg1/gen-worker), + * 模型路由用 models.yaml stage1(design/code/fix/player_text=deepseek-v4-flash、player_vision=MiniMax-M3)。 + * + *

验收口径(按任务要求):理想 verdict.pass=true;若 maxRepairs 内未过门,只要全图机制(条件边/repair 回环/ + * 各节点)真跑通也算 step4 达成(过门质量是后续调参/对比的事)。 + * + *

纪律(step4 边界):emit 不接 DifyCallbackReqVO/HMAC(step7);saver 用默认内存态(MysqlSaver 是 step5);不接 job 契约。 + * + *

运行(mini-desktop,需 NEWAPI_KEY + harness): + *

+ *   NEWAPI_KEY=sk-... mvn -pl ...aigc-server -am -Dtest=SaaStudioGraphTest \
+ *       -Dsaa.harness=1 -DGAME_RUNTIME_DIR=/root/games-development-ai/game-runtime \
+ *       -Dsurefire.failIfNoSpecifiedTests=false test
+ * 
+ * + * @author 造梦AI + */ +@EnabledIfEnvironmentVariable(named = "NEWAPI_KEY", matches = ".+") +@EnabledIfSystemProperty(named = "saa.harness", matches = "1", + disabledReason = "需 mini-desktop harness 运行机(node+esbuild+Chrome),无机时跳过;开关 -Dsaa.harness=1") +class SaaStudioGraphTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 主仓 game-runtime 根(harness + few-shot 探针所在)。 */ + private static final Path GAME_RUNTIME_ROOT = Paths.get( + System.getProperty("GAME_RUNTIME_DIR", + System.getenv().getOrDefault("GAME_RUNTIME_DIR", "/root/games-development-ai/game-runtime"))); + + /** + * 对比测试 brief 名(-Dsaa.brief=breakout|whack|flappy|...;默认 breakout)。 + * P4 对比测试参数化:同一全图按不同 brief 各跑一遍,只换 brief(编排框架/模型/门全等)。 + */ + private static final String BRIEF_NAME = System.getProperty("saa.brief", "breakout"); + + /** step4/P4 专用唯一 game id 基名(避免撞现有游戏;随 brief 名变,run 内再缀 runId)。 */ + private static final String GAME_ID = "saa-" + BRIEF_NAME; + + /** new-api host 根(Spring AI baseUrl 不带 /v1,剥之;坑见 §7-1)。 */ + private static final String NEW_API_BASE_URL = stripV1Suffix( + System.getenv().getOrDefault("NEWAPI_BASE_URL", "http://100.64.0.8:3000/v1")); + + /** 修复轮上限(对齐 studio.py:max_repairs 默认 5)。 */ + private static final int MAX_REPAIRS = 5; + /** player 顾问轮上限(对齐 studio.py:player_rounds 默认 1)。 */ + private static final int MAX_PLAYER_ROUNDS = 1; + /** + * 递归硬刹车(repair 回环成环时的超步预算;优雅终止非异常,对齐蓝图 §3.2 限次双保险)。 + * A-7:写死推导式 = (maxRepairs+1) 轮 × 每轮经过节点数 + 余量。 + * 每轮主链最长路径节点数 ≈ 7(render/design 仅首轮各 1 次,循环体 generate→validate→scaffold→build→play→player→repair); + * 取 (5+1)×7=42 再宽放余量至 120(远超实际所需,确保正常收口不会误触超步)。 + */ + private static final int RECURSION_LIMIT = (MAX_REPAIRS + 1) * 7 + 78; // =42+78=120 + + // models.yaml stage1 角色模型名(逐字对齐 wg1/gen-worker/worker/models.yaml)。 + private static final String M_DESIGN = "deepseek-v4-flash"; + private static final String M_CODE = "deepseek-v4-flash"; + private static final String M_FIX = "deepseek-v4-flash"; + private static final String M_PLAYER_TEXT = "deepseek-v4-flash"; + private static final String M_PLAYER_VISION = "MiniMax-M3"; + + private static String stripV1Suffix(String url) { + String u = url.trim(); + while (u.endsWith("/")) { + u = u.substring(0, u.length() - 1); + } + if (u.endsWith("/v1")) { + u = u.substring(0, u.length() - 3); + } + return u; + } + + /** + * 建一个指向 new-api 的 OpenAiChatModel(共用 OpenAiApi,仅 defaultOptions 异,对齐 dossier §1-24)。 + * A-3:per-role 采样面随请求下发(temperature/maxTokens 按角色,逐字对齐 Python studio.py 各位置)。 + * + * @param temperature 采样温度;null=不下发 temperature(让网关用服务端默认,对齐 AgentScope 默认—— + * Python config.build_model 不传 generate_kwargs,design 等角色即用服务端默认温度)。 + * @param maxTokens 最大补全 tokens(code/fix=16000、player_text=4000、player_vision=900,对齐 studio.py:160/167)。 + */ + private static OpenAiChatModel model(OpenAiApi api, String modelName, Double temperature, int maxTokens) { + OpenAiChatOptions.Builder opts = OpenAiChatOptions.builder() + .model(modelName) + .maxTokens(maxTokens); + if (temperature != null) { + opts.temperature(temperature); + } + return OpenAiChatModel.builder() + .openAiApi(api) + .defaultOptions(opts.build()) + .build(); + } + + @Test + void full_graph_generates_one_breakout_end_to_end() throws Exception { + String key = System.getenv("NEWAPI_KEY"); + assertNotNull(key, "NEWAPI_KEY 未设(密钥须经环境变量,绝不入库)"); + + // 预检:harness + few-shot 探针就位。 + assertTrue(Files.isDirectory(GAME_RUNTIME_ROOT), "GAME_RUNTIME_DIR 不存在:" + GAME_RUNTIME_ROOT); + assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("games/_wg1-gen/_shared/serve-and-play.sh")), + "缺 serve-and-play.sh(harness 地板),路径:" + GAME_RUNTIME_ROOT); + assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("node_modules/esbuild")), + "缺 node_modules/esbuild(build 节点依赖)"); + assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve(SaaPrompts.PROBE_REL)), + "缺 few-shot 探针 factory:" + SaaPrompts.PROBE_REL); + + // == 1) N 个共用 OpenAiApi 的角色模型(全指 new-api;公平性=与 Python 同模型路由)== + OpenAiApi api = OpenAiApi.builder().baseUrl(NEW_API_BASE_URL).apiKey(key).build(); + // A-3:per-role 采样面(temperature/maxTokens)逐字对齐 Python—— + // design:Python config.build_model 不传 temperature → AgentScope 用服务端默认 → 此处 temperature=null(不下发); + // code/fix:_client.chat temperature=0.0、max_tokens=16000(确定性产出); + // player_text:studio.py:167 _judge_text max_tokens=4000、temperature=0.3; + // player_vision:studio.py:160 _judge_vision max_tokens=900、temperature=0.3。 + OpenAiChatModel designModel = model(api, M_DESIGN, null, 16000); + OpenAiChatModel codeModel = model(api, M_CODE, 0.0, 16000); + OpenAiChatModel fixModel = model(api, M_FIX, 0.0, 16000); + OpenAiChatModel playerTextModel = model(api, M_PLAYER_TEXT, 0.3, 4000); + OpenAiChatModel playerVisionModel = model(api, M_PLAYER_VISION, 0.3, 900); + + // == 2) 组装全 StateGraph(蓝图 §3.1)== + KeyStrategyFactory keyFactory = () -> { + Map keys = new HashMap<>(); + for (String k : SaaStudioNodes.ALL_KEYS) { + keys.put(k, new ReplaceStrategy()); + } + return keys; + }; + + StateGraph graph = new StateGraph("saa-studio-full", keyFactory) + // -- 节点 -- + .addNode("render", node_async(SaaStudioNodes.renderNode())) + .addNode("design", node_async(SaaStudioNodes.designNode(designModel, M_DESIGN, GAME_RUNTIME_ROOT))) + .addNode("generate", node_async(SaaStudioNodes.generateNode(codeModel, fixModel, M_CODE, M_FIX, GAME_RUNTIME_ROOT))) + .addNode("validate", node_async(SaaGenNodes.validateNode())) + .addNode("scaffold", node_async(SaaGenNodes.scaffoldNode(GAME_RUNTIME_ROOT))) + .addNode("build", node_async(SaaGenNodes.buildNode(GAME_RUNTIME_ROOT))) + .addNode("play", node_async(SaaGenNodes.playNode(GAME_RUNTIME_ROOT))) + .addNode("player", node_async(SaaStudioNodes.playerNode(playerTextModel, playerVisionModel, M_PLAYER_TEXT, M_PLAYER_VISION, GAME_RUNTIME_ROOT))) + .addNode("repair", node_async(SaaStudioNodes.repairNode())) + .addNode("emit", node_async(SaaStudioNodes.emitNode(GAME_RUNTIME_ROOT))) + .addNode("giveup", node_async(SaaStudioNodes.giveupNode())) + // -- 直边 -- + .addEdge(START, "render") + .addEdge("render", "design") + .addEdge("design", "generate") + .addEdge("generate", "validate") + .addEdge("scaffold", "build") + .addEdge("repair", "generate") + .addEdge("emit", END) + .addEdge("giveup", END) + // -- 条件边(失败回边共用判据;九门=硬地板;player=软门)-- + .addConditionalEdges("validate", + edge_async(SaaStudioNodes.okElseRepairOrGiveup(SaaStudioNodes.K_VALIDATE_OK, MAX_REPAIRS)), + Map.of("ok", "scaffold", "repair", "repair", "giveup", "giveup")) + .addConditionalEdges("build", + edge_async(SaaStudioNodes.okElseRepairOrGiveup(SaaStudioNodes.K_BUILD_OK, MAX_REPAIRS)), + Map.of("ok", "play", "repair", "repair", "giveup", "giveup")) + .addConditionalEdges("play", + edge_async(SaaStudioNodes.playRouter(MAX_REPAIRS)), + Map.of("ok", "player", "repair", "repair", "giveup", "giveup")) + .addConditionalEdges("player", + edge_async(SaaStudioNodes.playerRouter(MAX_REPAIRS, MAX_PLAYER_ROUNDS)), + Map.of("emit", "emit", "repair", "repair")); + + // recursionLimit 给足(repair 回环成环;超步=优雅终止)。 + CompiledGraph compiled = graph.compile( + CompileConfig.builder().recursionLimit(RECURSION_LIMIT).build()); + + // == 3) 跑一款真 brief(breakout,与 step2 同)端到端 == + // A-9:runId/gameId 参数化——每 run 唯一 gameId(基名+runId),落独立目录,避免并行/重跑互撞(对照 run.py:56-63)。 + // 端口 port/cdpPort 也入 state(默认 4320/9222);worker 并行形态可每 run 错开端口并配 Semaphore(1) 串行真玩门。 + // 【Semaphore(1) 挂点】真玩门(play)用固定端口+Chrome,多 run 并行须串行化:worker 形态在此 acquire 一把 + // 全局 Semaphore(1)、跑完 release(本测试单 run,仅留挂点注释,不引入并发)。 + String runId = new java.text.SimpleDateFormat("yyyyMMdd-HHmmss").format(new java.util.Date()); + String gameId = GAME_ID + "-" + runId; + int port = Integer.getInteger("saa.port", 4320); + int cdpPort = Integer.getInteger("saa.cdpPort", 9222); + + // 跑前清旧 evidence/产物:唯一 gameId 目录若残留(极少数同秒重跑)先净场,保证证据是本 run 的。 + Path gameDir = GAME_RUNTIME_ROOT.resolve("games/_wg1-gen").resolve(gameId); + deleteRecursively(gameDir); + + BriefData bd = readBrief(BRIEF_NAME); + Map inputs = new HashMap<>(); + inputs.put(SaaStudioNodes.K_BRIEF, bd.brief); + inputs.put(SaaStudioNodes.K_GAME_ID, gameId); + // 公平性铁律:play_spec 基线取 brief 自带 play_spec(含 inputs/driver/controlCheck/assertAfterPlay/ + // expectedEngineCallPrefixes),与 Python run._load_brief 同口径;design 自产 gatespec 再 merge 覆盖 + // exportState/driver/controlCheck/assertAfterPlay/expectLatch(与 studio.py:218-223 同),inputs/ + // expectedEngineCallPrefixes 不在覆盖名单故保留——否则 SAA 侧 whack 无 inputs、各 brief 无 F 门期望前缀=比 Python 宽松,不公平。 + inputs.put(SaaStudioNodes.K_PLAY_SPEC, bd.playSpec); + inputs.put("port", port); + inputs.put("cdpPort", cdpPort); + inputs.put("runId", runId); + + RunnableConfig runConfig = RunnableConfig.builder().threadId(gameId).build(); + + long t0 = System.currentTimeMillis(); + Optional result = compiled.invoke(inputs, runConfig); + long wallMs = System.currentTimeMillis() - t0; + + assertTrue(result.isPresent(), "全图 invoke 应返回 state"); + OverAllState s = result.get(); + + // == 读出最终态 == + // A-7:recursionLimit 静默不完整态兜底——超步等优雅终止时 K_STATUS 可能为空(未抵 emit/giveup), + // 此处把「空 status」兜底当 failed(视为未收口的失败终态),杜绝下游误判为成功。 + String status = s.value(SaaStudioNodes.K_STATUS, String.class) + .filter(v -> !v.isBlank()).orElse("failed"); + boolean playPass = s.value(SaaStudioNodes.K_PLAY_PASS, Boolean.class).orElse(false); + int repairs = ((Number) s.value(SaaStudioNodes.K_REPAIR_COUNT, 0)).intValue(); + int playerRounds = ((Number) s.value(SaaStudioNodes.K_PLAYER_ROUND, 0)).intValue(); + long tin = ((Number) s.value(SaaStudioNodes.K_TOKENS_IN, 0)).longValue(); + long tout = ((Number) s.value(SaaStudioNodes.K_TOKENS_OUT, 0)).longValue(); + String designText = s.value(SaaStudioNodes.K_DESIGN_TEXT, String.class).orElse(""); + String gatespec = s.value(SaaStudioNodes.K_GATESPEC, String.class).orElse(""); + String playSpec = s.value(SaaStudioNodes.K_PLAY_SPEC, String.class).orElse(""); + String panel = s.value(SaaStudioNodes.K_PLAYER_PANEL, String.class).orElse("(no panel)"); + String engineBundle = s.value(SaaStudioNodes.K_ENGINE_BUNDLE, String.class).orElse(""); + String failure = s.value(SaaStudioNodes.K_FAILURE, String.class).orElse(""); + String feedback = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse(""); + + // == 证据落锤 ==(gameDir 已在 run 前按唯一 gameId 算好,此处复用) + Path vpath = gameDir.resolve("evidence/verdict.json"); + System.out.println("================ SAA step4 全图端到端证据 ================"); + System.out.println("[gameId] " + gameId + " runId=" + runId + " cwd=" + GAME_RUNTIME_ROOT); + System.out.println("[status] " + status + " playPass=" + playPass); + System.out.println("[repairs] " + repairs + " (上限 " + MAX_REPAIRS + ") playerRounds=" + playerRounds); + System.out.println("[wall] " + wallMs + "ms"); + System.out.println("[tokens] in=" + tin + " out=" + tout + " total=" + (tin + tout)); + System.out.println("[模型路由] design/code/fix/player_text=" + M_CODE + " player_vision=" + M_PLAYER_VISION); + System.out.println("[gatespec] " + (gatespec.isEmpty() ? "(无)" : gatespec)); + System.out.println("[merged playSpec] " + playSpec); + System.out.println("[player panel] " + panel); + if (!failure.isEmpty()) { + System.out.println("[failureReason] " + failure); + } + if (!feedback.isEmpty()) { + System.out.println("[last feedback] " + (feedback.length() > 600 ? feedback.substring(0, 600) : feedback)); + } + System.out.println("[设计稿头 12 行] >>>>>>>>"); + String[] dl = designText.split("\n", -1); + for (int i = 0; i < Math.min(12, dl.length); i++) { + System.out.println(String.format("%3d| %s", i + 1, dl[i])); + } + System.out.println("<<<<<<<< end 设计稿"); + System.out.println("[evidence 四件套] verdict.json / first-paint.png / after-play.png @ " + gameDir.resolve("evidence")); + System.out.println("[engineBundle] len=" + engineBundle.length() + + " 含__GameBundle=" + engineBundle.contains("__GameBundle")); + if (Files.exists(vpath)) { + var v = MAPPER.readTree(vpath.toFile()); + System.out.println("[verdict.pass] " + v.path("pass").asBoolean(false)); + var guards = v.path("guards"); + if (guards.isObject()) { + var it = guards.fields(); + StringBuilder gb = new StringBuilder("[九门] "); + while (it.hasNext()) { + var e = it.next(); + gb.append(e.getKey()).append("=").append(e.getValue().path("pass").asBoolean(false) ? "✓" : "✗").append(" "); + } + System.out.println(gb.toString()); + } + } + System.out.println("========================================================="); + + // == B-8 诊断 JSON 落盘(per-model token + cost¥ + attempts,schema 对齐 studio.py:297-307)== + // cost ¥ 复用 Python cost.py 子进程(传 tokens_by_model → 出 ¥),不在 Java 重写折算口径。 + try { + String tokensByModel = s.value(SaaStudioNodes.K_TOKENS_BY_MODEL, String.class).orElse("{}"); + String attempts = s.value(SaaStudioNodes.K_ATTEMPTS, String.class).orElse("[]"); + JsonNode costNode = computeCostViaPython(tokensByModel); // 子进程算 ¥(失败返回 error 节点,不中断)。 + + com.fasterxml.jackson.databind.node.ObjectNode diag = MAPPER.createObjectNode(); + diag.put("game_id", gameId); + diag.put("run_id", runId); + diag.put("stage", "stage1"); + diag.put("pass", playPass); + diag.put("status", status); + diag.put("repairs", repairs); + diag.put("player_rounds_used", playerRounds); + diag.put("wall_ms", wallMs); + diag.set("tokens", MAPPER.createObjectNode().put("prompt", tin).put("completion", tout)); + diag.set("tokens_by_model", MAPPER.readTree(tokensByModel)); + diag.set("attempts", MAPPER.readTree(attempts)); + diag.set("cost", costNode); + diag.set("models", MAPPER.createObjectNode() + .put("design", M_DESIGN).put("code", M_CODE).put("fix", M_FIX) + .put("player_text", M_PLAYER_TEXT).put("player_vision", M_PLAYER_VISION)); + diag.set("seven_gate_verdict", Files.exists(vpath) ? MAPPER.readTree(vpath.toFile()) : null); + // P4:把 player panel({text,vision} 评判)落进 diag JSON(schema 对齐 studio.py:304 的 player 字段,便于对比脚本直读)。 + try { + JsonNode panelNode = SaaPrompts.looseParse(panel); + diag.set("player", (panelNode != null && panelNode.isObject()) ? panelNode : null); + } catch (Exception ignorePanel) { + diag.set("player", null); + } + + Path diagDir = GAME_RUNTIME_ROOT.getParent().resolve("wg1/gen-worker/results"); + Files.createDirectories(diagDir); + Path diagPath = diagDir.resolve(gameId + ".saa.json"); + Files.write(diagPath, MAPPER.writerWithDefaultPrettyPrinter().writeValueAsBytes(diag)); + System.out.println("[诊断JSON] " + diagPath); + System.out.println("[cost¥] " + costNode); + System.out.println("[tokensByModel] " + tokensByModel); + } catch (Exception e) { + System.out.println("[诊断JSON] 写失败(不影响 step4 判定):" + e.getMessage()); + } + + // == 断言(step4 达成口径)== + // ① 全图机制真跑通:必抵达一个终态节点(emit succeeded / failed-emit / giveup)。 + assertTrue("succeeded".equals(status) || "failed".equals(status), + "全图必须抵达终态(emit/giveup),实得 status=" + status + " failure=" + failure); + // ② 编排链真执行过:设计稿 + 至少一次生成(gatespec 或 factorySrc 落盘)。 + assertTrue(designText.length() > 20, "design 节点应产出设计稿"); + assertTrue(Files.exists(gameDir.resolve("generated-factory.js")), + "scaffold 应至少落盘一次 generated-factory.js(证明 generate→validate→scaffold 链通)"); + // ③ 理想路径(pass=true)落锤:若过门,verdict.pass 必 true + engineBundle 含全局名。 + if (playPass) { + assertTrue(Files.exists(vpath), "过门应产出 verdict.json"); + var v = MAPPER.readTree(vpath.toFile()); + assertTrue(v.path("pass").asBoolean(false), "playPass=true 时 verdict.json pass 必 true"); + assertTrue("succeeded".equals(status), "过门应 status=succeeded"); + assertTrue(engineBundle.contains("__GameBundle"), "succeeded 的 engineBundle 必含 __GameBundle 全局名"); + } else { + // 未过门:机制仍须真跑(repair 回环至少触发一次或抵 giveup)。 + System.out.println("[note] 本次未过九门(status=" + status + ")。step4 达成口径=全图机制真跑通(条件边/repair 回环/各节点);过门质量留后续调参。"); + assertTrue(repairs >= 1 || "failed".equals(status), + "未过门时应至少触发一次 repair 回环或抵 giveup,实得 repairs=" + repairs + " status=" + status); + } + } + + /** + * B-8:复用 Python cost.py 子进程把 tokens_by_model 折成 ¥(不在 Java 重写 new-api quota 口径)。 + * 调 gen-worker .venv 的 python(有 httpx/openai),cost.py --stdin 读 {"tokens_by_model":...} → 出 {by_model,total_rmb,gate_rmb}。 + * 子进程缺失/网络失败 → 返回 {"error":...}(不中断 step4 判定)。 + */ + private static JsonNode computeCostViaPython(String tokensByModelJson) { + try { + Path workerRoot = GAME_RUNTIME_ROOT.getParent().resolve("wg1/gen-worker"); + Path venvPy = workerRoot.resolve(".venv/bin/python"); + String py = Files.exists(venvPy) ? venvPy.toString() : "python3"; + ProcessBuilder pb = new ProcessBuilder(py, "-m", "worker.cost", "--stdin"); + pb.directory(workerRoot.toFile()); + pb.redirectErrorStream(false); // 分离 stderr,避免污染 JSON。 + Process p = pb.start(); + // 喂 stdin:{"tokens_by_model": {...}}。 + try (var os = p.getOutputStream()) { + os.write(("{\"tokens_by_model\":" + tokensByModelJson + "}").getBytes(StandardCharsets.UTF_8)); + } + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + boolean done = p.waitFor(60, java.util.concurrent.TimeUnit.SECONDS); + if (!done) { + p.destroyForcibly(); + return MAPPER.createObjectNode().put("error", "cost.py 子进程超时(60s)"); + } + if (p.exitValue() != 0 || out.isBlank()) { + return MAPPER.createObjectNode().put("error", + "cost.py exit=" + p.exitValue() + " stderr=" + (err.length() > 300 ? err.substring(0, 300) : err)); + } + return MAPPER.readTree(out); + } catch (Exception e) { + return MAPPER.createObjectNode().put("error", "cost.py 调用异常:" + e.getMessage()); + } + } + + /** 净场删目录(递归;不存在则跳过)。A-9:每 run 唯一 gameId 目录跑前清旧。 */ + private static void deleteRecursively(Path dir) throws Exception { + if (!Files.exists(dir)) { + return; + } + try (var walk = Files.walk(dir)) { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (Exception ignore) { + // 单文件删失败不阻断(极少;目录唯一名,残留无害)。 + } + }); + } + } + + /** brief 题面 + play_spec 串(与 Python run._load_brief 同口径:brief 正文 + 自带 play_spec JSON 串)。 */ + private record BriefData(String brief, String playSpec) { + } + + /** + * 读指定 brief(题面正文 + 自带 play_spec),与 Python 同一来源 wg1/gen-worker/briefs/<name>.json。 + * play_spec 缺省给 "{}"(design 自产 gatespec 仍可 merge);brief 文件不可达直接抛(P4 必须真 brief,不走内联兜底以免静默换题)。 + */ + private static BriefData readBrief(String name) throws Exception { + // briefs 在主仓根(非 game-runtime 下):从 GAME_RUNTIME_ROOT 上推一级。 + Path briefPath = GAME_RUNTIME_ROOT.getParent().resolve("wg1/gen-worker/briefs/" + name + ".json"); + assertTrue(Files.exists(briefPath), "brief 文件不存在(P4 须真 brief):" + briefPath); + var node = MAPPER.readTree(briefPath.toFile()); + String b = node.path("brief").asText(""); + assertTrue(!b.isEmpty(), "brief 文件缺 brief 正文:" + briefPath); + JsonNode ps = node.path("play_spec"); + String psStr = (ps != null && !ps.isMissingNode() && !ps.isNull()) ? ps.toString() : "{}"; + return new BriefData(b, psStr); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java new file mode 100644 index 00000000..baecf0e9 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java @@ -0,0 +1,745 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.action.EdgeAction; +import com.alibaba.cloud.ai.graph.action.NodeAction; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.util.MimeTypeUtils; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * SaaStudioNodes —— SAA 迁移 step 4:LLM 驱动的编排节点 + 条件边谓词(generate/design/player/repair/emit/giveup)。 + * + *

本类承接 Python studio.py 的编排循环语义(design→[code|fix 环]→九门→player 顾问轮), + * 与 {@link SaaGenNodes}(validate/scaffold/build/play,step3 已建)拼成蓝图 §3.1 的全 StateGraph。 + * + *

公平性铁律:prompt/角色/模型路由全部走 {@link SaaPrompts}(逐字移植 Python),本类只负责「把它接进图」,不改 prompt 语义。 + * + *

语义逐一对齐 Python 参照(wg1/gen-worker/worker/): + *

    + *
  • {@link #renderNode} ≈ studio.py:213 起初始化(role=code/repairCount=0/playerRound=0,把 brief 落 state);
  • + *
  • {@link #designNode(ChatModel,Path)} ≈ studio.py:214-223(设计 agent + 抽 gatespec + merge play_spec);
  • + *
  • {@link #generateNode(ChatModel,ChatModel,Path)} ≈ studio.py:231-245(首轮 code/重出 fix,build_messages 回喂,抽 ```js);
  • + *
  • {@link #playerNode(ChatModel,ChatModel,Path)} ≈ studio.py:171-280(M3 视觉位+文本位 panel,软门回喂);
  • + *
  • {@link #repairNode} ≈ studio.py:231 的 attempt 自增 + role=fix(feedback 已由失败节点写好);
  • + *
  • {@link #emitNode(Path)} ≈ service.py:184-195(读 bundle.iife.js→断 __GameBundle→status/engineBundle);
  • + *
  • {@link #giveupNode} = 超 maxRepairs 的失败终态(status=failed/failureReason)。
  • + *
+ * + *

纪律(本步边界):emit 不接 DifyCallbackReqVO/HMAC 回调(那是 step7 形态②);不接 saver(step5);不接 job 契约。 + * + * @author 造梦AI + */ +final class SaaStudioNodes { + + private SaaStudioNodes() { + } + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** esbuild iife 全局名(契约冻结:出厂自检据此,对齐 service.py:184)。 */ + private static final String GLOBAL_NAME = "__GameBundle"; + + // ====================== state key 约定(OverAllState) ====================== + static final String K_BRIEF = "brief"; // 原始题面(in) + static final String K_ENRICHED = "enriched"; // brief + 设计稿(design 节点产出,喂 generate) + static final String K_DESIGN_TEXT = "designText"; // 设计稿正文 + static final String K_GATESPEC = "gatespec"; // 设计 agent 自产 gatespec(JSON 串,诊断) + static final String K_GATESPEC_ERROR = "gatespecParseError"; // gatespec 有块但解析失败的错因(A-5;诊断+回喂) + static final String K_PLAY_SPEC = "playSpec"; // play-spec JSON 串(merge gatespec 后;scaffold 落盘) + static final String K_FACTORY_SRC = "factorySrc"; // 工厂源码(generate 产出,validate/scaffold 读) + static final String K_GAME_ID = "gameId"; // 唯一游戏 id + static final String K_ROLE = "role"; // code|fix(区分模型) + static final String K_REPAIR_COUNT = "repairCount"; // 修复轮计数(主限次) + static final String K_PLAYER_ROUND = "playerRound"; // player 顾问轮计数(软门限次) + static final String K_FEEDBACK = "feedback"; // 上轮失败回喂(generate 注入 prompt;失败路由写) + static final String K_PLAYER_FEEDBACK = "playerFeedback"; // player 软门体验问题(与失败 feedback 解耦,避免串味) + static final String K_VALIDATE_OK = "validateOk"; // validate 节点产出(SaaGenNodes 写) + static final String K_BUILD_OK = "buildOk"; // build 节点产出 + static final String K_PLAY_PASS = "playPass"; // play 九门是否全过 + static final String K_VERDICT = "verdict"; // 九门 verdict(JSON 串) + static final String K_PLAYER_PANEL = "playerPanel"; // player panel 评判(JSON 串,诊断) + static final String K_TOKENS_IN = "tokensIn"; // 累计 prompt tokens + static final String K_TOKENS_OUT = "tokensOut"; // 累计 completion tokens + static final String K_TOKENS_BY_MODEL = "tokensByModel"; // per-model token(JSON 串:{model:{in,out}},B 对比指标/成本拆账) + static final String K_ATTEMPTS = "attempts"; // 每轮记录(JSON 数组串:{attempt,role,usage,stage_fail,guards},对齐 studio.py:attempts) + static final String K_STATUS = "status"; // succeeded|failed(emit/giveup 写) + static final String K_FAILURE = "failureReason"; // 失败原因(giveup/emit 写) + static final String K_ENGINE_BUNDLE = "engineBundle"; // bundle.iife.js 全文(emit 产出) + + /** state key 全集(KeyStrategyFactory 注册用,全 ReplaceStrategy 覆盖语义)。 */ + static final String[] ALL_KEYS = { + K_BRIEF, K_ENRICHED, K_DESIGN_TEXT, K_GATESPEC, K_GATESPEC_ERROR, K_PLAY_SPEC, K_FACTORY_SRC, K_GAME_ID, + K_ROLE, K_REPAIR_COUNT, K_PLAYER_ROUND, K_FEEDBACK, K_VALIDATE_OK, K_BUILD_OK, + K_PLAY_PASS, K_VERDICT, K_PLAYER_PANEL, K_PLAYER_FEEDBACK, K_TOKENS_IN, K_TOKENS_OUT, + K_TOKENS_BY_MODEL, K_ATTEMPTS, + K_STATUS, K_FAILURE, K_ENGINE_BUNDLE, + // SaaGenNodes 内部 key(scaffold/build/play 写): + "gameDir", "validateErrors", "buildLog", + // A-9 参数化入参(port/cdpPort/runId 由 invoke inputs 注入;play 读 port/cdpPort,默认 4320/9222): + "port", "cdpPort", "runId", + }; + + // ====================== ```js 抽取(对齐 validate.extract_code) ====================== + private static final Pattern JS_FENCE = + Pattern.compile("```(?:js|javascript)\\s*\\n(.*?)```", Pattern.DOTALL | Pattern.CASE_INSENSITIVE); + private static final Pattern ANY_FENCE = Pattern.compile("```\\s*\\n(.*?)```", Pattern.DOTALL); + + /** 从模型输出抽 ```js 代码块(对齐 validate.py:extract_code / step2 extractJsBlock)。 */ + static String extractJsBlock(String content) { + if (content == null || content.isEmpty()) { + return ""; + } + Matcher m = JS_FENCE.matcher(content); + if (m.find()) { + return m.group(1).strip(); + } + m = ANY_FENCE.matcher(content); + if (m.find()) { + return m.group(1).strip(); + } + return content.contains("export default") ? content.strip() : ""; + } + + // ====================== LLM 调用:单次 180s 超时 + ≤3 次重试(仅 429/5xx/IO/timeout) ====================== + + /** 单次 LLM 调用墙钟超时秒数(对齐 Python _client.py:57 timeout=180.0)。 */ + private static final long LLM_TIMEOUT_SECS = 180; + /** LLM 最大尝试次数(对齐 Python _client.py:60 tries=3)。 */ + private static final int LLM_MAX_TRIES = 3; + /** 重试间隔毫秒(对齐 Python _client.py:60 retry_delay=2.0)。 */ + private static final long LLM_RETRY_DELAY_MS = 2000; + + /** LLM 调用的专用执行器(守护线程,供 Future 超时取消)。 */ + private static final ExecutorService LLM_POOL = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "saa-llm-call"); + t.setDaemon(true); + return t; + }); + + /** + * 给一次 LLM 调用套单次 180s 墙钟超时 + 最多 3 次重试(只重 429/5xx/IO/timeout,对齐 Python _client.py:57-98)。 + *

纪律(评审 A-1):模型异常不再裸退图——超限后由调用方写 feedback/status,避免一次瞬时网关 502/超时就让整张图崩。 + * Spring AI 自身的 maxRetries 只兜瞬时 HTTP,这里再加墙钟超时(防单次调用挂死无限期阻塞节点)+ 统一重试分类。 + * + * @param call 实际产出 [文本,in,out] 的调用(同步阻塞,由本方法在独立线程跑 + 限时取消)。 + * @return [文本, inDelta(Long), outDelta(Long)]。 + * @throws Exception 超过最大尝试次数仍失败(最后一次异常上抛,调用方按节点语义兜底)。 + */ + private static Object[] callWithTimeoutAndRetry(Callable call) throws Exception { + Exception last = null; + for (int attempt = 0; attempt < LLM_MAX_TRIES; attempt++) { + Future f = LLM_POOL.submit(call); + try { + return f.get(LLM_TIMEOUT_SECS, TimeUnit.SECONDS); + } catch (TimeoutException te) { + f.cancel(true); // 取消挂死的调用(中断底层 HTTP 等待)。 + last = new RuntimeException("LLM 调用超时(" + LLM_TIMEOUT_SECS + "s)", te); + } catch (ExecutionException ee) { + Throwable cause = ee.getCause() != null ? ee.getCause() : ee; + last = (cause instanceof Exception) ? (Exception) cause : new RuntimeException(cause); + if (!isRetryable(cause)) { + throw last; // 不可重试错误(如 4xx 鉴权/参数):立即上抛,不浪费重试。 + } + } + // 还有下一次 → 退避后重试(最后一次失败落到循环外上抛)。 + if (attempt < LLM_MAX_TRIES - 1) { + try { + Thread.sleep(LLM_RETRY_DELAY_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw last != null ? last : ie; + } + } + } + throw last != null ? last : new RuntimeException("LLM 调用失败(重试 " + LLM_MAX_TRIES + " 次后)"); + } + + /** + * 判定异常是否值得重试:只重 429 / 5xx / IO / 超时(瞬时类);4xx(鉴权/参数)不重(对齐 Python only retry 429/5xx/IO/timeout)。 + * 用类型 + 消息双判(Spring AI 把网关错误包成 NonTransient/Transient/HttpStatusCodeException,消息里带状态码)。 + */ + private static boolean isRetryable(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof java.io.IOException || c instanceof TimeoutException + || c instanceof org.springframework.ai.retry.TransientAiException) { + return true; + } + String cn = c.getClass().getName(); + String msg = c.getMessage() == null ? "" : c.getMessage(); + // ResourceAccessException(连不上/读超时)、SocketTimeout 等按 IO/超时处理。 + if (cn.contains("ResourceAccessException") || cn.contains("SocketTimeout") + || cn.contains("ConnectException") || cn.contains("Timeout")) { + return true; + } + // HTTP 状态码:429 或 5xx 才重(4xx 不重)。 + if (msg.contains("429") || msg.matches("(?s).*\\b5\\d\\d\\b.*") + || msg.contains("Too Many Requests") || msg.contains("Internal Server Error") + || msg.contains("Bad Gateway") || msg.contains("Service Unavailable") + || msg.contains("Gateway Timeout")) { + return true; + } + } + return false; + } + + /** 调模型并把 usage 累计回 state(对齐 RecordingChatModel),含单次 180s 超时 + ≤3 重试。返回 [文本, inDelta, outDelta]。 */ + private static Object[] callAndRecord(ChatModel model, String system, String user) throws Exception { + return callWithTimeoutAndRetry(() -> { + Prompt prompt = new Prompt(List.of(new SystemMessage(system), new UserMessage(user))); + ChatResponse resp = model.call(prompt); + String text = resp.getResult().getOutput().getText(); + long in = 0, out = 0; + Usage u = resp.getMetadata().getUsage(); + if (u != null) { + in = u.getPromptTokens() == null ? 0 : u.getPromptTokens(); + out = u.getCompletionTokens() == null ? 0 : u.getCompletionTokens(); + } + return new Object[]{text, in, out}; + }); + } + + /** + * 把 usage delta 累加进 out map(基线优先取本节点 out 内已写值,否则取 state 旧值 + delta)。 + * 注:同一节点多次累加(如 player 文本位+视觉位)时,第二次须叠加在第一次已写入 out 的值上, + * 而非重读未变的 state(否则后者会覆盖前者)。 + */ + private static void accumTokens(OverAllState s, Map out, long inDelta, long outDelta) { + long in0 = out.containsKey(K_TOKENS_IN) + ? ((Number) out.get(K_TOKENS_IN)).longValue() : ((Number) s.value(K_TOKENS_IN, 0)).longValue(); + long out0 = out.containsKey(K_TOKENS_OUT) + ? ((Number) out.get(K_TOKENS_OUT)).longValue() : ((Number) s.value(K_TOKENS_OUT, 0)).longValue(); + out.put(K_TOKENS_IN, in0 + inDelta); + out.put(K_TOKENS_OUT, out0 + outDelta); + } + + /** + * B-8:per-model token 累计(读 state 旧 JSON 串 → 累加本模型 in/out → 写回)。 + * 用于成本拆账(cost.py 按 tokens_by_model 折 ¥)+ Stage 对比,对齐 studio.py:per_model。 + */ + private static void accumTokensByModel(OverAllState s, Map out, + String modelName, long inDelta, long outDelta) { + // 与总量累加同步:先把总量也加上(调用方不必再单独调 accumTokens)。 + accumTokens(s, out, inDelta, outDelta); + // 基线优先取本节点 out 内已写值(同一节点多次累加须叠加),否则取 state 旧值。 + String prev = out.containsKey(K_TOKENS_BY_MODEL) + ? (String) out.get(K_TOKENS_BY_MODEL) + : s.value(K_TOKENS_BY_MODEL, String.class).orElse("{}"); + com.fasterxml.jackson.databind.node.ObjectNode root; + try { + JsonNode n = SaaPrompts.looseParse(prev); + root = (n != null && n.isObject()) + ? (com.fasterxml.jackson.databind.node.ObjectNode) n + : MAPPER.createObjectNode(); + } catch (Exception e) { + root = MAPPER.createObjectNode(); + } + com.fasterxml.jackson.databind.node.ObjectNode m = root.has(modelName) && root.get(modelName).isObject() + ? (com.fasterxml.jackson.databind.node.ObjectNode) root.get(modelName) + : MAPPER.createObjectNode(); + m.put("in", m.path("in").asLong(0) + inDelta); + m.put("out", m.path("out").asLong(0) + outDelta); + root.set(modelName, m); + out.put(K_TOKENS_BY_MODEL, root.toString()); + } + + /** + * B-8:追加一条 attempt 记录(读 state 旧 JSON 数组 → append → 写回)。 + * fields 对齐 studio.py:attempts 元素:{attempt,role,model,usage:{in,out},stage_fail,guards}。 + */ + private static void appendAttempt(OverAllState s, Map out, + com.fasterxml.jackson.databind.node.ObjectNode rec) { + com.fasterxml.jackson.databind.node.ArrayNode arr; + try { + JsonNode n = MAPPER.readTree(s.value(K_ATTEMPTS, String.class).orElse("[]")); + arr = (n != null && n.isArray()) + ? (com.fasterxml.jackson.databind.node.ArrayNode) n + : MAPPER.createArrayNode(); + } catch (Exception e) { + arr = MAPPER.createArrayNode(); + } + arr.add(rec); + out.put(K_ATTEMPTS, arr.toString()); + } + + // ====================== ① render 节点(初始化) ====================== + + /** + * render 节点(对齐 studio.py:213 起的初始化):把 job 入参落 state 初值(role=code/计数器归零)。 + * 入参 brief/gameId/playSpec 来自 invoke inputs;本节点保证默认值齐全(图入口)。 + */ + static NodeAction renderNode() { + return (OverAllState s) -> { + Map out = new HashMap<>(); + out.put(K_ROLE, "code"); // 首轮 code 角色(对齐 studio.py:232) + out.put(K_REPAIR_COUNT, 0); + out.put(K_PLAYER_ROUND, 0); + out.put(K_TOKENS_IN, 0); + out.put(K_TOKENS_OUT, 0); + return out; + }; + } + + // ====================== ② design 节点(设计 agent + 自产 gatespec) ====================== + + /** + * design 节点(对齐 studio.py:214-223):DESIGN_SYSTEM → 设计 model 出设计稿 + 自产 gatespec; + * 抽 gatespec → merge 进 playSpec(覆盖 brief 默认,使确定性门适用任意游戏)。 + * + * @param designModel 设计角色模型(指 new-api,model=models.yaml stage1.design)。 + * @param designModelName 设计模型名(per-model token 拆账用;B-8)。 + * @param gameRuntimeRoot game-runtime 根(few-shot 用;design 不读,仅口径一致)。 + */ + static NodeAction designNode(ChatModel designModel, String designModelName, Path gameRuntimeRoot) { + return (OverAllState s) -> { + String brief = s.value(K_BRIEF, String.class).orElse(""); + String designText; + long inDelta = 0, outDelta = 0; + // A-1:design LLM 调用套 180s 超时 + ≤3 重试;超限不裸退图——降级为「无设计稿」继续(generate 仍可凭 brief 跑), + // 并把错因落 feedback(诊断可见)。设计稿正文给可读占位(保证下游断言/enriched 不空)。 + try { + Object[] r = callAndRecord(designModel, SaaPrompts.DESIGN_SYSTEM, brief); + designText = (String) r[0]; + inDelta = (Long) r[1]; + outDelta = (Long) r[2]; + } catch (Exception e) { + designText = "(设计 agent 调用失败,降级为无设计稿:" + trunc(e.getMessage(), 200) + ")"; + } + + // enriched = brief + 设计稿(对齐 studio.py:216)。 + String enriched = brief + "\n\n## 设计稿(设计 agent 产出)\n" + designText; + + // gate-H:抽 gatespec → merge play_spec(覆盖 brief 默认;对齐 studio.py:218-223)。 + JsonNode gatespec = SaaPrompts.extractGatespec(designText); + // A-5:有 ```gatespec 块但解析失败(gatespec==null 且块存在)→ 不静默退 {},记 gatespecParseError 并作为设计回喂, + // 提示代码 agent 重新产出规范 gatespec(区别于「无块」的静默降级,对齐评审「失败不静默」要求)。 + String gatespecParseErr = ""; + if (gatespec == null && SaaPrompts.hasGatespecBlock(designText)) { + gatespecParseErr = "设计稿的 ```gatespec 块解析失败(JSON 非法),已退回 brief 默认 play_spec;" + + "请在设计稿末尾重附一个合法 JSON 的 ```gatespec 块(双引号、无尾逗号)。"; + } + String playSpecStr = s.value(K_PLAY_SPEC, String.class).orElse("{}"); + JsonNode playSpec = SaaPrompts.looseParse(playSpecStr); + com.fasterxml.jackson.databind.node.ObjectNode merged = + (playSpec != null && playSpec.isObject()) + ? (com.fasterxml.jackson.databind.node.ObjectNode) playSpec.deepCopy() + : MAPPER.createObjectNode(); + if (gatespec != null) { + for (String k : new String[]{"exportState", "driver", "controlCheck", "assertAfterPlay", "expectLatch"}) { + if (gatespec.has(k) && !gatespec.get(k).isNull()) { + merged.set(k, gatespec.get(k)); + } + } + } + + Map out = new HashMap<>(); + out.put(K_DESIGN_TEXT, designText); + out.put(K_ENRICHED, enriched); + out.put(K_PLAY_SPEC, merged.toString()); + out.put(K_GATESPEC, gatespec == null ? "" : gatespec.toString()); + out.put(K_GATESPEC_ERROR, gatespecParseErr); + // 有 gatespec 解析错 → 作为首轮 generate 的回喂 feedback(buildMessages 会附到 user 末尾,role 仍 code)。 + if (!gatespecParseErr.isEmpty()) { + out.put(K_FEEDBACK, gatespecParseErr); + } + accumTokensByModel(s, out, designModelName, inDelta, outDelta); // B-8:per-model 拆账(含总量累加)。 + // B-8:记 design 这一轮(attempt 0 之前的设计步,role=design)。 + appendAttempt(s, out, MAPPER.createObjectNode() + .put("role", "design").put("model", designModelName) + .set("usage", MAPPER.createObjectNode().put("in", inDelta).put("out", outDelta))); + return out; + }; + } + + // ====================== ③ generate 节点(code/fix,回喂全量重出) ====================== + + /** + * generate 节点(对齐 studio.py:231-245):role=code→codeModel / role=fix→fixModel; + * buildMessages(enriched, feedback) 回喂上轮失败原因;抽 ```js → factorySrc。 + * + * @param codeModel code 角色模型(首轮)。 + * @param fixModel fix 角色模型(repair 轮)。 + * @param codeModelName code 模型名(per-model 拆账用;B-8)。 + * @param fixModelName fix 模型名(per-model 拆账用;B-8)。 + * @param gameRuntimeRoot game-runtime 根(few-shot 探针)。 + */ + static NodeAction generateNode(ChatModel codeModel, ChatModel fixModel, + String codeModelName, String fixModelName, Path gameRuntimeRoot) { + return (OverAllState s) -> { + String role = s.value(K_ROLE, "code"); + boolean isFix = "fix".equals(role); + ChatModel model = isFix ? fixModel : codeModel; + String mname = isFix ? fixModelName : codeModelName; + int attemptNo = ((Number) s.value(K_REPAIR_COUNT, 0)).intValue(); // 当前修复轮序(首轮=0)。 + String enriched = s.value(K_ENRICHED, String.class) + .orElse(s.value(K_BRIEF, String.class).orElse("")); + String feedback = s.value(K_FEEDBACK, String.class).orElse(null); + + String[] msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot); + Map out = new HashMap<>(); + // A-1:generate(含 repair 轮,role=fix 时)LLM 调用套 180s 超时 + ≤3 重试; + // 超限不裸退图——把错因写 feedback + factorySrc 留空(validate 判空过短 → 路由 repair/giveup,对齐 run.py:103-105)。 + Object[] r; + try { + r = callAndRecord(model, msgs[0], msgs[1]); + } catch (Exception e) { + out.put(K_FACTORY_SRC, ""); + out.put(K_FEEDBACK, "模型调用失败(已重试):" + trunc(e.getMessage(), 300)); + // B-8:LLM 失败也记一轮(stage_fail=generate,usage 0)。 + appendAttempt(s, out, MAPPER.createObjectNode() + .put("attempt", attemptNo).put("role", role).put("model", mname) + .put("stage_fail", "generate") + .set("usage", MAPPER.createObjectNode().put("in", 0).put("out", 0))); + return out; + } + String src = extractJsBlock((String) r[0]); + out.put(K_FACTORY_SRC, src); + // 抽不到 ```js → 写 feedback(对齐 studio.py:242-245 的 extract 失败桶),validate 会判空过短再兜。 + if (src.isEmpty()) { + out.put(K_FEEDBACK, "未能从输出中提取 ```js 代码块。请只输出一个完整的 ```js 代码块,块内是完整工厂源码。"); + } + long in = (Long) r[1], outTok = (Long) r[2]; + accumTokensByModel(s, out, mname, in, outTok); // B-8:per-model 拆账(含总量累加)。 + // B-8:记本生成轮(attempt 序号/角色/模型/usage;后续 validate/build/play 失败由各节点补 stage_fail 到尾记录)。 + appendAttempt(s, out, MAPPER.createObjectNode() + .put("attempt", attemptNo).put("role", role).put("model", mname) + .put("stage_fail", src.isEmpty() ? "extract" : (String) null) + .set("usage", MAPPER.createObjectNode().put("in", in).put("out", outTok))); + return out; + }; + } + + // ====================== ④ player 节点(M3 视觉位 + 文本位,软门) ====================== + + /** + * player 节点(对齐 studio.py:171-280 的 run_player_panel + 软门回喂): + * 文本位(仅运行数据)+ 视觉位(M3 + 首帧/玩后截图,hook 容错)多人格评判; + * 任一位 verdict=fix 且 playerRound<maxRounds → 写 feedback 回 repair(七门仍是硬地板,player 软门)。 + * + * @param playerTextModel 文本位模型(models.yaml stage1.player_text)。 + * @param playerVisionModel 视觉位模型(恒 MiniMax-M3;hook,失败优雅降级)。 + * @param playerTextModelName 文本位模型名(per-model 拆账用;B-8)。 + * @param playerVisionModelName 视觉位模型名(per-model 拆账用;B-8)。 + * @param gameRuntimeRoot game-runtime 根(读 evidence 截图)。 + */ + static NodeAction playerNode(ChatModel playerTextModel, ChatModel playerVisionModel, + String playerTextModelName, String playerVisionModelName, Path gameRuntimeRoot) { + return (OverAllState s) -> { + String gameId = s.value(K_GAME_ID, String.class).orElse(""); + String brief = s.value(K_BRIEF, String.class).orElse(""); + String verdictStr = s.value(K_VERDICT, String.class).orElse(""); + JsonNode verdict = SaaPrompts.looseParse(verdictStr); + + String summary = playSummary(verdict); + String base = "游戏题面:\n" + brief + "\n\n" + summary + "\n\n" + + "请基于以上证据(及截图,若有)给出你的玩家评判。"; + + com.fasterxml.jackson.databind.node.ObjectNode panel = MAPPER.createObjectNode(); + List problems = new ArrayList<>(); + Map out = new HashMap<>(); + + // -- 文本位(细致完整度审查者,只信运行数据,对齐 studio.py:183)-- + try { + String sys = SaaPrompts.playerSystem("细致的完整度审查者,只信运行数据、不脑补"); + Object[] r = callAndRecord(playerTextModel, sys, base); + accumTokensByModel(s, out, playerTextModelName, (Long) r[1], (Long) r[2]); // B-8:per-model 拆账。 + JsonNode j = SaaPrompts.looseParse((String) r[0]); + JsonNode nj = normalizeJudge(j, (String) r[0]); // 先归一(problems→数组、类型校验)再收集,避免读原始脏值。 + panel.set("text", nj); + collectFixProblems(nj, problems); + } catch (Exception e) { + panel.set("text", MAPPER.createObjectNode().put("error", trunc(e.getMessage(), 200))); + } + + // -- 视觉位(M3 + 首帧/玩后截图;hook:失败优雅降级不连坐软门,对齐 studio.py:191-192)-- + try { + Object[] r = judgeVision(playerVisionModel, "急性子休闲玩家,凭第一眼观感和反馈下判断", + base, gameRuntimeRoot, gameId); + accumTokensByModel(s, out, playerVisionModelName, (Long) r[1], (Long) r[2]); // B-8:per-model 拆账。 + JsonNode j = SaaPrompts.looseParse((String) r[0]); + JsonNode nj = normalizeJudge(j, (String) r[0]); // 先归一再收集(同文本位口径)。 + panel.set("vision", nj); + collectFixProblems(nj, problems); + } catch (Exception e) { + panel.set("vision", MAPPER.createObjectNode().put("error", trunc(e.getMessage(), 200))); + } + + out.put(K_PLAYER_PANEL, panel.toString()); + + // 软门:有体验问题 → 写 playerFeedback(与失败 feedback 解耦,避免串味,对齐 studio.py:277-278); + // 无问题写空串(让 playerRouter 拿到干净信号)。playerRound 由 repair 节点统一自增。 + if (!problems.isEmpty()) { + StringBuilder fb = new StringBuilder("七门已过,但玩家 agent 反馈以下体验问题,请修复并保持七门可过:\n"); + int n = 0; + for (String p : problems) { + if (n++ >= 8) { + break; + } + fb.append("- ").append(p).append("\n"); + } + out.put(K_PLAYER_FEEDBACK, fb.toString()); + } else { + out.put(K_PLAYER_FEEDBACK, ""); + } + return out; + }; + } + + /** 把九门 verdict 摘成给玩家 agent 的运行数据文字(对齐 studio.py:_play_summary)。 */ + private static String playSummary(JsonNode verdict) { + if (verdict == null) { + return "(无 verdict:疑似装载即崩)"; + } + JsonNode guards = verdict.path("guards"); + StringBuilder sb = new StringBuilder("七门真玩取证:\n"); + if (guards.isObject()) { + var it = guards.fields(); + while (it.hasNext()) { + var e = it.next(); + boolean pass = e.getValue().path("pass").asBoolean(false); + String g = e.getValue().toString(); + sb.append("- 七门 ").append(e.getKey()).append(": ").append(pass ? "过" : "未过") + .append(" ").append(g.length() > 160 ? g.substring(0, 160) : g).append("\n"); + } + } + return sb.toString(); + } + + /** + * M3 多模态评判:文本 + 首帧/玩后截图(Spring AI Media;OpenAI 兼容走 image_url)。返回 [文本, in, out]。 + * A-1:调用同样套 180s 超时 + ≤3 重试(视觉位 hook,调用方 catch 后优雅降级不连坐软门)。 + */ + private static Object[] judgeVision(ChatModel visionModel, String persona, String userText, + Path gameRuntimeRoot, String gameId) throws Exception { + Path ev = gameRuntimeRoot.resolve("games").resolve("_wg1-gen").resolve(gameId).resolve("evidence"); + List medias = new ArrayList<>(); + for (String fn : new String[]{"first-paint.png", "after-play.png"}) { + Path png = ev.resolve(fn); + try { + if (Files.exists(png)) { + byte[] bytes = Files.readAllBytes(png); + medias.add(Media.builder() + .mimeType(MimeTypeUtils.IMAGE_PNG) + .data(new ByteArrayResource(bytes)) + .id(fn) + .build()); + } + } catch (Exception ignore) { + // 单张截图读失败不连坐(视觉位整体仍可用文字判)。 + } + } + UserMessage um = medias.isEmpty() + ? new UserMessage(userText) + : UserMessage.builder().text(userText).media(medias).build(); + Prompt prompt = new Prompt(List.of(new SystemMessage(SaaPrompts.playerSystem(persona)), um)); + return callWithTimeoutAndRetry(() -> { + ChatResponse resp = visionModel.call(prompt); + String text = resp.getResult().getOutput().getText(); + long in = 0, out = 0; + Usage u = resp.getMetadata().getUsage(); + if (u != null) { + in = u.getPromptTokens() == null ? 0 : u.getPromptTokens(); + out = u.getCompletionTokens() == null ? 0 : u.getCompletionTokens(); + } + return new Object[]{text, in, out}; + }); + } + + /** + * 容错归一玩家评判 JSON(对齐 studio.py:_parse_judge 118-122): + * 解不动给占位(completeness/fun=null、verdict="?"、note=raw 截断); + * A-6:解出对象后做字段归一——problems 缺省补 []、非数组(字符串等)转单元素数组; + * verdict/fun/completeness 做类型校验(verdict 非文本→"?";fun/completeness 非数字→null)。 + */ + private static JsonNode normalizeJudge(JsonNode j, String raw) { + if (j == null || !j.isObject()) { + return MAPPER.createObjectNode() + .putNull("completeness") + .putNull("fun") + .put("verdict", "?") + .put("note", trunc(raw, 120)); + } + com.fasterxml.jackson.databind.node.ObjectNode on = (com.fasterxml.jackson.databind.node.ObjectNode) j; + // problems:缺省补 [];非数组(字符串/对象/数字)转单元素字符串数组(对齐 studio.py:120-122)。 + JsonNode ps = on.get("problems"); + if (ps == null || ps.isNull()) { + on.set("problems", MAPPER.createArrayNode()); + } else if (!ps.isArray()) { + com.fasterxml.jackson.databind.node.ArrayNode arr = MAPPER.createArrayNode(); + arr.add(ps.isValueNode() ? ps.asText() : ps.toString()); + on.set("problems", arr); + } + // verdict:非文本归一为 "?"(防 null/数字混入误判软门)。 + if (!on.path("verdict").isTextual()) { + on.put("verdict", "?"); + } + // fun / completeness:非数字归一为 null(分锚口径只认数字,防字符串/对象污染对比指标)。 + for (String f : new String[]{"fun", "completeness"}) { + JsonNode v = on.get(f); + if (v != null && !v.isNumber()) { + on.putNull(f); + } + } + return on; + } + + /** 若评判 verdict=fix,收集 problems(对齐 studio.py:189-190)。入参应为已 normalizeJudge 归一后的节点(problems 必为数组)。 */ + private static void collectFixProblems(JsonNode j, List problems) { + if (j != null && j.isObject() && "fix".equals(j.path("verdict").asText())) { + JsonNode ps = j.path("problems"); + if (ps.isArray()) { + ps.forEach(p -> problems.add(p.asText())); + } + } + } + + // ====================== ⑤ repair 节点(自增计数 + role=fix) ====================== + + /** + * repair 节点(对齐 studio.py:231 的 attempt 自增 + studio.py:232 的 role=fix): + * 统一自增 repairCount(失败回边);若来自 player 软门(playerRound 路径)则另自增 playerRound。 + * feedback 已由失败节点(validate/build/play)或 player 节点写好,本节点只翻 role + 计数。 + */ + static NodeAction repairNode() { + return (OverAllState s) -> { + Map out = new HashMap<>(); + out.put(K_ROLE, "fix"); + int rc = ((Number) s.value(K_REPAIR_COUNT, 0)).intValue(); + out.put(K_REPAIR_COUNT, rc + 1); + // player 软门触发的修复:有 playerFeedback(体验问题)→ 提升为 generate 的回喂 feedback + 自增 playerRound。 + // (失败路触发的修复:feedback 已由 validate/build/play 写好,本节点不动。) + String pfb = s.value(K_PLAYER_FEEDBACK, String.class).orElse(""); + if (!pfb.isEmpty()) { + out.put(K_FEEDBACK, pfb); + int pr = ((Number) s.value(K_PLAYER_ROUND, 0)).intValue(); + out.put(K_PLAYER_ROUND, pr + 1); + // A-4:消费后立即清空 playerFeedback——避免本轮 player 体验问题在下一轮 validate/build/play 硬门失败时 + // 被 playerRouter/repair 误读为「仍有体验问题」而串味(player 反馈与失败 feedback 必须解耦)。 + out.put(K_PLAYER_FEEDBACK, ""); + } + return out; + }; + } + + // ====================== ⑥ emit 节点(组结果,本步不接回调契约) ====================== + + /** + * emit 节点(对齐 service.py:184-195 出厂自检):读 bundle.iife.js → 断 __GameBundle → + * status=succeeded + engineBundle(全文);缺全局名判失败不落坏包。 + *

本步不接 DifyCallbackReqVO/HMAC 回调(step7 形态②)。 + * + * @param gameRuntimeRoot game-runtime 根。 + */ + static NodeAction emitNode(Path gameRuntimeRoot) { + return (OverAllState s) -> { + String gameId = s.value(K_GAME_ID, String.class).orElse(""); + Path bundlePath = gameRuntimeRoot.resolve("games").resolve("_wg1-gen") + .resolve(gameId).resolve("bundle.iife.js"); + Map out = new HashMap<>(); + try { + String bundle = new String(Files.readAllBytes(bundlePath), StandardCharsets.UTF_8); + if (!bundle.contains(GLOBAL_NAME)) { + out.put(K_STATUS, "failed"); + out.put(K_FAILURE, "bundle_missing_global_name"); + return out; + } + out.put(K_STATUS, "succeeded"); + out.put(K_ENGINE_BUNDLE, bundle); + } catch (Exception e) { + out.put(K_STATUS, "failed"); + out.put(K_FAILURE, "bundle_read_error:" + trunc(e.getMessage(), 200)); + } + return out; + }; + } + + // ====================== ⑦ giveup 节点(超限失败终态) ====================== + + /** giveup 节点:超 maxRepairs 仍未过门 → status=failed(失败原因取最后 feedback)。 */ + static NodeAction giveupNode() { + return (OverAllState s) -> { + Map out = new HashMap<>(); + out.put(K_STATUS, "failed"); + String fb = s.value(K_FEEDBACK, String.class).orElse("超过最大修复轮数仍未过门"); + out.put(K_FAILURE, "max_repairs_exceeded: " + trunc(fb, 400)); + return out; + }; + } + + // ====================== 条件边谓词(EdgeAction → String) ====================== + + /** + * validate/build 失败回边共用判据(对齐蓝图 §3.1):ok? 下一步 : repairCount>=N? giveup : repair。 + * + * @param okKey 成功标志 state key(validateOk / buildOk)。 + * @param maxRepairs 修复轮上限。 + */ + static EdgeAction okElseRepairOrGiveup(String okKey, int maxRepairs) { + return (OverAllState s) -> { + if (s.value(okKey, Boolean.class).orElse(false)) { + return "ok"; + } + int rc = ((Number) s.value(K_REPAIR_COUNT, 0)).intValue(); + return rc >= maxRepairs ? "giveup" : "repair"; + }; + } + + /** play 条件边:九门 pass→player;否则 repairCount>=N? giveup : repair(九门=硬地板)。 */ + static EdgeAction playRouter(int maxRepairs) { + return okElseRepairOrGiveup(K_PLAY_PASS, maxRepairs); + } + + /** + * player 条件边(软门,对齐 studio.py:274-280): + * 无体验问题(feedback 空) 或 playerRound 已尽 → emit;否则(且 repairCount 未尽)→ repair。 + * 注:player 是软门,七门已过;这里 repair 走 fix 修体验,repairCount 仍受 maxRepairs 兜底。 + * + * @param maxRepairs 修复轮上限(硬刹车)。 + * @param maxPlayerRounds player 顾问轮上限。 + */ + static EdgeAction playerRouter(int maxRepairs, int maxPlayerRounds) { + return (OverAllState s) -> { + String pfb = s.value(K_PLAYER_FEEDBACK, String.class).orElse(""); + int pr = ((Number) s.value(K_PLAYER_ROUND, 0)).intValue(); + int rc = ((Number) s.value(K_REPAIR_COUNT, 0)).intValue(); + // 有体验问题 且 player 轮未尽 且 repair 轮未尽 → 回修;否则收口 emit。 + if (!pfb.isEmpty() && pr < maxPlayerRounds && rc < maxRepairs) { + return "repair"; + } + return "emit"; + }; + } + + /** 截断字符串(日志/失败原因摘要)。 */ + private static String trunc(String s, int n) { + if (s == null) { + return ""; + } + return s.length() <= n ? s : s.substring(0, n); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodesRegressionTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodesRegressionTest.java new file mode 100644 index 00000000..95c7835c --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodesRegressionTest.java @@ -0,0 +1,168 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.KeyStrategy; +import com.alibaba.cloud.ai.graph.action.NodeAction; +import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * SaaStudioNodesRegressionTest —— 对比前必修项的纯单元回归(无 LLM/无 harness,确定性)。 + * + *

覆盖评审整改项: + *

    + *
  • A-4 playerFeedback 串味:player 软门修复后若下一硬门(validate)失败,回喂必须是 validate 失败原因、非旧 player 反馈; + * 且 repairNode 消费 playerFeedback 后立即清空。
  • + *
  • A-6 player 评判归一:problems 非数组(字符串)→ 单元素数组、缺省→[];verdict 非文本→"?";fun/completeness 非数字→null。
  • + *
  • A-5 gatespec 宽松解析:单引号 / 尾随逗号 / 未引号字段名的脏 JSON 仍能救活(不静默退 {})。
  • + *
+ * + * @author 造梦AI + */ +class SaaStudioNodesRegressionTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 用 SaaStudioNodes.ALL_KEYS 全 ReplaceStrategy 建一个可读写的 OverAllState(含 playerFeedback 等键)。 */ + private static OverAllState newFullState(Map init) { + // 公共构造:data + 注册全 key 的 ReplaceStrategy(覆盖语义,便于链式断言)。 + OverAllState s = new OverAllState(init == null ? new HashMap<>() : new HashMap<>(init)); + Map ks = new HashMap<>(); + for (String k : SaaStudioNodes.ALL_KEYS) { + ks.put(k, new ReplaceStrategy()); + } + s.registerKeyAndStrategy(ks); + return s; + } + + /** 执行一个 NodeAction(同步),把其返回的 partial 按已注册策略并回 state,便于链式断言。 */ + private static void applyNode(OverAllState s, NodeAction node) throws Exception { + Map partial = node.apply(s); + s.updateState(partial); // 实例 updateState:按已注册 KeyStrategy(ReplaceStrategy=覆盖)写回。 + } + + // ====================== A-4:playerFeedback 串味回归 ====================== + + @Test + void repair_consumes_and_clears_player_feedback() throws Exception { + // 初态:player 软门写了体验问题 playerFeedback,repairCount=0。 + Map init = new HashMap<>(); + init.put(SaaStudioNodes.K_PLAYER_FEEDBACK, "七门已过,但玩家 agent 反馈以下体验问题:\n- 反馈太弱"); + init.put(SaaStudioNodes.K_REPAIR_COUNT, 0); + init.put(SaaStudioNodes.K_PLAYER_ROUND, 0); + OverAllState s = newFullState(init); + + applyNode(s, SaaStudioNodes.repairNode()); + + // repair 应:role=fix;feedback 提升为 playerFeedback 内容;playerFeedback 清空;playerRound+1;repairCount+1。 + assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse("")); + assertTrue(s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("").contains("反馈太弱"), + "feedback 应被提升为 player 体验问题"); + assertEquals("", s.value(SaaStudioNodes.K_PLAYER_FEEDBACK, String.class).orElse(""), + "A-4:repair 消费后 playerFeedback 必须清空"); + assertEquals(1, ((Number) s.value(SaaStudioNodes.K_PLAYER_ROUND, 0)).intValue()); + assertEquals(1, ((Number) s.value(SaaStudioNodes.K_REPAIR_COUNT, 0)).intValue()); + } + + @Test + void player_then_validate_fail_feedback_is_validate_not_player() throws Exception { + // 链路:player 写 pfb → repair 促升+清空 → validate 失败(坏样)→ 断言回喂=validate 失败原因、非旧 player 反馈。 + Map init = new HashMap<>(); + init.put(SaaStudioNodes.K_PLAYER_FEEDBACK, "玩家反馈:粒子太弱看不到"); + init.put(SaaStudioNodes.K_REPAIR_COUNT, 0); + init.put(SaaStudioNodes.K_PLAYER_ROUND, 0); + // 放一个会被 validate 判不过的坏 factorySrc(缺 export default / 过短)。 + init.put("factorySrc", "const x=1; // 不是工厂,validate 必不过"); + OverAllState s = newFullState(init); + + // ① repair 促升 player 反馈为 feedback 并清空 pfb。 + applyNode(s, SaaStudioNodes.repairNode()); + assertEquals("", s.value(SaaStudioNodes.K_PLAYER_FEEDBACK, String.class).orElse("")); + + // ② validate 节点(硬门)失败 → 写 feedback=校验未过…,并(A-4)清空 pfb(本就已空,幂等)。 + applyNode(s, SaaGenNodes.validateNode()); + + String fb = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse(""); + assertTrue(fb.contains("校验未过"), "A-4:硬门失败后回喂必须是 validate 失败原因,实得:" + fb); + assertFalse(fb.contains("粒子太弱"), "A-4:回喂不得再串入旧 player 体验反馈,实得:" + fb); + assertEquals(false, s.value(SaaStudioNodes.K_VALIDATE_OK, Boolean.class).orElse(true)); + assertEquals("", s.value(SaaStudioNodes.K_PLAYER_FEEDBACK, String.class).orElse(""), + "A-4:validate 硬门失败应保持 playerFeedback 为空"); + } + + // ====================== A-6:player 评判归一回归(反射调私有 normalizeJudge) ====================== + + private static JsonNode callNormalizeJudge(JsonNode j, String raw) throws Exception { + Method m = SaaStudioNodes.class.getDeclaredMethod("normalizeJudge", JsonNode.class, String.class); + m.setAccessible(true); + return (JsonNode) m.invoke(null, j, raw); + } + + @Test + void normalize_judge_problems_and_types() throws Exception { + // problems 是字符串(非数组)→ 单元素数组;verdict 非文本(数字)→ "?";fun 非数字(字符串)→ null。 + JsonNode dirty = MAPPER.readTree("{\"completeness\":3,\"fun\":\"high\",\"problems\":\"反馈太弱\",\"verdict\":1}"); + JsonNode n = callNormalizeJudge(dirty, "raw"); + assertTrue(n.path("problems").isArray(), "problems 应归一为数组"); + assertEquals(1, n.path("problems").size()); + assertEquals("反馈太弱", n.path("problems").get(0).asText()); + assertTrue(n.path("verdict").isTextual() && "?".equals(n.path("verdict").asText()), + "verdict 非文本应归一为 \"?\""); + assertTrue(n.path("fun").isNull(), "fun 非数字应归一为 null"); + assertEquals(3, n.path("completeness").asInt(), "completeness 数字应保留"); + } + + @Test + void normalize_judge_missing_problems_becomes_empty_array() throws Exception { + JsonNode j = MAPPER.readTree("{\"verdict\":\"pass\",\"fun\":4,\"completeness\":4}"); + JsonNode n = callNormalizeJudge(j, "raw"); + assertTrue(n.path("problems").isArray() && n.path("problems").isEmpty(), + "缺省 problems 应补为空数组"); + } + + @Test + void normalize_judge_unparsable_gives_placeholder() throws Exception { + JsonNode n = callNormalizeJudge(null, "这不是JSON只是一句话"); + assertTrue(n.path("completeness").isNull()); + assertTrue(n.path("fun").isNull()); + assertEquals("?", n.path("verdict").asText()); + assertTrue(n.path("note").asText().contains("这不是JSON"), "解不动应保留 raw 占位 note"); + } + + // ====================== A-5:gatespec / looseParse 宽松解析回归 ====================== + + @Test + void loose_parse_recovers_single_quotes_and_trailing_comma() { + // 单引号 + 尾随逗号 + 未引号字段名(便宜模型高频脏 JSON)→ 仍能救活(非静默退 null)。 + JsonNode n = SaaPrompts.looseParse("{ phase: 'playing', score: 0, items: [1,2,3,], }"); + org.junit.jupiter.api.Assertions.assertNotNull(n, "宽松解析应救活脏 JSON"); + assertEquals("playing", n.path("phase").asText()); + assertEquals(3, n.path("items").size()); + } + + @Test + void extract_gatespec_ballpath_y_to_x_and_default_latch() { + // 设计稿带 gatespec(paddle-intercept 误填 ball.y)→ 抽取后应 .y→.x 归一 + 缺省补 expectLatch=true。 + String design = "设计正文……\n```gatespec\n{\"driver\":{\"type\":\"paddle-intercept\",\"ballPath\":\"ball.y\",\"paddleY\":800}}\n```"; + JsonNode g = SaaPrompts.extractGatespec(design); + org.junit.jupiter.api.Assertions.assertNotNull(g); + assertEquals("ball.x", g.path("driver").path("ballPath").asText(), "ballPath .y 应归一为 .x"); + assertTrue(g.path("expectLatch").asBoolean(false), "expectLatch 缺省应补 true"); + } + + @Test + void has_gatespec_block_detects_presence() { + assertTrue(SaaPrompts.hasGatespecBlock("x\n```gatespec\n{bad json}\n```")); + assertFalse(SaaPrompts.hasGatespecBlock("没有任何 gatespec 块的设计稿")); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-golden/generated-factory.js b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-golden/generated-factory.js new file mode 100644 index 00000000..16f6a79a --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-golden/generated-factory.js @@ -0,0 +1,385 @@ +'use strict'; + +export default function createGame(opts) { + const o = opts || {}; + let ctx = null; + let boot = null; + let gameState = null; // { phase, score, remaining, ball, paddle, bricks, targetX, ... } + let inputSubs = []; + let nowMs = 0; + + // 砖块配置 + const COLS = 6; + const ROWS = 5; + const BRICK_W = 62; + const BRICK_H = 20; + const BRICK_GAP = 3; + const BOARD_Y = 50; + const PADDLE_W = 70; + const PADDLE_H = 16; + const PADDLE_Y = 810; + const BALL_R = 7; + const BALL_SPEED = 370; // px/s + const MAX_DEFLECT_ANGLE = 70 * Math.PI / 180; + + function init(bootData) { + boot = bootData; + ctx = boot.ctx; + nowMs = ctx.time.nowMs(); + + // 初始化砖块 + const bricks = []; + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + const health = ctx.random.range(0, 1) > 0.7 ? 2 : 1; // 30% 需要2次 + bricks.push({ + x: col * (BRICK_W + BRICK_GAP) + 4, + y: BOARD_Y + row * (BRICK_H + BRICK_GAP), + w: BRICK_W, + h: BRICK_H, + health: health, + maxHealth: health + }); + } + } + + // 挡板(坐标为中心) + const paddle = { + x: 195, + y: PADDLE_Y, + w: PADDLE_W, + h: PADDLE_H + }; + + // 球(中心) + const ball = { + x: 195, + y: PADDLE_Y - 20, + vx: 0, + vy: 0 + }; + + // 初始球方向随机(向上偏) + const angle = ctx.random.range(-45, 45) * Math.PI / 180; + ball.vx = BALL_SPEED * Math.sin(angle); + ball.vy = -BALL_SPEED * Math.cos(angle); + + gameState = { + phase: 'playing', + score: 0, + remaining: bricks.length, + bricks: bricks, + paddle: paddle, + ball: ball, + targetX: 195, + tapToRestart: false, + gameoverReason: '' + }; + + // 输入订阅 + const input = ctx.getInput(); + if (input) { + const subDown = input.on('pointerdown', onPointer); + const subMove = input.on('pointermove', onPointer); + const subKey = input.on('keydown', onKey); + inputSubs = [subDown, subMove, subKey]; + } + + // 尝试首次触发引擎能力(常驻粒子) + const eng = ctx.getEngine(); + if (eng && eng.particles) { + try { + eng.particles.spawnEmitter({ + pos: { x: 195, y: 300 }, + emitRate: 15, + emitTime: 0, + particleTime: 1.0, + speed: 60, + colorStart: { r: 1, g: 0.8, b: 0.2, a: 1 }, + colorEnd: { r: 1, g: 0.2, b: 0, a: 0 } + }); + } catch (_) {} + } + } + + function onPointer(e) { + if (!gameState) return; + if (e.type === 'pointerdown') { + // 检查是否gameover后点击重玩 + if (gameState.phase === 'gameover') { + restartGame(); + return; + } + } + // 更新目标位置(逻辑像素坐标) + gameState.targetX = Math.max(0, Math.min(390, e.x || 0)); + } + + function onKey(e) { + if (!gameState) return; + if (e.type === 'keydown' && e.key === ' ') { + if (gameState.phase === 'gameover') { + restartGame(); + } + } + } + + function restartGame() { + // 销毁当前订阅 + destroySubs(); + // 重新初始化 + init(boot); + } + + function update(dt) { + // 每个逻辑步刷新时间 + nowMs = ctx.time.nowMs(); + + if (!gameState || gameState.phase !== 'playing') return; + + const paddle = gameState.paddle; + const ball = gameState.ball; + const bricks = gameState.bricks; + + // 挡板平滑跟随目标 + paddle.x += (gameState.targetX - paddle.x) * 0.25; + paddle.x = Math.max(paddle.w / 2, Math.min(390 - paddle.w / 2, paddle.x)); + + // 球移动 + ball.x += ball.vx * dt; + ball.y += ball.vy * dt; + + // 左右墙反弹(考虑球半径) + if (ball.x - BALL_R < 0) { + ball.x = BALL_R; + ball.vx = -ball.vx; + playHitSound(); + } + if (ball.x + BALL_R > 390) { + ball.x = 390 - BALL_R; + ball.vx = -ball.vx; + playHitSound(); + } + + // 顶墙反弹 + if (ball.y - BALL_R < 0) { + ball.y = BALL_R; + ball.vy = -ball.vy; + playHitSound(); + } + + // 底部失败 + if (ball.y + BALL_R > 844) { + gameState.phase = 'gameover'; + gameState.gameoverReason = 'ball_lost'; + // 爆炸粒子 + spawnBurst(ball.x, ball.y, { r: 1, g: 0.2, b: 0.2, a: 1 }); + playHitSound(); + return; + } + + // 砖块碰撞(按顺序,每块一次) + for (let i = bricks.length - 1; i >= 0; i--) { + const b = bricks[i]; + // 检测矩形与圆的碰撞(简化为AABB + 圆球) + const closestX = Math.max(b.x, Math.min(ball.x, b.x + b.w)); + const closestY = Math.max(b.y, Math.min(ball.y, b.y + b.h)); + const dx = ball.x - closestX; + const dy = ball.y - closestY; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < BALL_R) { + // 确定碰撞方向(主要从哪面进入) + const overlapX = (b.w / 2 + BALL_R) - Math.abs(ball.x - (b.x + b.w/2)); + const overlapY = (b.h / 2 + BALL_R) - Math.abs(ball.y - (b.y + b.h/2)); + if (overlapX < overlapY) { + ball.vx = -ball.vx; + } else { + ball.vy = -ball.vy; + } + // 将球推出砖块 + if (overlapX < overlapY) { + if (ball.x < b.x + b.w/2) ball.x = b.x - BALL_R; + else ball.x = b.x + b.w + BALL_R; + } else { + if (ball.y < b.y + b.h/2) ball.y = b.y - BALL_R; + else ball.y = b.y + b.h + BALL_R; + } + + // 砖块耐久减1 + b.health--; + if (b.health <= 0) { + bricks.splice(i, 1); + gameState.score++; + gameState.remaining = bricks.length; + // 粒子爆发 + spawnBurst(b.x + b.w/2, b.y + b.h/2, { r: 0.2, g: 0.9, b: 1, a: 1 }); + // 音效 + playScoreSound(); + } else { + // 击中但未碎,给个小型粒子 + spawnBurst(b.x + b.w/2, b.y + b.h/2, { r: 0.9, g: 0.6, b: 0.2, a: 0.8 }, 8); + playHitSound(); + } + break; // 每帧只处理一个砖块碰撞,避免多个同时 + } + } + + // 挡板碰撞 + const paddleLeft = paddle.x - paddle.w/2; + const paddleRight = paddle.x + paddle.w/2; + const paddleTop = paddle.y - paddle.h/2; + const paddleBottom = paddle.y + paddle.h/2; + if (ball.y + BALL_R >= paddleTop && ball.y - BALL_R <= paddleBottom && + ball.x + BALL_R >= paddleLeft && ball.x - BALL_R <= paddleRight) { + // 击中挡板,计算反弹角度 + const offset = (ball.x - paddle.x) / (paddle.w/2); + const clampedOffset = Math.max(-1, Math.min(1, offset)); // -1~1 + const angle = clampedOffset * MAX_DEFLECT_ANGLE; + ball.vx = BALL_SPEED * Math.sin(angle); + ball.vy = -BALL_SPEED * Math.cos(angle); + // 将球置于挡板上沿 + ball.y = paddleTop - BALL_R; + playHitSound(); + // 粒子 + spawnBurst(ball.x, ball.y, { r: 0.3, g: 0.8, b: 0.3, a: 1 }, 12); + } + + // 检查通关 + if (gameState.bricks.length === 0) { + gameState.phase = 'gameover'; + gameState.gameoverReason = 'cleared_all'; + // 胜利粒子 + spawnBurst(195, 400, { r: 1, g: 1, b: 0.2, a: 1 }, 30); + playScoreSound(); + } + } + + function spawnBurst(x, y, color, count) { + const eng = ctx.getEngine(); + if (eng && eng.particles) { + try { + eng.particles.spawnEmitter({ + pos: { x, y }, + count: count || 22, + speed: 80, + particleTime: 0.5, + sizeStart: 10, + sizeEnd: 2, + colorStart: color || { r: 1, g: 0.8, b: 0.2, a: 1 }, + colorEnd: { r: 0.2, g: 0.5, b: 1, a: 0 } + }); + } catch (_) {} + } + } + + function playHitSound() { + const eng = ctx.getEngine(); + if (eng && eng.audio && eng.audio.synth) { + try { + eng.audio.synth.synthSfx([0.5, 0.05, 440, , , 0.1, , 1.2]); + } catch (_) {} + } + } + + function playScoreSound() { + const eng = ctx.getEngine(); + if (eng && eng.audio && eng.audio.synth) { + try { + eng.audio.synth.synthSfx([1, 0.1, 880, , , 0.15, , 1.8]); + } catch (_) {} + } + } + + function render(g) { + if (!gameState) return; + const state = gameState; + const paddle = state.paddle; + const ball = state.ball; + const bricks = state.bricks; + + // 背景 + g.fillStyle = '#121212'; + g.fillRect(0, 0, 390, 844); + + // 砖块 + for (const b of bricks) { + const hpRatio = b.health / b.maxHealth; + let color; + if (hpRatio > 0.66) color = '#00cc66'; + else if (hpRatio > 0.33) color = '#ffaa00'; + else color = '#ff3333'; + g.fillStyle = color; + g.fillRect(b.x, b.y, b.w, b.h); + g.strokeStyle = '#222'; + g.lineWidth = 1; + g.strokeRect(b.x, b.y, b.w, b.h); + } + + // 挡板 + g.fillStyle = '#43c6e6'; + g.fillRect(paddle.x - paddle.w/2, paddle.y - paddle.h/2, paddle.w, paddle.h); + + // 球 + g.fillStyle = '#ffcc00'; + g.beginPath(); + g.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2); + g.fill(); + + // HUD + g.fillStyle = '#cccccc'; + g.font = '18px monospace'; + g.fillText('Score: ' + state.score + ' Remaining: ' + state.remaining, 10, 30); + + // 游戏结束覆盖 + if (state.phase === 'gameover') { + g.fillStyle = 'rgba(0,0,0,0.6)'; + g.fillRect(0, 0, 390, 844); + g.fillStyle = '#ffffff'; + g.font = 'bold 36px sans-serif'; + g.textAlign = 'center'; + if (state.gameoverReason === 'cleared_all') { + g.fillText('YOU WIN!', 195, 380); + } else { + g.fillText('GAME OVER', 195, 380); + } + g.font = '18px sans-serif'; + g.fillText('Tap to restart', 195, 440); + g.textAlign = 'start'; + } + } + + function destroySubs() { + inputSubs.forEach(sub => { + try { sub.cancel(); } catch(_) {} + }); + inputSubs = []; + } + + function destroy() { + destroySubs(); + gameState = null; + boot = null; + ctx = null; + } + + function _forensicsView() { + return { + seed: o.seed || 0, + state: function() { + const state = gameState; + if (!state) return { phase: 'unknown', score: 0, remaining: 0, ball: {x:0,y:0,vx:0,vy:0}, paddle: {x:0,y:0,w:0} }; + return { + phase: state.phase, + score: state.score, + remaining: state.remaining, + progress: state.remaining === 0 ? 1 : (1 - state.remaining / (COLS*ROWS)), + gameoverReason: state.gameoverReason || '', + ball: { x: state.ball.x, y: state.ball.y, vx: state.ball.vx, vy: state.ball.vy }, + paddle: { x: state.paddle.x, y: state.paddle.y, w: state.paddle.w } + }; + } + }; + } + + return { init, update, render, destroy, _forensicsView }; +} \ No newline at end of file diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-golden/play-spec.json b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-golden/play-spec.json new file mode 100644 index 00000000..b91664d8 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-golden/play-spec.json @@ -0,0 +1,33 @@ +{ + "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 +} \ No newline at end of file diff --git a/wg1/gen-worker/worker/cost.py b/wg1/gen-worker/worker/cost.py index eba86347..50fc1a8e 100644 --- a/wg1/gen-worker/worker/cost.py +++ b/wg1/gen-worker/worker/cost.py @@ -78,6 +78,40 @@ def cost_for_result(result, pricing, qpu, usd_rate): return c +def main_stdin(): + """B-8 接缝:从 stdin 读 {"tokens_by_model": {model: {in, out}}} → 出 per-model + total ¥ JSON 到 stdout。 + + 供 SAA-Java(SaaStudioGraphTest)经子进程复用本折算口径(不在 Java 重写 new-api quota 公式)。 + 出参:{"by_model": {model: {in, out, rmb}}, "total_rmb": x, "gate_rmb": GATE_RMB, + "quota_per_unit": qpu, "usd_rate": rate, "group_ratio": 1.0, "note": "¥上界(不计缓存折扣)"}。 + """ + import sys + payload = json.loads(sys.stdin.read() or "{}") + tbm = payload.get("tokens_by_model", {}) or {} + pricing = fetch_pricing() + qpu, usd_rate = fetch_status() + by_model = {} + total = 0.0 + for model, tok in tbm.items(): + # 兼容 in/out(SAA 口径)与 prompt_tokens/completion_tokens(Python 口径)。 + pin = int(tok.get("in", tok.get("prompt_tokens", 0)) or 0) + cout = int(tok.get("out", tok.get("completion_tokens", 0)) or 0) + c = compute(model, pin, cout, pricing, qpu, usd_rate) + by_model[model] = {"in": pin, "out": cout, "rmb": c["rmb"]} + total += c["rmb"] + out = { + "by_model": by_model, + "total_rmb": round(total, 5), + "gate_rmb": GATE_RMB, + "quota_per_unit": qpu, + "usd_rate": usd_rate, + "group_ratio": 1.0, + "note": "¥上界(不计缓存折扣)", + } + # 只把 JSON 打到 stdout(调用方按行解析;诊断信息走 stderr)。 + print(json.dumps(out, ensure_ascii=False)) + + def main(): pricing = fetch_pricing() qpu, usd_rate = fetch_status() @@ -96,4 +130,9 @@ def main(): if __name__ == "__main__": - main() + import sys + # B-8:--stdin 模式(SAA-Java 子进程复用 ¥ 折算);无参=原 results/ 汇总表。 + if "--stdin" in sys.argv: + main_stdin() + else: + main()