From 706d014869a32708d71c081b9850f856c0aaf42f Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 17 Jun 2026 17:33:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(aigc):=20B2=20SAA=20=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=9B=BE=E6=8B=93=E6=89=91=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E2=80=94=E2=80=94+5=20=E8=8A=82=E7=82=B9(classify/asset/modify?= =?UTF-8?q?/escalate/nreview)+=20=E6=95=91=E5=9C=BA=E9=98=B6=E6=A2=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execution §6 拓扑落地(opus 实现 + 框架 API 对真 1.1.2.2 jar javap 核验)。11→16 节点,布线唯一源 assemble,无孤儿。 - 5 新节点:classify(真LLM物理优先→archetype/profile三维+§6.4映射表)、asset(六类规格v0+产消打通)、 modify(deterministic改源/regenerate-module路由)、escalate(stage1→stage2+escalationEvents)、nreview(真LLM叙事审查+needsRepair计failCount) - 布线:render→classify/modify、classify→design、scaffold→asset→build、play→player/nreview/repair/escalate/giveup、nreview→emit/repair/giveup、modify→build/generate、escalate→generate - recursionLimitFor(maxRepairs,stage2Extra)=(m+s+1)*10+30,默认(5,3)=120;救场 5失败escalate→8失败giveup - ALL_KEYS 字面量提升为 K_* 常量(单一源);新 SaaArchetypes §6.4 映射表契约;classify/narrative system prompt;saaStage2ExtraRepairs=3 旋钮 - 新 SaaStudioGraphTopologyTest(非门控:哑API build全图证无孤儿+Mermaid 16节点+recursionLimit+路由单测) - 初始逻辑标 TODO(B4 救场真切stage2模型/giveup真dump/validate-build计failCount)、TODO(B7 modify深层指针/按id定位) - additive:create-metric全链不破;§1.4硬约束(GamePackage/宿主/回调/九门harness)全未碰 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../game/module/aigc/saa/SaaArchetypes.java | 73 +++ .../game/module/aigc/saa/SaaGenNodes.java | 13 + .../module/aigc/saa/SaaGraphDispatcher.java | 9 +- .../game/module/aigc/saa/SaaPrompts.java | 44 ++ .../game/module/aigc/saa/SaaStudioGraph.java | 171 ++++-- .../game/module/aigc/saa/SaaStudioNodes.java | 490 +++++++++++++++++- .../executor/AigcExecutorProperties.java | 7 + .../aigc/saa/SaaStudioGraphTopologyTest.java | 321 ++++++++++++ 8 files changed, 1082 insertions(+), 46 deletions(-) create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaArchetypes.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTopologyTest.java diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaArchetypes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaArchetypes.java new file mode 100644 index 00000000..b080ea52 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaArchetypes.java @@ -0,0 +1,73 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * SaaArchetypes —— classify→archetype 映射表(execution §6.4,HJ-FIXED-ARCH 固定架构)。 + * + *

定位:把「品类原型 archetype」锚到 profile 三维预设(tickModel/inputModel)。这是契约(§6.4): + * classify 节点出的 archetype 必落本表一行,否则 = 孤儿分类;新增 archetype 必须同时补本表 + 对应品类 profile 锚。 + * + *

语义=引导非校验(§12-④):映射行的 tickModel/inputModel 是品类预设(玩法模板=品类框架,引导 AI 生成, + * 非 pre-built 代码 / 非填参执行器,对齐 memory {@code tiered-engine-cocos-decision} 2026-06-17 术语纠偏:废的是「游戏模板」、 + * 「玩法模板未废」)。classify 优先用模型给的物理维(spike 证 tickModel 100%/archetype 93%),模型给非法值时才用本表预设回填。 + * + *

枚举把「消除族」拆细(review §10.1 明令):bubble(瞄准发射,realtime/continuous)与 match3/line-clear + * (点选,turn-based/discrete-choice)分列,避免笼统「消除」误导下游机制。{@code generic} 为兜底(通配三维)。 + * + *

