feat(aigc): B2 SAA 固定架构图拓扑扩展——+5 节点(classify/asset/modify/escalate/nreview)+ 救场阶梯

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) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-17 17:33:15 +00:00
parent 0f380a516b
commit 706d014869
8 changed files with 1082 additions and 46 deletions

View File

@ -0,0 +1,73 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* SaaArchetypes classifyarchetype 映射表execution §6.4HJ-FIXED-ARCH 固定架构
*
* <p><b>定位</b>品类原型 archetype锚到 profile 三维预设tickModel/inputModel这是<b>契约</b>§6.4
* classify 节点出的 archetype 必落本表一行否则 = 孤儿分类新增 archetype 必须同时补本表 + 对应品类 profile
*
* <p><b>语义=引导非校验§12-</b>映射行的 tickModel/inputModel <b>品类预设</b>玩法模板=品类框架引导 AI 生成
* pre-built 代码 / 非填参执行器对齐 memory {@code tiered-engine-cocos-decision} 2026-06-17 术语纠偏废的是游戏模板
* 玩法模板未废classify 优先用模型给的物理维spike tickModel 100%/archetype 93%模型给非法值时才用本表预设回填
*
* <p><b>枚举把消除族拆细review §10.1 明令</b>bubble瞄准发射realtime/continuous match3/line-clear
* 点选turn-based/discrete-choice分列避免笼统消除误导下游机制{@code generic} 为兜底通配三维
*
* <p>映射锚到现 {@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 保序便于审计/可视化
* <p>逐行对齐 execution §6.4clicker/dodge/runner/bubble/match3/line-clear/merge/idle/tycoon/generic
*/
static final Map<String, Profile> MAP = buildMap();
private static Map<String, Profile> buildMap() {
Map<String, Profile> 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;
}
}

View File

