feat(saa): U2 接缝 + U3 真产 gameDefinition + U4 多模型回退(gamedef 路 additive 灰度)

plan 2026-06-18-001 U2(后端接缝)/U3/U4 · 后端线 · 真结构化生成上产线(saaSourceMode=gamedef,默认 factory 字节不变)。

U3 真产 gameDefinition:
- SaaPrompts 新增 GAMEDEF_SYSTEM(与现 SYSTEM 物理隔离,公平性铁律)+ buildGameDefMessages;内容=runtime-api-2d.md
  约定提炼(schema/rt 面/命名约定/硬约束)+ 内联 scan-clean few-shot;硬约束由 build-from-source 静态扫描兜底。
- SaaStudioNodes.generateNode gamedef 分支:产结构化 gameDefinition(looseParse 脏 JSON 容错)落 K_SOURCE_PROJECT;
  factory 路(含 regenerate 消费 base 源)字节不变。

U2 后端接缝(gamedef→可玩,scaffold/build/play/harness/build.mjs 全零改):
- build-from-source.mjs 加 CLI(node build-from-source.mjs <src> <out> / --validate;被 import 不执行)。
- SaaGenNodes.validateNode(gameRuntimeRoot,sourceMode) gamedef 分支:经 build-from-source.mjs 子进程校验+装配
  sourceProject→factorySrc(下游 scaffold/build 零改消费);CLI 失败回喂 repair。
- 确定性点改解锁:modifyNode(sourceMode) gamedef 路真改源不 fail-loud;modifyRouter(sourceMode) gamedef
  deterministic→validate(装配 patched 源重验)/ factory→fail-loud;assetNode 幂等(已有 assets 跳过,不覆盖点改)。

U4 多模型回退链:generateNode 主模型(含 stage1/stage2)耗尽 180s×3 重试后,回退同 role 跨家档(M_CODE/FIX_FALLBACK
  =MiniMax-M3)再试;接现 callWithTimeoutAndRetry/looseParse/救场阶梯不另起限次。GenModels record 收敛入参。

线程:AigcExecutorProperties.saaSourceMode(默认 factory)→ SaaGraphDispatcher → SaaStudioGraph.build 9 参重载 →
  assemble → generate/validate/modify/modifyRouter;8/7/5 参 build 旧重载 delegate "factory" 不破现调用。
worker prompt.py 不动(gamedef 为 SAA 路专属、http worker 仍 iife,无 shared prompt 漂移=无 split-brain)。

验收(lili-mac 快走查):mvn test-compile 过(main+test);模型无关单测 SaaStudioGraphTopologyTest 30/30
(含新增 gamedef deterministic→validate 路由证)+ SaaStudioNodesRegressionTest 8/8,零失败;build-from-source CLI
  实证(--validate exit0/2、assemble 产正确 import 路径工厂)。**gamedef 全链过门率≥60% cutover 门需 NEWAPI_KEY+
  mini-desktop(authoritative,未在本机跑)**。默认 factory 路行为字节不变(灰度 opt-in)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-18 00:05:18 -07:00
parent 160d9bc3e0
commit fd5b245baa
9 changed files with 441 additions and 114 deletions

View File

