com.wanxiang
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java
new file mode 100644
index 00000000..aa813b31
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java
@@ -0,0 +1,468 @@
+package com.wanxiang.huijing.game.module.aigc.saa;
+
+import com.alibaba.cloud.ai.graph.OverAllState;
+import com.alibaba.cloud.ai.graph.action.NodeAction;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+/**
+ * SaaGenNodes —— SAA 迁移 step 3:四个生成流水 NodeAction(validate / scaffold / build / play)。
+ *
+ * 目标(窄):证明「SAA-Java 能驱动既有 build + 九门 harness」。本步与「LLM 能否生成过门游戏」解耦,
+ * 用金样 factory(已知过九门的 breakout/generated-factory.js)作输入,只验 harness 驱动链。
+ *
+ *
语义逐一对齐 Python 参照(wg1/gen-worker/worker/):
+ *
+ * - {@link #validateNode()} ≈ validate.py:55-111(static_check 静态契约扫描 + node --check ESM 语法门);
+ * - {@link #scaffoldNode(Path)} ≈ run.py:33-42(落 generated-factory.js + 拷 entry-bundle/index 模板 + 写 play-spec.json);
+ * - {@link #buildNode(Path)} ≈ run.py:45-53(ProcessBuilder node scripts/build.mjs ... --global-name=__GameBundle);
+ * - {@link #playNode(Path)} ≈ run.py:56-72(ProcessBuilder bash serve-and-play.sh,timeout 180s,读 evidence/verdict.json)。
+ *
+ *
+ * 纪律(本步边界,对齐迁移设计 §6.2-step3):不接 LLM(不调 new-api)、不接 saver、不接 job 契约、不组全图。
+ * harness(serve-and-play.sh / play.cdp.cjs / build.mjs)子进程复用·禁重写(模型无关确定性地板)。
+ *
+ *
状态 key 约定(OverAllState):
+ *
+ * - {@code factorySrc}(in) —— 工厂源码(金样或后续 generate 节点产出);
+ * - {@code playSpec}(in) —— play-spec JSON 文本(驱动九门 F/G/H/I);
+ * - {@code gameId}(in) —— 唯一游戏 id(落 games/_wg1-gen/<id>/,避免覆盖现有游戏);
+ * - {@code validateOk}/{@code validateErrors} —— validate 产出;
+ * - {@code gameDir} —— scaffold 落盘目录绝对路径;
+ * - {@code buildOk}/{@code buildLog} —— build 产出;
+ * - {@code verdict}(JSON)/{@code playPass}/{@code feedback} —— play 产出(playPass=九门是否全过)。
+ *
+ *
+ * @author 造梦AI
+ */
+final class SaaGenNodes {
+
+ private SaaGenNodes() {
+ }
+
+ /** Jackson(读 verdict.json / play-spec 透传)。yudao 自带 jackson-databind 2.21.x。 */
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** esbuild iife 全局名(契约冻结:宿主从 window.__GameBundle 取;出厂自检也据此)。 */
+ private static final String GLOBAL_NAME = "__GameBundle";
+
+ // ====================== 子进程驱动(统一超时·并发 drain·强杀,对齐 Python subprocess.run(timeout=)) ======================
+
+ /**
+ * 子进程运行结果({@link #runProcess})。
+ * 修复点(评审最高优):原实现先 {@code readAllBytes()} 再 {@code waitFor()}——若子进程输出量大塞满管道缓冲、
+ * 而我们又卡在 readAllBytes 读不完,子进程会因写阻塞永不退出 → {@code waitFor(timeout)} 形同虚设、timeout 到不了。
+ * 故改为:启动后另起线程并发 drain stdout 到内存 buffer(管道不堵),主线程只 {@code waitFor(timeout)};
+ * 超时则 {@code destroy()}→{@code destroyForcibly()}→二次 {@code waitFor},日志取 tail(对齐 run.py 子进程 timeout 语义)。
+ */
+ static final class ProcResult {
+ final boolean timedOut; // true=超时被强杀(未在限时内退出)
+ final int exitCode; // 进程退出码(timedOut=true 时无意义,置 -1)
+ final String output; // 合并后的 stdout(redirectErrorStream,已含 stderr)
+
+ ProcResult(boolean timedOut, int exitCode, String output) {
+ this.timedOut = timedOut;
+ this.exitCode = exitCode;
+ this.output = output;
+ }
+ }
+
+ /**
+ * 统一子进程运行器(build/play/node-check 三处共用):合并 stderr→stdout,并发 drain 防管道死锁,
+ * 限时 {@code waitFor},超时 destroy→destroyForcibly→二次 waitFor 兜底强杀。
+ *
+ * @param pb 已配好 command/directory 的 ProcessBuilder(本方法负责 redirectErrorStream(true))。
+ * @param timeoutSecs 超时秒数(对齐 Python 对应 subprocess.run(timeout=))。
+ * @return ProcResult(timedOut / exitCode / output)。
+ * @throws IOException 进程启动失败。
+ * @throws InterruptedException 等待被中断(向上抛,由调用方按节点语义处理)。
+ */
+ static ProcResult runProcess(ProcessBuilder pb, long timeoutSecs) throws IOException, InterruptedException {
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+ // 并发 drain:另起守护线程把 stdout 抽干到内存 buffer,确保子进程写管道永不阻塞(修复 readAllBytes/waitFor 死锁)。
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ Thread drain = new Thread(() -> {
+ try (InputStream in = p.getInputStream()) {
+ byte[] chunk = new byte[8192];
+ int n;
+ while ((n = in.read(chunk)) != -1) {
+ synchronized (buf) {
+ buf.write(chunk, 0, n);
+ }
+ }
+ } catch (IOException ignore) {
+ // 进程结束/管道关闭引发的读异常可忽略(已尽量抽干)。
+ }
+ }, "saa-proc-drain");
+ drain.setDaemon(true);
+ drain.start();
+
+ boolean done = p.waitFor(timeoutSecs, TimeUnit.SECONDS);
+ if (!done) {
+ // 超时:先温和 destroy,再强杀,二次短等确保回收(对齐 Python destroy()→destroyForcibly()→二次 waitFor)。
+ p.destroy();
+ if (!p.waitFor(2, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ p.waitFor(2, TimeUnit.SECONDS);
+ }
+ }
+ // 等 drain 线程收尾(最多 2s),把已抽到的输出取出(超时场景取 tail 由调用方做)。
+ drain.join(2000);
+ String output;
+ synchronized (buf) {
+ output = buf.toString(StandardCharsets.UTF_8);
+ }
+ return new ProcResult(!done, done ? p.exitValue() : -1, output.trim());
+ }
+
+ // ====================== ① validate 节点 ======================
+ // 静态契约扫描(抄 validate.py:55-77 语义)+ node --check ESM 门(抄 validate.py:80-100)。
+
+ /** 禁用模式(正则, 说明)——违反即【契约违约】,拦在打包前。逐条对齐 validate.py:_FORBIDDEN。 */
+ private static final Object[][] FORBIDDEN = {
+ {Pattern.compile("(^|\\n)\\s*import\\s"), "禁 import:工厂必须自包含单文件(含禁 import littlejsengine)"},
+ {Pattern.compile("\\bimport\\s*\\("), "禁动态 import()"},
+ {Pattern.compile("\\brequire\\s*\\("), "禁 require"},
+ {Pattern.compile("littlejsengine"), "禁引擎名字空间"},
+ {Pattern.compile("\\bMath\\.random\\b"), "禁 Math.random(用 ctx.random)"},
+ {Pattern.compile("\\bDate\\.now\\b"), "禁 Date.now(用 ctx.time.nowMs)"},
+ {Pattern.compile("\\bperformance\\.now\\b"), "禁 performance.now(用 ctx.time)"},
+ {Pattern.compile("\\baddEventListener\\b"), "禁 addEventListener(用 ctx.getInput().on)"},
+ {Pattern.compile("\\brequestAnimationFrame\\b"), "禁 requestAnimationFrame(宿主驱动)"},
+ {Pattern.compile("new\\s+AudioContext"), "禁 new AudioContext(用 ctx.getAudioContext)"},
+ {Pattern.compile("\\bdocument\\s*\\."), "禁直接操作 document"},
+ {Pattern.compile("\\bwindow\\s*\\."), "禁直接操作 window"},
+ };
+
+ /**
+ * 去行注释/块注释,避免禁用词扫描被注释里的字样误伤(对齐 validate.py:_strip_comments)。
+ */
+ private static String stripComments(String src) {
+ String s = Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL).matcher(src).replaceAll("");
+ s = Pattern.compile("//[^\\n]*").matcher(s).replaceAll("");
+ return s;
+ }
+
+ /**
+ * 静态契约检查(对齐 validate.py:static_check)。返回错误列表(空=过)。
+ * 规则:源码非空且≥60 字 / 含 export default / 四方法(init·update·render·destroy)标识符存在 /
+ * 含 ctx 用法 / 至少一次 getEngine(真接线门信号)/ 无 12 条禁用词。
+ */
+ static List staticCheck(String src) {
+ List errors = new ArrayList<>();
+ if (src == null || src.trim().length() < 60) {
+ errors.add("源码为空或过短(疑似未产出有效代码)");
+ return errors;
+ }
+ if (!src.contains("export default")) {
+ errors.add("缺少 export default(必须默认导出工厂函数)");
+ }
+ String scan = stripComments(src);
+ // 四方法存在性(宽松:方法名作为标识符出现)。
+ for (String meth : new String[]{"init", "update", "render", "destroy"}) {
+ if (!Pattern.compile("\\b" + meth + "\\b").matcher(scan).find()) {
+ errors.add("缺少 " + meth + " 方法");
+ }
+ }
+ // 必须经 boot.ctx 取能力(至少出现 ctx. 用法 → 宽松判 ctx 标识符存在)。
+ if (!scan.contains("ctx")) {
+ errors.add("未见 ctx 用法(能力须经 boot.ctx)");
+ }
+ // 必须至少用一次 getEngine(硬性验收点:真接线门要有信号)。
+ if (!scan.contains("getEngine")) {
+ errors.add("未调用 getEngine()(必须在某事件上用一次引擎能力,否则真接线门无信号)");
+ }
+ // 禁用词扫描。
+ for (Object[] f : FORBIDDEN) {
+ Pattern pat = (Pattern) f[0];
+ if (pat.matcher(scan).find()) {
+ errors.add("违禁:" + f[1]);
+ }
+ }
+ return errors;
+ }
+
+ /**
+ * node --check ESM 语法门(对齐 validate.py:node_syntax_check)。返回错误(空=过)。
+ * 写临时 .mjs → ProcessBuilder("node","--check",tmp);退出码 0=过,否则取 stderr。
+ */
+ static String nodeCheck(String src) {
+ Path tmp = null;
+ try {
+ tmp = Files.createTempFile("saa-validate-", ".mjs");
+ Files.write(tmp, src.getBytes(StandardCharsets.UTF_8));
+ // 子进程经统一运行器(并发 drain 防死锁 + 30s 超时强杀,对齐 validate.py subprocess.run(timeout=30))。
+ ProcResult r = runProcess(new ProcessBuilder("node", "--check", tmp.toString()), 30);
+ if (r.timedOut) {
+ return "node --check 超时";
+ }
+ if (r.exitCode == 0) {
+ return "";
+ }
+ return r.output.isEmpty() ? "node --check 失败(无输出)" : r.output;
+ } catch (IOException | InterruptedException e) {
+ return "node --check 异常:" + e.getMessage();
+ } finally {
+ if (tmp != null) {
+ try {
+ Files.deleteIfExists(tmp);
+ } catch (IOException ignore) {
+ // 临时文件清理失败可忽略。
+ }
+ }
+ }
+ }
+
+ /**
+ * validate 节点(对齐 validate.py:validate):静态过了才跑 node --check(省时)。
+ * 写 state:validateOk(boolean) / validateErrors(List) / feedback(失败回喂文字)。
+ */
+ static NodeAction validateNode() {
+ return (OverAllState s) -> {
+ String src = s.value("factorySrc", String.class).orElse("");
+ List errs = staticCheck(src);
+ if (errs.isEmpty()) {
+ String nc = nodeCheck(src);
+ if (!nc.isEmpty()) {
+ String trimmed = nc.length() > 500 ? nc.substring(0, 500) : nc;
+ errs = new ArrayList<>();
+ errs.add("语法错误(node --check):" + trimmed);
+ }
+ }
+ Map out = new HashMap<>();
+ boolean ok = errs.isEmpty();
+ out.put("validateOk", ok);
+ out.put("validateErrors", errs);
+ if (!ok) {
+ StringBuilder fb = new StringBuilder("校验未过:\n");
+ for (String e : errs) {
+ fb.append("- ").append(e).append("\n");
+ }
+ out.put("feedback", fb.toString());
+ clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味(仅全图注册了该 key 时生效)。
+ }
+ return out;
+ };
+ }
+
+ // ====================== ② scaffold 节点 ======================
+ // 纯 Java 文件 IO(抄 run.py:33-42 语义)。
+
+ /**
+ * scaffold 节点(对齐 run.py:scaffold):把工厂源码落 game-runtime/games/_wg1-gen/<id>/。
+ * 写 generated-factory.js(entry-bundle 壳 import './generated-factory.js')
+ * + 拷 _shared/entry-bundle.template.js → entry-bundle.js
+ * + 拷 _shared/index.template.html → index.html
+ * + 写 play-spec.json(来自 state.playSpec)。
+ * 写 state:gameDir(落盘目录绝对路径)。
+ *
+ * @param gameRuntimeRoot 主仓 game-runtime 根(有 node_modules+esbuild+_shared 脚本+金样)。
+ */
+ static NodeAction scaffoldNode(Path gameRuntimeRoot) {
+ return (OverAllState s) -> {
+ String gameId = s.value("gameId", String.class).orElseThrow(
+ () -> new IllegalStateException("scaffold 节点缺 gameId"));
+ String factorySrc = s.value("factorySrc", String.class).orElse("");
+ String playSpec = s.value("playSpec", String.class).orElse("{\"inputs\":[],\"expectedEngineCallPrefixes\":[]}");
+
+ Path genDir = gameRuntimeRoot.resolve("games").resolve("_wg1-gen");
+ Path shared = genDir.resolve("_shared");
+ Path gameDir = genDir.resolve(gameId);
+ Files.createDirectories(gameDir);
+
+ // 官方靶名 generated-factory.js(entry-bundle.template.js 内 import './generated-factory.js')。
+ Files.write(gameDir.resolve("generated-factory.js"), factorySrc.getBytes(StandardCharsets.UTF_8));
+ // 拷模板(entry-bundle 壳 + index 页)。
+ Files.copy(shared.resolve("entry-bundle.template.js"), gameDir.resolve("entry-bundle.js"),
+ StandardCopyOption.REPLACE_EXISTING);
+ Files.copy(shared.resolve("index.template.html"), gameDir.resolve("index.html"),
+ StandardCopyOption.REPLACE_EXISTING);
+ // play-spec.json(驱动九门 F/G/H/I)。
+ Files.write(gameDir.resolve("play-spec.json"), playSpec.getBytes(StandardCharsets.UTF_8));
+
+ Map out = new HashMap<>();
+ out.put("gameDir", gameDir.toString());
+ return out;
+ };
+ }
+
+ // ====================== ③ build 节点 ======================
+ // ProcessBuilder 调既有 esbuild 锁参脚本(抄 run.py:45-53;harness 禁重写)。
+
+ /**
+ * build 节点(对齐 run.py:build):node scripts/build.mjs <entry> <out> --global-name=__GameBundle,
+ * cwd=主仓 game-runtime;断言出 bundle.iife.js。
+ * 写 state:buildOk(boolean) / buildLog(esbuild 日志,失败回喂用) / feedback(失败时)。
+ *
+ * @param gameRuntimeRoot 主仓 game-runtime 根(cwd;esbuild 在此 node_modules)。
+ */
+ static NodeAction buildNode(Path gameRuntimeRoot) {
+ return (OverAllState s) -> {
+ String gameId = s.value("gameId", String.class).orElseThrow(
+ () -> new IllegalStateException("build 节点缺 gameId"));
+ String rel = "games/_wg1-gen/" + gameId;
+
+ ProcessBuilder pb = new ProcessBuilder(
+ "node", "scripts/build.mjs",
+ rel + "/entry-bundle.js", rel + "/bundle.iife.js",
+ "--global-name=" + GLOBAL_NAME);
+ pb.directory(gameRuntimeRoot.toFile());
+ // 子进程经统一运行器(并发 drain 防死锁 + 120s 超时强杀,对齐 run.py:build subprocess.run(timeout=120))。
+ ProcResult r;
+ try {
+ r = runProcess(pb, 120);
+ } catch (IOException | InterruptedException e) {
+ Map oe = new HashMap<>();
+ oe.put("buildOk", false);
+ String msg = "打包子进程异常:" + e.getMessage();
+ oe.put("buildLog", msg);
+ oe.put("feedback", "打包失败(esbuild):\n" + msg);
+ return oe;
+ }
+ String log = r.output;
+ boolean ok;
+ if (r.timedOut) {
+ ok = false;
+ log = "打包超时(esbuild)\n" + tail(log, 600);
+ } else {
+ ok = r.exitCode == 0
+ && Files.exists(gameRuntimeRoot.resolve(rel).resolve("bundle.iife.js"));
+ }
+
+ Map out = new HashMap<>();
+ out.put("buildOk", ok);
+ out.put("buildLog", log);
+ if (!ok) {
+ String head = log.length() > 600 ? log.substring(0, 600) : log;
+ out.put("feedback", "打包失败(esbuild):\n" + head);
+ clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味。
+ }
+ return out;
+ };
+ }
+
+ // ====================== ④ play 节点 ======================
+ // ProcessBuilder 调既有九门 harness(抄 run.py:56-72;harness 禁重写)。
+
+ /**
+ * play 节点(对齐 run.py:play):bash games/_wg1-gen/_shared/serve-and-play.sh <id> 4320 9222,
+ * cwd=主仓 game-runtime,timeout 180s,destroyForcibly;读 evidence/verdict.json,解析 pass。
+ * 九门 pass 判定 = 退出码 0 ∧ verdict.pass(对齐 run.py:run_job 的 `rc==0 and verdict.pass`)。
+ * 写 state:verdict(JSON) / playPass(boolean) / feedback(失败摘守卫,对齐 _verdict_feedback)。
+ *
+ * @param gameRuntimeRoot 主仓 game-runtime 根(cwd;serve-and-play.sh / Chrome / static-serve 在此)。
+ */
+ static NodeAction playNode(Path gameRuntimeRoot) {
+ return (OverAllState s) -> {
+ String gameId = s.value("gameId", String.class).orElseThrow(
+ () -> new IllegalStateException("play 节点缺 gameId"));
+
+ // serve-and-play.sh 第二参=HTTP 端口、第三参=CDP 端口;脚本自带净场杀这两端口。
+ // A-9:端口参数化(从 state 读,默认 4320/9222;worker 并行形态可每 run 错开,对照 run.py:56-63 的 port/cdp_port 入参)。
+ int port = ((Number) s.value("port", 4320)).intValue();
+ int cdpPort = ((Number) s.value("cdpPort", 9222)).intValue();
+ ProcessBuilder pb = new ProcessBuilder(
+ "bash", "games/_wg1-gen/_shared/serve-and-play.sh", gameId,
+ String.valueOf(port), String.valueOf(cdpPort));
+ pb.directory(gameRuntimeRoot.toFile());
+ // 子进程经统一运行器(并发 drain 防死锁 + 180s 超时强杀,对齐 run.py:play subprocess.run(timeout=180))。
+ ProcResult r = runProcess(pb, 180);
+ String pout = r.output;
+
+ Map out = new HashMap<>();
+ if (r.timedOut) {
+ out.put("playPass", false);
+ out.put("feedback", "真玩超时(180s)。\n" + tail(pout, 600));
+ clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味。
+ return out;
+ }
+
+ int rc = r.exitCode;
+ // 读回 evidence/verdict.json(九门判定权威产物)。
+ Path vpath = gameRuntimeRoot.resolve("games").resolve("_wg1-gen")
+ .resolve(gameId).resolve("evidence").resolve("verdict.json");
+ JsonNode verdict = null;
+ if (Files.exists(vpath)) {
+ try {
+ verdict = MAPPER.readTree(vpath.toFile());
+ } catch (IOException e) {
+ // verdict 解析失败 → 留 null,下方判失败。
+ out.put("feedback", "verdict.json 解析失败:" + e.getMessage());
+ }
+ }
+ boolean pass = rc == 0 && verdict != null && verdict.path("pass").asBoolean(false);
+ out.put("playPass", pass);
+ if (verdict != null) {
+ // 以字符串形态塞 state(避免 KeyStrategy 序列化 JsonNode 的额外约束;诊断读回即可)。
+ out.put("verdict", verdict.toString());
+ }
+ if (!pass && !out.containsKey("feedback")) {
+ out.put("feedback", summarizeFailedGuards(verdict));
+ }
+ if (!pass) {
+ clearPlayerFeedback(s, out); // A-4:硬门失败清空 player 软门反馈,杜绝串味。
+ }
+ return out;
+ };
+ }
+
+ /**
+ * A-4 辅助:硬门(validate/build/play)失败时清空 player 软门反馈,避免上一轮 player 体验问题串到本轮失败回喂。
+ * 仅当当前图注册了 playerFeedback 策略(全图 SaaStudioGraphTest)时才写——step3 最小图未注册该 key,跳过不破坏。
+ */
+ private static void clearPlayerFeedback(OverAllState s, Map out) {
+ if (s.containStrategy("playerFeedback")) {
+ out.put("playerFeedback", "");
+ }
+ }
+
+ /**
+ * 把失败的守卫摘成回喂文字(对齐 run.py:_verdict_feedback)。
+ */
+ static String summarizeFailedGuards(JsonNode verdict) {
+ if (verdict == null) {
+ return "真玩阶段无 verdict 产出(疑似装载即崩/超时)。";
+ }
+ List lines = new ArrayList<>();
+ JsonNode guards = verdict.path("guards");
+ if (guards.isObject()) {
+ Iterator> it = guards.fields();
+ while (it.hasNext()) {
+ Map.Entry e = it.next();
+ if (!e.getValue().path("pass").asBoolean(false)) {
+ String g = e.getValue().toString();
+ lines.add("- 守卫 " + e.getKey() + " 未过:" + (g.length() > 200 ? g.substring(0, 200) : g));
+ }
+ }
+ }
+ if (verdict.hasNonNull("error")) {
+ String err = verdict.get("error").asText();
+ lines.add("- 运行错误:" + (err.length() > 300 ? err.substring(0, 300) : err));
+ }
+ return lines.isEmpty() ? "真玩未过(原因未知)。" : "真玩未过:\n" + String.join("\n", lines);
+ }
+
+ /** 取字符串尾部 n 字(日志摘要用)。 */
+ private static String tail(String s, int n) {
+ if (s == null) {
+ return "";
+ }
+ return s.length() <= n ? s : s.substring(s.length() - n);
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHarnessDriveTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHarnessDriveTest.java
new file mode 100644
index 00000000..23935031
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHarnessDriveTest.java
@@ -0,0 +1,206 @@
+package com.wanxiang.huijing.game.module.aigc.saa;
+
+import com.alibaba.cloud.ai.graph.CompiledGraph;
+import com.alibaba.cloud.ai.graph.KeyStrategy;
+import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
+import com.alibaba.cloud.ai.graph.OverAllState;
+import com.alibaba.cloud.ai.graph.StateGraph;
+import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static com.alibaba.cloud.ai.graph.StateGraph.END;
+import static com.alibaba.cloud.ai.graph.StateGraph.START;
+import static com.alibaba.cloud.ai.graph.action.AsyncNodeAction.node_async;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * SaaHarnessDriveTest —— SAA 迁移 step 3:证明「SAA-Java 能驱动既有 build + 九门 harness」。
+ *
+ * 与「LLM 能否生成过门游戏」解耦:用金样 factory(breakout/generated-factory.js,
+ * 已知过九门)作输入,只验 {@link SaaGenNodes} 四节点(validate/scaffold/build/play)的驱动链。
+ *
+ *
两组验证:
+ *
+ * - {@link #validate_passes_golden_and_rejects_broken()}:validate 节点单独验——金样过、故意坏样不过(不依赖 harness 跑机,故无条件跑);
+ * - {@link #golden_drives_nine_gates_to_pass()}:scaffold→build→play 三节点串成最小 StateGraph,
+ * 金样经 SAA 真驱动既有九门 harness,断言 verdict.pass=true(需 mini-desktop:node+esbuild+Chrome,
+ * 故 {@code -Dsaa.harness=1} 时才跑;否则跳过,避免在无 harness 机误判 fail)。
+ *
+ *
+ * 纪律(迁移设计 §6.2-step3):不接 LLM、不接 saver、不接 job 契约、不组全图(下一步);harness 子进程复用·禁重写。
+ *
+ *
运行(mini-desktop,cwd=主仓 game-runtime 有 node_modules+esbuild+_shared 脚本+金样):
+ *
+ * mvn -pl ...aigc-server -am -Dtest=SaaHarnessDriveTest \
+ * -Dsaa.harness=1 -DGAME_RUNTIME_DIR=/root/games-development-ai/game-runtime \
+ * -Dsurefire.failIfNoSpecifiedTests=false test
+ *
+ *
+ * @author 造梦AI
+ */
+class SaaHarnessDriveTest {
+
+ /** 主仓 game-runtime 根(harness 运行时所在;有 node_modules+esbuild+_shared 脚本+金样)。可被系统属性覆盖。 */
+ private static final Path GAME_RUNTIME_ROOT = Paths.get(
+ System.getProperty("GAME_RUNTIME_DIR",
+ System.getenv().getOrDefault("GAME_RUNTIME_DIR", "/root/games-development-ai/game-runtime")));
+
+ /** step3 专用唯一 game id(避免覆盖现有游戏 breakout)。 */
+ private static final String GAME_ID = "saa-step3-golden";
+
+ /**
+ * 读金样 factory 源码(test resources,权威已过九门 breakout/generated-factory.js 的拷贝)。
+ */
+ private static String readGoldenFactory() throws Exception {
+ try (var in = SaaHarnessDriveTest.class.getResourceAsStream("/saa-golden/generated-factory.js")) {
+ assertNotNull(in, "金样 factory 资源 /saa-golden/generated-factory.js 缺失");
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ /** 读金样 play-spec(驱动九门 F/G/H/I)。 */
+ private static String readGoldenPlaySpec() throws Exception {
+ try (var in = SaaHarnessDriveTest.class.getResourceAsStream("/saa-golden/play-spec.json")) {
+ assertNotNull(in, "金样 play-spec 资源 /saa-golden/play-spec.json 缺失");
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ /**
+ * 故意坏样:违反多条静态契约(含 import / Math.random / 缺 getEngine / 缺 destroy),validate 必须判不过。
+ */
+ private static final String BROKEN_FACTORY = ""
+ + "'use strict';\n"
+ + "import {foo} from 'bar';\n" // 违禁:import
+ + "export default function createGame(opts){\n"
+ + " const o = opts || {};\n"
+ + " let r = Math.random();\n" // 违禁:Math.random
+ + " function init(b){ /* no ctx, no getEngine */ }\n" // 缺 ctx/getEngine
+ + " function update(dt){ r += dt; }\n"
+ + " function render(g){ g.fillRect(0,0,r,r); }\n"
+ + " return { init, update, render };\n" // 缺 destroy
+ + "}\n";
+
+ // ====================== ① validate 节点单测:金样过 / 坏样不过 ======================
+
+ @Test
+ void validate_passes_golden_and_rejects_broken() throws Exception {
+ // -- 坏样:静态契约即应拦下(不跑 node --check 也能列错),断言不过且命中预期违约 --
+ List brokenErrs = SaaGenNodes.staticCheck(BROKEN_FACTORY);
+ System.out.println("[validate/broken] errors=" + brokenErrs);
+ assertFalse(brokenErrs.isEmpty(), "故意坏样应被静态契约判不过");
+ assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("import")), "应命中 import 违禁");
+ assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("Math.random")), "应命中 Math.random 违禁");
+ assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("getEngine")), "应命中缺 getEngine");
+ assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("destroy")), "应命中缺 destroy 方法");
+
+ // -- 金样:静态契约应全过(空错误列表)--
+ String golden = readGoldenFactory();
+ List goldenStaticErrs = SaaGenNodes.staticCheck(golden);
+ System.out.println("[validate/golden static] errors=" + goldenStaticErrs);
+ assertTrue(goldenStaticErrs.isEmpty(), "金样应过静态契约,实得错误:" + goldenStaticErrs);
+
+ // -- 金样:node --check ESM 语法门也应过(需 node 在 PATH;mini-desktop 有)--
+ String nc = SaaGenNodes.nodeCheck(golden);
+ System.out.println("[validate/golden node--check] " + (nc.isEmpty() ? "OK" : nc));
+ assertTrue(nc.isEmpty(), "金样应过 node --check,实得:" + nc);
+ }
+
+ // ====================== ② scaffold→build→play 三节点串图:金样过九门 verdict.pass=true ======================
+
+ @Test
+ @EnabledIfSystemProperty(named = "saa.harness", matches = "1",
+ disabledReason = "需 mini-desktop harness 运行机(node+esbuild+Chrome),无机时跳过避免误判 fail;开关 -Dsaa.harness=1")
+ void golden_drives_nine_gates_to_pass() throws Exception {
+ // 预检:主仓 game-runtime + _shared 脚本 + esbuild 必须就位(否则给清晰指引而非晦涩子进程错误)。
+ assertTrue(Files.isDirectory(GAME_RUNTIME_ROOT), "GAME_RUNTIME_DIR 不存在:" + GAME_RUNTIME_ROOT);
+ assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("games/_wg1-gen/_shared/serve-and-play.sh")),
+ "缺 serve-and-play.sh(harness 子进程地板),路径:" + GAME_RUNTIME_ROOT);
+ assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("node_modules/esbuild")),
+ "缺 node_modules/esbuild(build 节点依赖),请在 game-runtime 跑 npm i -D esbuild");
+
+ String golden = readGoldenFactory();
+ String playSpec = readGoldenPlaySpec();
+
+ // -- 串接:START -> scaffold -> build -> play -> END(条件边留下一步;本步直串验驱动链通)--
+ KeyStrategyFactory keyFactory = () -> {
+ Map keys = new HashMap<>();
+ for (String k : new String[]{"factorySrc", "playSpec", "gameId",
+ "gameDir", "buildOk", "buildLog", "verdict", "playPass", "feedback"}) {
+ keys.put(k, new ReplaceStrategy());
+ }
+ return keys;
+ };
+
+ StateGraph graph = new StateGraph("saa-step3-harness-drive", keyFactory)
+ .addNode("scaffold", node_async(SaaGenNodes.scaffoldNode(GAME_RUNTIME_ROOT)))
+ .addNode("build", node_async(SaaGenNodes.buildNode(GAME_RUNTIME_ROOT)))
+ .addNode("play", node_async(SaaGenNodes.playNode(GAME_RUNTIME_ROOT)))
+ .addEdge(START, "scaffold")
+ .addEdge("scaffold", "build")
+ .addEdge("build", "play")
+ .addEdge("play", END);
+
+ CompiledGraph compiled = graph.compile();
+
+ Map inputs = new HashMap<>();
+ inputs.put("factorySrc", golden);
+ inputs.put("playSpec", playSpec);
+ inputs.put("gameId", GAME_ID);
+
+ long t0 = System.currentTimeMillis();
+ Optional result = compiled.invoke(inputs);
+ long dt = System.currentTimeMillis() - t0;
+
+ assertTrue(result.isPresent(), "图 invoke 应返回 state");
+ OverAllState s = result.get();
+
+ boolean buildOk = s.value("buildOk", Boolean.class).orElse(false);
+ boolean playPass = s.value("playPass", Boolean.class).orElse(false);
+ String gameDir = s.value("gameDir", String.class).orElse("?");
+ String verdict = s.value("verdict", String.class).orElse("(no verdict)");
+ String feedback = s.value("feedback", String.class).orElse("");
+
+ // == 证据:各节点产出 + verdict 全文(含各门 guard 结果)==
+ System.out.println("================ SAA step3 harness 驱动证据 ================");
+ System.out.println("[gameId] " + GAME_ID + " cwd=" + GAME_RUNTIME_ROOT);
+ System.out.println("[gameDir] " + gameDir);
+ System.out.println("[buildOk] " + buildOk);
+ System.out.println("[playPass] " + playPass + " wall=" + dt + "ms");
+ if (!feedback.isEmpty()) {
+ System.out.println("[feedback] " + feedback);
+ }
+ System.out.println("[verdict] " + verdict);
+ System.out.println("==========================================================");
+
+ // == 断言:金样经 SAA 四节点真驱动九门 → verdict.pass=true ==
+ assertTrue(buildOk, "build 节点应成功产出 bundle.iife.js");
+ assertTrue(playPass, "金样应经 SAA play 节点驱动九门拿到 verdict.pass=true,feedback=" + feedback);
+
+ // 进一步落锤:直接读回 verdict.json,断言 pass=true 且九门核心守卫全过(证明真到九门判定,非空过)。
+ Path vpath = GAME_RUNTIME_ROOT.resolve("games/_wg1-gen").resolve(GAME_ID)
+ .resolve("evidence/verdict.json");
+ assertTrue(Files.exists(vpath), "应产出 verdict.json:" + vpath);
+ var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
+ var v = mapper.readTree(vpath.toFile());
+ assertEquals(true, v.path("pass").asBoolean(false), "verdict.json pass 应为 true");
+ // 核心确定性门(A 装载 / B 未捕获 / C 掌帧 / D 真渲 / E 活性 / F 真接线 / G 输入)全过。
+ for (String g : new String[]{"A_boot", "B_uncaught", "C_frame", "D_render", "E_live", "F_wiring", "G_input"}) {
+ assertEquals(true, v.path("guards").path(g).path("pass").asBoolean(false),
+ "九门守卫 " + g + " 应 pass,verdict=" + v);
+ }
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java
new file mode 100644
index 00000000..fba9e9ea
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java
@@ -0,0 +1,222 @@
+package com.wanxiang.huijing.game.module.aigc.saa;
+
+import com.alibaba.cloud.ai.graph.CompiledGraph;
+import com.alibaba.cloud.ai.graph.KeyStrategy;
+import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
+import com.alibaba.cloud.ai.graph.OverAllState;
+import com.alibaba.cloud.ai.graph.StateGraph;
+import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.springframework.ai.chat.messages.SystemMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.metadata.Usage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.openai.OpenAiChatModel;
+import org.springframework.ai.openai.OpenAiChatOptions;
+import org.springframework.ai.openai.api.OpenAiApi;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static com.alibaba.cloud.ai.graph.StateGraph.END;
+import static com.alibaba.cloud.ai.graph.StateGraph.START;
+import static com.alibaba.cloud.ai.graph.action.AsyncNodeAction.node_async;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * SAA -> new-api -> 生成游戏工厂源码 最小冒烟(迁移分步 step 2 首件)。
+ *
+ * 目标(窄):证明「SAA 在 game-cloud 内能调 new-api 生成游戏工厂源码」这一条链。
+ * START -> generate(node) -> END:generate 节点用 OpenAiChatModel(指 new-api,
+ * 模型=models.yaml stage1 的 code 角色=deepseek-v4-flash) 出 js 代码块,抽 js 块
+ * (等价 Python validate.extract_code),断言拿到非空工厂源码。
+ *
+ * 纪律(对齐 dossier 0/6.2-step2):
+ * - 只验「SAA->new-api->生成源码」最小链;不接 build/play harness、不接 saver、不接 job 契约。
+ * - 密钥只走环境变量 NEWAPI_KEY(同 Python worker _client.get_api_key 口径,绝不入库);
+ * 未设 NEWAPI_KEY 时 @EnabledIfEnvironmentVariable 直接跳过(不算失败,与"密钥外置"一致)。
+ * - system 用 prompt.SYSTEM 的精简等价(模块默认导出 createGame + init/update/render/destroy +
+ * getEngine 容错),先不求完整(harness 九门是后续 step 的事)。
+ *
+ * 运行:在 mini-desktop 上
+ * NEWAPI_KEY=sk-... mvn -pl ...aigc-server -am -Dtest=SaaNewApiGenerateSmokeTest test
+ *
+ * @author 造梦AI
+ */
+@EnabledIfEnvironmentVariable(named = "NEWAPI_KEY", matches = ".+")
+class SaaNewApiGenerateSmokeTest {
+
+ /**
+ * new-api OpenAI 兼容网关 host 根(同 Python _client.BASE_URL;可被 NEWAPI_BASE_URL 覆盖)。
+ * 注意 Spring-AI 口径 ≠ openai-python 口径:openai SDK 的 base_url 要带 /v1(Python worker 配的就是 .../v1),
+ * 但 Spring AI OpenAiApi 会自己拼默认 completionsPath=/v1/chat/completions,故 baseUrl 必须是【host 根、不带 /v1】,
+ * 否则拼成 /v1/v1/chat/completions → 404(spike 实测坑)。这里把末尾 /v1 剥掉,让同一 .env 值两侧通用。
+ */
+ private static final String NEW_API_BASE_URL = stripV1Suffix(
+ System.getenv().getOrDefault("NEWAPI_BASE_URL", "http://100.64.0.8:3000/v1"));
+
+ /** 剥掉末尾的 /v1(及多余结尾斜杠),适配 Spring AI baseUrl=host 根的口径。 */
+ private static String stripV1Suffix(String url) {
+ String u = url.trim();
+ while (u.endsWith("/")) {
+ u = u.substring(0, u.length() - 1);
+ }
+ if (u.endsWith("/v1")) {
+ u = u.substring(0, u.length() - 3);
+ }
+ return u;
+ }
+
+ /** stage1 code 角色模型(models.yaml: stage1.roles.code = deepseek-v4-flash) */
+ private static final String STAGE1_CODE_MODEL = "deepseek-v4-flash";
+
+ /** state key:工厂源码(generate 节点产出) */
+ private static final String K_FACTORY_SRC = "factorySrc";
+ /** state key:本次模型名(诊断用) */
+ private static final String K_MODEL = "modelName";
+ /** state key:usage 累计(诊断用) */
+ private static final String K_USAGE = "usage";
+
+ /**
+ * 精简版 system prompt:prompt.SYSTEM 的最小等价。
+ * 只保留「模块形状 + 生命周期 + getEngine 容错 + 只输出一个 js 代码块」——
+ * 足够让便宜模型产出一个可抽取的工厂源码;不含九门/好玩接地(后续 step)。
+ */
+ private static final String SYSTEM_PROMPT = ""
+ + "你是轻量 H5 小游戏代码生成器。唯一产出 = 一个独立的 GameHostFactory 模块(原生 JS ESM 单文件)。\n"
+ + "【输出格式·严格】只输出一个 ```js 代码块,块内是完整工厂源码,块外不要任何解释文字。\n"
+ + "【模块形状】\n"
+ + "- 文件用 'use strict'; 开头。\n"
+ + "- 默认导出工厂函数:export default function createGame(opts) { ... return { init, update, render, destroy, getEngine }; }\n"
+ + "- 返回的实例必须有 init / update / render / destroy 四个方法;另导出 getEngine() 返回引擎能力或 null(必须容错)。\n"
+ + "【生命周期(宿主这样驱动)】\n"
+ + "- game = createGame(opts) // opts 可能 undefined,需 const o = opts || {} 防御\n"
+ + "- await game.init(boot) // boot = { ctx, mainContext, canvas, seed }\n"
+ + "- game.update(dt) // dt 恒 = 1/60 秒\n"
+ + "- game.render(g) // g === boot.mainContext(2D 画布上下文)\n"
+ + "- game.destroy() // 卸载,幂等\n"
+ + "【硬禁止】禁 import 任何包(自包含单文件);禁 Math.random/Date.now(用 ctx.random/ctx.time);禁 addEventListener/requestAnimationFrame。\n";
+
+ @Test
+ void saa_graph_calls_newapi_and_generates_factory_source() throws Exception {
+ String key = System.getenv("NEWAPI_KEY");
+ assertNotNull(key, "NEWAPI_KEY 未设(密钥须经环境变量,绝不入库)");
+
+ // == 1) 建指向 new-api 的 OpenAiChatModel(共用一个 OpenAiApi,defaultOptions.model=便宜档) ==
+ OpenAiApi openAiApi = OpenAiApi.builder()
+ .baseUrl(NEW_API_BASE_URL)
+ .apiKey(key)
+ .build();
+ OpenAiChatModel codeModel = OpenAiChatModel.builder()
+ .openAiApi(openAiApi)
+ .defaultOptions(OpenAiChatOptions.builder()
+ .model(STAGE1_CODE_MODEL)
+ .temperature(0.0) // 确定性产出(对齐 Python _client.chat temperature=0)
+ .maxTokens(16000)
+ .build())
+ .build();
+
+ // == 2) 最小 StateGraph:START -> generate -> END ==
+ // KeyStrategyFactory:三个 key 都用 ReplaceStrategy(覆盖,非追加)。
+ KeyStrategyFactory keyFactory = () -> {
+ Map keys = new HashMap<>();
+ keys.put(K_FACTORY_SRC, new ReplaceStrategy());
+ keys.put(K_MODEL, new ReplaceStrategy());
+ keys.put(K_USAGE, new ReplaceStrategy());
+ return keys;
+ };
+
+ StateGraph graph = new StateGraph("saa-newapi-smoke", keyFactory)
+ .addNode("generate", node_async(state -> {
+ // generate 节点:调 code 模型出 js 块,抽 js 块(等价 validate.extract_code)
+ String brief = state.value("brief", "打砖块 breakout:挡板接球消砖,清空所有砖块即胜。");
+ Prompt prompt = new Prompt(List.of(
+ new SystemMessage(SYSTEM_PROMPT),
+ new UserMessage("请实现下面这款游戏,产出一个合规的 GameHostFactory 模块(只输出一个 js 代码块):\n\n" + brief)));
+ ChatResponse resp = codeModel.call(prompt);
+ String content = resp.getResult().getOutput().getText();
+ String src = extractJsBlock(content); // 抽 js 块
+
+ // 读 usage(对齐 Python RecordingChatModel:每次调用记 token)
+ String usageStr = "(no usage)";
+ Usage u = resp.getMetadata().getUsage();
+ if (u != null) {
+ usageStr = "prompt=" + u.getPromptTokens()
+ + " completion=" + u.getCompletionTokens()
+ + " total=" + u.getTotalTokens();
+ }
+
+ Map out = new HashMap<>();
+ out.put(K_FACTORY_SRC, src);
+ out.put(K_MODEL, STAGE1_CODE_MODEL);
+ out.put(K_USAGE, usageStr);
+ return out;
+ }))
+ .addEdge(START, "generate")
+ .addEdge("generate", END);
+
+ CompiledGraph compiled = graph.compile();
+
+ // == 3) invoke 图,断言拿到非空工厂源码 ==
+ Optional result = compiled.invoke(Map.of(
+ "brief", "打砖块 breakout:挡板接球消砖,清空所有砖块即胜,掉球则负。"));
+
+ assertTrue(result.isPresent(), "图 invoke 应返回 state");
+ OverAllState finalState = result.get();
+ String factorySrc = finalState.value(K_FACTORY_SRC, String.class).orElse("");
+ String modelName = finalState.value(K_MODEL, String.class).orElse("?");
+ String usage = finalState.value(K_USAGE, String.class).orElse("?");
+
+ // == 真实响应证据:模型名 + usage + 源码前 20 行 ==
+ System.out.println("================ SAA->new-api 冒烟证据 ================");
+ System.out.println("[model] " + modelName + " @ " + NEW_API_BASE_URL);
+ System.out.println("[usage] " + usage);
+ System.out.println("[src len] " + factorySrc.length() + " chars");
+ System.out.println("[src head 20 lines] >>>>>>>>");
+ String[] lines = factorySrc.split("\n", -1);
+ for (int i = 0; i < Math.min(20, lines.length); i++) {
+ System.out.println(String.format("%3d| %s", i + 1, lines[i]));
+ }
+ System.out.println("<<<<<<<< end head");
+ System.out.println("=====================================================");
+
+ // 断言:拿到非空工厂源码,且形状像工厂(含 export default + createGame)
+ assertTrue(factorySrc.length() > 60, "工厂源码应非空且有实质内容,实得长度=" + factorySrc.length());
+ assertTrue(factorySrc.contains("export default"),
+ "工厂源码应含 export default(默认导出工厂),实得头部:\n" + factorySrc.substring(0, Math.min(200, factorySrc.length())));
+ assertTrue(factorySrc.contains("createGame"),
+ "工厂源码应含 createGame 工厂名");
+ }
+
+ /**
+ * 从模型输出抽 js 代码块(等价 Python validate.extract_code)。
+ * 优先 ```js/```javascript;退而取首个 ``` 块;再无则:若含 export default 用全文,否则空串。
+ */
+ private static String extractJsBlock(String content) {
+ if (content == null || content.isEmpty()) {
+ return "";
+ }
+ // ```js / ```javascript 块
+ Matcher m = Pattern.compile("```(?:js|javascript)\\s*\\n(.*?)```", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(content);
+ if (m.find()) {
+ return m.group(1).strip();
+ }
+ // 任意 ``` 块(取第一个)
+ m = Pattern.compile("```\\s*\\n(.*?)```", Pattern.DOTALL).matcher(content);
+ if (m.find()) {
+ return m.group(1).strip();
+ }
+ // 无 fence:含 export default 则用全文
+ if (content.contains("export default")) {
+ return content.strip();
+ }
+ return "";
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java
new file mode 100644
index 00000000..8b604e39
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java
@@ -0,0 +1,319 @@
+package com.wanxiang.huijing.game.module.aigc.saa;
+
+import com.fasterxml.jackson.core.json.JsonReadFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * SaaPrompts —— SAA 迁移 step 4:把 Python 生成系统的 prompt / 角色 / gatespec 抽取逐字移植到 Java。
+ *
+ * 公平性铁律(对比测试只换编排框架、不换 prompt/模型):本类内容与 Python 侧逐字对齐,不得改写语义:
+ *
+ * - {@link #SYSTEM} ≡ wg1/gen-worker/worker/prompt.py:24-107(模块形状/能力面/12 禁止/硬规则/P0 latch/few-shot 挂点);
+ * - {@link #buildMessages} ≡ prompt.py:110-119(system 注入 few-shot;user 末尾回喂上轮失败原因,全量重出非 diff);
+ * - {@link #DESIGN_SYSTEM} ≡ roles.py:8-32(设计 agent + 末尾自产 ```gatespec);
+ * - {@link #playerSystem} ≡ roles.py:38-57(玩家 agent 人格 + 1-5 分锚 + 空心必判 fix);
+ * - {@link #extractGatespec} ≡ studio.py:72-102(正则抠 ```gatespec → 容错解析 → 缺省补 expectLatch / ballPath .y→.x 归一)。
+ *
+ *
+ * few-shot 与 Python 同口径:读 game-runtime/games/_wg1-gen/_goldenpath-probe/factory.js(读文件防漂移,prompt.py:9-20)。
+ *
+ * @author 造梦AI
+ */
+final class SaaPrompts {
+
+ private SaaPrompts() {
+ }
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /**
+ * 宽松 JSON 解析器(A-5:等价 Python json_repair 的常见容错):
+ * 容许单引号、未引号字段名、尾随逗号、注释、控制字符——便宜模型高频脏点,尽量救活而非静默退 {}。
+ */
+ private static final ObjectMapper LENIENT = com.fasterxml.jackson.databind.json.JsonMapper.builder()
+ .enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
+ .enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
+ .enable(JsonReadFeature.ALLOW_TRAILING_COMMA)
+ .enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
+ .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS)
+ .build();
+
+ /** few-shot 参照工厂相对 game-runtime 根的路径(与 prompt.py:_PROBE 同一文件,读文件防漂移)。 */
+ static final String PROBE_REL = "games/_wg1-gen/_goldenpath-probe/factory.js";
+
+ // ============================================================================
+ // ① SYSTEM —— 逐字移植 prompt.py:24-107(__FEWSHOT__ 运行时注入)。
+ // 注:Java 文本块对 \\ 与 " 有转义,原文为 Python 原始串(r""")且不含反斜杠,故内容逐字一致。
+ // ============================================================================
+ static final String SYSTEM = """
+ 你是轻量 H5 小游戏代码生成器。你的唯一产出 = 一个【独立的 GameHostFactory 模块】(原生 JS ESM 单文件)。
+ 宿主会把它打成 bundle 在浏览器里真玩。你不写 HTML、不写宿主、不写引擎——只写这一个工厂模块。
+
+ 【输出格式·严格】只输出一个 ```js 代码块,块内是完整工厂源码,块外不要任何解释文字。
+
+ 【模块形状】
+ - 文件用 'use strict'; 开头。
+ - 默认导出一个工厂函数:export default function createGame(opts) { ... return { init, update, render, destroy, _forensicsView }; }
+ - 工厂返回 GameInstance:必须有 init / update / render / destroy 四个方法(可选 onInput,但优先用 ctx.getInput());
+ 另【必须】导出 _forensicsView(可测性红线,见下),harness 据此确定性判"游戏真有进展"。
+
+ 【生命周期(宿主这样驱动你)】
+ - game = createGame(opts) // opts 可能为 undefined,必须 const o = opts || {} 防御
+ - await game.init(boot) // 一次性。boot = { ctx, mainContext, canvas, seed }
+ - game.update(dt) // 每逻辑步,dt 恒定 = 1/60 秒
+ - game.render(g) // 每渲染帧,g === boot.mainContext(同一个 2D 画布上下文)
+ - game.destroy() // 卸载,幂等
+
+ 【能力一律经 boot.ctx(init 里存下 ctx 再用)】
+ - ctx.getEngine() -> 引擎能力 | null(可能为 null,必须容错!)。返回三个面:
+ · getEngine().particles.spawnEmitter(spec) -> 句柄{isActive(),stop()};引擎自动渲染粒子到画布,你无需手动画。
+ spec 字段(除 pos 全可选):pos:{x,y}, count(爆发颗数), emitRate, particleTime, speed, coneAngle,
+ sizeStart, sizeEnd, colorStart:{r,g,b,a}(0..1), colorEnd:{r,g,b,a}, additive。坐标=逻辑像素。
+ · getEngine().audio.synth.synthSfx(paramArray) -> 合成音效样本(ZzFX 参数数组,如 [1,0.05,220,,,0.18,,1.4])。
+ · getEngine().math.lerp(a,b,t) / math.smoothStep(t) / math.easing?.quadOut(t)(easing 是可选,用 ?. 防御)。
+ - ctx.getInput() -> { on(type, handler) -> {cancel()}, off(...) }
+ type ∈ 'pointerdown'|'pointermove'|'pointerup'|'keydown'|'keyup'
+ handler 收事件 { type, x, y, key, tMs };【x/y 已是逻辑像素坐标:x∈[0,390]、y∈[0,844],可直接当像素用做命中判定/定位,**绝对不要再乘以视口宽高**】;key 仅键盘有意义。
+ - ctx.time.nowMs() / ctx.time.elapsedMs() // 受控时钟。update 首行应 const now = ctx.time.nowMs();
+ - ctx.random.next() / ctx.random.range(min,max) / ctx.random.reseed(seed) // 受控随机
+ - boot.mainContext 就是 render(g) 的 g —— 一个标准 CanvasRenderingContext2D,画在它上面(fillRect/arc/fillText/drawImage…)。
+
+ 【视口】逻辑坐标系固定 390 宽 × 844 高(移动竖屏)。按逻辑像素绘制,不要自己处理 DPR。
+
+ 【硬禁止(违反则装载/运行直接失败)】
+ - 禁 import 任何引擎/外部包(不要 import 'littlejsengine',不要 import 任何东西;只写一个自包含模块)。
+ - 禁 Math.random()(用 ctx.random)、禁 Date.now()/performance.now()(用 ctx.time)。
+ 注意:Math.sin/cos/floor/max/min/PI 等【允许】,只有 Math.random 禁。
+ - 禁 addEventListener(用 ctx.getInput().on)、禁 requestAnimationFrame(宿主驱动 update/render)。
+ - 禁 new AudioContext / document / window / 直接操作全局 canvas / 直接 new 引擎对象。
+ - render 只画到入参 g,不要去拿别的画布。
+
+ 【硬规则(务必遵守)】
+ 1. 闭包状态(游戏对象/分数/数组/订阅句柄)在 init 内初始化,不要写在工厂函数顶层常量里。
+ 2. update 首行刷新 const now = ctx.time.nowMs(); 全帧用它,不要自己累加私有时钟。
+ 3. ctx.getEngine() 可能返回 null:所有用法都要 const eng = ctx.getEngine(); if (eng && eng.particles) {...},easing 用 ?.。
+ 4. destroy 里取消所有输入订阅(try { sub.cancel(); } catch(_){}),幂等。
+ 5. 【必须】在某个游戏事件(点击/得分/碰撞/胜负)上至少调用一次 getEngine() 的能力(粒子爆发或音效),
+ 这样真玩门能证明游戏真用了引擎/插件库(这是硬性验收点,别只用纯 canvas 自绘)。
+ 6. 画面必须每帧有可见变化(动起来),背景要铺满 390×844(别留透明,否则判白屏)。
+ 7. 资产容错:不要依赖外部图片;用 canvas 几何图形画(矩形/圆/路径/文字)。
+
+ 【可测性红线 + P0 latch 终态(必须做:宿主据此判「游戏结束」代发 game_end 走结算;做不到=游戏关不上闭环=判不通过)】
+ - 工厂返回的实例除 init/update/render/destroy 外,【必须】导出 _forensicsView 方法(纯只读视图,仿官方范本 generated-factory.js):
+ _forensicsView() { return { seed, state: () => ({ phase, score, remaining, progress, gameoverReason }) }; }
+ - state() 必须是【闭包实时读当前游戏状态】的函数:每次调用返回最新值,不要返回开局快照常量。
+ - 【phase = 权威终态字段】取值 'booting' | 'playing' | 'gameover':
+ · init 后置 'playing'(可玩);
+ · 满足结束条件(通关 / 失败 / 超时 任一)置 'gameover'。
+ - 【★ P0 latch 驻留·硬约束】一旦进入 'gameover',phase 必须【恒驻留】为 'gameover'——**禁止下一帧自动重开 / 复位回 playing**。
+ 原因:宿主每 500ms 轮询 host.state().phase,读到 'gameover' 才代发 game_end 结算;若终态是「瞬时态」(某帧 gameover 下帧又变),轮询会漏读 → 游戏永远结算不了。**结束后必须停住、驻留终态(不自动重开)。**
+ - 其余字段(反映真实状态、与画面一致):
+ · score:当前分数(数字,从 0 起)。
+ · remaining:剩余目标数(剩余砖块/敌人/回合/目标,数字;确无此概念可置 null)。
+ · progress:0~1 进度(可选数字)。
+ · gameoverReason:终态原因短串(如 'cleared_all' / 'ball_lost' / 'timeout';未结束置 '')。
+ · 【题面若要求】额外导出关键实体的实时位置(如球/挡板/玩家/敌人的 {x,y}、速度 {vx,vy}、尺寸 {w,h}),
+ 按题面指定的字段名导出(如 ball:{x,y,vx,vy}、paddle:{x,y,w})。harness 据此【适配性真玩】(自动接球等)+
+ 【确定性验手感】(控制是否跟手、碰撞是否正确);位置必须是闭包实时真值,与画面一致。
+ - 红线含义:撞砖加分→score 真涨、砖被消→remaining 真减、判负/通关→phase 真切 'gameover' 且**驻留**。
+ harness 会真玩到 gameover 并验【驻留 + 真有进展(remaining 下降等)】;终态瞬时/数值原地不动 = 判不通过。
+
+ 【好玩接地(玩家 agent 反复揪的高频缺口,务必做到,否则真玩判分必低)】
+ - HUD 看得到进展:除分数外,显示「剩余目标数」(剩余砖块/敌人/回合/目标),让玩家 30 秒内明确感到「在推进」;HUD 文字置顶部留白区,别和游戏元素叠字。
+ - 反馈看得见:getEngine().particles.spawnEmitter 用够强参数(count≥20、sizeStart 8~14、particleTime≥0.4、colorStart 用亮色且 a=1),否则截图里看不到粒子=等于没反馈;关键事件(命中/得分/胜负)都要爆一次。
+ - 必有失败态 + 胜负演出:游戏要「会输」(掉命/超时/破产任一),且胜/负时有明确画面变化或文案(别只停住不动)。
+ - 节奏可上手:开局不要立刻高难或死局,前 30 秒要能上手、有正反馈、看得到分数增长。
+
+ 下面是一个【最小但处处合规的参照实现】,请严格模仿它的结构、取能力方式、防御写法,但实现成题目要求的游戏(注意:参照件只示范「合规骨架」,你要在其上做到上面的「好玩接地」):
+
+ ```js
+ __FEWSHOT__
+ ```
+ """;
+
+ // ============================================================================
+ // ② DESIGN_SYSTEM —— 逐字移植 roles.py:8-32(设计 agent + 末尾自产 gatespec)。
+ // ============================================================================
+ static final String DESIGN_SYSTEM = """
+ 你是轻量 H5 小游戏的【设计 agent】。输入是一句话/一段题面,你的产出是一份**简洁、可直接实现**的设计稿,供下游代码 agent 写成游戏。
+
+ 【只产出设计稿,绝不写代码】输出用紧凑中文条目,覆盖:
+ 1. 核心机制:玩家做什么、目标是什么(一两句说清玩法循环)。
+ 2. 胜负条件:怎样算赢、怎样算输(**必须有"会输"的失败态**——超时/失误/资源耗尽任一)。
+ 3. 操作:触摸/按键如何映射到动作(记住坐标是逻辑像素 390×844 竖屏)。
+ 4. 关键数值:速度/数量/尺寸/难度梯度的建议区间(给具体数,别含糊)。
+ 5. juice 反馈:哪些事件该有粒子/音效/屏震(至少 1 处必须触发引擎能力)。
+ 6. 画面要点:背景铺满、主要元素形状与颜色、HUD(分数等)。
+ 7. 可观测进展:点明"玩家在推进"的可量化信号(如剩余目标数下降 / 分数上升 / 关卡推进),并说明胜负如何切状态——这是判"真有进展、非空心"的依据。
+
+ 【约束】设计正文控制在 ~200 字内,聚焦"代码 agent 实现时需要的决策",不堆砌辞藻、不写引擎/契约细节(那是代码 agent 的事)。
+
+ 【末尾必附 gate-spec(机器可读·harness 据此确定性验"能玩 + 手感"·这是 gate-H 推广到任意游戏的关键)】
+ 设计正文之后另起一个 ```gatespec 代码块,块内为 JSON,声明本游戏怎样被确定性验证:
+ ```gatespec
+ {
+ "exportState": ["phase","score","remaining","progress","<关键实体位置:如 ball:{x,y,vx,vy}、paddle:{x,y,w};纯点击类可省位置>"],
+ "driver": {"type":"paddle-intercept","ballPath":"ball.x","paddleY":800},
+ "controlCheck": {"paddlePath":"paddle.x","tapXs":[50,340]},
+ "assertAfterPlay": [{"path":"remaining","op":"decreased","why":"击碎砖块→剩余下降"}],
+ "expectLatch": true
+ }
+ ```
+ 规则:① `expectLatch` 恒 true(游戏必须能真玩到 gameover 且驻留);② 有"连续控制体(挡板/角色随手指平滑移动)"才给 `controlCheck`,纯点击/无连续控制给 null;③ 技巧类(挡板接球/接物)`driver.type="paddle-intercept"`,`ballPath`=**球的水平 x 坐标字段**(挡板水平移动去对齐它,几乎总是 `ball.x`,**绝不要填 ball.y**),`paddleY`=挡板所在 y;纯点击类用 `{"type":"none"}`;④ `assertAfterPlay` 至少一条"真有进展"断言(op ∈ increased/decreased/changed/>/>=/<=/==/in),path 用你让代码导出的字段名(须与 exportState 一致)。""";
+
+ // ============================================================================
+ // ③ player_system —— 逐字移植 roles.py:38-57(玩家 agent 人格 + 1-5 分锚 + 空心必 fix)。
+ // ============================================================================
+
+ /** 玩家 agent system prompt(persona 注入),逐字对齐 roles.py:player_system。 */
+ static String playerSystem(String persona) {
+ return "你是一名【玩家 agent】,人格设定:" + persona + "。\n"
+ + "你会拿到一款 H5 小游戏的【运行数据】(确定性 harness 真玩取证:是否真渲染/输入是否真生效/调了哪些引擎能力、"
+ + "以及 H_progress 机制进展门是否过),可能还附【首帧截图】与【玩后截图】。"
+ + "请像真玩家那样、严格基于**证据**判断(不要脑补没看到的东西)。\n\n"
+ + "【第一步必须先判「真有进展」,再谈好玩】对比首帧↔玩后截图、看运行数据:\n"
+ + "- 若画面玩前玩后几乎无变化,或数据显示分数/剩余/进度【零变化】(如剩余砖数没减、分数没动),"
+ + "或 H_progress(机制进展)门未过 → 判定为【空心游戏】:fun 必须给 1~2、verdict=fix。**空心绝不给 3 分及以上。**\n"
+ + "- 截图里看不到反馈(粒子/数字跳动/元素消除)→ 按「反馈缺失」扣分。\n\n"
+ + "【好玩 fun(1-5) 分锚——务必拉开档,别都给 3】\n"
+ + " 1 = 不能玩 / 空心零进展;2 = 能跑但无聊(无反馈 / 无失败态 / 原地打转);"
+ + "3 = 基础可玩(循环成立、有反馈,但平淡);4 = 有手感、看得到进展、想再玩一把;5 = 惊艳,节奏与反馈都到位。\n"
+ + "【完整度 completeness(1-5)】能玩吗?核心循环 / 可见反馈 / 胜负态是否齐全(空心或缺胜负 → ≤2)。\n"
+ + "【verdict】真有进展且至少基础可玩(fun≥3) → 可 pass;空心或明显体验缺陷 → fix。"
+ + "别对每个游戏都 fix(会失去区分力),也别放过空心。\n"
+ + "诚实、挑剔,但分数要反映**真实差距**:好游戏和空壳必须给出不同的分。\n"
+ + "【只输出 JSON】:{\"completeness\":1-5, \"fun\":1-5, \"problems\":[\"...\"], "
+ + "\"verdict\":\"pass\"或\"fix\", \"note\":\"一句话总评\"}。";
+ }
+
+ // ============================================================================
+ // ④ build_messages —— 逐字移植 prompt.py:110-119(few-shot 注入 + 回喂全量重出)。
+ // ============================================================================
+
+ /**
+ * 组装 (system, user),逐字对齐 prompt.py:build_messages。
+ *
+ * @param briefText 题面(含设计稿,对齐 studio.py:216 的 enriched)。
+ * @param retryFeedback 重试时回喂上轮失败原因(null=首轮 code)。
+ * @param gameRuntimeRoot game-runtime 根(读 few-shot 探针 factory)。
+ * @return [0]=system(已注入 few-shot),[1]=user。
+ */
+ static String[] buildMessages(String briefText, String retryFeedback, Path gameRuntimeRoot) {
+ String system = SYSTEM.replace("__FEWSHOT__", fewShot(gameRuntimeRoot));
+ String user = "请实现下面这款游戏,产出一个合规的 GameHostFactory 模块(只输出一个 ```js 代码块):\n\n" + briefText;
+ if (retryFeedback != null && !retryFeedback.isEmpty()) {
+ user += "\n\n———\n上一次生成【未通过】,失败原因如下,请针对性修正后重新产出完整模块(不要只给 diff):\n"
+ + retryFeedback;
+ }
+ return new String[]{system, user};
+ }
+
+ /** 读 few-shot 探针 factory(读文件防漂移,对齐 prompt.py:_few_shot)。 */
+ static String fewShot(Path gameRuntimeRoot) {
+ try {
+ return new String(Files.readAllBytes(gameRuntimeRoot.resolve(PROBE_REL)), StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ return "(few-shot 参照缺失)";
+ }
+ }
+
+ // ============================================================================
+ // ⑤ extractGatespec —— 逐字移植 studio.py:72-102(无 json_repair,做最小宽松解析)。
+ // ============================================================================
+
+ /** ```gatespec 块抽取正则(对齐 studio.py:75)。 */
+ private static final Pattern GATESPEC = Pattern.compile("```gatespec\\s*(.+?)```", Pattern.DOTALL);
+
+ /**
+ * A-5:设计稿里是否存在 ```gatespec 块(不论能否解析)。
+ * 用于区分「无块」(静默退 brief 默认 play_spec,对齐 Python)与「有块但解析失败」(须记 gatespecParseError 并回喂)。
+ */
+ static boolean hasGatespecBlock(String designText) {
+ return designText != null && GATESPEC.matcher(designText).find();
+ }
+
+ /**
+ * 从设计稿抽 ```gatespec JSON 块(gate-H 推广),对齐 studio.py:_extract_gatespec:
+ * 正则抠块 → Jackson 容错解析(无 json_repair,做最小宽松解析)→ 缺省补 expectLatch=true、
+ * paddle-intercept 的 ballPath .y→.x 安全归一。无块/解析失败返回 null。
+ *
+ * @return 解析出的 gatespec(JsonNode,对象),或 null。
+ */
+ static JsonNode extractGatespec(String designText) {
+ if (designText == null) {
+ return null;
+ }
+ Matcher m = GATESPEC.matcher(designText);
+ if (!m.find()) {
+ return null;
+ }
+ String raw = m.group(1).strip();
+ JsonNode obj = looseParse(raw);
+ if (obj == null || !obj.isObject()) {
+ return null;
+ }
+ com.fasterxml.jackson.databind.node.ObjectNode on = (com.fasterxml.jackson.databind.node.ObjectNode) obj;
+ // 轻校验:assertAfterPlay 须 list;非 list 则置空数组(对齐 studio.py:93-94)。
+ if (on.has("assertAfterPlay") && !on.get("assertAfterPlay").isArray()) {
+ on.set("assertAfterPlay", MAPPER.createArrayNode());
+ }
+ // expectLatch 缺省补 true(官方 latch 契约恒需,对齐 studio.py:95)。
+ if (!on.has("expectLatch")) {
+ on.put("expectLatch", true);
+ }
+ // 安全归一:paddle-intercept 的 ballPath .y→.x(便宜模型偶把 ball.y 当横向,对齐 studio.py:97-101)。
+ JsonNode drv = on.get("driver");
+ if (drv != null && drv.isObject() && "paddle-intercept".equals(drv.path("type").asText())) {
+ JsonNode bp = drv.get("ballPath");
+ if (bp != null && bp.isTextual() && bp.asText().endsWith(".y")) {
+ String fixed = bp.asText();
+ ((com.fasterxml.jackson.databind.node.ObjectNode) drv)
+ .put("ballPath", fixed.substring(0, fixed.length() - 2) + ".x");
+ }
+ }
+ return on;
+ }
+
+ /**
+ * 宽松 JSON 解析(替代 Python json_repair,A-5 加强):
+ * ① 严格 Jackson;② 截最外层 {...} 去前后说明文字 + 去尾随逗号后再严格;
+ * ③ 仍不行则用 {@link #LENIENT}(容单引号/未引号字段名/尾逗号/注释/控制字符)兜底。
+ * 三关全败返回 null(降级=退 brief 默认 play_spec)。
+ */
+ static JsonNode looseParse(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ // ① 严格解析。
+ try {
+ return MAPPER.readTree(raw);
+ } catch (Exception ignore) {
+ // 落到宽松分支。
+ }
+ // ② 截最外层 {...}(去掉块内可能混入的前后说明文字)+ 去对象/数组尾随逗号(,] / ,})。
+ int lo = raw.indexOf('{');
+ int hi = raw.lastIndexOf('}');
+ if (lo < 0 || hi <= lo) {
+ return null;
+ }
+ String body = raw.substring(lo, hi + 1).replaceAll(",\\s*([}\\]])", "$1");
+ try {
+ return MAPPER.readTree(body);
+ } catch (Exception ignore) {
+ // 落到宽松 mapper 兜底(单引号/未引号字段名/注释等)。
+ }
+ // ③ 宽松 mapper(等价 json_repair 常见容错)最后一搏。
+ try {
+ return LENIENT.readTree(body);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTest.java
new file mode 100644
index 00000000..2a02aa3c
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTest.java
@@ -0,0 +1,451 @@
+package com.wanxiang.huijing.game.module.aigc.saa;
+
+import com.alibaba.cloud.ai.graph.CompileConfig;
+import com.alibaba.cloud.ai.graph.CompiledGraph;
+import com.alibaba.cloud.ai.graph.KeyStrategy;
+import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
+import com.alibaba.cloud.ai.graph.OverAllState;
+import com.alibaba.cloud.ai.graph.RunnableConfig;
+import com.alibaba.cloud.ai.graph.StateGraph;
+import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.springframework.ai.openai.OpenAiChatModel;
+import org.springframework.ai.openai.OpenAiChatOptions;
+import org.springframework.ai.openai.api.OpenAiApi;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import static com.alibaba.cloud.ai.graph.StateGraph.END;
+import static com.alibaba.cloud.ai.graph.StateGraph.START;
+import static com.alibaba.cloud.ai.graph.action.AsyncEdgeAction.edge_async;
+import static com.alibaba.cloud.ai.graph.action.AsyncNodeAction.node_async;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * SaaStudioGraphTest —— SAA 迁移 step 4(关键里程碑):组装全 StateGraph + 跑通一款真 brief 端到端,
+ * 产出第一款 SAA 真生成的游戏。
+ *
+ *
全图拓扑(蓝图 §3.1):
+ *
+ * START → render → design → generate → validate
+ * validate --ok--> scaffold --> build
+ * validate --fail&cnt<N--> repair build --ok--> play build --fail&cnt<N--> repair
+ * play --九门pass--> player play --fail&cnt<N--> repair play --fail&cnt>=N--> giveup
+ * player --无体验问题/轮尽--> emit player --有问题&轮未尽--> repair
+ * repair --> generate emit/giveup --> END
+ *
+ *
+ * 公平性铁律(对比测试只换编排框架):prompt/角色/模型路由全部走 {@link SaaPrompts}(逐字移植 Python wg1/gen-worker),
+ * 模型路由用 models.yaml stage1(design/code/fix/player_text=deepseek-v4-flash、player_vision=MiniMax-M3)。
+ *
+ *
验收口径(按任务要求):理想 verdict.pass=true;若 maxRepairs 内未过门,只要全图机制(条件边/repair 回环/
+ * 各节点)真跑通也算 step4 达成(过门质量是后续调参/对比的事)。
+ *
+ *
纪律(step4 边界):emit 不接 DifyCallbackReqVO/HMAC(step7);saver 用默认内存态(MysqlSaver 是 step5);不接 job 契约。
+ *
+ *
运行(mini-desktop,需 NEWAPI_KEY + harness):
+ *
+ * NEWAPI_KEY=sk-... mvn -pl ...aigc-server -am -Dtest=SaaStudioGraphTest \
+ * -Dsaa.harness=1 -DGAME_RUNTIME_DIR=/root/games-development-ai/game-runtime \
+ * -Dsurefire.failIfNoSpecifiedTests=false test
+ *
+ *
+ * @author 造梦AI
+ */
+@EnabledIfEnvironmentVariable(named = "NEWAPI_KEY", matches = ".+")
+@EnabledIfSystemProperty(named = "saa.harness", matches = "1",
+ disabledReason = "需 mini-desktop harness 运行机(node+esbuild+Chrome),无机时跳过;开关 -Dsaa.harness=1")
+class SaaStudioGraphTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** 主仓 game-runtime 根(harness + few-shot 探针所在)。 */
+ private static final Path GAME_RUNTIME_ROOT = Paths.get(
+ System.getProperty("GAME_RUNTIME_DIR",
+ System.getenv().getOrDefault("GAME_RUNTIME_DIR", "/root/games-development-ai/game-runtime")));
+
+ /**
+ * 对比测试 brief 名(-Dsaa.brief=breakout|whack|flappy|...;默认 breakout)。
+ * P4 对比测试参数化:同一全图按不同 brief 各跑一遍,只换 brief(编排框架/模型/门全等)。
+ */
+ private static final String BRIEF_NAME = System.getProperty("saa.brief", "breakout");
+
+ /** step4/P4 专用唯一 game id 基名(避免撞现有游戏;随 brief 名变,run 内再缀 runId)。 */
+ private static final String GAME_ID = "saa-" + BRIEF_NAME;
+
+ /** new-api host 根(Spring AI baseUrl 不带 /v1,剥之;坑见 §7-1)。 */
+ private static final String NEW_API_BASE_URL = stripV1Suffix(
+ System.getenv().getOrDefault("NEWAPI_BASE_URL", "http://100.64.0.8:3000/v1"));
+
+ /** 修复轮上限(对齐 studio.py:max_repairs 默认 5)。 */
+ private static final int MAX_REPAIRS = 5;
+ /** player 顾问轮上限(对齐 studio.py:player_rounds 默认 1)。 */
+ private static final int MAX_PLAYER_ROUNDS = 1;
+ /**
+ * 递归硬刹车(repair 回环成环时的超步预算;优雅终止非异常,对齐蓝图 §3.2 限次双保险)。
+ * A-7:写死推导式 = (maxRepairs+1) 轮 × 每轮经过节点数 + 余量。
+ * 每轮主链最长路径节点数 ≈ 7(render/design 仅首轮各 1 次,循环体 generate→validate→scaffold→build→play→player→repair);
+ * 取 (5+1)×7=42 再宽放余量至 120(远超实际所需,确保正常收口不会误触超步)。
+ */
+ private static final int RECURSION_LIMIT = (MAX_REPAIRS + 1) * 7 + 78; // =42+78=120
+
+ // models.yaml stage1 角色模型名(逐字对齐 wg1/gen-worker/worker/models.yaml)。
+ private static final String M_DESIGN = "deepseek-v4-flash";
+ private static final String M_CODE = "deepseek-v4-flash";
+ private static final String M_FIX = "deepseek-v4-flash";
+ private static final String M_PLAYER_TEXT = "deepseek-v4-flash";
+ private static final String M_PLAYER_VISION = "MiniMax-M3";
+
+ private static String stripV1Suffix(String url) {
+ String u = url.trim();
+ while (u.endsWith("/")) {
+ u = u.substring(0, u.length() - 1);
+ }
+ if (u.endsWith("/v1")) {
+ u = u.substring(0, u.length() - 3);
+ }
+ return u;
+ }
+
+ /**
+ * 建一个指向 new-api 的 OpenAiChatModel(共用 OpenAiApi,仅 defaultOptions 异,对齐 dossier §1-24)。
+ * A-3:per-role 采样面随请求下发(temperature/maxTokens 按角色,逐字对齐 Python studio.py 各位置)。
+ *
+ * @param temperature 采样温度;null=不下发 temperature(让网关用服务端默认,对齐 AgentScope 默认——
+ * Python config.build_model 不传 generate_kwargs,design 等角色即用服务端默认温度)。
+ * @param maxTokens 最大补全 tokens(code/fix=16000、player_text=4000、player_vision=900,对齐 studio.py:160/167)。
+ */
+ private static OpenAiChatModel model(OpenAiApi api, String modelName, Double temperature, int maxTokens) {
+ OpenAiChatOptions.Builder opts = OpenAiChatOptions.builder()
+ .model(modelName)
+ .maxTokens(maxTokens);
+ if (temperature != null) {
+ opts.temperature(temperature);
+ }
+ return OpenAiChatModel.builder()
+ .openAiApi(api)
+ .defaultOptions(opts.build())
+ .build();
+ }
+
+ @Test
+ void full_graph_generates_one_breakout_end_to_end() throws Exception {
+ String key = System.getenv("NEWAPI_KEY");
+ assertNotNull(key, "NEWAPI_KEY 未设(密钥须经环境变量,绝不入库)");
+
+ // 预检:harness + few-shot 探针就位。
+ assertTrue(Files.isDirectory(GAME_RUNTIME_ROOT), "GAME_RUNTIME_DIR 不存在:" + GAME_RUNTIME_ROOT);
+ assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("games/_wg1-gen/_shared/serve-and-play.sh")),
+ "缺 serve-and-play.sh(harness 地板),路径:" + GAME_RUNTIME_ROOT);
+ assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("node_modules/esbuild")),
+ "缺 node_modules/esbuild(build 节点依赖)");
+ assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve(SaaPrompts.PROBE_REL)),
+ "缺 few-shot 探针 factory:" + SaaPrompts.PROBE_REL);
+
+ // == 1) N 个共用 OpenAiApi 的角色模型(全指 new-api;公平性=与 Python 同模型路由)==
+ OpenAiApi api = OpenAiApi.builder().baseUrl(NEW_API_BASE_URL).apiKey(key).build();
+ // A-3:per-role 采样面(temperature/maxTokens)逐字对齐 Python——
+ // design:Python config.build_model 不传 temperature → AgentScope 用服务端默认 → 此处 temperature=null(不下发);
+ // code/fix:_client.chat temperature=0.0、max_tokens=16000(确定性产出);
+ // player_text:studio.py:167 _judge_text max_tokens=4000、temperature=0.3;
+ // player_vision:studio.py:160 _judge_vision max_tokens=900、temperature=0.3。
+ OpenAiChatModel designModel = model(api, M_DESIGN, null, 16000);
+ OpenAiChatModel codeModel = model(api, M_CODE, 0.0, 16000);
+ OpenAiChatModel fixModel = model(api, M_FIX, 0.0, 16000);
+ OpenAiChatModel playerTextModel = model(api, M_PLAYER_TEXT, 0.3, 4000);
+ OpenAiChatModel playerVisionModel = model(api, M_PLAYER_VISION, 0.3, 900);
+
+ // == 2) 组装全 StateGraph(蓝图 §3.1)==
+ KeyStrategyFactory keyFactory = () -> {
+ Map keys = new HashMap<>();
+ for (String k : SaaStudioNodes.ALL_KEYS) {
+ keys.put(k, new ReplaceStrategy());
+ }
+ return keys;
+ };
+
+ StateGraph graph = new StateGraph("saa-studio-full", keyFactory)
+ // -- 节点 --
+ .addNode("render", node_async(SaaStudioNodes.renderNode()))
+ .addNode("design", node_async(SaaStudioNodes.designNode(designModel, M_DESIGN, GAME_RUNTIME_ROOT)))
+ .addNode("generate", node_async(SaaStudioNodes.generateNode(codeModel, fixModel, M_CODE, M_FIX, GAME_RUNTIME_ROOT)))
+ .addNode("validate", node_async(SaaGenNodes.validateNode()))
+ .addNode("scaffold", node_async(SaaGenNodes.scaffoldNode(GAME_RUNTIME_ROOT)))
+ .addNode("build", node_async(SaaGenNodes.buildNode(GAME_RUNTIME_ROOT)))
+ .addNode("play", node_async(SaaGenNodes.playNode(GAME_RUNTIME_ROOT)))
+ .addNode("player", node_async(SaaStudioNodes.playerNode(playerTextModel, playerVisionModel, M_PLAYER_TEXT, M_PLAYER_VISION, GAME_RUNTIME_ROOT)))
+ .addNode("repair", node_async(SaaStudioNodes.repairNode()))
+ .addNode("emit", node_async(SaaStudioNodes.emitNode(GAME_RUNTIME_ROOT)))
+ .addNode("giveup", node_async(SaaStudioNodes.giveupNode()))
+ // -- 直边 --
+ .addEdge(START, "render")
+ .addEdge("render", "design")
+ .addEdge("design", "generate")
+ .addEdge("generate", "validate")
+ .addEdge("scaffold", "build")
+ .addEdge("repair", "generate")
+ .addEdge("emit", END)
+ .addEdge("giveup", END)
+ // -- 条件边(失败回边共用判据;九门=硬地板;player=软门)--
+ .addConditionalEdges("validate",
+ edge_async(SaaStudioNodes.okElseRepairOrGiveup(SaaStudioNodes.K_VALIDATE_OK, MAX_REPAIRS)),
+ Map.of("ok", "scaffold", "repair", "repair", "giveup", "giveup"))
+ .addConditionalEdges("build",
+ edge_async(SaaStudioNodes.okElseRepairOrGiveup(SaaStudioNodes.K_BUILD_OK, MAX_REPAIRS)),
+ Map.of("ok", "play", "repair", "repair", "giveup", "giveup"))
+ .addConditionalEdges("play",
+ edge_async(SaaStudioNodes.playRouter(MAX_REPAIRS)),
+ Map.of("ok", "player", "repair", "repair", "giveup", "giveup"))
+ .addConditionalEdges("player",
+ edge_async(SaaStudioNodes.playerRouter(MAX_REPAIRS, MAX_PLAYER_ROUNDS)),
+ Map.of("emit", "emit", "repair", "repair"));
+
+ // recursionLimit 给足(repair 回环成环;超步=优雅终止)。
+ CompiledGraph compiled = graph.compile(
+ CompileConfig.builder().recursionLimit(RECURSION_LIMIT).build());
+
+ // == 3) 跑一款真 brief(breakout,与 step2 同)端到端 ==
+ // A-9:runId/gameId 参数化——每 run 唯一 gameId(基名+runId),落独立目录,避免并行/重跑互撞(对照 run.py:56-63)。
+ // 端口 port/cdpPort 也入 state(默认 4320/9222);worker 并行形态可每 run 错开端口并配 Semaphore(1) 串行真玩门。
+ // 【Semaphore(1) 挂点】真玩门(play)用固定端口+Chrome,多 run 并行须串行化:worker 形态在此 acquire 一把
+ // 全局 Semaphore(1)、跑完 release(本测试单 run,仅留挂点注释,不引入并发)。
+ String runId = new java.text.SimpleDateFormat("yyyyMMdd-HHmmss").format(new java.util.Date());
+ String gameId = GAME_ID + "-" + runId;
+ int port = Integer.getInteger("saa.port", 4320);
+ int cdpPort = Integer.getInteger("saa.cdpPort", 9222);
+
+ // 跑前清旧 evidence/产物:唯一 gameId 目录若残留(极少数同秒重跑)先净场,保证证据是本 run 的。
+ Path gameDir = GAME_RUNTIME_ROOT.resolve("games/_wg1-gen").resolve(gameId);
+ deleteRecursively(gameDir);
+
+ BriefData bd = readBrief(BRIEF_NAME);
+ Map inputs = new HashMap<>();
+ inputs.put(SaaStudioNodes.K_BRIEF, bd.brief);
+ inputs.put(SaaStudioNodes.K_GAME_ID, gameId);
+ // 公平性铁律:play_spec 基线取 brief 自带 play_spec(含 inputs/driver/controlCheck/assertAfterPlay/
+ // expectedEngineCallPrefixes),与 Python run._load_brief 同口径;design 自产 gatespec 再 merge 覆盖
+ // exportState/driver/controlCheck/assertAfterPlay/expectLatch(与 studio.py:218-223 同),inputs/
+ // expectedEngineCallPrefixes 不在覆盖名单故保留——否则 SAA 侧 whack 无 inputs、各 brief 无 F 门期望前缀=比 Python 宽松,不公平。
+ inputs.put(SaaStudioNodes.K_PLAY_SPEC, bd.playSpec);
+ inputs.put("port", port);
+ inputs.put("cdpPort", cdpPort);
+ inputs.put("runId", runId);
+
+ RunnableConfig runConfig = RunnableConfig.builder().threadId(gameId).build();
+
+ long t0 = System.currentTimeMillis();
+ Optional result = compiled.invoke(inputs, runConfig);
+ long wallMs = System.currentTimeMillis() - t0;
+
+ assertTrue(result.isPresent(), "全图 invoke 应返回 state");
+ OverAllState s = result.get();
+
+ // == 读出最终态 ==
+ // A-7:recursionLimit 静默不完整态兜底——超步等优雅终止时 K_STATUS 可能为空(未抵 emit/giveup),
+ // 此处把「空 status」兜底当 failed(视为未收口的失败终态),杜绝下游误判为成功。
+ String status = s.value(SaaStudioNodes.K_STATUS, String.class)
+ .filter(v -> !v.isBlank()).orElse("failed");
+ boolean playPass = s.value(SaaStudioNodes.K_PLAY_PASS, Boolean.class).orElse(false);
+ int repairs = ((Number) s.value(SaaStudioNodes.K_REPAIR_COUNT, 0)).intValue();
+ int playerRounds = ((Number) s.value(SaaStudioNodes.K_PLAYER_ROUND, 0)).intValue();
+ long tin = ((Number) s.value(SaaStudioNodes.K_TOKENS_IN, 0)).longValue();
+ long tout = ((Number) s.value(SaaStudioNodes.K_TOKENS_OUT, 0)).longValue();
+ String designText = s.value(SaaStudioNodes.K_DESIGN_TEXT, String.class).orElse("");
+ String gatespec = s.value(SaaStudioNodes.K_GATESPEC, String.class).orElse("");
+ String playSpec = s.value(SaaStudioNodes.K_PLAY_SPEC, String.class).orElse("");
+ String panel = s.value(SaaStudioNodes.K_PLAYER_PANEL, String.class).orElse("(no panel)");
+ String engineBundle = s.value(SaaStudioNodes.K_ENGINE_BUNDLE, String.class).orElse("");
+ String failure = s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("");
+ String feedback = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("");
+
+ // == 证据落锤 ==(gameDir 已在 run 前按唯一 gameId 算好,此处复用)
+ Path vpath = gameDir.resolve("evidence/verdict.json");
+ System.out.println("================ SAA step4 全图端到端证据 ================");
+ System.out.println("[gameId] " + gameId + " runId=" + runId + " cwd=" + GAME_RUNTIME_ROOT);
+ System.out.println("[status] " + status + " playPass=" + playPass);
+ System.out.println("[repairs] " + repairs + " (上限 " + MAX_REPAIRS + ") playerRounds=" + playerRounds);
+ System.out.println("[wall] " + wallMs + "ms");
+ System.out.println("[tokens] in=" + tin + " out=" + tout + " total=" + (tin + tout));
+ System.out.println("[模型路由] design/code/fix/player_text=" + M_CODE + " player_vision=" + M_PLAYER_VISION);
+ System.out.println("[gatespec] " + (gatespec.isEmpty() ? "(无)" : gatespec));
+ System.out.println("[merged playSpec] " + playSpec);
+ System.out.println("[player panel] " + panel);
+ if (!failure.isEmpty()) {
+ System.out.println("[failureReason] " + failure);
+ }
+ if (!feedback.isEmpty()) {
+ System.out.println("[last feedback] " + (feedback.length() > 600 ? feedback.substring(0, 600) : feedback));
+ }
+ System.out.println("[设计稿头 12 行] >>>>>>>>");
+ String[] dl = designText.split("\n", -1);
+ for (int i = 0; i < Math.min(12, dl.length); i++) {
+ System.out.println(String.format("%3d| %s", i + 1, dl[i]));
+ }
+ System.out.println("<<<<<<<< end 设计稿");
+ System.out.println("[evidence 四件套] verdict.json / first-paint.png / after-play.png @ " + gameDir.resolve("evidence"));
+ System.out.println("[engineBundle] len=" + engineBundle.length()
+ + " 含__GameBundle=" + engineBundle.contains("__GameBundle"));
+ if (Files.exists(vpath)) {
+ var v = MAPPER.readTree(vpath.toFile());
+ System.out.println("[verdict.pass] " + v.path("pass").asBoolean(false));
+ var guards = v.path("guards");
+ if (guards.isObject()) {
+ var it = guards.fields();
+ StringBuilder gb = new StringBuilder("[九门] ");
+ while (it.hasNext()) {
+ var e = it.next();
+ gb.append(e.getKey()).append("=").append(e.getValue().path("pass").asBoolean(false) ? "✓" : "✗").append(" ");
+ }
+ System.out.println(gb.toString());
+ }
+ }
+ System.out.println("=========================================================");
+
+ // == B-8 诊断 JSON 落盘(per-model token + cost¥ + attempts,schema 对齐 studio.py:297-307)==
+ // cost ¥ 复用 Python cost.py 子进程(传 tokens_by_model → 出 ¥),不在 Java 重写折算口径。
+ try {
+ String tokensByModel = s.value(SaaStudioNodes.K_TOKENS_BY_MODEL, String.class).orElse("{}");
+ String attempts = s.value(SaaStudioNodes.K_ATTEMPTS, String.class).orElse("[]");
+ JsonNode costNode = computeCostViaPython(tokensByModel); // 子进程算 ¥(失败返回 error 节点,不中断)。
+
+ com.fasterxml.jackson.databind.node.ObjectNode diag = MAPPER.createObjectNode();
+ diag.put("game_id", gameId);
+ diag.put("run_id", runId);
+ diag.put("stage", "stage1");
+ diag.put("pass", playPass);
+ diag.put("status", status);
+ diag.put("repairs", repairs);
+ diag.put("player_rounds_used", playerRounds);
+ diag.put("wall_ms", wallMs);
+ diag.set("tokens", MAPPER.createObjectNode().put("prompt", tin).put("completion", tout));
+ diag.set("tokens_by_model", MAPPER.readTree(tokensByModel));
+ diag.set("attempts", MAPPER.readTree(attempts));
+ diag.set("cost", costNode);
+ diag.set("models", MAPPER.createObjectNode()
+ .put("design", M_DESIGN).put("code", M_CODE).put("fix", M_FIX)
+ .put("player_text", M_PLAYER_TEXT).put("player_vision", M_PLAYER_VISION));
+ diag.set("seven_gate_verdict", Files.exists(vpath) ? MAPPER.readTree(vpath.toFile()) : null);
+ // P4:把 player panel({text,vision} 评判)落进 diag JSON(schema 对齐 studio.py:304 的 player 字段,便于对比脚本直读)。
+ try {
+ JsonNode panelNode = SaaPrompts.looseParse(panel);
+ diag.set("player", (panelNode != null && panelNode.isObject()) ? panelNode : null);
+ } catch (Exception ignorePanel) {
+ diag.set("player", null);
+ }
+
+ Path diagDir = GAME_RUNTIME_ROOT.getParent().resolve("wg1/gen-worker/results");
+ Files.createDirectories(diagDir);
+ Path diagPath = diagDir.resolve(gameId + ".saa.json");
+ Files.write(diagPath, MAPPER.writerWithDefaultPrettyPrinter().writeValueAsBytes(diag));
+ System.out.println("[诊断JSON] " + diagPath);
+ System.out.println("[cost¥] " + costNode);
+ System.out.println("[tokensByModel] " + tokensByModel);
+ } catch (Exception e) {
+ System.out.println("[诊断JSON] 写失败(不影响 step4 判定):" + e.getMessage());
+ }
+
+ // == 断言(step4 达成口径)==
+ // ① 全图机制真跑通:必抵达一个终态节点(emit succeeded / failed-emit / giveup)。
+ assertTrue("succeeded".equals(status) || "failed".equals(status),
+ "全图必须抵达终态(emit/giveup),实得 status=" + status + " failure=" + failure);
+ // ② 编排链真执行过:设计稿 + 至少一次生成(gatespec 或 factorySrc 落盘)。
+ assertTrue(designText.length() > 20, "design 节点应产出设计稿");
+ assertTrue(Files.exists(gameDir.resolve("generated-factory.js")),
+ "scaffold 应至少落盘一次 generated-factory.js(证明 generate→validate→scaffold 链通)");
+ // ③ 理想路径(pass=true)落锤:若过门,verdict.pass 必 true + engineBundle 含全局名。
+ if (playPass) {
+ assertTrue(Files.exists(vpath), "过门应产出 verdict.json");
+ var v = MAPPER.readTree(vpath.toFile());
+ assertTrue(v.path("pass").asBoolean(false), "playPass=true 时 verdict.json pass 必 true");
+ assertTrue("succeeded".equals(status), "过门应 status=succeeded");
+ assertTrue(engineBundle.contains("__GameBundle"), "succeeded 的 engineBundle 必含 __GameBundle 全局名");
+ } else {
+ // 未过门:机制仍须真跑(repair 回环至少触发一次或抵 giveup)。
+ System.out.println("[note] 本次未过九门(status=" + status + ")。step4 达成口径=全图机制真跑通(条件边/repair 回环/各节点);过门质量留后续调参。");
+ assertTrue(repairs >= 1 || "failed".equals(status),
+ "未过门时应至少触发一次 repair 回环或抵 giveup,实得 repairs=" + repairs + " status=" + status);
+ }
+ }
+
+ /**
+ * B-8:复用 Python cost.py 子进程把 tokens_by_model 折成 ¥(不在 Java 重写 new-api quota 口径)。
+ * 调 gen-worker .venv 的 python(有 httpx/openai),cost.py --stdin 读 {"tokens_by_model":...} → 出 {by_model,total_rmb,gate_rmb}。
+ * 子进程缺失/网络失败 → 返回 {"error":...}(不中断 step4 判定)。
+ */
+ private static JsonNode computeCostViaPython(String tokensByModelJson) {
+ try {
+ Path workerRoot = GAME_RUNTIME_ROOT.getParent().resolve("wg1/gen-worker");
+ Path venvPy = workerRoot.resolve(".venv/bin/python");
+ String py = Files.exists(venvPy) ? venvPy.toString() : "python3";
+ ProcessBuilder pb = new ProcessBuilder(py, "-m", "worker.cost", "--stdin");
+ pb.directory(workerRoot.toFile());
+ pb.redirectErrorStream(false); // 分离 stderr,避免污染 JSON。
+ Process p = pb.start();
+ // 喂 stdin:{"tokens_by_model": {...}}。
+ try (var os = p.getOutputStream()) {
+ os.write(("{\"tokens_by_model\":" + tokensByModelJson + "}").getBytes(StandardCharsets.UTF_8));
+ }
+ String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
+ boolean done = p.waitFor(60, java.util.concurrent.TimeUnit.SECONDS);
+ if (!done) {
+ p.destroyForcibly();
+ return MAPPER.createObjectNode().put("error", "cost.py 子进程超时(60s)");
+ }
+ if (p.exitValue() != 0 || out.isBlank()) {
+ return MAPPER.createObjectNode().put("error",
+ "cost.py exit=" + p.exitValue() + " stderr=" + (err.length() > 300 ? err.substring(0, 300) : err));
+ }
+ return MAPPER.readTree(out);
+ } catch (Exception e) {
+ return MAPPER.createObjectNode().put("error", "cost.py 调用异常:" + e.getMessage());
+ }
+ }
+
+ /** 净场删目录(递归;不存在则跳过)。A-9:每 run 唯一 gameId 目录跑前清旧。 */
+ private static void deleteRecursively(Path dir) throws Exception {
+ if (!Files.exists(dir)) {
+ return;
+ }
+ try (var walk = Files.walk(dir)) {
+ walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ } catch (Exception ignore) {
+ // 单文件删失败不阻断(极少;目录唯一名,残留无害)。
+ }
+ });
+ }
+ }
+
+ /** brief 题面 + play_spec 串(与 Python run._load_brief 同口径:brief 正文 + 自带 play_spec JSON 串)。 */
+ private record BriefData(String brief, String playSpec) {
+ }
+
+ /**
+ * 读指定 brief(题面正文 + 自带 play_spec),与 Python 同一来源 wg1/gen-worker/briefs/<name>.json。
+ * play_spec 缺省给 "{}"(design 自产 gatespec 仍可 merge);brief 文件不可达直接抛(P4 必须真 brief,不走内联兜底以免静默换题)。
+ */
+ private static BriefData readBrief(String name) throws Exception {
+ // briefs 在主仓根(非 game-runtime 下):从 GAME_RUNTIME_ROOT 上推一级。
+ Path briefPath = GAME_RUNTIME_ROOT.getParent().resolve("wg1/gen-worker/briefs/" + name + ".json");
+ assertTrue(Files.exists(briefPath), "brief 文件不存在(P4 须真 brief):" + briefPath);
+ var node = MAPPER.readTree(briefPath.toFile());
+ String b = node.path("brief").asText("");
+ assertTrue(!b.isEmpty(), "brief 文件缺 brief 正文:" + briefPath);
+ JsonNode ps = node.path("play_spec");
+ String psStr = (ps != null && !ps.isMissingNode() && !ps.isNull()) ? ps.toString() : "{}";
+ return new BriefData(b, psStr);
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java
new file mode 100644
index 00000000..baecf0e9
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java
@@ -0,0 +1,745 @@
+package com.wanxiang.huijing.game.module.aigc.saa;
+
+import com.alibaba.cloud.ai.graph.OverAllState;
+import com.alibaba.cloud.ai.graph.action.EdgeAction;
+import com.alibaba.cloud.ai.graph.action.NodeAction;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.ai.chat.messages.SystemMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.metadata.Usage;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.content.Media;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.util.MimeTypeUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * SaaStudioNodes —— SAA 迁移 step 4:LLM 驱动的编排节点 + 条件边谓词(generate/design/player/repair/emit/giveup)。
+ *
+ * 本类承接 Python studio.py 的编排循环语义(design→[code|fix 环]→九门→player 顾问轮),
+ * 与 {@link SaaGenNodes}(validate/scaffold/build/play,step3 已建)拼成蓝图 §3.1 的全 StateGraph。
+ *
+ *
公平性铁律:prompt/角色/模型路由全部走 {@link SaaPrompts}(逐字移植 Python),本类只负责「把它接进图」,不改 prompt 语义。
+ *
+ *
语义逐一对齐 Python 参照(wg1/gen-worker/worker/):
+ *
+ * - {@link #renderNode} ≈ studio.py:213 起初始化(role=code/repairCount=0/playerRound=0,把 brief 落 state);
+ * - {@link #designNode(ChatModel,Path)} ≈ studio.py:214-223(设计 agent + 抽 gatespec + merge play_spec);
+ * - {@link #generateNode(ChatModel,ChatModel,Path)} ≈ studio.py:231-245(首轮 code/重出 fix,build_messages 回喂,抽 ```js);
+ * - {@link #playerNode(ChatModel,ChatModel,Path)} ≈ studio.py:171-280(M3 视觉位+文本位 panel,软门回喂);
+ * - {@link #repairNode} ≈ studio.py:231 的 attempt 自增 + role=fix(feedback 已由失败节点写好);
+ * - {@link #emitNode(Path)} ≈ service.py:184-195(读 bundle.iife.js→断 __GameBundle→status/engineBundle);
+ * - {@link #giveupNode} = 超 maxRepairs 的失败终态(status=failed/failureReason)。
+ *
+ *
+ * 纪律(本步边界):emit 不接 DifyCallbackReqVO/HMAC 回调(那是 step7 形态②);不接 saver(step5);不接 job 契约。
+ *
+ * @author 造梦AI
+ */
+final class SaaStudioNodes {
+
+ private SaaStudioNodes() {
+ }
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** esbuild iife 全局名(契约冻结:出厂自检据此,对齐 service.py:184)。 */
+ private static final String GLOBAL_NAME = "__GameBundle";
+
+ // ====================== state key 约定(OverAllState) ======================
+ static final String K_BRIEF = "brief"; // 原始题面(in)
+ static final String K_ENRICHED = "enriched"; // brief + 设计稿(design 节点产出,喂 generate)
+ static final String K_DESIGN_TEXT = "designText"; // 设计稿正文
+ static final String K_GATESPEC = "gatespec"; // 设计 agent 自产 gatespec(JSON 串,诊断)
+ static final String K_GATESPEC_ERROR = "gatespecParseError"; // gatespec 有块但解析失败的错因(A-5;诊断+回喂)
+ static final String K_PLAY_SPEC = "playSpec"; // play-spec JSON 串(merge gatespec 后;scaffold 落盘)
+ static final String K_FACTORY_SRC = "factorySrc"; // 工厂源码(generate 产出,validate/scaffold 读)
+ static final String K_GAME_ID = "gameId"; // 唯一游戏 id
+ static final String K_ROLE = "role"; // code|fix(区分模型)
+ static final String K_REPAIR_COUNT = "repairCount"; // 修复轮计数(主限次)
+ static final String K_PLAYER_ROUND = "playerRound"; // player 顾问轮计数(软门限次)
+ static final String K_FEEDBACK = "feedback"; // 上轮失败回喂(generate 注入 prompt;失败路由写)
+ static final String K_PLAYER_FEEDBACK = "playerFeedback"; // player 软门体验问题(与失败 feedback 解耦,避免串味)
+ static final String K_VALIDATE_OK = "validateOk"; // validate 节点产出(SaaGenNodes 写)
+ static final String K_BUILD_OK = "buildOk"; // build 节点产出
+ static final String K_PLAY_PASS = "playPass"; // play 九门是否全过
+ static final String K_VERDICT = "verdict"; // 九门 verdict(JSON 串)
+ static final String K_PLAYER_PANEL = "playerPanel"; // player panel 评判(JSON 串,诊断)
+ static final String K_TOKENS_IN = "tokensIn"; // 累计 prompt tokens
+ static final String K_TOKENS_OUT = "tokensOut"; // 累计 completion tokens
+ static final String K_TOKENS_BY_MODEL = "tokensByModel"; // per-model token(JSON 串:{model:{in,out}},B 对比指标/成本拆账)
+ static final String K_ATTEMPTS = "attempts"; // 每轮记录(JSON 数组串:{attempt,role,usage,stage_fail,guards},对齐 studio.py:attempts)
+ static final String K_STATUS = "status"; // succeeded|failed(emit/giveup 写)
+ static final String K_FAILURE = "failureReason"; // 失败原因(giveup/emit 写)
+ static final String K_ENGINE_BUNDLE = "engineBundle"; // bundle.iife.js 全文(emit 产出)
+
+ /** state key 全集(KeyStrategyFactory 注册用,全 ReplaceStrategy 覆盖语义)。 */
+ static final String[] ALL_KEYS = {
+ K_BRIEF, K_ENRICHED, K_DESIGN_TEXT, K_GATESPEC, K_GATESPEC_ERROR, K_PLAY_SPEC, K_FACTORY_SRC, K_GAME_ID,
+ K_ROLE, K_REPAIR_COUNT, K_PLAYER_ROUND, K_FEEDBACK, K_VALIDATE_OK, K_BUILD_OK,
+ K_PLAY_PASS, K_VERDICT, K_PLAYER_PANEL, K_PLAYER_FEEDBACK, K_TOKENS_IN, K_TOKENS_OUT,
+ K_TOKENS_BY_MODEL, K_ATTEMPTS,
+ K_STATUS, K_FAILURE, K_ENGINE_BUNDLE,
+ // SaaGenNodes 内部 key(scaffold/build/play 写):
+ "gameDir", "validateErrors", "buildLog",
+ // A-9 参数化入参(port/cdpPort/runId 由 invoke inputs 注入;play 读 port/cdpPort,默认 4320/9222):
+ "port", "cdpPort", "runId",
+ };
+
+ // ====================== ```js 抽取(对齐 validate.extract_code) ======================
+ private static final Pattern JS_FENCE =
+ Pattern.compile("```(?:js|javascript)\\s*\\n(.*?)```", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
+ private static final Pattern ANY_FENCE = Pattern.compile("```\\s*\\n(.*?)```", Pattern.DOTALL);
+
+ /** 从模型输出抽 ```js 代码块(对齐 validate.py:extract_code / step2 extractJsBlock)。 */
+ static String extractJsBlock(String content) {
+ if (content == null || content.isEmpty()) {
+ return "";
+ }
+ Matcher m = JS_FENCE.matcher(content);
+ if (m.find()) {
+ return m.group(1).strip();
+ }
+ m = ANY_FENCE.matcher(content);
+ if (m.find()) {
+ return m.group(1).strip();
+ }
+ return content.contains("export default") ? content.strip() : "";
+ }
+
+ // ====================== LLM 调用:单次 180s 超时 + ≤3 次重试(仅 429/5xx/IO/timeout) ======================
+
+ /** 单次 LLM 调用墙钟超时秒数(对齐 Python _client.py:57 timeout=180.0)。 */
+ private static final long LLM_TIMEOUT_SECS = 180;
+ /** LLM 最大尝试次数(对齐 Python _client.py:60 tries=3)。 */
+ private static final int LLM_MAX_TRIES = 3;
+ /** 重试间隔毫秒(对齐 Python _client.py:60 retry_delay=2.0)。 */
+ private static final long LLM_RETRY_DELAY_MS = 2000;
+
+ /** LLM 调用的专用执行器(守护线程,供 Future 超时取消)。 */
+ private static final ExecutorService LLM_POOL = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "saa-llm-call");
+ t.setDaemon(true);
+ return t;
+ });
+
+ /**
+ * 给一次 LLM 调用套单次 180s 墙钟超时 + 最多 3 次重试(只重 429/5xx/IO/timeout,对齐 Python _client.py:57-98)。
+ *
纪律(评审 A-1):模型异常不再裸退图——超限后由调用方写 feedback/status,避免一次瞬时网关 502/超时就让整张图崩。
+ * Spring AI 自身的 maxRetries 只兜瞬时 HTTP,这里再加墙钟超时(防单次调用挂死无限期阻塞节点)+ 统一重试分类。
+ *
+ * @param call 实际产出 [文本,in,out] 的调用(同步阻塞,由本方法在独立线程跑 + 限时取消)。
+ * @return [文本, inDelta(Long), outDelta(Long)]。
+ * @throws Exception 超过最大尝试次数仍失败(最后一次异常上抛,调用方按节点语义兜底)。
+ */
+ private static Object[] callWithTimeoutAndRetry(Callable