@ -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 判据用
* <p>仅当当前图<b>注册了 failCount 策略</b>全图 SaaStudioGraph时才写step3 最小图未注册该 key跳过不破坏
* {@link #clearPlayerFeedback} 同一守护范式 SaaHarnessDriveTest 等最小图回归不破
*/
private static void bumpFailCount(OverAllState s, Map<String, Object> 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 体验问题串到本轮失败回喂
* <p>仅当当前图<b>注册了 playerFeedback 策略</b>全图 SaaStudioGraphTest时才写step3 最小图未注册该 key跳过不破坏

View File

@ -285,12 +285,15 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
// checkpointstep5开关开 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");
}

View File

@ -168,6 +168,50 @@ final class SaaPrompts {
```
规则 `expectLatch` true游戏必须能真玩到 gameover 且驻留 "连续控制体(挡板/角色随手指平滑移动)"才给 `controlCheck`纯点击/无连续控制给 null 玩法形态二分选 driver这是判"机制是否真点亮"的命门别一律给 nonea技巧类挡板接球/接物玩法=连续控制体随手指水平移动去拦截`driver.type="paddle-intercept"``ballPath`=**球的水平 x 坐标字段**挡板水平移动去对齐它几乎总是 `ball.x`**绝不要填 ball.y**`paddleY`=挡板所在 yb离散点击目标类井字棋/五子棋/打地鼠/翻牌/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/>/>=/</<=/==/inpath 用你让代码导出的字段名须与 exportState 一致""";
// ============================================================================
// .1 CLASSIFY_SYSTEM 固定架构新增B2execution §6.4/§5.4品类分类 + profile 三维
// net-new Python 移植物理优先=先定 tickModel archetypespike tick 100%/archetype 93%
// ============================================================================
static final String CLASSIFY_SYSTEM = """
你是轻量 H5 小游戏的分类 agent输入是一句话/一段游戏题面你要据其玩法本质给出结构化分类
物理优先先判定时基(tickModel)与输入(inputModel)这两个物理维度再据此选品类原型(archetype)
只输出 JSON不要任何解释文字
{"archetype":"...","tickModel":"...","inputModel":"...","progressModel":"..."}
字段取值务必从下列枚举中选别造新词
- tickModel "realtime"(每帧连续推进,如躲避/跑酷) | "turn-based"(回合/点选推进,如井字棋/三消点选) | "event"(事件驱动,如点击器/放置/合成)
- inputModel "continuous"(连续控制,手指拖动/方向持续) | "discrete-choice"(离散点选,逐个点目标/格子) | "text-command"(文本指令)
- progressModel "metric"(可量化进展:分数/剩余/进度绝大多数玩法) | "narrative"(剧情/叙事推进:文字冒险/对话/剧情分支)
- archetype品类原型从下列里选最贴近的一个拿不准选 generic
· clicker(点击器/增量点击) · dodge(躲避) · runner(跑酷/无限奔跑)
· bubble(瞄准发射式消除,如泡泡龙) · match3(三消/点选连消) · line-clear(俄罗斯方块式消行)
· merge(合成/2048 ) · idle(放置/挂机) · tycoon(经营/模拟)
· generic(以上都不贴近时的通用兜底)
判定要点
- 大多数休闲小游戏 progressModel=metric仅当题面明确是"剧情/对话/文字冒险/叙事分支"才给 narrative
- "消除"别笼统:瞄准发射bubble(realtime/continuous)点选连消/消行match3 line-clear(turn-based/discrete-choice)
- 点击器/放置/合成是事件驱动(tickModel=event)不是 realtime
""";
// ============================================================================
// .2 NARRATIVE_REVIEW_SYSTEM 固定架构新增B2execution §5.9叙事质量门 reviewer
// net-newprogressModel=narrative 路替代九门九门确定性测不了叙事连贯
// ============================================================================
static final String NARRATIVE_REVIEW_SYSTEM = """
你是叙事设计审查者本作 progressModel=narrative剧情/叙事类无法用确定性九门(看分数/剩余下降)衡量
改由你审查其叙事连贯性与设计完整度剧情是否自洽推进是否有动机是否有明确的开端/冲突/结局文案与玩法是否相符
只输出 JSON不要任何解释文字
{"problems":["...具体问题..."],"needsRepair":true false,"founderFinal":false}
规则
- problems列出发现的叙事/设计问题每条具体可改无问题给空数组 []
- needsRepair有实质性叙事缺陷(剧情断裂/无动机/无结局/文案与玩法矛盾) true叙事基本自洽完整 false
- founderFinal恒填 false叙事类终判留人工你只给机器初判
诚实挑剔但有区分力别对每个作品都判 needsRepair也别放过明显断裂的剧情
""";
// ============================================================================
// player_system 逐字移植 roles.py:38-57玩家 agent 人格 + 1-5 分锚 + 空心必 fix
// ============================================================================

View File

@ -71,24 +71,37 @@ public final class SaaStudioGraph {
public static final String M_PLAYER_TEXT = "deepseek-v4-flash";
/** player 视觉位模型名(恒 MiniMax-M3跨家视觉位。 */
public static final String M_PLAYER_VISION = "MiniMax-M3";
/** classify 角色模型名(固定架构 B2物理优先分类spike 同口径=MiniMax-M3tick 100%/archetype 93%)。 */
public static final String M_CLASSIFY = "MiniMax-M3";
/** narrative-reviewer 角色模型名(固定架构 B2叙事质量门文本推理位用 stage1 文本模型)。 */
public static final String M_NARRATIVE = "deepseek-v4-flash";
/** 图名(可观测/可视化标识)。 */
public static final String GRAPH_NAME = "saa-studio-full";
/** 救场阶梯 stage2 额外修复轮上限默认值execution §6.3stage1 5 次 → 升档 → stage2 再 3 次 → giveup。 */
public static final int DEFAULT_STAGE2_EXTRA_REPAIRS = 3;
/**
* 五角色模型名集合可覆盖默认 stage1 路由bake-off / 阶段切换用
* 默认 {@link #stage1()} = models.yaml stage1
* 角色模型名集合可覆盖默认 stage1 路由bake-off / 阶段切换用
* 默认 {@link #stage1()} = models.yaml stage1 + 固定架构新增 classify/narrative 角色
*
* <p>固定架构 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 拓扑<b>唯一布线源</b>只产未编译的 {@link StateGraph}不挂 CompileConfig
* 组装全 StateGraphexecution §6 固定架构拓扑<b>唯一布线源</b>只产未编译的 {@link StateGraph}不挂 CompileConfig
*
* <p>拓扑STARTrenderdesigngeneratevalidatevalidate/build/play 三处失败回边共用判据
* ok?下一步 : repairCount&gt;=maxRepairs?giveup:repairplay 九门 passplayer软门emit/giveup
* repair 自增计数回 generate
* <p><b>拓扑§6.1/§6.2固定架构 B2 扩展为 16 节点</b>
* <pre>
* STARTrender
* render (create) classifydesigngenerate
* (regenerate-module) generate
* (deterministic) build
* generatevalidate (ok) scaffoldassetbuild
* (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
* repairgenerate escalategenerate emitEND giveupEND
* </pre>
* 救场阶梯§6.3cheap(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
* <p><b>无孤儿§6.2/§11-5</b>每节点都有入边+出边<b>modify 接入裁定</b>§6.2 render 行写modify generate/build
* modify 节点须真做事 patch 改源/ regenerate 意图且不可孤儿故本图让 render <b>任一 modify 入口</b>
* modifyMode 非空 deterministic / regenerate-module统一分流到 <b>modify 节点</b>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 ==
// designPython config.build_model 不传 temperature 服务端默认 temperature=null不下发
// code/fix_client.chat temperature=0.0max_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);
// 固定架构新增两角色B2classify物理优先分类温度低求稳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") // classifydesign§6.2
.addEdge("design", "generate")
.addEdge("generate", "validate")
.addEdge("scaffold", "build")
.addEdge("scaffold", "asset") // scaffoldasset asset§6.2
.addEdge("asset", "build") // assetbuild§6.2
.addEdge("repair", "generate")
.addEdge("escalate", "generate") // escalate 升档后回 generate§6.2
.addEdge("emit", END)
.addEdge("giveup", END)
// -- 条件边失败回边共用判据九门=硬地板player=软门--
// -- 条件边 --
// rendercreateclassify任一 modify 入口modifyMode 非空 modify 节点modify 真改源后再分流无孤儿
.addConditionalEdges("render",
edge_async(SaaStudioNodes.renderRouter()),
Map.of("classify", "classify", "modify", "modify"))
// validate 失败回边救场阶梯okscaffold / 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 失败回边救场阶梯okplay / 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"))
// playmetric&passplayer / narrativenreview / failrepair·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"))
// nreviewneedsRepair=falseemit / true&轮未尽repair / 救场耗尽giveup§5.9
.addConditionalEdges("nreview",
edge_async(SaaStudioNodes.nreviewRouter(maxRepairs, stage2ExtraRepairs)),
Map.of("emit", "emit", "repair", "repair", "giveup", "giveup"))
// modifydeterministicbuild / regenerate-modulegenerate§6.2
.addConditionalEdges("modify",
edge_async(SaaStudioNodes.modifyRouter()),
Map.of("build", "build", "generate", "generate"));
}
/**
* 推导递归硬刹车额度repair 回环成环超步=优雅终止非异常
* 推导式 = (maxRepairs+1) × 每轮主链节点数(7) + 宽余量(78)远超实际所需确保正常收口不误触超步
* 推导递归硬刹车额度repair/escalate 回环成环超步=优雅终止非异常
*
* @param maxRepairs 修复轮上限
* <p><b>固定架构重算execution §6.3</b>救场总轮 = maxRepairs(stage1) + stage2ExtraRepairs(stage2) + 1终轮主轮
* 每轮主链 10 节点renderclassifydesigngeneratevalidatescaffoldassetbuildplayplayer/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 {
* </ul>
*
* @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_REPAIRS5+3 阶梯§6.3需配置走 8 参重载
return build(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds,
DEFAULT_STAGE2_EXTRA_REPAIRS, saverConfig, observationRegistry);
}
/**
* 组装并编译全图固定架构 B2 全量重载显式 {@code stage2ExtraRepairs} 救场阶梯参数
*
* <p>布线唯一源 = {@link #assemble}所有 build 重载共用同一份杜绝 split-brainrecursionLimit
* {@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));
// -- checkpointstep5挂权威单 saverreleaseThread(false) 才能崩溃后同 threadId 续跑 --
if (saverConfig != null) {

View File

@ -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 keyexecution §5.4B2 拓扑扩展写入 ======================
static final String K_ARCHETYPE = "archetype"; // 品类原型classify §6.4 映射表枚举未知generic
static final String K_TICK_MODEL = "tickModel"; // profile 三维之一realtime|turn-based|eventclassify
static final String K_INPUT_MODEL = "inputModel"; // profile 三维之一continuous|discrete-choice|text-commandclassify
static final String K_PROGRESS_MODEL = "progressModel"; // profile 三维之一metric|narrativeclassify narrative nreview
static final String K_SOURCE_PROJECT = "sourceProject"; // 源项目工件 JSON §5.1scaffold/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-modulerender 入参 modify 路由读
static final String K_MODIFY_PATCH = "modifyPatch"; // modify 寻址+载荷 JSON §5.6modify 节点读 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|stage2render stage1escalate 升档
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 keyexecution §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 keyexecution §5.4B2 拓扑扩展节点写入 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本节点保证默认值齐全图入口
*
* <p>固定架构扩展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}<b>透传</b> state缺省给空串=create {@link #renderRouter} 据此分流到
* classify(create) / generate(regenerate-module) / build(deterministic)本节点只落初值/透传<b>不做路由判定</b>
* 路由是 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失败计数归零模型档起 stage1escalate 命中阈值才升 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
*
* <p>固定架构扩展execution §6.2/§6.3额外落 {@link #K_GIVEUP_DUMP_PATH}放弃前完整 dump 的落盘路径Opus 离线读
* <b>本期边界B4</b>本节点产出确定性 dump 路径 gameId 派生<b>初始逻辑</b>保证 trace 链路有此键可观测
* 把所有 attempts 源码/verdict 整包真写盘到该路径 {@code TODO(B4)}giveup 节点当前无 gameRuntimeRoot 句柄
* 真落盘待 B4 补节点入参 + 写盘逻辑节点<b>非空壳</b>真置终态 + 真产 dump 路径键
*/
static NodeAction giveupNode() {
return (OverAllState s) -> {
Map<String, Object> 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<b> LLM 调用</b>spike 模型=MiniMax-M3 brief 物理优先分类
* 先定 {@link #K_TICK_MODEL}spike 100% 再定 {@link #K_ARCHETYPE}spike 93%并落 profile 三维
* tickModel/inputModel/progressModel
*
* <p><b>映射表是契约§6.4</b>archetype 必落 {@link SaaArchetypes#MAP} 的一行模型出未知 archetype
* 兜底 {@code generic} + 告警不污染下游generic 行的 tickModel/inputModel 取通配不强写错品类
* tickModel/inputModel/progressModel 若模型给非法值 archetype 映射行的预设回填引导非校验§12-
*
* <p><b>诚实边界</b>本节点 v0 = 分类引导 profile 三维供下游 design/scaffold/QA 选机制不产代码不产源项目
* LLM 失败 180s/重试耗尽 不裸退图兜底 generic + 默认三维realtime/discrete-choice/metric继续对齐 design 节点降级范式
*
* @param classifyModel 分类模型 new-apimodel=MiniMax-M3spike 同口径
* @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<String, Object> out = new HashMap<>();
// 默认兜底三维 + archetypeLLM 失败或出脏值时用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);
// 物理优先先取模型 tickModelspike 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-8per-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<b>v0 产规格不真生图</b>canvas 几何兜底 {@link #K_ARCHETYPE}
* {@link #K_SOURCE_PROJECT}.gameDefinition 产六类资产<b>规格</b>sprite/character/effect/scene/ui/music
* {@link #K_ASSET_SPEC}JSON 数组串+ <b>merge {@code sourceProject.assets[]}</b>产消通道打通否则孤儿
*
* <p><b>v0 边界§5.5</b>不真调 provider 生图对齐现 prompt.py 硬规则 7资产容错= canvas 几何画
* 每条 assetSpec 形态 = {id,category,ref,provider}provider 默认 {@code mmx-cli}可插拔ref 为占位逻辑名
* 真资产生成是 additive 升级 provider 只改本节点不动消费侧真生图属后续 provider 接入本期标 {@code TODO(B7?)} 不做
*
* <p><b>消费</b>下游 build/logic {@code sourceProject.assets[].ref}§5.5本节点保证 assets[] 非空即便
* gameDefinition 为空也产一条 ui 占位补上 v2 review §2assets[] 缺口
*/
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<String, Object> out = new HashMap<>();
// 产六类资产规格v0 几何兜底每类一条占位 specref=逻辑名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}
* <ul>
* <li><b>deterministic</b> JSON 指针(path)/部件 id {@link #K_SOURCE_PROJECT} <b>一处</b> LLM秒级 路由 build</li>
* <li><b>regenerate-module</b> {@code role=fix} + 把改玩法意图写 {@link #K_FEEDBACK} generate只重生成那个 behavior 模块 路由 generate</li>
* </ul>
* 本节点只产 {@code sourceProject 改一处} / {@code role+feedback}<b>不做路由</b>路由是 assemble modifyRouter 条件边
*
* <p><b>本期边界B7 完整应用 follow-up</b>deterministic 路实现按顶层 JSON 指针(/config/.. /assets/..)
* 或部件 id 覆写一处<b>初始逻辑</b>够跑通 build 验证产消更完整的 JSON 指针解析深层 /gameDefinition/scenes/2
* 任意深度数组下标越界容错 id behaviors[]/assets[] 精确定位 {@code TODO(B7)} B7 完善节点本身<b>非空壳</b>
* 真改 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<String, Object> 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 一处确定性编辑
* <p>本期支持 顶层单段指针/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 节点固定架构新增救场升档 stage1stage2 ======================
/**
* escalate 节点execution §6.3救场阶梯命中升档窗时把 {@link #K_MODEL_TIER} stage1stage2 append 一条
* {@link #K_ESCALATION_EVENTS}{tierBefore,tierAfter,atFailCount,ts} 路由回 generate
*
* <p><b>本期边界B4 全阶梯路由 follow-up</b>本节点只做升档动作本身 modelTier + 记升档事件<b>真逻辑非空壳</b>
* 救场可观测两处属 {@code TODO(B4)} generate modelTier <b>真切更强模型</b> generate role code/fix 模型
* 暂未消费 modelTier故升档后仍用 stage1 模型重试 stage2 模型路由是 B4 更精细的stage2 续计 vs 重置failCount 边界
*/
static NodeAction escalateNode() {
return (OverAllState s) -> {
Map<String, Object> out = new HashMap<>();
String tierBefore = s.value(K_MODEL_TIER, String.class).orElse("stage1");
String tierAfter = "stage2"; // 本期单级升档stage1stage2多级升档 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<b> LLM 调用</b>narrative-reviewer 角色 {@link #K_PROGRESS_MODEL}=narrative
* 路经过替代九门九门确定性测不了叙事连贯 {@link #K_NARRATIVE_VERDICT}{problems,needsRepair,founderFinal}
*
* <p><b>failCount 口径§5.9/§12-</b>{@code needsRepair=true} <b>等价一次九门失败计入 {@link #K_FAIL_COUNT}}</b>
* 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<String, Object> 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-8per-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.2modify 节点后据 {@link #K_MODIFY_MODE} 分流
* deterministic buildregenerate-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.9needsRepair=false emittrue 且救场轮未尽 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.3validate/build/play 共用·替代旧 {@link #okElseRepairOrGiveup}
* ok 下一步否则按 failCount + modelTier 决定 repair / escalate / giveup
* <ul>
* <li>{@code failCount < maxRepairs} repairstage1 </li>
* <li>{@code failCount == maxRepairs modelTier==stage1} escalate stage2failCount 续计</li>
* <li>{@code failCount >= maxRepairs + stage2ExtraRepairs}stage2 extra giveup</li>
* <li>其余stage2 未到 giveup repairstage2 </li>
* </ul>
*
* <p><b>本期边界B4</b>play 节点已自增 failCount {@link #playNode}validate/build 暂仍以 repairCount 兜底
* 完整的validate/build 失败也 +failCount统一计数标 {@code TODO(B4)}本判据对三门生效create-metric 正常路
* failCount 随九门失败累加阶梯成立
*
* @param okKey 成功标志 state keyvalidateOk/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.1ok? 下一步 : repairCount>=N? giveup : repair
*
@ -720,6 +1170,32 @@ final class SaaStudioNodes {
return okElseRepairOrGiveup(K_PLAY_PASS, maxRepairs);
}
/**
* play 条件边固定架构扩展execution §6.1/§6.2
* <ul>
* <li>{@link #K_PROGRESS_MODEL}=narrative <b>nreview</b>叙事路不走九门 playRouter改走 reviewer §5.9</li>
* <li>metric 九门 pass player软门</li>
* <li>metric fail 救场阶梯 {@link #okElseRepairOrEscalateOrGiveup}repair/escalate/giveup</li>
* </ul>
*
* @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 passplayer否则救场阶梯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

View File

@ -172,6 +172,13 @@ public class AigcExecutorProperties {
/** SAA 图 player 顾问轮上限saa 路专用,对齐 Python studio.py:player_rounds 默认 1。 */
private Integer saaMaxPlayerRounds = 1;
/**
* SAA 图救场阶梯 stage2 额外修复轮上限固定架构 B2execution §6.3cheap(stage1) 连续 {@link #saaMaxRepairs}
* 九门失败 escalate 升档 stage2 stage2 再失败这么多次 giveup放弃前完整 dump默认 35+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 管控

View File

@ -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 拓扑扩展的<b>纯结构回归</b> LLM/ harness/无网络确定性
*
* <p>覆盖 execution §6 拓扑表
* <ul>
* <li><b>编译即结构校验无孤儿/无悬挂边</b><b> OpenAiApi</b>不发网络lazy client建全图SAA
* {@code StateGraph.compile()validateGraph()} 对任一边引用缺失节点 / 节点无路径会抛 {@code GraphStateException}
* 本测试 build 成功 = 16 节点全有入边出边无孤儿§6.2/§11-5</li>
* <li><b>新节点/边在图里</b>导出 Mermaid 表示断言 5 个新节点classify/asset/modify/escalate/nreview+ 存量节点
* + 关键边scaffoldassetbuildrender 分流escalategenerate 出现</li>
* <li><b>recursionLimit 重算值</b>{@code recursionLimitFor(5,3)==120}{@code (0,0)==40}§6.3 公式</li>
* <li><b>新节点确定性逻辑 + 路由谓词</b>asset 产消modify 确定性编辑escalate 升档archetype 映射 router
* 分流classify/nreview 含真 LLM 调用不在此跑其路由谓词与 archetype 归一在此确定性验</li>
* </ul>
*
* <p>纪律本测试<b> invoke </b>不跑节点不调 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<String, Object> init) {
OverAllState s = new OverAllState(init == null ? new HashMap<>() : new HashMap<>(init));
Map<String, KeyStrategy> 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<String, Object> 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();
// createmodifyMode 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<String> 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=5patch /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 节点升档 stage1stage2 + 记事件 ======================
@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=5giveup =5+3=8
// pass ok
assertEquals("ok", ladder.apply(newFullState(Map.of(SaaStudioNodes.K_PLAY_PASS, true))));
// fail & failCount<5stage1 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已升档 repairstage2 未到 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>=8stage2 3 giveup
assertEquals("giveup", ladder.apply(newFullState(Map.of(
SaaStudioNodes.K_PLAY_PASS, false, SaaStudioNodes.K_FAIL_COUNT, 8,
SaaStudioNodes.K_MODEL_TIER, "stage2"))));
}
// ====================== playRouterV2narrativenreview / metric passplayer ======================
@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"))));
}
// ====================== nreviewRouterneedsRepair 分流 ======================
@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/continuousmatch3=turn-based/discrete-choicereview §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"));
}
}