@ -230,11 +230,23 @@ final class SaaGenNodes {
}
/**
* validate 节点(对齐 validate.py:validate):静态过了才跑 node --check(省时)。
* 写 state:validateOk(boolean) / validateErrors(List) / feedback(失败回喂文字)。
* validate 节点:按 sourceMode 二分(plan U3,additive)。
* <ul>
* <li><b>factory</b>(默认,字节不变,对齐 validate.py):静态契约扫描 {@code factorySrc} + node --check ESM 语法门;</li>
* <li><b>gamedef</b>(plan U2 接缝):经 {@code build-from-source.mjs} 子进程<b>校验 + 装配</b> {@code sourceProject} →
* 产 {@code factorySrc}(下游 scaffold/build <b>零改</b>消费);CLI 失败(校验拒/超时)→ feedback 回喂 repair。</li>
* </ul>
* 写 state:validateOk(boolean) / validateErrors(List) / feedback(失败) / factorySrc(gamedef 成功时=装配产物)。
*
* @param gameRuntimeRoot game-runtime 根(gamedef 路 build-from-source.mjs 子进程 cwd;factory 路不读)。
* @param sourceMode 生成产物形态(factory|gamedef,AigcExecutorProperties.saaSourceMode)。
*/
static NodeAction validateNode() {
static NodeAction validateNode(Path gameRuntimeRoot, String sourceMode) {
return (OverAllState s) -> {
if ("gamedef".equals(sourceMode)) {
return validateGameDef(s, gameRuntimeRoot);
}
// ── factory 路(现行 iife,字节不变):静态契约扫描 + node --check ──
String src = s.value("factorySrc", String.class).orElse("");
List<String> errs = staticCheck(src);
if (errs.isEmpty()) {
@ -245,23 +257,84 @@ final class SaaGenNodes {
errs.add("语法错误(node --check):" + trimmed);
}
}
Map<String, Object> out = new HashMap<>();
boolean ok = errs.isEmpty();
out.put("validateOk", ok);
out.put("validateErrors", errs);
if (!ok) {
StringBuilder fb = new StringBuilder("校验未过:\n");
for (String e : errs) {
fb.append("- ").append(e).append("\n");
}
out.put("feedback", fb.toString());
clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味(仅全图注册了该 key 时生效)。
bumpFailCount(s, out); // P0-1:validate 硬门失败也计 failCount(救场阶梯对 validate/build/play 任一门统一成立,§6.3)。
}
return out;
return validateOut(s, errs);
};
}
/**
* gamedef 路校验+装配(plan U2 接缝):sourceProject JSON 经 {@code build-from-source.mjs <src> <out>} 子进程
* 校验(schema/引用/静态扫描)+装配出 generated-factory 文本;成功落 {@code factorySrc}(下游 scaffold/build 零改),
* 失败把 CLI 错误(stderr,经 redirectErrorStream 并入 output)回喂 repair。harness/build.mjs 全程零改。
*/
private static Map<String, Object> validateGameDef(OverAllState s, Path gameRuntimeRoot) {
String sp = s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("");
List<String> errs = new ArrayList<>();
if (sp.isEmpty()) {
errs.add("gamedef 路 sourceProject 为空(generate 未产出结构化源)");
return validateOut(s, errs);
}
Path tmpSrc = null;
Path tmpOut = null;
try {
tmpSrc = Files.createTempFile("saa-gdsrc-", ".json");
tmpOut = Files.createTempFile("saa-gdfac-", ".js");
Files.write(tmpSrc, sp.getBytes(StandardCharsets.UTF_8));
// 经唯一构建脚本同机的 build-from-source.mjs 装配(cwd=game-runtime 根,使产物 import 路径与 scaffold 落点一致)。
ProcessBuilder pb = new ProcessBuilder("node", "src/host/build-from-source.mjs",
tmpSrc.toString(), tmpOut.toString());
pb.directory(gameRuntimeRoot.toFile());
ProcResult r = runProcess(pb, 30);
if (r.timedOut) {
errs.add("gamedef 装配超时(build-from-source.mjs 30s)");
} else if (r.exitCode != 0) {
String out = r.output == null ? "" : r.output;
errs.add("gamedef 校验/装配未过:\n" + (out.length() > 800 ? out.substring(0, 800) : out));
} else {
String factory = new String(Files.readAllBytes(tmpOut), StandardCharsets.UTF_8);
Map<String, Object> ok = new HashMap<>();
ok.put("validateOk", true);
ok.put("validateErrors", new ArrayList<String>());
ok.put("factorySrc", factory); // 装配产物 → 下游 scaffold/build 零改消费(gamedef 接缝核心)
return ok;
}
} catch (IOException | InterruptedException e) {
errs.add("gamedef 装配子进程异常:" + e.getMessage());
} finally {
deleteQuiet(tmpSrc);
deleteQuiet(tmpOut);
}
return validateOut(s, errs);
}
/** 统一 validate 输出(成功=validateOk:true;失败=feedback + 清 player 软门 + 计 failCount)。 */
private static Map<String, Object> validateOut(OverAllState s, List<String> errs) {
Map<String, Object> out = new HashMap<>();
boolean ok = errs.isEmpty();
out.put("validateOk", ok);
out.put("validateErrors", errs);
if (!ok) {
StringBuilder fb = new StringBuilder("校验未过:\n");
for (String e : errs) {
fb.append("- ").append(e).append("\n");
}
out.put("feedback", fb.toString());
clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味(仅全图注册了该 key 时生效)。
bumpFailCount(s, out); // P0-1:validate 硬门失败也计 failCount(救场阶梯对 validate/build/play 任一门统一成立,§6.3)。
}
return out;
}
/** 静默删临时文件(清理失败仅忽略)。 */
private static void deleteQuiet(Path p) {
if (p != null) {
try {
Files.deleteIfExists(p);
} catch (IOException ignore) {
// 临时文件清理失败可忽略。
}
}
}
// ====================== ② scaffold 节点 ======================
// 纯 Java 文件 IO(抄 run.py:33-42 语义)。

View File

@ -322,6 +322,7 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
compiledGraph = SaaStudioGraph.build(api, SaaStudioGraph.Models.stage1(), gameRuntimeRoot,
properties.getSaaMaxRepairs(), properties.getSaaMaxPlayerRounds(),
properties.getSaaStage2ExtraRepairs(),
properties.getSaaSourceMode(), // plan U3:factory(默认,iife) | gamedef(真结构化源)
saverConfig, observationRegistry);
log.info("[saa-dispatch] SAA 图构造完成(缓存复用):maxRepairs={}, stage2Extra={}, maxPlayerRounds={}, "
+ "newApiBase={}, checkpoint={}, observation={}",

View File

@ -138,6 +138,72 @@ final class SaaPrompts {
```
""";
// ============================================================================
// ①.1 GAMEDEF_SYSTEM —— plan 2026-06-18-001 U3:真结构化 gameDefinition 生成 SYSTEM。
// 与 ① SYSTEM(iife factory)<b>物理隔离·并存</b>(公平性铁律:不改现 SYSTEM 一字);
// 仅 saaSourceMode=gamedef 路用。内容 = game-runtime/src/host/runtime-api-2d.md 约定的提炼 + 内联 few-shot。
// 硬约束由 build-from-source.mjs 校验边界静态扫描兜底(违反→拒绝→repair),非靠模型自觉。
// ============================================================================
static final String GAMEDEF_SYSTEM = """
你是轻量 2D 小游戏的【结构化源生成器】。产出 = 一份声明式 gameDefinition(JSON)——游戏=可维护结构化源
(实体/组件/场景/规则 + 各带逻辑 JS 的 behaviors),平台据它构建可玩包(改源不改包)。
【输出格式·严格】只输出一个 JSON 对象(就是 gameDefinition 本身),不要 ```代码块标记、不要任何解释文字。顶层键:
{"entities":[...],"components":[...],"behaviors":[...],"scenes":[...],"rules":[...]}
【结构】
- entities[]: {"id","transform":{"position":{"x","y"}},"components":[组件id...]}(可加 "vx","vy","tags":[...] 及任意数值字段如 idx)
- components[]: {"id","kind",...} kind ∈ render/physics/collision/custom
· render: {"kind":"render","shape":"rect|circle|fill","color":"#rrggbb"}; rect 加 "w","h"; circle 加 "r"; fill 铺满作背景
· physics: {"kind":"physics","gravity":数}; 挂此组件的实体每帧自动 x+=vx*dt,y+=vy*dt(+重力)
- behaviors[]: {"id","trigger":"init|update|input|collision|timer","code":"<一段 JS 逻辑串>"}
code 编译为 function(rt,self,dt){...}: init 跑一次,其余每帧跑; self=本 behavior 持久态(跨帧存计时器/累加器)
- scenes[]: {"id","entityRefs":[实体id...]}(scenes[0] 决定初始实例化哪些实体)
- rules[]: {"id","condition":"<JS 布尔表达式,rt 在作用域>","outcome":"win|lose|score|advance"}
【behavior/condition 唯一能用的 rt 面】
- 实体: rt.getEntity(id) / rt.entities() / rt.query(tag) / rt.spawn({x,y,vx,vy,tags,components}) / rt.destroy(e); 实体含 .x.y.vx.vy.alive.tags + 可直接读写属性(e.hp=3)
- 输入: rt.input.isDown(key) / justPressed(key) / justTapped() / pointer{x,y,down}
- 时间随机(确定性): rt.time.now()(秒,从0) / rt.dt / rt.random() / rt.randRange(a,b) / rt.randInt(a,b)
- 分数胜负: rt.score / rt.addScore(n=1) / rt.setScore(n) / rt.win() / rt.lose()(置不可逆终态)
- 工具特效: rt.clamp(v,lo,hi) / rt.dist(ax,ay,bx,by) / rt.overlap(a,b)(AABB,{x,y,w,h}) / rt.fx.burst(x,y,color) / rt.fx.beep(kind) / rt.view{w:390,h:844}
【硬约束(违反=校验拒绝→打回重做,非靠自觉)】
1. 确定性: 随机一律 rt.random/randRange/randInt, 时间一律 rt.time.now()/dt。禁 Math.random、Date、performance(Math.sin/abs/floor/PI 等允许)。
2. 只用 rt: 禁 process/require/eval/.constructor/Function/document/window/fetch/addEventListener/import。要生成实体用 rt.spawn(别自造约定外 helper)。
3. 终态: 胜负用 rt.win()/rt.lose()(不可逆), 别用分数/flag 表达结束; 游戏必须"会输"(超时/碰撞/耗尽任一)。
4. 特效: 碰撞/得分等关键事件调 rt.fx.burst/beep(真接引擎=满真接线门)。
5. condition 是无副作用布尔表达式: 禁 ; / 赋值 / 调 win/lose/addScore/spawn/destroy。
6. 死循环: 禁 while(true)/for(;;)。
【可玩 + 被九门驱动(关键命名约定,务必对齐)】
- 实体须有 render 组件才可见; 放一个 shape:"fill" 实体铺满 390x844 作背景(否则判白屏)。
- 九门据【命名路径】驱动你的游戏:
· 挡板接球类: 球实体 id 命名 "ball"、挡板命名 "paddle"(九门读 ball.x/paddle.x 真玩对齐)。
· 离散点击类(井字棋/打地鼠/翻牌/扫雷): 每个可点目标实体打 tags:["target"], 设 idx(序号)/occupied(是否已占)(九门据 targets[] 逐个点; 不打 target 标→点不到→机制门必挂)。
- HUD/进展可见: 分数/剩余看得到; 前 30 秒能上手、有正反馈、看得到分数增长。
【参照(严格按此结构产 JSON, 但实现成题目要求的那款游戏; 这是个躲避类示例)】
{
"components":[
{"id":"bg","kind":"render","shape":"fill","color":"#101028"},
{"id":"rp","kind":"render","shape":"rect","color":"#33dd66","w":34,"h":34},
{"id":"re","kind":"render","shape":"circle","color":"#ff4444","r":14}
],
"entities":[
{"id":"world","transform":{"position":{"x":0,"y":0}},"components":["bg"]},
{"id":"player","transform":{"position":{"x":195,"y":720}},"components":["rp"]}
],
"behaviors":[
{"id":"control","trigger":"update","code":"const p=rt.getEntity('player'); if(!p) return; if(rt.input.isDown('ArrowLeft')) p.x-=260*dt; if(rt.input.isDown('ArrowRight')) p.x+=260*dt; if(rt.input.pointer.down) p.x=rt.input.pointer.x; p.x=rt.clamp(p.x,20,370);"},
{"id":"spawn","trigger":"update","code":"self.t=(self.t||0)+dt; self.s=(self.s||0)+dt; if(self.t>=0.4){ self.t=0; rt.spawn({x:rt.randRange(20,370),y:-20,vy:rt.randRange(220,360),tags:['enemy'],components:[{kind:'render',shape:'circle',color:'#ff4444',r:14}]}); } if(self.s>=1){ self.s-=1; rt.addScore(1); }"},
{"id":"enemies","trigger":"update","code":"const p=rt.getEntity('player'); for(const e of rt.query('enemy')){ e.y+=e.vy*dt; if(e.y>880){ rt.destroy(e); } else if(p && rt.overlap({x:e.x,y:e.y,w:26,h:26},{x:p.x,y:p.y,w:34,h:34})){ rt.fx.burst(e.x,e.y,'#ff0000'); rt.fx.beep('lose'); rt.lose(); } }"}
],
"scenes":[{"id":"main","entityRefs":["world","player"]}],
"rules":[{"id":"survive","condition":"rt.score >= 20","outcome":"win"}]
}
""";
// ============================================================================
// ② DESIGN_SYSTEM —— 逐字移植 roles.py:8-32(设计 agent + 末尾自产 gatespec)。
// ============================================================================
@ -259,6 +325,28 @@ final class SaaPrompts {
return new String[]{system, user};
}
/**
* 组装 gamedef 路 (system, user)(plan U3,saaSourceMode=gamedef)。与 {@link #buildMessages}(iife) 物理隔离:
* system=GAMEDEF_SYSTEM(含 schema/rt 面/命名约定/内联 few-shot),user=题面 + 品类提示 + 回喂上轮失败原因(全量重出)。
*
* @param briefText 题面(含设计稿 enriched)。
* @param retryFeedback 重试回喂(null/空=首轮)。
* @param archetype 品类原型(K_ARCHETYPE,generic 不附提示)。
* @return [0]=system(GAMEDEF_SYSTEM),[1]=user。
*/
static String[] buildGameDefMessages(String briefText, String retryFeedback, String archetype) {
StringBuilder user = new StringBuilder("请为下面这款游戏产出一份 gameDefinition(只输出一个 JSON 对象,不要 ```代码块、不要解释):\n\n");
user.append(briefText == null ? "" : briefText);
if (archetype != null && !archetype.isEmpty() && !"generic".equals(archetype)) {
user.append("\n\n(品类提示:").append(archetype).append(")");
}
if (retryFeedback != null && !retryFeedback.isEmpty()) {
user.append("\n\n———\n上一次生成【未通过】,失败原因如下,请针对性修正后重新产出完整 gameDefinition JSON(不要只给片段):\n")
.append(retryFeedback);
}
return new String[]{GAMEDEF_SYSTEM, user.toString()};
}
/** base 源注入上限(防超长 prompt 撑爆上下文/计费;便宜模型上下文窗口有限)。超出则截断并标注。 */
private static final int SOURCE_PROJECT_MAX_CHARS = 12000;

View File

@ -83,6 +83,13 @@ public final class SaaStudioGraph {
/** stage2 fix 角色模型名(强档;models.yaml stage2.fix)。 */
public static final String M_FIX_STAGE2 = "deepseek-v4-pro";
// ===== U4 可靠性回退档模型名(plan 2026-06-18-001 U4)=====
// 主模型(含 stage1/stage2)耗尽 180s×3 重试后再试一档,跨家提高可用性(治 MiniMax 超时/M2.5 返空/deepseek JSON 截断)。
/** code 角色回退模型名(U4;跨家,提高可用性)。 */
public static final String M_CODE_FALLBACK = "MiniMax-M3";
/** fix 角色回退模型名(U4)。 */
public static final String M_FIX_FALLBACK = "MiniMax-M3";
/** 图名(可观测/可视化标识)。 */
public static final String GRAPH_NAME = "saa-studio-full";
@ -108,11 +115,12 @@ public final class SaaStudioGraph {
* @param fixStage2 救场升档后 fix 角色模型名(P1-5;强档)
*/
public record Models(String design, String code, String fix, String playerText, String playerVision,
String classify, String narrative, String codeStage2, String fixStage2) {
/** models.yaml stage1 默认路由(+ 固定架构 classify/narrative 角色 + 救场 stage2 强档 code/fix)。 */
String classify, String narrative, String codeStage2, String fixStage2,
String codeFallback, String fixFallback) {
/** models.yaml stage1 默认路由(+ 固定架构 classify/narrative + 救场 stage2 强档 + U4 回退档 code/fix)。 */
public static Models stage1() {
return new Models(M_DESIGN, M_CODE, M_FIX, M_PLAYER_TEXT, M_PLAYER_VISION, M_CLASSIFY, M_NARRATIVE,
M_CODE_STAGE2, M_FIX_STAGE2);
M_CODE_STAGE2, M_FIX_STAGE2, M_CODE_FALLBACK, M_FIX_FALLBACK);
}
}
@ -203,7 +211,8 @@ public final class SaaStudioGraph {
* @throws GraphStateException 图结构非法(节点/边布线错误,编译期暴露)
*/
private static StateGraph assemble(OpenAiApi api, Models models, Path gameRuntimeRoot,
int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs)
int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs,
String sourceMode)
throws GraphStateException {
// == per-role 采样面(temperature/maxTokens)逐字对齐 Python ==
// design:Python config.build_model 不传 temperature → 服务端默认 → temperature=null(不下发);
@ -216,6 +225,9 @@ public final class SaaStudioGraph {
// P1-5:救场 stage2 强档 code/fix(同 16000/temperature=0,仅模型名换强档);generate 据 modelTier 真切,使升档生效。
OpenAiChatModel codeStage2Model = model(api, models.codeStage2(), 0.0, 16000);
OpenAiChatModel fixStage2Model = model(api, models.fixStage2(), 0.0, 16000);
// U4 回退档(同采样面 0.0/16000,仅模型名换跨家;generateNode 主耗尽重试后再试一档)。
OpenAiChatModel codeFallbackModel = model(api, models.codeFallback(), 0.0, 16000);
OpenAiChatModel fixFallbackModel = model(api, models.fixFallback(), 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(叙事审查,温度低)。
@ -236,17 +248,20 @@ public final class SaaStudioGraph {
.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,
codeStage2Model, fixStage2Model, models.code(), models.fix(),
models.codeStage2(), models.fixStage2(), gameRuntimeRoot)))
.addNode("validate", node_async(SaaGenNodes.validateNode()))
.addNode("generate", node_async(SaaStudioNodes.generateNode(
new SaaStudioNodes.GenModels(codeModel, fixModel, codeStage2Model, fixStage2Model,
codeFallbackModel, fixFallbackModel,
models.code(), models.fix(), models.codeStage2(), models.fixStage2(),
models.codeFallback(), models.fixFallback()),
gameRuntimeRoot, sourceMode)))
.addNode("validate", node_async(SaaGenNodes.validateNode(gameRuntimeRoot, sourceMode)))
.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("modify", node_async(SaaStudioNodes.modifyNode(sourceMode)))
.addNode("escalate", node_async(SaaStudioNodes.escalateNode()))
.addNode("repair", node_async(SaaStudioNodes.repairNode()))
.addNode("emit", node_async(SaaStudioNodes.emitNode(gameRuntimeRoot)))
@ -295,9 +310,10 @@ public final class SaaStudioGraph {
// modify:regenerate-module→generate / deterministic→END(fail)(§6.2 + P0-4 guard)。
// P0-4:deterministic 路本期 guard fail-loud(modify 节点已置 failed 终态,直达 END),
// 绝不走会忽略 sourceProject 的旧 build;TODO(B7/engine-E2) 接入从 sourceProject 真构建后改回 "build"→build。
// modify:失败终态→END(fail) / regenerate-module→generate / deterministic(gamedef)→validate(装配 patched 源重验) / deterministic(factory)→fail。
.addConditionalEdges("modify",
edge_async(SaaStudioNodes.modifyRouter()),
Map.of("generate", "generate", "fail", END));
edge_async(SaaStudioNodes.modifyRouter(sourceMode)),
Map.of("generate", "generate", "validate", "validate", "fail", END));
}
/**
@ -383,7 +399,24 @@ public final class SaaStudioGraph {
int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs,
SaverConfig saverConfig, ObservationRegistry observationRegistry)
throws GraphStateException {
StateGraph graph = assemble(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, stage2ExtraRepairs);
// 默认 factory 形态(现行行为字节不变);gamedef 路走下方 9 参重载(dispatcher 据 saaSourceMode 选)。
return build(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, stage2ExtraRepairs,
"factory", saverConfig, observationRegistry);
}
/**
* 组装并编译全图(plan 2026-06-18-001 U3 全量重载:显式 {@code sourceMode}=factory|gamedef)。
* 布线唯一源 = {@link #assemble}(所有 build 重载共用)。
*
* @param sourceMode 生成产物形态(factory=iife 现行 / gamedef=真结构化源;dispatcher 据 AigcExecutorProperties.saaSourceMode 传)。
*/
public static CompiledGraph build(OpenAiApi api, Models models, Path gameRuntimeRoot,
int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs,
String sourceMode,
SaverConfig saverConfig, ObservationRegistry observationRegistry)
throws GraphStateException {
StateGraph graph = assemble(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds,
stage2ExtraRepairs, sourceMode);
CompileConfig.Builder cc = CompileConfig.builder()
.recursionLimit(recursionLimitFor(maxRepairs, stage2ExtraRepairs));

View File

@ -431,71 +431,112 @@ final class SaaStudioNodes {
* @param fixStage2Name fix stage2 模型名(per-model 拆账)。
* @param gameRuntimeRoot game-runtime 根(few-shot 探针)。
*/
static NodeAction generateNode(ChatModel codeModel, ChatModel fixModel,
ChatModel codeStage2Model, ChatModel fixStage2Model,
String codeModelName, String fixModelName,
String codeStage2Name, String fixStage2Name, Path gameRuntimeRoot) {
/**
* generate 节点的模型束(收敛入参,plan U4 加回退档):(role × tier) 选主模型 + 同 role 回退模型。
* stage1/stage2 = P1-5 救场升档档;fallback = U4 可靠性回退档(主模型耗尽重试后再试,治超时/返空/JSON 截断)。
*/
record GenModels(ChatModel code, ChatModel fix, ChatModel codeStage2, ChatModel fixStage2,
ChatModel codeFallback, ChatModel fixFallback,
String codeName, String fixName, String codeStage2Name, String fixStage2Name,
String codeFallbackName, String fixFallbackName) {
}
static NodeAction generateNode(GenModels gm, Path gameRuntimeRoot, String sourceMode) {
return (OverAllState s) -> {
String role = s.value(K_ROLE, "code");
boolean isFix = "fix".equals(role);
// P1-5:modelTier=stage2(escalate 升档后)→ 选 stage2 强档模型;否则用 stage1。模型对象与模型名选择同口径。
boolean isStage2 = "stage2".equals(s.value(K_MODEL_TIER, String.class).orElse("stage1"));
ChatModel model = isStage2
? (isFix ? fixStage2Model : codeStage2Model)
: (isFix ? fixModel : codeModel);
String mname = selectGenerateModelName(role, isStage2 ? "stage2" : "stage1",
codeModelName, fixModelName, codeStage2Name, fixStage2Name);
ChatModel primary = isStage2
? (isFix ? gm.fixStage2() : gm.codeStage2())
: (isFix ? gm.fix() : gm.code());
String pname = selectGenerateModelName(role, isStage2 ? "stage2" : "stage1",
gm.codeName(), gm.fixName(), gm.codeStage2Name(), gm.fixStage2Name());
// U4 回退档(同 role;主模型耗尽 180s×3 重试后再试一档,治超时/返空/截断;接现救场体系不另起限次)。
ChatModel fallback = isFix ? gm.fixFallback() : gm.codeFallback();
String fname = isFix ? gm.fixFallbackName() : gm.codeFallbackName();
int attemptNo = ((Number) s.value(K_REPAIR_COUNT, 0)).intValue(); // 当前修复轮序(首轮=0)。
String enriched = s.value(K_ENRICHED, String.class)
.orElse(s.value(K_BRIEF, String.class).orElse(""));
String feedback = s.value(K_FEEDBACK, String.class).orElse(null);
boolean gamedef = "gamedef".equals(sourceMode);
// ── P0-2(regenerate-module 路真消费 base 源):仅当本轮是 regenerate-module 路(modifyMode=regenerate-module
// ∧ role=fix〔modify 节点置〕)∧ base 源非空时,把 base 源 + 目标 behavior + intent 注入 prompt
// (informed-by-base 重生成);否则(create / 普通 repair 失败回边)走原 buildMessages,prompt 字节零变。
// target/intent 从 K_MODIFY_PATCH 复用 resolveBehaviorTarget 再取(单一事实源,与 modify 节点同口径;
// modify 节点已经 P0-1 守卫保证 regenerate 路 target 非空)。
// ── 组 messages:gamedef 路用 GAMEDEF_SYSTEM(真结构化源);factory 路保现行(regenerate 消费 base 源 / 普通)。
String modifyMode = s.value(K_MODIFY_MODE, String.class).orElse("");
String sourceProject = s.value(K_SOURCE_PROJECT, String.class).orElse("");
boolean regeneratePath = isFix && "regenerate-module".equals(modifyMode) && !sourceProject.isEmpty();
String[] msgs;
if (regeneratePath) {
JsonNode patch = SaaPrompts.looseParse(s.value(K_MODIFY_PATCH, String.class).orElse(""));
String target = resolveBehaviorTarget(patch);
String intent = patch != null ? patch.path("payload").path("intent").asText("") : "";
msgs = SaaPrompts.buildRegenerateMessages(enriched, feedback, gameRuntimeRoot,
sourceProject, target, intent);
if (gamedef) {
// gamedef:题面 + 品类提示 + 回喂;regenerate-module 在 gamedef 路退化为带反馈整源重生成(确定性点改走 modify 节点)。
String archetype = s.value(K_ARCHETYPE, String.class).orElse("generic");
msgs = SaaPrompts.buildGameDefMessages(enriched, feedback, archetype);
} else {
msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot);
// P0-2(factory regenerate-module 路真消费 base 源·informed-by-base 重生成);否则普通 buildMessages,prompt 字节零变。
boolean regeneratePath = isFix && "regenerate-module".equals(modifyMode) && !sourceProject.isEmpty();
if (regeneratePath) {
JsonNode patch = SaaPrompts.looseParse(s.value(K_MODIFY_PATCH, String.class).orElse(""));
String target = resolveBehaviorTarget(patch);
String intent = patch != null ? patch.path("payload").path("intent").asText("") : "";
msgs = SaaPrompts.buildRegenerateMessages(enriched, feedback, gameRuntimeRoot,
sourceProject, target, intent);
} else {
msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot);
}
}
Map<String, Object> out = new HashMap<>();
// A-1:generate(含 repair 轮,role=fix 时)LLM 调用套 180s 超时 + ≤3 重试;
// 超限不裸退图——把错因写 feedback + factorySrc 留空(validate 判空过短 → 路由 repair/giveup,对齐 run.py:103-105)。
Object[] r;
try {
r = callAndRecord(model, msgs[0], msgs[1]);
} catch (Exception e) {
out.put(K_FACTORY_SRC, "");
out.put(K_FEEDBACK, "模型调用失败(已重试):" + trunc(e.getMessage(), 300));
// B-8:LLM 失败也记一轮(stage_fail=generate,usage 0)。
// ── U4:主→回退多模型链(每档内仍 180s×3 重试)。任一档成功即用;全失败不裸退图(写 feedback,下游路由 repair/giveup)。
ChatModel[] chain = (fallback != null && fallback != primary)
? new ChatModel[]{primary, fallback} : new ChatModel[]{primary};
String[] chainNames = (fallback != null && fallback != primary)
? new String[]{pname, fname} : new String[]{pname};
Object[] r = null;
String usedModel = pname;
Exception lastErr = null;
for (int i = 0; i < chain.length; i++) {
try {
r = callAndRecord(chain[i], msgs[0], msgs[1]);
usedModel = chainNames[i];
lastErr = null;
break;
} catch (Exception e) {
lastErr = e; // 本档耗尽重试 → 试下一回退档(最后一档仍失败落循环外)。
}
}
if (r == null) {
String key = gamedef ? K_SOURCE_PROJECT : K_FACTORY_SRC;
out.put(key, "");
out.put(K_FEEDBACK, "模型调用失败(主+回退档均已重试):" + trunc(lastErr == null ? "" : lastErr.getMessage(), 300));
appendAttempt(s, out, MAPPER.createObjectNode()
.put("attempt", attemptNo).put("role", role).put("model", mname)
.put("attempt", attemptNo).put("role", role).put("model", pname)
.put("stage_fail", "generate")
.set("usage", MAPPER.createObjectNode().put("in", 0).put("out", 0)));
return out;
}
String src = extractJsBlock((String) r[0]);
out.put(K_FACTORY_SRC, src);
// 抽不到 ```js → 写 feedback(对齐 studio.py:242-245 的 extract 失败桶),validate 会判空过短再兜。
if (src.isEmpty()) {
out.put(K_FEEDBACK, "未能从输出中提取 ```js 代码块。请只输出一个完整的 ```js 代码块,块内是完整工厂源码。");
// ── 抽产物:gamedef → 结构化 gameDefinition(looseParse 脏 JSON 容错)落 K_SOURCE_PROJECT;factory → ```js 落 K_FACTORY_SRC。
String stageFail = null;
if (gamedef) {
JsonNode gd = SaaPrompts.looseParse((String) r[0]);
if (gd == null || !gd.isObject()) {
out.put(K_SOURCE_PROJECT, "");
out.put(K_FEEDBACK, "未能从输出解析出 gameDefinition JSON。请只输出一个 JSON 对象(顶层含 entities/components/behaviors/scenes/rules)。");
stageFail = "extract";
} else {
out.put(K_SOURCE_PROJECT, gd.toString()); // validate(gamedef) 经 build-from-source 校验+strip+装配
}
} else {
String src = extractJsBlock((String) r[0]);
out.put(K_FACTORY_SRC, src);
if (src.isEmpty()) {
out.put(K_FEEDBACK, "未能从输出中提取 ```js 代码块。请只输出一个完整的 ```js 代码块,块内是完整工厂源码。");
stageFail = "extract";
}
}
long in = (Long) r[1], outTok = (Long) r[2];
accumTokensByModel(s, out, mname, in, outTok); // B-8:per-model 拆账(含总量累加)。
// B-8:记本生成轮(attempt 序号/角色/模型/usage;后续 validate/build/play 失败由各节点补 stage_fail 到尾记录)。
accumTokensByModel(s, out, usedModel, in, outTok); // B-8:per-model 拆账(记真实所用档,含回退)。
appendAttempt(s, out, MAPPER.createObjectNode()
.put("attempt", attemptNo).put("role", role).put("model", mname)
.put("stage_fail", src.isEmpty() ? "extract" : (String) null)
.put("attempt", attemptNo).put("role", role).put("model", usedModel)
.put("stage_fail", stageFail)
.set("usage", MAPPER.createObjectNode().put("in", in).put("out", outTok)));
return out;
};
@ -970,6 +1011,13 @@ final class SaaStudioNodes {
String spStr = s.value(K_SOURCE_PROJECT, String.class).orElse("");
Map<String, Object> out = new HashMap<>();
// 幂等(plan U2):sourceProject 已有非空 assets[](如 deterministic modify 复用既有源、repair 回环重入)→ 跳过,
// 不覆盖既有资产(杜绝确定性点改的 assets 改动被本节点重产覆盖)。
JsonNode pre = SaaPrompts.looseParse(spStr);
if (pre != null && pre.isObject() && pre.path("assets").isArray() && pre.path("assets").size() > 0) {
return out; // passthrough(已有资产,不重产)
}
// ① 产六类资产规格(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"}) {
@ -1019,7 +1067,7 @@ final class SaaStudioNodes {
* <p><b>regenerate-module 路本期可达</b>:它走 generate(LLM 在 sourceProject 基础上重出 behavior 模块),
* 不依赖「从 sourceProject 构建」管线,故不受 P0-4 限制(B7 完整应用时按 target 精确定位模块为增强)。
*/
static NodeAction modifyNode() {
static NodeAction modifyNode(String sourceMode) {
return (OverAllState s) -> {
String mode = s.value(K_MODIFY_MODE, String.class).orElse("");
String patchStr = s.value(K_MODIFY_PATCH, String.class).orElse("");
@ -1093,14 +1141,15 @@ final class SaaStudioNodes {
applyDeterministicPatch(sp, patch.path("target"), patch.path("payload").path("value"));
}
out.put(K_SOURCE_PROJECT, sp.toString());
out.put(K_MODIFY_MODE, "deterministic"); // 固化路由信号(modifyRouter 据此走 fail-loud 终态)。
// P0-4 guard fail-loud:从 sourceProject 真构建的管线(B7+引擎线 E2)本期未实现 → 显式置失败终态,
// 绝不静默走会忽略 sourceProject 的旧 build(旧 build 从 factorySrc/gameDir 构建,确定性编辑改的 sourceProject 不会生效)。
// TODO(B7/engine-E2):补「从 sourceProject 真构建」管线后,本路改为真路由 build(删除以下两行 + modifyRouter 改回 deterministic→build)。
out.put(K_STATUS, "failed");
out.put(K_FAILURE, "modify 源构建管线待 B7+引擎线 E2(从 sourceProject 构建)实现:"
+ "deterministic 编辑已真改源项目,但当前 build 节点仅从 factorySrc/gameDir 构建、不消费 sourceProject,"
+ "为避免静默产出忽略编辑的错误产物,本路显式失败(非空壳:sourceProject 已真改、待下游构建管线接入)");
out.put(K_MODIFY_MODE, "deterministic"); // 固化路由信号(modifyRouter 据 sourceMode 分流)。
// plan U2 接缝:gamedef 路确定性点改已解锁——build-from-source(U2) 可从 sourceProject 真构建,
// 故 gamedef 路<b>真改源后不置失败</b>,由 modifyRouter→validate 装配 patched 源→build/play 重验。
// factory 路仍 fail-loud:源即 iife factorySrc、无结构化源可从之确定性构建,避免静默产出忽略编辑的错误产物。
if (!"gamedef".equals(sourceMode)) {
out.put(K_STATUS, "failed");
out.put(K_FAILURE, "factory 形态无结构化源可确定性点改(源即 iife):deterministic 编辑无处生效,"
+ "本路显式失败(saaSourceMode=gamedef 才支持确定性点改:改 gameDefinition 一处→重装配重验)");
}
return out;
};
}
@ -1419,17 +1468,27 @@ final class SaaStudioNodes {
}
/**
* modify 路由(execution §6.2):modify 节点后据 {@link #K_MODIFY_MODE} 分流——
* modify 路由(execution §6.2 + plan U2 接缝):modify 节点后分流——
* <ul>
* <li>regenerate-module → generate(LLM 在 sourceProject 基础上重出 behavior 模块,本期可达);</li>
* <li>deterministic → {@code "fail"}(P0-4 guard fail-loud:modify 节点已置失败终态,路由直达 END,
* 绝不走会忽略 sourceProject 的旧 build;TODO(B7/engine-E2) 接入「从 sourceProject 真构建」后改回 deterministic→build)。</li>
* <li>{@link #K_STATUS}=failed(regenerate 守卫失败 / factory 确定性 fail-loud)→ {@code "fail"}(直达 END);</li>
* <li>regenerate-module(未失败)→ generate(LLM 在 sourceProject 基础上重出 behavior 模块);</li>
* <li>deterministic(未失败)→ <b>gamedef 路</b> {@code "validate"}(U2 接缝:真改源 → validate 经 build-from-source
* 装配 patched 源 → scaffold/build/play 重验,解锁确定性点改);<b>factory 路</b> {@code "fail"}(无结构化源可装配)。</li>
* </ul>
*
* @param sourceMode 生成产物形态(factory|gamedef):决定 deterministic 路是否解锁(gamedef→validate / factory→fail)。
*/
static EdgeAction modifyRouter() {
static EdgeAction modifyRouter(String sourceMode) {
return (OverAllState s) -> {
if ("failed".equals(s.value(K_STATUS, String.class).orElse(""))) {
return "fail"; // 失败终态(regenerate 守卫失败 / factory 确定性 fail-loud)→ END
}
String mode = s.value(K_MODIFY_MODE, String.class).orElse("deterministic");
return "regenerate-module".equals(mode) ? "generate" : "fail";
if ("regenerate-module".equals(mode)) {
return "generate";
}
// deterministic(未失败):gamedef 真改源 → validate 装配重验;factory 无结构化源 → fail-loud。
return "gamedef".equals(sourceMode) ? "validate" : "fail";
};
}

View File

@ -189,6 +189,14 @@ public class AigcExecutorProperties {
*/
private Boolean saaCheckpointEnabled = true;
/**
* SAA 生成产物形态(plan 2026-06-18-001 U3,additive 灰度):{@code factory}=现行 iife factorySrc(generate 产 ```js 工厂、
* validate 走 12 禁词静态门,<b>默认·行为字节不变</b>);{@code gamedef}=真结构化 gameDefinition(generate 产声明式源落
* {@code sourceProject}、validate 经 build-from-source.mjs 校验+装配出工厂——「改源不改包/可维护结构化源」范式上产线)。
* <b>cutover 量化门 = gamedef 路 SaaFullGraphE2eTest 过门率 ≥ 60%(iife 现基线)</b>;达标才切默认。仅 dispatcher=saa 生效。
*/
private String saaSourceMode = "factory";
/**
* 阈值关系铁律校验(HJ-AGENT-LOOP-EXEC-002 §5.2 精确式;执行器装配时调用,误配置 fail-fast)
*

View File

@ -178,18 +178,36 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "deterministic",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
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(), "未指定字段保持不变(只改一处)");
// P0-4:deterministic 路本期 guard fail-loud——sourceProject 真改后路由 "fail"(→END),不走会忽略 sourceProject 的旧 build。
assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s),
assertEquals("fail", SaaStudioNodes.modifyRouter("factory").apply(s),
"P0-4:deterministic 路由 fail(→END),TODO(B7/engine-E2) 接入从 sourceProject 真构建后改回 build");
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""),
"P0-4:deterministic 显式失败终态(不静默产出忽略编辑的错产物)");
}
@Test
void modify_deterministic_gamedef_unlocks_validate_route() throws Exception {
// plan U2 接缝:gamedef 路确定性点改解锁——真改源 + 路由 validate(装配 patched 源重验),不再 fail-loud。
String base = "{\"schemaVersion\":\"1.0\",\"config\":{\"speed\":5}}";
String patch = "{\"mode\":\"deterministic\",\"target\":{\"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("gamedef"));
JsonNode sp = MAPPER.readTree(s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("{}"));
assertEquals(9, sp.path("config").path("speed").asInt(), "gamedef 确定性改也真改源一处");
assertTrue(!"failed".equals(s.value(SaaStudioNodes.K_STATUS, String.class).orElse("")),
"gamedef 路确定性改不再 fail-loud(已解锁)");
assertEquals("validate", SaaStudioNodes.modifyRouter("gamedef").apply(s),
"U2 接缝:gamedef deterministic → validate(装配 patched 源 → build/play 重验)");
}
@Test
void modify_deterministic_top_level_segment_overwrite() throws Exception {
// 顶层整段覆写 /config。
@ -200,7 +218,7 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "deterministic",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
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());
@ -214,12 +232,12 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, "{\"schemaVersion\":\"1.0\"}"));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
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));
assertEquals("generate", SaaStudioNodes.modifyRouter("factory").apply(s));
}
// ====================== ⑦ escalate 节点:升档 stage1→stage2 + 记事件 ======================
@ -341,7 +359,7 @@ class SaaStudioGraphTopologyTest {
OverAllState s = newFullState(Map.of(
"factorySrc", "", // 空源码 → staticCheck「为空或过短」失败(纯内存,无子进程)
SaaStudioNodes.K_FAIL_COUNT, 0));
applyNode(s, SaaGenNodes.validateNode());
applyNode(s, SaaGenNodes.validateNode(null, "factory"));
assertFalse(s.value(SaaStudioNodes.K_VALIDATE_OK, Boolean.class).orElse(true), "空源码 validate 应失败");
assertEquals(1, ((Number) s.value(SaaStudioNodes.K_FAIL_COUNT, -1)).intValue(),
"P0-1:validate 硬门失败必须 failCount++(旧实现漏此,阶梯永不触发)");
@ -417,18 +435,18 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "deterministic",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
// ① sourceProject 已真改(非空壳:编辑生效、不丢)。
JsonNode sp = MAPPER.readTree(s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("{}"));
assertEquals(9, sp.path("config").path("speed").asInt(), "deterministic 应真改 sourceProject(不丢编辑)");
// ② 显式失败终态(fail-loud),failureReason 指向 B7/引擎线 E2 待实现。
// ② 显式失败终态(fail-loud),failureReason 指明 factory 形态不支持确定性点改、须用 gamedef(U2 已解锁 gamedef 路)。
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""),
"P0-4:deterministic 路必须显式失败,绝不静默走会忽略 sourceProject 的旧 build");
assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("B7"),
"failureReason 应明确指向 B7+引擎线 E2 源构建管线待实现");
"factory 路 deterministic 必须显式失败,绝不静默走会忽略 sourceProject 的旧 build");
assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("gamedef"),
"failureReason 应指明 factory 形态无结构化源可确定性点改、须用 saaSourceMode=gamedef(U2 已解锁)");
// ③ 路由:modifyRouter → "fail"(图里 → END,不走 build)。
assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s),
assertEquals("fail", SaaStudioNodes.modifyRouter("factory").apply(s),
"deterministic 路由应为 fail(→END),不走 build");
// 复核:regenerate-module 路不受 P0-4 deterministic guard 影响,仍 → generate(本期可达)。
// 注(U6/B7):regenerate-module 现要求 base 源非空(取 base 源失败 fail-loud,非静默全量重生成),故此处补 K_SOURCE_PROJECT
@ -439,8 +457,8 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
SaaStudioNodes.K_MODIFY_PATCH, "{\"mode\":\"regenerate-module\",\"target\":{\"id\":\"jump\"},\"payload\":{\"intent\":\"x\"}}",
SaaStudioNodes.K_SOURCE_PROJECT, "{\"schemaVersion\":\"1.0\"}"));
applyNode(s2, SaaStudioNodes.modifyNode());
assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s2));
applyNode(s2, SaaStudioNodes.modifyNode("factory"));
assertEquals("generate", SaaStudioNodes.modifyRouter("factory").apply(s2));
}
/**
@ -457,7 +475,7 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "deterministic",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
JsonNode sp = MAPPER.readTree(s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("{}"));
assertTrue(sp.path("assets").isArray() && sp.path("assets").size() == 2,
"数组长度不变(P1-6:下标不再被当对象键误写、不损坏数组)");
@ -471,7 +489,7 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "deterministic",
SaaStudioNodes.K_MODIFY_PATCH, patchAppend,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s2, SaaStudioNodes.modifyNode());
applyNode(s2, SaaStudioNodes.modifyNode("factory"));
JsonNode sp2 = MAPPER.readTree(s2.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("{}"));
assertEquals(3, sp2.path("assets").size(), "/assets/- 应末尾追加一元");
assertEquals("a2", sp2.path("assets").get(2).path("id").asText());
@ -596,7 +614,7 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""), "regenerate 置 role=fix");
String fb = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("");
@ -607,7 +625,7 @@ class SaaStudioGraphTopologyTest {
assertTrue(s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("").contains("shoot"),
"base 源应透传保留(generate 在其基础上重出 jump)");
// 路由 → generate(本期可达)。
assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s));
assertEquals("generate", SaaStudioNodes.modifyRouter("factory").apply(s));
}
/**
@ -621,13 +639,13 @@ class SaaStudioGraphTopologyTest {
OverAllState s = newFullState(Map.of(
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
SaaStudioNodes.K_MODIFY_PATCH, patch)); // 不给 K_SOURCE_PROJECT = base 源缺失
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""),
"base 源缺失应 fail-loud(非静默全量重生成)");
assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("base 源"),
"failureReason 应明确指向 base 源缺失");
assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s), "base 缺 → 路由 fail(→END)");
assertEquals("fail", SaaStudioNodes.modifyRouter("factory").apply(s), "base 缺 → 路由 fail(→END)");
}
/**
@ -643,13 +661,13 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""),
"target 指向不存在 behavior 应 fail-loud");
assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("不存在"),
"failureReason 应明确 target behavior 不存在");
assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s));
assertEquals("fail", SaaStudioNodes.modifyRouter("factory").apply(s));
}
/**
@ -666,10 +684,10 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
// 不误杀:role=fix、路由 generate(target 存在性留 generate 处置)。
assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""));
assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s),
assertEquals("generate", SaaStudioNodes.modifyRouter("factory").apply(s),
"无 behaviors 容器(早期源骨架)不误杀,路由 generate");
}
@ -716,7 +734,7 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""),
"缺 target 必须 fail-loud(绝不静默全量重生成)");
@ -724,7 +742,7 @@ class SaaStudioGraphTopologyTest {
assertTrue(reason.contains("缺 target") || reason.contains("target"),
"failureReason 应说明 regenerate 缺 target,实得=" + reason);
// 未进入收窄路:role 不应被置 fix(不进入 generate)。
assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s), "缺 target → 路由 fail(→END),不可达 generate");
assertEquals("fail", SaaStudioNodes.modifyRouter("factory").apply(s), "缺 target → 路由 fail(→END),不可达 generate");
}
/**
@ -741,10 +759,10 @@ class SaaStudioGraphTopologyTest {
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
SaaStudioNodes.K_MODIFY_PATCH, patch,
SaaStudioNodes.K_SOURCE_PROJECT, base));
applyNode(s, SaaStudioNodes.modifyNode());
applyNode(s, SaaStudioNodes.modifyNode("factory"));
assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""),
"target.path 形态应视为有 target(不被缺-target 守卫误杀)");
assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s));
assertEquals("generate", SaaStudioNodes.modifyRouter("factory").apply(s));
}
/**

View File

@ -90,7 +90,7 @@ class SaaStudioNodesRegressionTest {
assertEquals("", s.value(SaaStudioNodes.K_PLAYER_FEEDBACK, String.class).orElse("<null>"));
// ② validate 节点(硬门)失败 → 写 feedback=校验未过…,并(A-4)清空 pfb(本就已空,幂等)。
applyNode(s, SaaGenNodes.validateNode());
applyNode(s, SaaGenNodes.validateNode(null, "factory"));
String fb = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("");
assertTrue(fb.contains("校验未过"), "A-4:硬门失败后回喂必须是 validate 失败原因,实得:" + fb);

View File

@ -210,3 +210,50 @@ export default function createGame(opts) {
}
export default assembleFactoryFromSource;
/* ════════════════════════════════════════════════════════════════════════════
* CLI(U2 Java 接缝调用点)——SaaGenNodes.validateNode(gamedef) 经 runProcess 子进程调本 CLI。
* node build-from-source.mjs <sourceJson> <outFactory> 校验+装配+写 generated-factory.js(失败 exit 2 + stderr 逐条错误)
* node build-from-source.mjs --validate <sourceJson> 只校验(失败 exit 2 + stderr 错误)
* 被 import 时不执行(保单测/装配器作纯函数用);仅作脚本直跑时执行 main。
* ════════════════════════════════════════════════════════════════════════════ */
async function cliMain(argv) {
const { readFileSync, writeFileSync } = await import('node:fs');
const args = argv.slice(2);
const validateOnly = args[0] === '--validate';
const files = validateOnly ? args.slice(1) : args;
const srcFile = files[0];
const outFile = files[1];
if (!srcFile) {
process.stderr.write('usage: build-from-source.mjs [--validate] <sourceJson> [outFactory]\n');
process.exit(64);
}
let sp;
try {
sp = JSON.parse(readFileSync(srcFile, 'utf8'));
} catch (e) {
process.stderr.write('source JSON 解析失败: ' + e.message + '\n');
process.exit(2);
}
if (validateOnly) {
const v = validateSourceProject(sp);
if (!v.ok) { process.stderr.write(v.errors.join('\n') + '\n'); process.exit(2); }
process.exit(0);
}
try {
const factory = assembleFactoryFromSource(sp);
if (outFile) { writeFileSync(outFile, factory, 'utf8'); } else { process.stdout.write(factory); }
process.exit(0);
} catch (err) {
process.stderr.write((err.validationErrors ? err.validationErrors.join('\n') : err.message) + '\n');
process.exit(2);
}
}
// 仅作为脚本直接运行时执行(import 时 process.argv[1] 非本文件 → 跳过)。
if (typeof process !== 'undefined' && process.argv && process.argv[1]) {
const here = new URL(import.meta.url).pathname;
if (process.argv[1] === here || process.argv[1].endsWith('/build-from-source.mjs')) {
cliMain(process.argv);
}
}