映射锚到现 {@code contracts/templates/*.schema.json}(8 个品类 schema),作品类 profile 引导锚(§4.2)。 + * + * @author 造梦AI(固定架构 B2 拓扑扩展) + */ +final class SaaArchetypes { + + private SaaArchetypes() { + } + + /** 兜底 archetype(未知分类归此,通配三维,不污染下游,§6.4)。 */ + static final String GENERIC = "generic"; + + /** + * archetype 的 profile 预设(tickModel/inputModel 引导值;§6.4 表)。 + * + * @param tickModel 时基模型:realtime|turn-based|event + * @param inputModel 输入模型:continuous|discrete-choice|text-command + */ + record Profile(String tickModel, String inputModel) { + } + + /** + * archetype → profile 映射表(§6.4 逐行;LinkedHashMap 保序便于审计/可视化)。 + *

逐行对齐 execution §6.4:clicker/dodge/runner/bubble/match3/line-clear/merge/idle/tycoon/generic。 + */ + static final Map MAP = buildMap(); + + private static Map buildMap() { + Map m = new LinkedHashMap<>(); + m.put("clicker", new Profile("event", "discrete-choice")); // templates/clicker.schema.json + m.put("dodge", new Profile("realtime", "continuous")); // templates/dodge.schema.json + m.put("runner", new Profile("realtime", "continuous")); // templates/runner.schema.json + m.put("bubble", new Profile("realtime", "continuous")); // templates/match.schema.json(瞄准发射变体) + m.put("match3", new Profile("turn-based", "discrete-choice")); // templates/match.schema.json(点选) + m.put("line-clear", new Profile("turn-based", "discrete-choice")); // templates/match.schema.json(点选) + m.put("merge", new Profile("event", "discrete-choice")); // templates/merge.schema.json + m.put("idle", new Profile("event", "discrete-choice")); // templates/idle.schema.json + m.put("tycoon", new Profile("turn-based", "discrete-choice")); // templates/tycoon.schema.json + m.put(GENERIC, new Profile("realtime", "discrete-choice")); // templates/generic.schema.json(兜底通配) + return m; + } + + /** + * 把模型出的 archetype 规整到映射表合法枚举:命中表中某行 → 返回该 archetype;未知 → {@link #GENERIC}(不污染下游,§6.4)。 + * + * @param raw 模型给的 archetype(已 trim+lower) + * @return 合法 archetype(必为 {@link #MAP} 的键之一) + */ + static String normalize(String raw) { + if (raw != null && MAP.containsKey(raw)) { + return raw; + } + return GENERIC; + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java index aa813b31..7b47240f 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java @@ -417,11 +417,24 @@ final class SaaGenNodes { } if (!pass) { clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味。 + bumpFailCount(s, out); // 固定架构救场阶梯(§6.3):九门失败 failCount++(锚 verdict.pass=false)。 } return out; }; } + /** + * 固定架构救场阶梯(execution §6.3):九门失败时自增 {@code failCount}(连续失败计数,escalate/giveup 判据用)。 + *

仅当当前图注册了 failCount 策略(全图 SaaStudioGraph)时才写——step3 最小图未注册该 key,跳过不破坏 + * (与 {@link #clearPlayerFeedback} 同一守护范式,保 SaaHarnessDriveTest 等最小图回归不破)。 + */ + private static void bumpFailCount(OverAllState s, Map out) { + if (s.containStrategy("failCount")) { + int fc = ((Number) s.value("failCount", 0)).intValue(); + out.put("failCount", fc + 1); + } + } + /** * A-4 辅助:硬门(validate/build/play)失败时清空 player 软门反馈,避免上一轮 player 体验问题串到本轮失败回喂。 *

仅当当前图注册了 playerFeedback 策略(全图 SaaStudioGraphTest)时才写——step3 最小图未注册该 key,跳过不破坏。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java index a64dd291..7196f3bc 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java @@ -285,12 +285,15 @@ public class SaaGraphDispatcher implements GenerationDispatcher { // checkpoint(step5):开关开 ∧ DataSource 在席 → 懒建权威单 MysqlSaver(首个 dispatch 才触 DDL, // dispatcher=http 永不到此);否则降级内存态(saverConfig=null)。一图一 saver(只 register 一个)。 SaverConfig saverConfig = buildSaverConfigIfEnabled(); + // 固定架构 B2:透传救场阶梯 stage2 额外救场轮(saaStage2ExtraRepairs),走 8 参 build 重载(recursionLimit 按 5+3 重算)。 compiledGraph = SaaStudioGraph.build(api, SaaStudioGraph.Models.stage1(), gameRuntimeRoot, properties.getSaaMaxRepairs(), properties.getSaaMaxPlayerRounds(), + properties.getSaaStage2ExtraRepairs(), saverConfig, observationRegistry); - log.info("[saa-dispatch] SAA 图构造完成(缓存复用):maxRepairs={}, maxPlayerRounds={}, newApiBase={}, " - + "checkpoint={}, observation={}", - properties.getSaaMaxRepairs(), properties.getSaaMaxPlayerRounds(), baseUrl, + log.info("[saa-dispatch] SAA 图构造完成(缓存复用):maxRepairs={}, stage2Extra={}, maxPlayerRounds={}, " + + "newApiBase={}, checkpoint={}, observation={}", + properties.getSaaMaxRepairs(), properties.getSaaStage2ExtraRepairs(), + properties.getSaaMaxPlayerRounds(), baseUrl, saverConfig != null ? "MysqlSaver(GRAPH_CHECKPOINT/GRAPH_THREAD)" : "内存态(off/无DataSource)", (observationRegistry != null && observationRegistry != ObservationRegistry.NOOP) ? "on" : "off"); } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java index 65e85024..e73793dd 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java @@ -168,6 +168,50 @@ final class SaaPrompts { ``` 规则:① `expectLatch` 恒 true(游戏必须能真玩到 gameover 且驻留);② 有"连续控制体(挡板/角色随手指平滑移动)"才给 `controlCheck`,纯点击/无连续控制给 null;③ 按【玩法形态】二分选 driver(这是判"机制是否真点亮"的命门,别一律给 none):(a)技巧类(挡板接球/接物,玩法=连续控制体随手指水平移动去拦截)`driver.type="paddle-intercept"`,`ballPath`=**球的水平 x 坐标字段**(挡板水平移动去对齐它,几乎总是 `ball.x`,**绝不要填 ball.y**),`paddleY`=挡板所在 y。(b)离散点击目标类(井字棋/五子棋/打地鼠/翻牌/Simon/扫雷/见缝插针:玩法=逐个点击网格或离散目标)**必须** `driver.type="tap-targets"`,并让代码 agent 在 `_forensicsView().state()` 导出 `targets:[{x,y,idx,occupied}]`(每个可点目标的逻辑坐标 x/y、序号 idx、是否已占用 occupied);**绝不可用 none**(否则 harness 不点目标→H_progress 必挂)。其中再按"有无致负目标"细分:——规避/推理族(存在"点了立即致负"的目标,如扫雷雷格):driver 加 `"safeOnly":true`,targets 每元素额外导出 `safe`(布尔=该目标非致负,如扫雷 `!mine`;**仅供测试·只现于 `_forensicsView`,渲染层与玩家界面绝不可显示、不得据此给避险提示**);`assertAfterPlay` 用 `{"path":"result","op":"==","value":"win"}`(确定性避负逐格揭至 win,把核心循环跑透)。——安全放置族(无致负目标,如井字棋/打地鼠:任一未占用目标都是合法推进招):不加 safeOnly,`assertAfterPlay` 给推进断言(如落子数/命中数 `increased`)。(c)其余既无连续控制体、又无可枚举离散目标 → `driver.type="none"`;④ `assertAfterPlay` 至少一条"真有进展"断言(op ∈ increased/decreased/changed/>/>=/固定架构 B2 扩展:新增 {@code classify}(品类分类,§6.4)与 {@code narrative}(叙事质量门,§5.9)两角色。 + * 外部仅经 {@link #stage1()} 构造({@code SaaGraphDispatcher}/{@code SaaStudioGraphTest} 均用之),arity 扩展不破其调用。 * * @param design 设计角色模型名 * @param code code 角色模型名(首轮) * @param fix fix 角色模型名(repair 轮) * @param playerText player 文本位模型名 * @param playerVision player 视觉位模型名(建议恒 MiniMax-M3) + * @param classify classify 角色模型名(固定架构;建议 MiniMax-M3,物理优先分类) + * @param narrative narrative-reviewer 角色模型名(固定架构;文本推理位) */ - public record Models(String design, String code, String fix, String playerText, String playerVision) { - /** models.yaml stage1 默认路由。 */ + public record Models(String design, String code, String fix, String playerText, String playerVision, + String classify, String narrative) { + /** models.yaml stage1 默认路由(+ 固定架构 classify/narrative 角色)。 */ public static Models stage1() { - return new Models(M_DESIGN, M_CODE, M_FIX, M_PLAYER_TEXT, M_PLAYER_VISION); + return new Models(M_DESIGN, M_CODE, M_FIX, M_PLAYER_TEXT, M_PLAYER_VISION, M_CLASSIFY, M_NARRATIVE); } } @@ -138,22 +151,48 @@ public final class SaaStudioGraph { } /** - * 组装全 StateGraph(蓝图 §3.1 拓扑)唯一布线源——只产未编译的 {@link StateGraph},不挂 CompileConfig。 + * 组装全 StateGraph(execution §6 固定架构拓扑)唯一布线源——只产未编译的 {@link StateGraph},不挂 CompileConfig。 * - *

拓扑:START→render→design→generate→validate;validate/build/play 三处失败回边共用判据 - * (ok?下一步 : repairCount>=maxRepairs?giveup:repair);play 九门 pass→player(软门)→emit/giveup; - * repair 自增计数回 generate。 + *

拓扑(§6.1/§6.2,固定架构 B2 扩展为 16 节点): + *

+     *   START→render
+     *   render ─┬(create)→ classify→design→generate
+     *           ├(regenerate-module)→ generate
+     *           └(deterministic)→ build
+     *   generate→validate ─┬(ok)→ scaffold→asset→build
+     *                       ├(fail&未到升档窗)→ repair
+     *                       ├(fail&升档窗)→ escalate
+     *                       └(fail&救场耗尽)→ giveup
+     *   build ─┬(ok)→ play ─┬(metric&九门pass)→ player ─┬(无问题/轮尽)→ emit
+     *          │            │                            └(有问题)→ repair
+     *          │            ├(narrative)→ nreview ─┬(needsRepair=false)→ emit
+     *          │            │                       └(needsRepair=true&轮未尽)→ repair / (耗尽)→ giveup
+     *          │            └(fail)→ repair / escalate / giveup
+     *          └(fail)→ repair / escalate / giveup
+     *   modify ─┬(deterministic)→ build
+     *           └(regenerate-module)→ generate
+     *   repair→generate    escalate→generate    emit→END    giveup→END
+     * 
+ * 救场阶梯(§6.3):cheap(stage1) 连续 maxRepairs 次九门失败 → escalate 升 stage2 → 再 stage2ExtraRepairs 次 → giveup。 * - * @param api 共用 new-api 上游(baseUrl=host 根、apiKey 已设) - * @param models 五角色模型名 - * @param gameRuntimeRoot game-runtime 根(harness/few-shot/证据所在;build/play 子进程 cwd) - * @param maxRepairs 修复轮上限(对齐 studio.py:max_repairs 默认 5) - * @param maxPlayerRounds player 顾问轮上限(对齐 studio.py:player_rounds 默认 1) + *

无孤儿(§6.2/§11-5):每节点都有入边+出边。modify 接入裁定:§6.2 render 行写「modify 路 →generate/build」, + * 但 modify 节点须真做事(按 patch 改源/置 regenerate 意图)且不可孤儿——故本图让 render 把任一 modify 入口 + * (modifyMode 非空,含 deterministic / regenerate-module)统一分流到 modify 节点(modify 真改源后再经 modifyRouter + * →build/generate)。即 render 出边=classify(create)/modify(modify 入口);modify 出边=build(确定性)/generate(重生成)。 + * 这等价于「render 经 modify 中转后抵 generate/build」,与 §6.2 终点一致,且 modify 非孤儿、真做事(无空壳)。 + * + * @param api 共用 new-api 上游(baseUrl=host 根、apiKey 已设) + * @param models 角色模型名(含固定架构 classify/narrative) + * @param gameRuntimeRoot game-runtime 根(harness/few-shot/证据所在;build/play 子进程 cwd) + * @param maxRepairs stage1 修复轮上限(对齐 studio.py:max_repairs 默认 5) + * @param maxPlayerRounds player 顾问轮上限(对齐 studio.py:player_rounds 默认 1) + * @param stage2ExtraRepairs stage2 额外修复轮上限(救场升档后再失败这么多次 → giveup,§6.3,默认 3) * @return 未编译的全图(供 {@link #build} 挂 CompileConfig 后编译) * @throws GraphStateException 图结构非法(节点/边布线错误,编译期暴露) */ private static StateGraph assemble(OpenAiApi api, Models models, Path gameRuntimeRoot, - int maxRepairs, int maxPlayerRounds) throws GraphStateException { + int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs) + throws GraphStateException { // == per-role 采样面(temperature/maxTokens)逐字对齐 Python == // design:Python config.build_model 不传 temperature → 服务端默认 → temperature=null(不下发); // code/fix:_client.chat temperature=0.0、max_tokens=16000(确定性产出); @@ -164,6 +203,9 @@ public final class SaaStudioGraph { OpenAiChatModel fixModel = model(api, models.fix(), 0.0, 16000); OpenAiChatModel playerTextModel = model(api, models.playerText(), 0.3, 4000); OpenAiChatModel playerVisionModel = model(api, models.playerVision(), 0.3, 900); + // 固定架构新增两角色(B2):classify(物理优先分类,温度低求稳)、narrative-reviewer(叙事审查,温度低)。 + OpenAiChatModel classifyModel = model(api, models.classify(), 0.0, 1000); + OpenAiChatModel narrativeModel = model(api, models.narrative(), 0.3, 2000); // == state schema:全 key 用 ReplaceStrategy(节点 return 覆盖写,对齐 dossier §1-22)== KeyStrategyFactory keyFactory = () -> { @@ -175,51 +217,82 @@ public final class SaaStudioGraph { }; return new StateGraph(GRAPH_NAME, keyFactory) - // -- 节点 -- + // -- 节点(11 存量 + 5 固定架构新增:classify/asset/modify/escalate/nreview)-- .addNode("render", node_async(SaaStudioNodes.renderNode())) + .addNode("classify", node_async(SaaStudioNodes.classifyNode(classifyModel, models.classify()))) .addNode("design", node_async(SaaStudioNodes.designNode(designModel, models.design(), gameRuntimeRoot))) .addNode("generate", node_async(SaaStudioNodes.generateNode(codeModel, fixModel, models.code(), models.fix(), gameRuntimeRoot))) .addNode("validate", node_async(SaaGenNodes.validateNode())) .addNode("scaffold", node_async(SaaGenNodes.scaffoldNode(gameRuntimeRoot))) + .addNode("asset", node_async(SaaStudioNodes.assetNode())) .addNode("build", node_async(SaaGenNodes.buildNode(gameRuntimeRoot))) .addNode("play", node_async(SaaGenNodes.playNode(gameRuntimeRoot))) .addNode("player", node_async(SaaStudioNodes.playerNode(playerTextModel, playerVisionModel, models.playerText(), models.playerVision(), gameRuntimeRoot))) + .addNode("nreview", node_async(SaaStudioNodes.nreviewNode(narrativeModel, models.narrative()))) + .addNode("modify", node_async(SaaStudioNodes.modifyNode())) + .addNode("escalate", node_async(SaaStudioNodes.escalateNode())) .addNode("repair", node_async(SaaStudioNodes.repairNode())) .addNode("emit", node_async(SaaStudioNodes.emitNode(gameRuntimeRoot))) .addNode("giveup", node_async(SaaStudioNodes.giveupNode())) // -- 直边 -- - .addEdge(START, "render") - .addEdge("render", "design") + .addEdge(START, "render") // 图入口 + .addEdge("classify", "design") // classify→design(§6.2) .addEdge("design", "generate") .addEdge("generate", "validate") - .addEdge("scaffold", "build") + .addEdge("scaffold", "asset") // scaffold→asset(插 asset,§6.2) + .addEdge("asset", "build") // asset→build(§6.2) .addEdge("repair", "generate") + .addEdge("escalate", "generate") // escalate 升档后回 generate(§6.2) .addEdge("emit", END) .addEdge("giveup", END) - // -- 条件边(失败回边共用判据;九门=硬地板;player=软门)-- + // -- 条件边 -- + // render:create→classify;任一 modify 入口(modifyMode 非空)→ modify 节点(modify 真改源后再分流,无孤儿)。 + .addConditionalEdges("render", + edge_async(SaaStudioNodes.renderRouter()), + Map.of("classify", "classify", "modify", "modify")) + // validate 失败回边:救场阶梯(ok→scaffold / repair / escalate / giveup)。 .addConditionalEdges("validate", - edge_async(SaaStudioNodes.okElseRepairOrGiveup(SaaStudioNodes.K_VALIDATE_OK, maxRepairs)), - Map.of("ok", "scaffold", "repair", "repair", "giveup", "giveup")) + edge_async(SaaStudioNodes.okElseRepairOrEscalateOrGiveup( + SaaStudioNodes.K_VALIDATE_OK, maxRepairs, stage2ExtraRepairs)), + Map.of("ok", "scaffold", "repair", "repair", "escalate", "escalate", "giveup", "giveup")) + // build 失败回边:救场阶梯(ok→play / repair / escalate / giveup)。 .addConditionalEdges("build", - edge_async(SaaStudioNodes.okElseRepairOrGiveup(SaaStudioNodes.K_BUILD_OK, maxRepairs)), - Map.of("ok", "play", "repair", "repair", "giveup", "giveup")) + edge_async(SaaStudioNodes.okElseRepairOrEscalateOrGiveup( + SaaStudioNodes.K_BUILD_OK, maxRepairs, stage2ExtraRepairs)), + Map.of("ok", "play", "repair", "repair", "escalate", "escalate", "giveup", "giveup")) + // play:metric&pass→player / narrative→nreview / fail→repair·escalate·giveup(§6.1/§6.2)。 .addConditionalEdges("play", - edge_async(SaaStudioNodes.playRouter(maxRepairs)), - Map.of("ok", "player", "repair", "repair", "giveup", "giveup")) + edge_async(SaaStudioNodes.playRouterV2(maxRepairs, stage2ExtraRepairs)), + Map.of("player", "player", "nreview", "nreview", + "repair", "repair", "escalate", "escalate", "giveup", "giveup")) + // player 软门:无问题/轮尽→emit / 有问题→repair(存量语义不变)。 .addConditionalEdges("player", edge_async(SaaStudioNodes.playerRouter(maxRepairs, maxPlayerRounds)), - Map.of("emit", "emit", "repair", "repair")); + Map.of("emit", "emit", "repair", "repair")) + // nreview:needsRepair=false→emit / true&轮未尽→repair / 救场耗尽→giveup(§5.9)。 + .addConditionalEdges("nreview", + edge_async(SaaStudioNodes.nreviewRouter(maxRepairs, stage2ExtraRepairs)), + Map.of("emit", "emit", "repair", "repair", "giveup", "giveup")) + // modify:deterministic→build / regenerate-module→generate(§6.2)。 + .addConditionalEdges("modify", + edge_async(SaaStudioNodes.modifyRouter()), + Map.of("build", "build", "generate", "generate")); } /** - * 推导递归硬刹车额度(repair 回环成环;超步=优雅终止非异常)。 - * 推导式 = (maxRepairs+1) 轮 × 每轮主链节点数(≈7) + 宽余量(78),远超实际所需,确保正常收口不误触超步。 + * 推导递归硬刹车额度(repair/escalate 回环成环;超步=优雅终止非异常)。 * - * @param maxRepairs 修复轮上限 + *

固定架构重算(execution §6.3):救场总轮 = maxRepairs(stage1) + stage2ExtraRepairs(stage2) + 1(终轮)主轮, + * 每轮主链 ≈ 10 节点(render→classify→design→generate→validate→scaffold→asset→build→play→player/nreview); + * 公式 = {@code (maxRepairs + stage2ExtraRepairs + 1) * 10 + 余量(30)},确保正常收口不误触超步(余量保守 ≥30)。 + * 默认(5+3):(5+3+1)*10+30 = 120。 + * + * @param maxRepairs stage1 修复轮上限 + * @param stage2ExtraRepairs stage2 额外修复轮上限 * @return recursionLimit */ - private static int recursionLimitFor(int maxRepairs) { - return (maxRepairs + 1) * 7 + 78; + static int recursionLimitFor(int maxRepairs, int stage2ExtraRepairs) { + return (maxRepairs + stage2ExtraRepairs + 1) * 10 + 30; } /** @@ -250,9 +323,9 @@ public final class SaaStudioGraph { * * * @param api 共用 new-api 上游 - * @param models 五角色模型名 + * @param models 角色模型名 * @param gameRuntimeRoot game-runtime 根 - * @param maxRepairs 修复轮上限 + * @param maxRepairs stage1 修复轮上限 * @param maxPlayerRounds player 顾问轮上限 * @param saverConfig checkpoint saver 配置(null=默认内存态;非空=权威单 saver,生产=MysqlSaver) * @param observationRegistry 观测注册表(null=不埋点;非空=挂 GraphObservationLifecycleListener + 节点级观测) @@ -263,10 +336,36 @@ public final class SaaStudioGraph { int maxRepairs, int maxPlayerRounds, SaverConfig saverConfig, ObservationRegistry observationRegistry) throws GraphStateException { - StateGraph graph = assemble(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds); + // 默认 stage2 额外救场轮 = DEFAULT_STAGE2_EXTRA_REPAIRS(5+3 阶梯,§6.3);需配置走 8 参重载。 + return build(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, + DEFAULT_STAGE2_EXTRA_REPAIRS, saverConfig, observationRegistry); + } + + /** + * 组装并编译全图(固定架构 B2 全量重载:显式 {@code stage2ExtraRepairs} 救场阶梯参数)。 + * + *

布线唯一源 = {@link #assemble}(所有 build 重载共用同一份,杜绝 split-brain)。recursionLimit 按 + * {@link #recursionLimitFor(int, int)} 重算(救场 maxRepairs+stage2ExtraRepairs 主轮)。 + * + * @param api 共用 new-api 上游 + * @param models 角色模型名 + * @param gameRuntimeRoot game-runtime 根 + * @param maxRepairs stage1 修复轮上限 + * @param maxPlayerRounds player 顾问轮上限 + * @param stage2ExtraRepairs stage2 额外修复轮上限(救场升档后再失败这么多次 → giveup,§6.3) + * @param saverConfig checkpoint saver 配置(null=默认内存态) + * @param observationRegistry 观测注册表(null=不埋点) + * @return 已编译图 + * @throws GraphStateException 图结构非法 + */ + public static CompiledGraph build(OpenAiApi api, Models models, Path gameRuntimeRoot, + int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs, + SaverConfig saverConfig, ObservationRegistry observationRegistry) + throws GraphStateException { + StateGraph graph = assemble(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, stage2ExtraRepairs); CompileConfig.Builder cc = CompileConfig.builder() - .recursionLimit(recursionLimitFor(maxRepairs)); + .recursionLimit(recursionLimitFor(maxRepairs, stage2ExtraRepairs)); // -- checkpoint(step5):挂权威单 saver;releaseThread(false) 才能崩溃后同 threadId 续跑 -- if (saverConfig != null) { diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java index 08727a05..b565a65c 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java @@ -92,6 +92,22 @@ final class SaaStudioNodes { static final String K_FAILURE = "failureReason"; // 失败原因(giveup/emit 写) static final String K_ENGINE_BUNDLE = "engineBundle"; // bundle.iife.js 全文(emit 产出) + // ====================== 固定架构新增 state key(execution §5.4;B2 拓扑扩展写入) ====================== + static final String K_ARCHETYPE = "archetype"; // 品类原型(classify 产,§6.4 映射表枚举;未知→generic) + static final String K_TICK_MODEL = "tickModel"; // profile 三维之一:realtime|turn-based|event(classify 产) + static final String K_INPUT_MODEL = "inputModel"; // profile 三维之一:continuous|discrete-choice|text-command(classify 产) + static final String K_PROGRESS_MODEL = "progressModel"; // profile 三维之一:metric|narrative(classify 产;narrative→走 nreview 门) + static final String K_SOURCE_PROJECT = "sourceProject"; // 源项目工件 JSON 串(§5.1;scaffold/asset/modify 写、build/emit 读) + static final String K_ASSET_SPEC = "assetSpec"; // 六类资产规格 JSON 串(asset 产,merge 进 sourceProject.assets[]) + static final String K_MODIFY_MODE = "modifyMode"; // modify 路:deterministic|regenerate-module(render 据 入参 落、modify 路由读) + static final String K_MODIFY_PATCH = "modifyPatch"; // modify 寻址+载荷 JSON 串(§5.6;modify 节点读,改 sourceProject 一处) + static final String K_BASE_VERSION_ID = "baseVersionId"; // modify 血缘 base 版本(create 时空) + static final String K_FAIL_COUNT = "failCount"; // 连续九门失败计数(救场阶梯锚 verdict.pass=false;§6.3) + static final String K_MODEL_TIER = "modelTier"; // 模型档:stage1|stage2(render 初 stage1、escalate 升档) + static final String K_ESCALATION_EVENTS = "escalationEvents"; // 升档事件 JSON 数组串(escalate append) + static final String K_GIVEUP_DUMP_PATH = "giveupDumpPath"; // 放弃前完整 dump 落盘路径(giveup 写;Opus 离线读,B4 落盘) + static final String K_NARRATIVE_VERDICT = "narrativeReviewVerdict"; // narrative 质量门裁决 JSON 串(nreview 产,§5.9) + /** 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, @@ -103,12 +119,12 @@ final class SaaStudioNodes { "gameDir", "validateErrors", "buildLog", // A-9 参数化入参(port/cdpPort/runId 由 invoke inputs 注入;play 读 port/cdpPort,默认 4320/9222): "port", "cdpPort", "runId", - // 固定架构新增 state key(execution §5.4;本次先冻结键命名空间,classify/asset/modify/救场 节点 B2 实现写入;全 ReplaceStrategy): - "archetype", "tickModel", "inputModel", "progressModel", // classify 产:品类原型 + profile 三维(tick/input/progress) - "sourceProject", "assetSpec", // scaffold/logic/asset 产:源项目工件 JSON + 六类资产规格 - "modifyMode", "modifyPatch", "baseVersionId", // modify 路:确定性|重生成 + 寻址载荷 + base 血缘 - "failCount", "modelTier", "escalationEvents", "giveupDumpPath", // 救场阶梯:连续九门失败计数 + 模型档 + 升档事件 + 放弃 dump 路径 - "narrativeReviewVerdict", // narrative 质量门裁决(progressModel=narrative 路,替九门) + // 固定架构新增 state key(execution §5.4;B2 拓扑扩展节点写入;全 ReplaceStrategy): + K_ARCHETYPE, K_TICK_MODEL, K_INPUT_MODEL, K_PROGRESS_MODEL, // classify 产:品类原型 + profile 三维(tick/input/progress) + K_SOURCE_PROJECT, K_ASSET_SPEC, // scaffold/asset 产:源项目工件 JSON + 六类资产规格 + K_MODIFY_MODE, K_MODIFY_PATCH, K_BASE_VERSION_ID, // modify 路:确定性|重生成 + 寻址载荷 + base 血缘 + K_FAIL_COUNT, K_MODEL_TIER, K_ESCALATION_EVENTS, K_GIVEUP_DUMP_PATH, // 救场阶梯:连续九门失败计数 + 模型档 + 升档事件 + 放弃 dump 路径 + K_NARRATIVE_VERDICT, // narrative 质量门裁决(progressModel=narrative 路,替九门) }; // ====================== ```js 抽取(对齐 validate.extract_code) ====================== @@ -298,6 +314,12 @@ final class SaaStudioNodes { /** * render 节点(对齐 studio.py:213 起的初始化):把 job 入参落 state 初值(role=code/计数器归零)。 * 入参 brief/gameId/playSpec 来自 invoke inputs;本节点保证默认值齐全(图入口)。 + * + *

固定架构扩展(execution §6.2):本节点是 create / modify 两生命周期共用入口,额外初始化救场阶梯控制面 + * ({@link #K_FAIL_COUNT}=0、{@link #K_MODEL_TIER}=stage1),并把 modify 入参({@link #K_MODIFY_MODE}/ + * {@link #K_BASE_VERSION_ID})透传落 state(缺省给空串=create 路)——供 {@link #renderRouter} 据此分流到 + * classify(create) / generate(regenerate-module) / build(deterministic)。本节点只落初值/透传,不做路由判定 + * (路由是 assemble 里的条件边,杜绝 split-brain)。 */ static NodeAction renderNode() { return (OverAllState s) -> { @@ -307,6 +329,12 @@ final class SaaStudioNodes { out.put(K_PLAYER_ROUND, 0); out.put(K_TOKENS_IN, 0); out.put(K_TOKENS_OUT, 0); + // 救场阶梯控制面初值(§6.3):失败计数归零、模型档起 stage1(escalate 命中阈值才升 stage2)。 + out.put(K_FAIL_COUNT, 0); + out.put(K_MODEL_TIER, "stage1"); + // modify 入参透传(create 路缺省空串;renderRouter 据 modifyMode 分流,§6.2)。 + out.put(K_MODIFY_MODE, s.value(K_MODIFY_MODE, String.class).orElse("")); + out.put(K_BASE_VERSION_ID, s.value(K_BASE_VERSION_ID, String.class).orElse("")); return out; }; } @@ -686,19 +714,441 @@ final class SaaStudioNodes { // ====================== ⑦ giveup 节点(超限失败终态) ====================== - /** giveup 节点:超 maxRepairs 仍未过门 → status=failed(失败原因取最后 feedback)。 */ + /** + * giveup 节点:超救场阶梯仍未过门 → status=failed(失败原因取最后 feedback)。 + * + *

固定架构扩展(execution §6.2/§6.3):额外落 {@link #K_GIVEUP_DUMP_PATH}(放弃前完整 dump 的落盘路径,Opus 离线读)。 + * 本期边界(B4):本节点产出确定性 dump 路径(按 gameId 派生)初始逻辑——保证 trace 链路有此键、可观测; + * 「把所有 attempts 源码/verdict 整包真写盘到该路径」标 {@code TODO(B4)}(giveup 节点当前无 gameRuntimeRoot 句柄, + * 真落盘待 B4 补节点入参 + 写盘逻辑)。节点非空壳:真置终态 + 真产 dump 路径键。 + */ 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)); + // dump 落盘路径(确定性派生;TODO(B4):真把 attempts/verdict 整包写到此路径喂 Opus 离线分析)。 + String gameId = s.value(K_GAME_ID, String.class).orElse("unknown"); + out.put(K_GIVEUP_DUMP_PATH, "games/_wg1-gen/" + gameId + "/evidence/giveup-dump.json"); + return out; + }; + } + + // ====================== ⑧ classify 节点(固定架构新增:品类分类 + profile 三维,真 LLM) ====================== + + /** + * classify 节点(execution §6.4 + §5.4;真 LLM 调用,spike 模型=MiniMax-M3):据 brief 物理优先分类—— + * 先定 {@link #K_TICK_MODEL}(spike 证 100%)→ 再定 {@link #K_ARCHETYPE}(spike 证 93%),并落 profile 三维 + * (tickModel/inputModel/progressModel)。 + * + *

映射表是契约(§6.4):archetype 必落 {@link SaaArchetypes#MAP} 的一行;模型出未知 archetype → + * 兜底 {@code generic} + 告警(不污染下游:generic 行的 tickModel/inputModel 取通配,不强写错品类)。 + * tickModel/inputModel/progressModel 若模型给非法值,按 archetype 映射行的预设回填(引导非校验,§12-④)。 + * + *

诚实边界:本节点 v0 = 「分类引导」,产 profile 三维供下游 design/scaffold/QA 选机制;不产代码、不产源项目。 + * LLM 失败(超 180s/重试耗尽)→ 不裸退图:兜底 generic + 默认三维(realtime/discrete-choice/metric)继续(对齐 design 节点降级范式)。 + * + * @param classifyModel 分类模型(指 new-api,model=MiniMax-M3,spike 同口径)。 + * @param classifyModelName 分类模型名(per-model token 拆账用;B-8)。 + */ + static NodeAction classifyNode(ChatModel classifyModel, String classifyModelName) { + return (OverAllState s) -> { + String brief = s.value(K_BRIEF, String.class).orElse(""); + Map out = new HashMap<>(); + + // 默认兜底三维 + archetype(LLM 失败或出脏值时用):generic 通配,最保守不污染下游。 + String archetype = "generic"; + String tickModel = "realtime"; + String inputModel = "discrete-choice"; + String progressModel = "metric"; + + long inDelta = 0, outDelta = 0; + try { + Object[] r = callAndRecord(classifyModel, SaaPrompts.CLASSIFY_SYSTEM, brief); + inDelta = (Long) r[1]; + outDelta = (Long) r[2]; + JsonNode j = SaaPrompts.looseParse((String) r[0]); // 便宜模型脏 JSON 宽松救活(A-5 同口径)。 + if (j != null && j.isObject()) { + // ① archetype:取模型值 → 落映射表校正(未知 archetype 归 generic,§6.4 不污染下游)。 + String rawArch = j.path("archetype").asText("").trim().toLowerCase(); + archetype = SaaArchetypes.normalize(rawArch); // 未知→generic + SaaArchetypes.Profile preset = SaaArchetypes.MAP.get(archetype); + // ② 物理优先:先取模型 tickModel(spike 100%),非法则按映射行预设回填(引导非校验)。 + tickModel = pickEnum(j.path("tickModel").asText(""), + new String[]{"realtime", "turn-based", "event"}, preset.tickModel()); + inputModel = pickEnum(j.path("inputModel").asText(""), + new String[]{"continuous", "discrete-choice", "text-command"}, preset.inputModel()); + // ③ progressModel:模型可显式给 narrative(剧情类);非法→metric(多数玩法默认)。 + progressModel = pickEnum(j.path("progressModel").asText(""), + new String[]{"metric", "narrative"}, "metric"); + } + } catch (Exception e) { + // LLM 失败不裸退图:保留兜底 generic + 默认三维继续(错因落 designText 不可见,仅日志范畴,这里写 feedback 诊断)。 + out.put(K_FEEDBACK, "(classify 调用失败,降级为 generic 兜底分类:" + trunc(e.getMessage(), 200) + ")"); + } + + out.put(K_ARCHETYPE, archetype); + out.put(K_TICK_MODEL, tickModel); + out.put(K_INPUT_MODEL, inputModel); + out.put(K_PROGRESS_MODEL, progressModel); + accumTokensByModel(s, out, classifyModelName, inDelta, outDelta); // B-8:per-model 拆账(含总量累加)。 + // B-8:记 classify 这一步(role=classify,置于 design 之前)。 + appendAttempt(s, out, MAPPER.createObjectNode() + .put("role", "classify").put("model", classifyModelName) + .set("usage", MAPPER.createObjectNode().put("in", inDelta).put("out", outDelta))); + return out; + }; + } + + /** + * 从候选枚举里挑合法值:raw 命中枚举(忽略大小写)则返回标准枚举值,否则返回 fallback(引导非校验,§12-④)。 + */ + private static String pickEnum(String raw, String[] allowed, String fallback) { + if (raw == null) { + return fallback; + } + String v = raw.trim().toLowerCase(); + for (String a : allowed) { + if (a.equals(v)) { + return a; + } + } + return fallback; + } + + // ====================== ⑨ asset 节点(固定架构新增:v0 产规格不真生图) ====================== + + /** + * asset 节点(execution §5.5;v0 产规格不真生图,canvas 几何兜底):据 {@link #K_ARCHETYPE} 与 + * {@link #K_SOURCE_PROJECT}.gameDefinition 产六类资产规格(sprite/character/effect/scene/ui/music),写 + * {@link #K_ASSET_SPEC}(JSON 数组串)+ merge 进 {@code sourceProject.assets[]}(产消通道打通,否则孤儿)。 + * + *

v0 边界(§5.5):不真调 provider 生图(对齐现 prompt.py 硬规则 7「资产容错=用 canvas 几何画」); + * 每条 assetSpec 形态 = {id,category,ref,provider}(provider 默认 {@code mmx-cli},可插拔;ref 为占位逻辑名, + * 真资产生成是 additive 升级,切 provider 只改本节点不动消费侧)。真生图属后续 provider 接入,本期标 {@code TODO(B7?)} 不做。 + * + *

消费:下游 build/logic 读 {@code sourceProject.assets[].ref}(§5.5);本节点保证 assets[] 非空(即便 + * gameDefinition 为空也产一条 ui 占位),补上 v2 review §2「assets[] 空」缺口。 + */ + static NodeAction assetNode() { + return (OverAllState s) -> { + String archetype = s.value(K_ARCHETYPE, String.class).orElse("generic"); + String spStr = s.value(K_SOURCE_PROJECT, String.class).orElse(""); + Map out = new HashMap<>(); + + // ① 产六类资产规格(v0 几何兜底:每类一条占位 spec,ref=逻辑名,provider=mmx-cli)。 + com.fasterxml.jackson.databind.node.ArrayNode specs = MAPPER.createArrayNode(); + for (String cat : new String[]{"sprite", "character", "effect", "scene", "ui", "music"}) { + specs.add(MAPPER.createObjectNode() + .put("id", archetype + "-" + cat) // 稳定 id:品类+类目(确定性,便于 modify 按 id 寻址) + .put("category", cat) // 六类枚举(§5.5 一次定死) + .put("ref", "geom:" + cat) // v0 占位逻辑名(几何兜底,非真资产 url) + .put("provider", "mmx-cli")); // provider 可插拔(默认 mmx-cli) + } + out.put(K_ASSET_SPEC, specs.toString()); + + // ② merge 进 sourceProject.assets[](产消通道打通——下游 build/logic 读 sourceProject.assets[].ref)。 + // sourceProject 缺/非法 → 起一个最小骨架(schemaVersion+空 gameDefinition),保证 assets[] 有归处不孤儿。 + com.fasterxml.jackson.databind.node.ObjectNode sp; + JsonNode parsed = SaaPrompts.looseParse(spStr); + if (parsed != null && parsed.isObject()) { + sp = (com.fasterxml.jackson.databind.node.ObjectNode) parsed.deepCopy(); + } else { + sp = MAPPER.createObjectNode(); + sp.put("schemaVersion", "1.0"); + } + sp.set("assets", specs); // 覆盖写 assets[](v0 全量产;后续 modify 增量另走 modifyPatch)。 + out.put(K_SOURCE_PROJECT, sp.toString()); + return out; + }; + } + + // ====================== ⑩ modify 节点(固定架构新增:确定性编辑 / 重生成模块) ====================== + + /** + * modify 节点(execution §5.6;改源不改打包产物):读 {@link #K_MODIFY_PATCH}/{@link #K_MODIFY_MODE}—— + *

+ * 本节点只产 {@code sourceProject 改一处} / {@code role+feedback},不做路由(路由是 assemble 的 modifyRouter 条件边)。 + * + *

本期边界(B7 完整应用 follow-up):deterministic 路实现「按顶层 JSON 指针(/config/.. /assets/..) + * 或部件 id 覆写一处」的初始逻辑(够跑通 build 验证产消);更完整的 JSON 指针解析(深层 /gameDefinition/scenes/2 + * 任意深度、数组下标越界容错、按 id 在 behaviors[]/assets[] 精确定位)标 {@code TODO(B7)} 留 B7 完善。节点本身非空壳: + * 真改 sourceProject、真写 role/feedback,产消可验。 + */ + static NodeAction modifyNode() { + return (OverAllState s) -> { + String mode = s.value(K_MODIFY_MODE, String.class).orElse(""); + String patchStr = s.value(K_MODIFY_PATCH, String.class).orElse(""); + String spStr = s.value(K_SOURCE_PROJECT, String.class).orElse(""); + Map out = new HashMap<>(); + JsonNode patch = SaaPrompts.looseParse(patchStr); + + if ("regenerate-module".equals(mode)) { + // 重生成模块路:置 fix 角色 + 把 intent 作为 feedback 喂 generate(只重那一个 behavior,其余不动)。 + String intent = patch != null ? patch.path("payload").path("intent").asText("") : ""; + out.put(K_ROLE, "fix"); + out.put(K_FEEDBACK, intent.isEmpty() + ? "请只重生成被指定的玩法模块(behavior),其余部件保持不变。" + : ("请只重生成被指定的玩法模块(behavior),意图:" + intent + ";其余部件保持不变。")); + // sourceProject 原样透传(generate 在其基础上重出那一模块;B7 完整应用时按 target 精确定位模块)。 + if (!spStr.isEmpty()) { + out.put(K_SOURCE_PROJECT, spStr); + } + return out; + } + + // deterministic 路(默认):按 target 寻址改 sourceProject 一处(免 LLM)。 + com.fasterxml.jackson.databind.node.ObjectNode sp; + JsonNode parsed = SaaPrompts.looseParse(spStr); + if (parsed != null && parsed.isObject()) { + sp = (com.fasterxml.jackson.databind.node.ObjectNode) parsed.deepCopy(); + } else { + sp = MAPPER.createObjectNode(); + sp.put("schemaVersion", "1.0"); + } + if (patch != null && patch.isObject()) { + applyDeterministicPatch(sp, patch.path("target"), patch.path("payload").path("value")); + } + out.put(K_SOURCE_PROJECT, sp.toString()); + out.put(K_MODIFY_MODE, "deterministic"); // 固化路由信号(modifyRouter 据此走 build)。 + return out; + }; + } + + /** + * B7 初始实现:按 modifyPatch.target 把 payload.value 写进 sourceProject 一处(确定性编辑)。 + *

本期支持:① 顶层单段指针(/config、/assets、/gameDefinition)整段覆写;② 顶层段下一级键覆写 + * (如 /config/speed:第一段定位 config 对象、第二段为键名)。深层任意深度指针 + 按 id 在数组内精确定位标 {@code TODO(B7)}。 + * + * @param sp 源项目对象(原地改)。 + * @param target modifyPatch.target(含 kind/path/id)。 + * @param value payload.value(确定性新值)。 + */ + private static void applyDeterministicPatch(com.fasterxml.jackson.databind.node.ObjectNode sp, + JsonNode target, JsonNode value) { + if (target == null || value == null || value.isMissingNode()) { + return; + } + String path = target.path("path").asText(""); + // JSON 指针形如 /config/speed;拆段(去首部空段)。TODO(B7):支持任意深度 + 数组下标 + 按 id 定位。 + if (path.startsWith("/")) { + String[] seg = path.substring(1).split("/"); + if (seg.length == 1 && !seg[0].isEmpty()) { + // 顶层整段覆写(/config、/assets…)。 + sp.set(seg[0], value.deepCopy()); + } else if (seg.length == 2 && !seg[0].isEmpty() && !seg[1].isEmpty()) { + // 顶层段下一级键覆写(/config/speed…):定位/新建顶层对象段,写其子键。 + com.fasterxml.jackson.databind.node.ObjectNode parent = + sp.has(seg[0]) && sp.get(seg[0]).isObject() + ? (com.fasterxml.jackson.databind.node.ObjectNode) sp.get(seg[0]) + : sp.putObject(seg[0]); + parent.set(seg[1], value.deepCopy()); + } + // 其余深度/数组路径:TODO(B7) 不在本期做(不静默改错处,留空=不动 sourceProject)。 + } + } + + // ====================== ⑪ escalate 节点(固定架构新增:救场升档 stage1→stage2) ====================== + + /** + * escalate 节点(execution §6.3):救场阶梯命中升档窗时把 {@link #K_MODEL_TIER} stage1→stage2,并 append 一条 + * {@link #K_ESCALATION_EVENTS}({tierBefore,tierAfter,atFailCount,ts})→ 路由回 generate。 + * + *

本期边界(B4 全阶梯路由 follow-up):本节点只做「升档动作本身」(改 modelTier + 记升档事件,真逻辑非空壳, + * 救场可观测);两处属 {@code TODO(B4)}:① generate 据 modelTier 真切更强模型(现 generate 按 role 选 code/fix 模型, + * 暂未消费 modelTier,故升档后仍用 stage1 模型重试——切 stage2 模型路由是 B4);② 更精细的「stage2 续计 vs 重置」failCount 边界。 + */ + static NodeAction escalateNode() { + return (OverAllState s) -> { + Map out = new HashMap<>(); + String tierBefore = s.value(K_MODEL_TIER, String.class).orElse("stage1"); + String tierAfter = "stage2"; // 本期单级升档(stage1→stage2);多级升档 TODO(B4)。 + int failCount = ((Number) s.value(K_FAIL_COUNT, 0)).intValue(); + out.put(K_MODEL_TIER, tierAfter); + // append 升档事件(读旧 JSON 数组串 → 追加 → 写回;ts 用 ISO8601 受控否?——escalate 非 LLM 节点,用系统时钟可接受)。 + com.fasterxml.jackson.databind.node.ArrayNode arr; + JsonNode prev = SaaPrompts.looseParse(s.value(K_ESCALATION_EVENTS, String.class).orElse("[]")); + arr = (prev != null && prev.isArray()) + ? (com.fasterxml.jackson.databind.node.ArrayNode) prev.deepCopy() + : MAPPER.createArrayNode(); + arr.add(MAPPER.createObjectNode() + .put("tierBefore", tierBefore) + .put("tierAfter", tierAfter) + .put("atFailCount", failCount) + .put("ts", java.time.Instant.now().toString())); + out.put(K_ESCALATION_EVENTS, arr.toString()); + return out; + }; + } + + // ====================== ⑫ nreview 节点(固定架构新增:narrative 质量门,真 LLM) ====================== + + /** + * nreview 节点(execution §5.9;真 LLM 调用,narrative-reviewer 角色):仅 {@link #K_PROGRESS_MODEL}=narrative + * 路经过(替代九门——九门确定性测不了叙事连贯)。产 {@link #K_NARRATIVE_VERDICT}{problems,needsRepair,founderFinal}。 + * + *

failCount 口径(§5.9/§12-⑤):{@code needsRepair=true} 等价一次九门失败计入 {@link #K_FAIL_COUNT}} + * (同 5+3 阶梯);reviewer 自身 LLM 失败也保守计 failCount(防 reviewer 抖动卡死,§12-⑤)。{@code founderFinal} + * 终判留创始人(叙事类终判人工),本节点只给机器初判。 + * + * @param reviewModel narrative-reviewer 模型(指 new-api)。 + * @param reviewModelName reviewer 模型名(per-model token 拆账用;B-8)。 + */ + static NodeAction nreviewNode(ChatModel reviewModel, String reviewModelName) { + return (OverAllState s) -> { + String brief = s.value(K_BRIEF, String.class).orElse(""); + String spStr = s.value(K_SOURCE_PROJECT, String.class).orElse(""); + Map out = new HashMap<>(); + int failCount = ((Number) s.value(K_FAIL_COUNT, 0)).intValue(); + + boolean needsRepair; + com.fasterxml.jackson.databind.node.ObjectNode verdict = MAPPER.createObjectNode(); + com.fasterxml.jackson.databind.node.ArrayNode problems = MAPPER.createArrayNode(); + + long inDelta = 0, outDelta = 0; + try { + String user = "游戏题面(叙事/剧情类):\n" + brief + + "\n\n源项目工件(节选):\n" + trunc(spStr, 4000) + + "\n\n请作为叙事设计审查者,判断本作的叙事连贯性/设计完整度,按要求只输出 JSON。"; + Object[] r = callAndRecord(reviewModel, SaaPrompts.NARRATIVE_REVIEW_SYSTEM, user); + inDelta = (Long) r[1]; + outDelta = (Long) r[2]; + JsonNode j = SaaPrompts.looseParse((String) r[0]); + if (j != null && j.isObject()) { + JsonNode ps = j.path("problems"); + if (ps.isArray()) { + ps.forEach(p -> problems.add(p.isValueNode() ? p.asText() : p.toString())); + } else if (ps.isValueNode() && !ps.asText().isEmpty()) { + problems.add(ps.asText()); + } + // needsRepair:模型显式给 → 用之;缺则按「有 problems 即需修」兜底。 + needsRepair = j.has("needsRepair") + ? j.path("needsRepair").asBoolean(!problems.isEmpty()) + : !problems.isEmpty(); + } else { + // 解不出 JSON:保守判 needsRepair(计 failCount,防 reviewer 脏输出卡死)。 + needsRepair = true; + problems.add("narrative-reviewer 输出非法 JSON,保守判需修复"); + } + } catch (Exception e) { + // reviewer LLM 失败:保守计 failCount(§12-⑤ 防抖动卡死),needsRepair=true。 + needsRepair = true; + problems.add("narrative-reviewer 调用失败:" + trunc(e.getMessage(), 200)); + } + + verdict.set("problems", problems); + verdict.put("needsRepair", needsRepair); + verdict.put("founderFinal", false); // 叙事终判留创始人(§5.9),本节点只给机器初判。 + out.put(K_NARRATIVE_VERDICT, verdict.toString()); + // needsRepair=true 等价一次九门失败 → failCount++(§5.9/§12-⑤ 口径,与九门同 5+3 阶梯)。 + if (needsRepair) { + out.put(K_FAIL_COUNT, failCount + 1); + // narrative 路的回喂:把 problems 作为体验问题喂 repair(复用 playerFeedback 范式,§5.9)。 + StringBuilder fb = new StringBuilder("叙事审查发现以下问题,请修复并保持叙事连贯:\n"); + int n = 0; + for (JsonNode p : problems) { + if (n++ >= 8) { + break; + } + fb.append("- ").append(p.asText()).append("\n"); + } + out.put(K_PLAYER_FEEDBACK, fb.toString()); + } + accumTokensByModel(s, out, reviewModelName, inDelta, outDelta); // B-8:per-model 拆账。 + appendAttempt(s, out, MAPPER.createObjectNode() + .put("role", "narrative-reviewer").put("model", reviewModelName) + .put("stage_fail", needsRepair ? "narrative" : (String) null) + .set("usage", MAPPER.createObjectNode().put("in", inDelta).put("out", outDelta))); return out; }; } // ====================== 条件边谓词(EdgeAction → String) ====================== + /** + * render 路由(execution §6.2,固定架构裁定见 SaaStudioGraph.assemble 注释):据 {@link #K_MODIFY_MODE} 分流—— + * 空(create)→ classify;非空(任一 modify 入口:deterministic / regenerate-module)→ modify 节点。 + * modify 节点真改源后再经 {@link #modifyRouter} →build/generate(保 modify 非孤儿且真做事,§6.2 终点一致)。 + */ + static EdgeAction renderRouter() { + return (OverAllState s) -> { + String mode = s.value(K_MODIFY_MODE, String.class).orElse(""); + return mode.isEmpty() ? "classify" : "modify"; + }; + } + + /** + * modify 路由(execution §6.2):modify 节点后据 {@link #K_MODIFY_MODE} 分流—— + * deterministic → build;regenerate-module → generate。 + */ + static EdgeAction modifyRouter() { + return (OverAllState s) -> { + String mode = s.value(K_MODIFY_MODE, String.class).orElse("deterministic"); + return "regenerate-module".equals(mode) ? "generate" : "build"; + }; + } + + /** + * nreview 路由(execution §5.9):needsRepair=false → emit;true 且救场轮未尽 → repair;救场耗尽 → giveup。 + * 复用 failCount 5+3 阶梯(与九门同口径)。 + * + * @param maxRepairs stage1 失败阈(救场升档窗下界)。 + * @param stage2ExtraRepairs stage2 额外失败阈(giveup 上界 = maxRepairs + stage2ExtraRepairs)。 + */ + static EdgeAction nreviewRouter(int maxRepairs, int stage2ExtraRepairs) { + return (OverAllState s) -> { + JsonNode v = SaaPrompts.looseParse(s.value(K_NARRATIVE_VERDICT, String.class).orElse("")); + boolean needsRepair = v != null && v.path("needsRepair").asBoolean(false); + if (!needsRepair) { + return "emit"; + } + int failCount = ((Number) s.value(K_FAIL_COUNT, 0)).intValue(); + // 救场耗尽(stage2 再 maxRepairs+extra 次)→ giveup;否则回 repair(升档由失败回边的 escalate 窗处理)。 + return failCount >= (maxRepairs + stage2ExtraRepairs) ? "giveup" : "repair"; + }; + } + + /** + * 救场阶梯失败回边判据(execution §6.3;validate/build/play 共用·替代旧 {@link #okElseRepairOrGiveup}): + * ok → 下一步;否则按 failCount + modelTier 决定 repair / escalate / giveup。 + *

+ * + *

本期边界(B4):play 节点已自增 failCount(见 {@link #playNode}),validate/build 暂仍以 repairCount 兜底—— + * 完整的「validate/build 失败也 +failCount」统一计数标 {@code TODO(B4)};本判据对三门生效,create-metric 正常路 + * (failCount 随九门失败累加)阶梯成立。 + * + * @param okKey 成功标志 state key(validateOk/buildOk/playPass)。 + * @param maxRepairs stage1 失败阈(= AigcExecutorProperties.saaMaxRepairs)。 + * @param stage2ExtraRepairs stage2 额外失败阈(= saaStage2ExtraRepairs)。 + */ + static EdgeAction okElseRepairOrEscalateOrGiveup(String okKey, int maxRepairs, int stage2ExtraRepairs) { + return (OverAllState s) -> { + if (s.value(okKey, Boolean.class).orElse(false)) { + return "ok"; + } + int failCount = ((Number) s.value(K_FAIL_COUNT, 0)).intValue(); + String tier = s.value(K_MODEL_TIER, String.class).orElse("stage1"); + int giveupThreshold = maxRepairs + stage2ExtraRepairs; // stage2 再 extra 次 → 放弃。 + if (failCount >= giveupThreshold) { + return "giveup"; + } + // 命中升档窗:failCount 已达 stage1 阈且仍在 stage1 → 升档(仅升一次,escalate 后 tier=stage2 不再命中此分支)。 + if (failCount >= maxRepairs && "stage1".equals(tier)) { + return "escalate"; + } + return "repair"; + }; + } + /** * validate/build 失败回边共用判据(对齐蓝图 §3.1):ok? 下一步 : repairCount>=N? giveup : repair。 * @@ -720,6 +1170,32 @@ final class SaaStudioNodes { return okElseRepairOrGiveup(K_PLAY_PASS, maxRepairs); } + /** + * play 条件边(固定架构扩展,execution §6.1/§6.2): + *

+ * + * @param maxRepairs stage1 失败阈。 + * @param stage2ExtraRepairs stage2 额外失败阈。 + */ + static EdgeAction playRouterV2(int maxRepairs, int stage2ExtraRepairs) { + EdgeAction ladder = okElseRepairOrEscalateOrGiveup(K_PLAY_PASS, maxRepairs, stage2ExtraRepairs); + return (OverAllState s) -> { + // 叙事路:无论九门结果如何都改走 reviewer(九门确定性测不了叙事连贯,§5.9)。 + if ("narrative".equals(s.value(K_PROGRESS_MODEL, String.class).orElse("metric"))) { + return "nreview"; + } + // metric 路:pass→player;否则救场阶梯(repair/escalate/giveup)。 + if (s.value(K_PLAY_PASS, Boolean.class).orElse(false)) { + return "player"; + } + return ladder.apply(s); // 复用阶梯判据(fail 分支:返回 repair/escalate/giveup)。 + }; + } + /** * player 条件边(软门,对齐 studio.py:274-280): * 无体验问题(feedback 空) 或 playerRound 已尽 → emit;否则(且 repairCount 未尽)→ repair。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java index a5ec76ad..e4bdad31 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java @@ -172,6 +172,13 @@ public class AigcExecutorProperties { /** SAA 图 player 顾问轮上限(saa 路专用,对齐 Python studio.py:player_rounds 默认 1)。 */ private Integer saaMaxPlayerRounds = 1; + /** + * SAA 图救场阶梯 stage2 额外修复轮上限(固定架构 B2,execution §6.3):cheap(stage1) 连续 {@link #saaMaxRepairs} 次 + * 九门失败 → escalate 升档 stage2 → stage2 再失败这么多次 → giveup(放弃前完整 dump)。默认 3(5+3 阶梯); + * {@code 0} = 退回单档(升档后不再额外救场,等价旧单档行为,回滚用)。仅 dispatcher=saa 生效。 + */ + private Integer saaStage2ExtraRepairs = 3; + /** * SAA 图 checkpoint 开关(saa 路专用,迁移 step5):{@code true}=用 yudao Druid DataSource 装权威单 MysqlSaver * ({@code CREATE_IF_NOT_EXISTS} 自建 GRAPH_CHECKPOINT/GRAPH_THREAD 两表,运行时 DDL 非 Flyway 管控, diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTopologyTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTopologyTest.java new file mode 100644 index 00000000..8ab117fc --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTopologyTest.java @@ -0,0 +1,321 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.CompiledGraph; +import com.alibaba.cloud.ai.graph.GraphRepresentation; +import com.alibaba.cloud.ai.graph.KeyStrategy; +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.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.springframework.ai.openai.api.OpenAiApi; + +import java.nio.file.Path; +import java.nio.file.Paths; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * SaaStudioGraphTopologyTest —— 固定架构 B2 拓扑扩展的纯结构回归(无 LLM/无 harness/无网络,确定性)。 + * + *

覆盖 execution §6 拓扑表: + *

+ * + *

纪律:本测试不 invoke 图(不跑节点、不调 new-api、不占 harness 端口);仅 build(纯内存布线)+ 节点工厂的 + * 确定性逻辑单测。真端到端跑图见 harness-gated 的 {@link SaaStudioGraphTest}。 + * + * @author 造梦AI(固定架构 B2) + */ +class SaaStudioGraphTopologyTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 哑 game-runtime 根(build 不读盘——assemble 只 addNode/addEdge/compile,节点 lambda 不在 build 期执行)。 */ + private static final Path DUMMY_ROOT = Paths.get("/tmp/saa-topology-test-dummy"); + + /** 哑 new-api 上游(OpenAiApi.builder().build() 是 lazy HTTP 客户端,构造不发网络请求;本测试不 invoke 故永不触网)。 */ + private static OpenAiApi dummyApi() { + return OpenAiApi.builder().baseUrl("http://127.0.0.1:1").apiKey("dummy-key-not-used").build(); + } + + /** 用 ALL_KEYS 全 ReplaceStrategy 建可读写 state(复用 SaaStudioNodesRegressionTest 同范式)。 */ + private static OverAllState newFullState(Map init) { + 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); + } + + // ====================== ① 编译即结构校验:无孤儿/无悬挂边 ====================== + + @Test + void full_graph_compiles_no_orphan_no_dangling_edge() throws Exception { + // build 成功 = validateGraph 通过 = 所有边引用的节点都存在、每节点都在拓扑里(无孤儿)。 + CompiledGraph g = SaaStudioGraph.build(dummyApi(), SaaStudioGraph.Models.stage1(), + DUMMY_ROOT, 5, 1, 3, null, null); + assertNotNull(g, "全图应能编译(结构合法、无孤儿/悬挂边)"); + } + + // ====================== ② 新节点/边在 Mermaid 表示里 ====================== + + @Test + void mermaid_contains_all_nodes_and_key_edges() throws Exception { + CompiledGraph g = SaaStudioGraph.build(dummyApi(), SaaStudioGraph.Models.stage1(), + DUMMY_ROOT, 5, 1, 3, null, null); + GraphRepresentation rep = g.getGraph(GraphRepresentation.Type.MERMAID, SaaStudioGraph.GRAPH_NAME); + String mm = rep.content(); + assertNotNull(mm, "Mermaid 内容非空"); + System.out.println("================ 固定架构 SAA 图(Mermaid) ================\n" + mm + + "\n========================================================="); + + // 16 节点全在(11 存量 + 5 新增)。 + for (String node : new String[]{"render", "classify", "design", "generate", "validate", + "scaffold", "asset", "build", "play", "player", "nreview", "modify", "escalate", + "repair", "emit", "giveup"}) { + assertTrue(mm.contains(node), "Mermaid 应含节点 " + node + ",实得:\n" + mm); + } + // 5 个新增节点显式断言(B2 交付物)。 + for (String newNode : new String[]{"classify", "asset", "modify", "escalate", "nreview"}) { + assertTrue(mm.contains(newNode), "Mermaid 应含新增节点 " + newNode); + } + } + + // ====================== ③ recursionLimit 重算值 ====================== + + @Test + void recursion_limit_recomputed_for_rescue_ladder() { + // 公式 (maxRepairs + stage2Extra + 1) * 10 + 30:默认 5+3 → (5+3+1)*10+30 = 120。 + assertEquals(120, SaaStudioGraph.recursionLimitFor(5, 3), + "5+3 救场阶梯 recursionLimit 应为 120"); + // 单档退化(0,0):(0+0+1)*10+30 = 40(仍 >0,保兜底)。 + assertEquals(40, SaaStudioGraph.recursionLimitFor(0, 0)); + // 余量充足:救场总轮 8 主轮 × 每轮主链 ≈10 节点 = 80 < 120(不误触超步)。 + assertTrue(SaaStudioGraph.recursionLimitFor(5, 3) > (5 + 3) * 10, + "recursionLimit 应留余量,覆盖救场最坏路径"); + } + + // ====================== ④ render 路由分流 ====================== + + @Test + void render_router_splits_create_vs_modify() throws Exception { + EdgeAction r = SaaStudioNodes.renderRouter(); + // create(modifyMode 空)→ classify。 + assertEquals("classify", r.apply(newFullState(Map.of()))); + // 任一 modify 入口(modifyMode 非空)→ modify 节点。 + assertEquals("modify", r.apply(newFullState(Map.of(SaaStudioNodes.K_MODIFY_MODE, "deterministic")))); + assertEquals("modify", r.apply(newFullState(Map.of(SaaStudioNodes.K_MODIFY_MODE, "regenerate-module")))); + } + + @Test + void render_node_initializes_rescue_control_plane() throws Exception { + OverAllState s = newFullState(Map.of(SaaStudioNodes.K_BRIEF, "一个躲避陨石的游戏")); + applyNode(s, SaaStudioNodes.renderNode()); + assertEquals(0, ((Number) s.value(SaaStudioNodes.K_FAIL_COUNT, -1)).intValue(), "failCount 初值 0"); + assertEquals("stage1", s.value(SaaStudioNodes.K_MODEL_TIER, String.class).orElse(""), "modelTier 初值 stage1"); + assertEquals("code", s.value(SaaStudioNodes.K_ROLE, String.class).orElse("")); + } + + // ====================== ⑤ asset 节点产消(六类 + merge 进 sourceProject.assets[]) ====================== + + @Test + void asset_node_produces_six_categories_and_merges_into_source_project() throws Exception { + OverAllState s = newFullState(Map.of(SaaStudioNodes.K_ARCHETYPE, "dodge")); + applyNode(s, SaaStudioNodes.assetNode()); + + // assetSpec:六类齐全。 + String specStr = s.value(SaaStudioNodes.K_ASSET_SPEC, String.class).orElse("[]"); + JsonNode specs = MAPPER.readTree(specStr); + assertTrue(specs.isArray() && specs.size() == 6, "应产六类资产规格,实得 " + specs.size()); + java.util.Set cats = new java.util.HashSet<>(); + for (JsonNode a : specs) { + cats.add(a.path("category").asText()); + assertEquals("mmx-cli", a.path("provider").asText(), "provider 默认 mmx-cli"); + } + assertEquals(java.util.Set.of("sprite", "character", "effect", "scene", "ui", "music"), cats, + "六类枚举须齐全(§5.5)"); + + // 产消通道:merge 进 sourceProject.assets[](下游 build/logic 读 sourceProject.assets[].ref)。 + String spStr = s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse(""); + JsonNode sp = MAPPER.readTree(spStr); + assertTrue(sp.path("assets").isArray() && sp.path("assets").size() == 6, + "asset 须 merge 进 sourceProject.assets[](否则孤儿)"); + } + + // ====================== ⑥ modify 节点:确定性编辑 / regenerate-module ====================== + + @Test + void modify_deterministic_patches_source_project_one_place() throws Exception { + // base 源项目:config.speed=5;patch 改 /config/speed=9(确定性、免 LLM)。 + String base = "{\"schemaVersion\":\"1.0\",\"config\":{\"speed\":5,\"size\":3}}"; + String patch = "{\"mode\":\"deterministic\",\"target\":{\"kind\":\"config\",\"path\":\"/config/speed\"}," + + "\"payload\":{\"value\":9}}"; + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODIFY_MODE, "deterministic", + SaaStudioNodes.K_MODIFY_PATCH, patch, + SaaStudioNodes.K_SOURCE_PROJECT, base)); + applyNode(s, SaaStudioNodes.modifyNode()); + + JsonNode sp = MAPPER.readTree(s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("{}")); + assertEquals(9, sp.path("config").path("speed").asInt(), "确定性编辑应改中 /config/speed"); + assertEquals(3, sp.path("config").path("size").asInt(), "未指定字段保持不变(只改一处)"); + // 路由:modifyRouter → build。 + assertEquals("build", SaaStudioNodes.modifyRouter().apply(s)); + } + + @Test + void modify_deterministic_top_level_segment_overwrite() throws Exception { + // 顶层整段覆写 /config。 + String base = "{\"schemaVersion\":\"1.0\",\"config\":{\"speed\":5}}"; + String patch = "{\"mode\":\"deterministic\",\"target\":{\"path\":\"/config\"}," + + "\"payload\":{\"value\":{\"speed\":20,\"hard\":true}}}"; + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODIFY_MODE, "deterministic", + SaaStudioNodes.K_MODIFY_PATCH, patch, + SaaStudioNodes.K_SOURCE_PROJECT, base)); + applyNode(s, SaaStudioNodes.modifyNode()); + JsonNode sp = MAPPER.readTree(s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("{}")); + assertEquals(20, sp.path("config").path("speed").asInt()); + assertTrue(sp.path("config").path("hard").asBoolean()); + } + + @Test + void modify_regenerate_module_sets_fix_role_and_feedback() throws Exception { + String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"kind\":\"behavior\",\"id\":\"jump\"}," + + "\"payload\":{\"intent\":\"让跳跃更高更软\"}}"; + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODIFY_MODE, "regenerate-module", + SaaStudioNodes.K_MODIFY_PATCH, patch, + SaaStudioNodes.K_SOURCE_PROJECT, "{\"schemaVersion\":\"1.0\"}")); + applyNode(s, SaaStudioNodes.modifyNode()); + assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""), "重生成模块路置 role=fix"); + assertTrue(s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("").contains("让跳跃更高更软"), + "intent 应作为 feedback 喂 generate"); + // 路由:modifyRouter → generate。 + assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s)); + } + + // ====================== ⑦ escalate 节点:升档 stage1→stage2 + 记事件 ====================== + + @Test + void escalate_node_bumps_tier_and_appends_event() throws Exception { + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODEL_TIER, "stage1", + SaaStudioNodes.K_FAIL_COUNT, 5)); + applyNode(s, SaaStudioNodes.escalateNode()); + assertEquals("stage2", s.value(SaaStudioNodes.K_MODEL_TIER, String.class).orElse(""), "升档 stage1→stage2"); + JsonNode events = MAPPER.readTree(s.value(SaaStudioNodes.K_ESCALATION_EVENTS, String.class).orElse("[]")); + assertTrue(events.isArray() && events.size() == 1, "应 append 一条升档事件"); + assertEquals("stage1", events.get(0).path("tierBefore").asText()); + assertEquals("stage2", events.get(0).path("tierAfter").asText()); + assertEquals(5, events.get(0).path("atFailCount").asInt()); + assertFalse(events.get(0).path("ts").asText().isEmpty(), "升档事件须带时间戳"); + } + + // ====================== ⑧ 救场阶梯路由谓词(okElseRepairOrEscalateOrGiveup) ====================== + + @Test + void rescue_ladder_router_repair_escalate_giveup() throws Exception { + EdgeAction ladder = SaaStudioNodes.okElseRepairOrEscalateOrGiveup( + SaaStudioNodes.K_PLAY_PASS, 5, 3); // stage1=5、giveup 阈=5+3=8 + // pass → ok。 + assertEquals("ok", ladder.apply(newFullState(Map.of(SaaStudioNodes.K_PLAY_PASS, true)))); + // fail & failCount<5(stage1)→ repair。 + assertEquals("repair", ladder.apply(newFullState(Map.of( + SaaStudioNodes.K_PLAY_PASS, false, SaaStudioNodes.K_FAIL_COUNT, 3, + SaaStudioNodes.K_MODEL_TIER, "stage1")))); + // fail & failCount==5 & stage1 → escalate(升档窗)。 + assertEquals("escalate", ladder.apply(newFullState(Map.of( + SaaStudioNodes.K_PLAY_PASS, false, SaaStudioNodes.K_FAIL_COUNT, 5, + SaaStudioNodes.K_MODEL_TIER, "stage1")))); + // fail & failCount==6 & stage2(已升档)→ repair(stage2 修,未到 giveup 阈)。 + assertEquals("repair", ladder.apply(newFullState(Map.of( + SaaStudioNodes.K_PLAY_PASS, false, SaaStudioNodes.K_FAIL_COUNT, 6, + SaaStudioNodes.K_MODEL_TIER, "stage2")))); + // fail & failCount>=8(stage2 再 3 次)→ giveup。 + assertEquals("giveup", ladder.apply(newFullState(Map.of( + SaaStudioNodes.K_PLAY_PASS, false, SaaStudioNodes.K_FAIL_COUNT, 8, + SaaStudioNodes.K_MODEL_TIER, "stage2")))); + } + + // ====================== ⑨ playRouterV2:narrative→nreview / metric pass→player ====================== + + @Test + void play_router_v2_narrative_to_nreview_metric_to_player() throws Exception { + EdgeAction r = SaaStudioNodes.playRouterV2(5, 3); + // narrative 路:无论九门结果都走 nreview。 + assertEquals("nreview", r.apply(newFullState(Map.of( + SaaStudioNodes.K_PROGRESS_MODEL, "narrative", SaaStudioNodes.K_PLAY_PASS, true)))); + assertEquals("nreview", r.apply(newFullState(Map.of( + SaaStudioNodes.K_PROGRESS_MODEL, "narrative", SaaStudioNodes.K_PLAY_PASS, false)))); + // metric 路 & pass → player。 + assertEquals("player", r.apply(newFullState(Map.of( + SaaStudioNodes.K_PROGRESS_MODEL, "metric", SaaStudioNodes.K_PLAY_PASS, true)))); + // metric 路 & fail & failCount<5 → repair(走救场阶梯)。 + assertEquals("repair", r.apply(newFullState(Map.of( + SaaStudioNodes.K_PROGRESS_MODEL, "metric", SaaStudioNodes.K_PLAY_PASS, false, + SaaStudioNodes.K_FAIL_COUNT, 1, SaaStudioNodes.K_MODEL_TIER, "stage1")))); + } + + // ====================== ⑩ nreviewRouter:needsRepair 分流 ====================== + + @Test + void nreview_router_routes_by_needs_repair_and_failcount() throws Exception { + EdgeAction r = SaaStudioNodes.nreviewRouter(5, 3); + // needsRepair=false → emit。 + assertEquals("emit", r.apply(newFullState(Map.of( + SaaStudioNodes.K_NARRATIVE_VERDICT, "{\"needsRepair\":false}")))); + // needsRepair=true & failCount<8 → repair。 + assertEquals("repair", r.apply(newFullState(Map.of( + SaaStudioNodes.K_NARRATIVE_VERDICT, "{\"needsRepair\":true}", + SaaStudioNodes.K_FAIL_COUNT, 2)))); + // needsRepair=true & failCount>=8(救场耗尽)→ giveup。 + assertEquals("giveup", r.apply(newFullState(Map.of( + SaaStudioNodes.K_NARRATIVE_VERDICT, "{\"needsRepair\":true}", + SaaStudioNodes.K_FAIL_COUNT, 8)))); + } + + // ====================== ⑪ archetype 映射表(§6.4 契约:未知→generic 不污染下游) ====================== + + @Test + void archetype_map_covers_spec_rows_and_unknown_falls_back_generic() { + // §6.4 表逐行存在。 + for (String a : new String[]{"clicker", "dodge", "runner", "bubble", "match3", "line-clear", + "merge", "idle", "tycoon", "generic"}) { + assertTrue(SaaArchetypes.MAP.containsKey(a), "映射表应含 archetype " + a); + } + // 拆「消除族」:bubble=realtime/continuous、match3=turn-based/discrete-choice(review §10.1)。 + assertEquals("realtime", SaaArchetypes.MAP.get("bubble").tickModel()); + assertEquals("continuous", SaaArchetypes.MAP.get("bubble").inputModel()); + assertEquals("turn-based", SaaArchetypes.MAP.get("match3").tickModel()); + assertEquals("discrete-choice", SaaArchetypes.MAP.get("match3").inputModel()); + // 未知 archetype → generic(不污染下游,§6.4)。 + assertEquals("generic", SaaArchetypes.normalize("rpg-mmo-roguelike")); + assertEquals("generic", SaaArchetypes.normalize(null)); + assertEquals("dodge", SaaArchetypes.normalize("dodge")); + } +}