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:
parent
0f380a516b
commit
706d014869
@ -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 固定架构)。
|
||||
*
|
||||
* <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.4:clicker/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;
|
||||
}
|
||||
}
|
||||
@ -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,跳过不破坏。
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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/>/>=/</<=/==/in),path 用你让代码导出的字段名(须与 exportState 一致)。""";
|
||||
|
||||
// ============================================================================
|
||||
// ②.1 CLASSIFY_SYSTEM —— 固定架构新增(B2,execution §6.4/§5.4):品类分类 + profile 三维。
|
||||
// net-new(非 Python 移植);物理优先=先定 tickModel 再 archetype(spike 证 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 —— 固定架构新增(B2,execution §5.9):叙事质量门 reviewer。
|
||||
// net-new;progressModel=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)。
|
||||
// ============================================================================
|
||||
|
||||
@ -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-M3,tick 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.3:stage1 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。
|
||||
* 组装全 StateGraph(execution §6 固定架构拓扑)<b>唯一布线源</b>——只产未编译的 {@link StateGraph},不挂 CompileConfig。
|
||||
*
|
||||
* <p>拓扑:START→render→design→generate→validate;validate/build/play 三处失败回边共用判据
|
||||
* (ok?下一步 : repairCount>=maxRepairs?giveup:repair);play 九门 pass→player(软门)→emit/giveup;
|
||||
* repair 自增计数回 generate。
|
||||
* <p><b>拓扑(§6.1/§6.2,固定架构 B2 扩展为 16 节点)</b>:
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
* 救场阶梯(§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)
|
||||
* <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 ==
|
||||
// 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 修复轮上限
|
||||
* <p><b>固定架构重算(execution §6.3)</b>:救场总轮 = 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 {
|
||||
* </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_REPAIRS(5+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-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) {
|
||||
|
||||
@ -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;本节点保证默认值齐全(图入口)。
|
||||
*
|
||||
* <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):失败计数归零、模型档起 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)。
|
||||
*
|
||||
* <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-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<String, Object> 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;<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 §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<String, Object> 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}——
|
||||
* <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 节点(固定架构新增:救场升档 stage1→stage2) ======================
|
||||
|
||||
/**
|
||||
* escalate 节点(execution §6.3):救场阶梯命中升档窗时把 {@link #K_MODEL_TIER} stage1→stage2,并 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"; // 本期单级升档(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;<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-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。
|
||||
* <ul>
|
||||
* <li>{@code failCount < maxRepairs} → repair(stage1 修);</li>
|
||||
* <li>{@code failCount == maxRepairs ∧ modelTier==stage1} → escalate(升 stage2,failCount 续计);</li>
|
||||
* <li>{@code failCount >= maxRepairs + stage2ExtraRepairs}(stage2 再 extra 次)→ giveup;</li>
|
||||
* <li>其余(stage2 中、未到 giveup 阈)→ repair(stage2 修)。</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 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):
|
||||
* <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 路: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。
|
||||
|
||||
@ -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 管控,
|
||||
|
||||
@ -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)+ 存量节点
|
||||
* + 关键边(scaffold→asset→build、render 分流、escalate→generate 等)出现。</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();
|
||||
// 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<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=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"));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user