merge(saa): 真结构化生成地基 feat→dev/2.0.0——U1-U4(运行时约定/build-from-source/真产gameDefinition/多模型回退)+6c6g评审整改(P1 K_STATUS清零/P2 fallback注释);flag默认factory灰度opt-in,生产零行为变更
This commit is contained in:
commit
39c16630ed
@ -20,6 +20,31 @@ canonical: docs/agent-specs/2026-06-17-固定游戏架构与SAA-agentic-studio-{
|
||||
|
||||
---
|
||||
|
||||
## 执行状态(2026-06-18 收口 · Mac 全开发线交付 · 分支 `feat/mac-saa-gen-lifecycle` 已推 origin 供 6c6g 审)
|
||||
|
||||
> 执行收口快照(决策正文 U1-U5 / KTD / HTD 不动)。状态轴 = 交付 / 验证 / 待办。
|
||||
|
||||
**U1–U4 已交付 + 本机验证 + 推送:**
|
||||
- **U1(引擎线·含正式化)**:`game-runtime/src/host/gd-runtime.js`(createRuntime/rt) + 单测 + 4 品类收敛证(gd-archetypes) + 约定文 `runtime-api-2d.md` + **`runtime-api-2d.d.ts`(TS 契约·第10类 additive·正式化产物)** + **clickable 声明式点击基元**(治便宜模型 tap-handler 顽疾)。`node --test` 60/60。
|
||||
- **U2(接缝·零改下游)**:`build-from-source.mjs`(装配器+CLI) + 后端接缝(`SaaGenNodes.validateNode` gamedef 经 CLI 子进程校验+装配 → factorySrc,**scaffold/build/harness/build.mjs 全零改**) + 确定性点改解锁(`modifyNode`/`modifyRouter` gamedef→validate)。
|
||||
- **U3**:`SaaPrompts.GAMEDEF_SYSTEM`(2 few-shot:realtime + tap-targets 声明式 clickable) + `buildGameDefMessages` + `generateNode` gamedef 分支产 `K_SOURCE_PROJECT`;flag `saaSourceMode`(默认 `factory` 字节不变,灰度 opt-in)。
|
||||
- **U4**:`generateNode` 多模型回退链(`GenModels` record,主 deepseek-v4-flash → stage2 救场升档)。
|
||||
- **双评审整改**:ce-correctness × ce-adversarial 18 条全核证整改(driver 命名路径投影 / 校验边界静态扫描治死循环+沙箱+确定性逃逸 / rt 冻结 / addScore 守 num)。
|
||||
- **后端验收**:`mvn test-compile` 过 + 模型无关单测 Topology 30/30 + Regression 8/8(含 gamedef deterministic→validate 路由证)。
|
||||
|
||||
**gamedef 路实证(lili-mac 快走查 `wg1/gen-worker/gamedef_quickcheck.py`):**
|
||||
- **范式端到端真成立·路径证正确**:声明式 gameDefinition → 装配 → 构建 → 真九门;手写金样两品类(realtime/physics + tap-targets)均**过 harness 9/9**;**repair 回环真抬率**(runner 7/9→9/9)。
|
||||
- **success rate = 引擎生成能力(非本计划完成项)**:便宜模型单管线产出**高方差**(同 brief 8/9~5/9),N=1~2 本机小样信噪比不足。**正式 cutover ≥60% 量测 authoritative = mini-desktop `SaaFullGraphE2eTest -Dsaa.e2e=1`**(多 brief + 5+3 repair + stage2)。
|
||||
|
||||
**U5(ReAct · 接法B)= 正式延期**:项目 pom 明示「ReactAgent/asNode 延 Phase1.5」+ 本计划「地基稳后」门 + R4 便宜模型 tool-use 待实测;本期只做 dependency 前置核版,未写不可验代码。
|
||||
|
||||
**待 6c6g(本计划「不动现 8 契约」/ 共享 DESIGN_SYSTEM / 评审 域):**
|
||||
- `source-project.schema.json` 的 component `kind` 枚举增 `clickable`(additive contract-first 补正;现经宽松校验功能可用)。
|
||||
- design↔gamedef 的 `assertAfterPlay` 字段词汇契约(design 常造 `moves`/`totalRound` 等取证读不到字段 → 应约束到 `score`/`remaining`)。
|
||||
- 每单元 codex 一轮评审(分支已推)。
|
||||
|
||||
---
|
||||
|
||||
## 摘要
|
||||
|
||||
把已双证的 PoC **产线化进 SAA**,让"改源不改包 / 可维护结构化源项目 / LLM as 工作室"真正上产线:运行时访问约定(2D 适配器契约)→ build-from-source 产线化 → generate 真产 gameDefinition(替现 iife 空壳)→ 模型可靠性兜底 → 地基稳后 **ReAct 演进**(接法B)。**接法A(裸图 16 节点 studio,已建七成、已合 dev/2.0.0)是接法B 的躯干,evolve 增量不返工。** 架构 SoT 见 canonical(固定架构与SAA-agentic-studio),本档是其**产线化执行计划**,不重复架构。
|
||||
|
||||
@ -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 语义)。
|
||||
|
||||
|
||||
@ -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={}",
|
||||
|
||||
@ -138,6 +138,102 @@ 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 作背景(否则判白屏)。
|
||||
- **命名逐字对齐设计稿 gatespec**: 设计稿末尾 ```gatespec 块的 driver.ballPath / controlCheck.paddlePath / assertAfterPlay.path
|
||||
引用的名字,你的实体 id 与进展字段必须**逐字一致**:
|
||||
· 挡板接球类: 球实体 id **就叫 "ball"**、挡板 **就叫 "paddle"**(别用 paddlePlayer/ball1 等变体——九门读 ball.x/paddle.x,变体=读到 undefined=挂)。
|
||||
· 离散点击类(井字棋/打地鼠/翻牌/扫雷): **声明式·免写 tap-handler**——每个可点目标实体打 tags:["target"]+idx,
|
||||
并加一个 **clickable 组件** `{kind:"clickable", score:1, mark:{shape:"circle", color:"#ffcc00", r:30}}`;运行时**内置**:
|
||||
点中该目标 → 自动翻 occupied + spawn mark(可见变化) + 计分 + fx(粒子/音效)。**绝不要自己写 justTapped/overlap 的 tap-handler**
|
||||
(实测便宜/强模型都常把命中门控在分离 state 实体/计时器/turn 上→盲点驱动永远点不中→E_live/G_input/F/H 全挂)。
|
||||
九门盲点 occupied!==true 的目标;打地鼠等计时类:让当前激活目标 occupied=false(driver 才点)、其余 occupied=true(或 spawn/destroy 控可点性)。
|
||||
- **可控体必须响应指针**(九门 driver 靠点击指针位驱动你的控制体): 挡板/玩家等可控实体务必随指针移动,如
|
||||
`const p=rt.getEntity('paddle'); if(p && rt.input.pointer.down){ p.x = rt.input.pointer.x; }`(只接键盘=driver 推不动=I_control 挂)。
|
||||
- **进展落在可取证字段**: 取证只暴露 score / remaining(=存活实体数,destroy 目标即降)/ 实体位置——进展务必体现在 rt.addScore 或
|
||||
rt.destroy(目标/砖)上; 确保设计稿 assertAfterPlay 的 path 落在 score 或 remaining(别用取证读不到的自定义字段)。
|
||||
- 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"}]
|
||||
}
|
||||
|
||||
【参照2(离散点击/tap-targets 类:井字棋/打地鼠/翻牌/三消——**声明式·无 behaviors**;每个目标加 clickable 组件,运行时自动处理点击翻态+计分+特效)】
|
||||
{
|
||||
"components":[
|
||||
{"id":"bg","kind":"render","shape":"fill","color":"#10101a"},
|
||||
{"id":"tile","kind":"render","shape":"rect","color":"#33384a","w":100,"h":100},
|
||||
{"id":"clk","kind":"clickable","score":1,"mark":{"shape":"circle","color":"#ffcc00","r":30}}
|
||||
],
|
||||
"entities":[
|
||||
{"id":"world","transform":{"position":{"x":0,"y":0}},"components":["bg"]},
|
||||
{"id":"t0","transform":{"position":{"x":75,"y":350}},"tags":["target"],"idx":0,"components":["tile","clk"]},
|
||||
{"id":"t1","transform":{"position":{"x":195,"y":350}},"tags":["target"],"idx":1,"components":["tile","clk"]},
|
||||
{"id":"t2","transform":{"position":{"x":315,"y":350}},"tags":["target"],"idx":2,"components":["tile","clk"]},
|
||||
{"id":"t3","transform":{"position":{"x":75,"y":520}},"tags":["target"],"idx":3,"components":["tile","clk"]},
|
||||
{"id":"t4","transform":{"position":{"x":195,"y":520}},"tags":["target"],"idx":4,"components":["tile","clk"]},
|
||||
{"id":"t5","transform":{"position":{"x":315,"y":520}},"tags":["target"],"idx":5,"components":["tile","clk"]}
|
||||
],
|
||||
"behaviors":[],
|
||||
"scenes":[{"id":"main","entityRefs":["world","t0","t1","t2","t3","t4","t5"]}],
|
||||
"rules":[{"id":"win","condition":"rt.score >= 6","outcome":"win"}]
|
||||
}
|
||||
""";
|
||||
|
||||
// ============================================================================
|
||||
// ② DESIGN_SYSTEM —— 逐字移植 roles.py:8-32(设计 agent + 末尾自产 gatespec)。
|
||||
// ============================================================================
|
||||
@ -259,6 +355,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;
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -333,6 +333,9 @@ final class SaaStudioNodes {
|
||||
// 救场阶梯控制面初值(§6.3):失败计数归零、模型档起 stage1(escalate 命中阈值才升 stage2)。
|
||||
out.put(K_FAIL_COUNT, 0);
|
||||
out.put(K_MODEL_TIER, "stage1");
|
||||
// 6c6g 评审 P1:清 stale K_STATUS——checkpoint/saver 跨 run 复用 state 时,上一轮 giveup 写的 K_STATUS="failed"
|
||||
// 会残留;下次 modify 进来 modifyRouter 首检 K_STATUS 会误判失败、把 gamedef 确定性点改错误路由到 END。render 起手归零。
|
||||
out.put(K_STATUS, "");
|
||||
// modify 入参透传(create 路缺省空串;renderRouter 据 modifyMode 分流,§6.2)。
|
||||
out.put(K_MODIFY_MODE, s.value(K_MODIFY_MODE, String.class).orElse(""));
|
||||
out.put(K_BASE_VERSION_ID, s.value(K_BASE_VERSION_ID, String.class).orElse(""));
|
||||
@ -431,71 +434,114 @@ 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 重试后再试一档,治超时/返空/截断;接现救场体系不另起限次)。
|
||||
// 6c6g 评审 P2(有意为之·保可用降级):stage2 升档后 primary=强档,但 fallback 仍取廉价档(codeFallback=MiniMax-M3)——
|
||||
// 这是【可用性优先于本次升档边际质量】的刻意取舍:强档也超时时仍要出包(有结果好过整 job 崩),而非追求"回退档也必须强档"。
|
||||
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 +1016,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 +1072,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 +1146,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 +1473,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";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
*
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
259
game-runtime/src/host/build-from-source.mjs
Normal file
259
game-runtime/src/host/build-from-source.mjs
Normal file
@ -0,0 +1,259 @@
|
||||
/**
|
||||
* build-from-source.mjs —— build-from-source 装配器(gameDefinition → 可玩工厂源文本 · build-from-source-v0)
|
||||
* owner:本 session(引擎线 · plan 2026-06-18-001 U2) | 消费方:SaaGenNodes.scaffoldNode(落 generated-factory.js)→ build.mjs(esbuild)→ 九门 harness
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把后端线产出的 sourceProject(声明式 gameDefinition + behaviors 各带逻辑 JS)装配成
|
||||
* `generated-factory.js` 源文本——对齐已冻装载契约(game-host.d.ts:default export 工厂 →
|
||||
* GameInstance{init,update,render,destroy,_forensicsView}),GameInstance 五法委托 U1 的 createRuntime。
|
||||
*
|
||||
* 【信任模型(评审定论·铁律)】模型产出的 behavior.code / rule.condition 是**不可信代码**。
|
||||
* - 它**只在浏览器**(Chrome via 九门 CDP harness)执行;esbuild 只**静态打包**(从不执行它);
|
||||
* 生产链上 **Node 永不执行不可信模型代码**(本仓 Node 仅在单测里跑「自产金样」)。浏览器即安全边界。
|
||||
* - 同步 JS 无法在进程内硬中断(死循环/逃逸),故**在校验边界静态拦截**({@link #scanLogic})——把
|
||||
* 危险模式(禁用全局/确定性逃逸/死循环/condition 副作用)在编译前转成 validationErrors → repair 反馈,
|
||||
* 而非靠 prompt 自觉。这是 new Function 不沙箱的现实缓解(对齐双评审 Critical 的边界化建议)。
|
||||
*
|
||||
* 【接现有·零改下游】产出文本 → scaffoldNode 落 generated-factory.js → scripts/build.mjs(esbuild,
|
||||
* 唯一构建脚本不动)→ bundle.iife.js → serve-and-play.sh + play.cdp.cjs 九门(harness 零改)。
|
||||
*
|
||||
* 【为何「behaviors 带 JS 经数据串内联」】gameDefinition 内联为 JS 对象字面量,behavior.code 作字符串随之内联;
|
||||
* 运行期 createRuntime 经 new Function 编译。JSON.stringify 完备转义;另把 `<` 转 `<`(保 inline-injection 安全,
|
||||
* 防 </script> 提前闭合 host 页内联 <script>)。
|
||||
*
|
||||
* 【fail-loud】校验失败抛错(带 validationErrors)——buildNode 捕获回灌 repair feedback。strip 未知键是产线容错必做。
|
||||
* 【迭代状态 v0】轻量校验 + top-level strip(不引 ajv 依赖,守 game-runtime「本机零依赖只跑 node --test」)。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/** gameDefinition 顶层白名单(对齐 schema#/properties/gameDefinition)。 */
|
||||
const GAMEDEF_KEYS = ['entities', 'components', 'behaviors', 'scenes', 'rules'];
|
||||
/** 生成 game 目录中,generated-factory.js → src/host/gd-runtime.js 的相对 import 路径(games/_wg1-gen/<name>/ 下三级回 game-runtime)。 */
|
||||
const DEFAULT_RUNTIME_IMPORT = '../../../src/host/gd-runtime.js';
|
||||
|
||||
/* ── 静态扫描禁则(边界拦截不可信模型逻辑·转 repair 信号) ───────────────────── */
|
||||
/** 通用禁则(behavior.code + rule.condition 都扫):确定性逃逸 + 沙箱逃逸 + DOM/网络 + 死循环。 */
|
||||
const LOGIC_BANS = [
|
||||
[/\bMath\s*\.\s*random\b/, '禁 Math.random(用 rt.random,否则取证不可复现)'],
|
||||
[/\bDate\s*\.\s*now\b/, '禁 Date.now(用 rt.time.now)'],
|
||||
[/\bnew\s+Date\b/, '禁 new Date(用 rt.time.now)'],
|
||||
[/\bperformance\s*\.\s*now\b/, '禁 performance.now(用 rt.time.now)'],
|
||||
[/\bprocess\b/, '禁 process(沙箱逃逸)'],
|
||||
[/\brequire\s*\(/, '禁 require(沙箱逃逸)'],
|
||||
[/\bimport\s*\(/, '禁动态 import(沙箱逃逸)'],
|
||||
[/\bglobalThis\b/, '禁 globalThis(沙箱逃逸)'],
|
||||
[/\beval\s*\(/, '禁 eval'],
|
||||
[/\.\s*constructor\b/, '禁 .constructor(Function 构造逃逸)'],
|
||||
[/\bFunction\s*\(/, '禁 Function 构造器(沙箱逃逸)'],
|
||||
[/\baddEventListener\b/, '禁 addEventListener(用 rt.input)'],
|
||||
[/\brequestAnimationFrame\b/, '禁 requestAnimationFrame(宿主驱动)'],
|
||||
[/\bdocument\b/, '禁 document(用 render 组件/rt)'],
|
||||
[/\bwindow\b/, '禁 window'],
|
||||
[/\bfetch\s*\(/, '禁 fetch(无网络)'],
|
||||
[/\bXMLHttpRequest\b/, '禁 XMLHttpRequest(无网络)'],
|
||||
[/while\s*\(\s*true\s*\)/, '禁 while(true) 无界循环(同步死循环会冻帧/挂进程)'],
|
||||
[/while\s*\(\s*1\s*\)/, '禁 while(1) 无界循环'],
|
||||
[/for\s*\(\s*;\s*;\s*\)/, '禁 for(;;) 无界循环'],
|
||||
];
|
||||
/** rule.condition 专属禁则:必须是无副作用的布尔表达式(不得含语句分隔/赋值/世界变更)。 */
|
||||
const CONDITION_BANS = [
|
||||
[/;/, 'rule.condition 不得含 ; (须是单个布尔表达式,非语句)'],
|
||||
[/(^|[^=!<>])=(?!=)/, 'rule.condition 不得含赋值(=)(须无副作用)'],
|
||||
[/\b(addScore|setScore|spawn|destroy)\s*\(/, 'rule.condition 不得调世界变更方法(addScore/setScore/spawn/destroy)'],
|
||||
[/\.\s*(win|lose)\s*\(/, 'rule.condition 不得调 win()/lose()(胜负经 outcome,非 condition 副作用)'],
|
||||
];
|
||||
|
||||
/** 扫一段逻辑字符串;返回违规消息数组(空=过)。isCondition=true 时附加 condition 专属禁则。 */
|
||||
function scanLogic(code, label, isCondition) {
|
||||
const out = [];
|
||||
if (typeof code !== 'string') return out;
|
||||
for (const [re, msg] of LOGIC_BANS) { if (re.test(code)) out.push(label + ' ' + msg); }
|
||||
if (isCondition) { for (const [re, msg] of CONDITION_BANS) { if (re.test(code)) out.push(label + ' ' + msg); } }
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 取 behavior 的逻辑代码(code 规范名 / js 兼容别名)。 */
|
||||
function behaviorCode(b) {
|
||||
if (!b) return null;
|
||||
if (typeof b.code === 'string') return b.code;
|
||||
if (typeof b.js === 'string') return b.js;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量校验 + strip(产线容错)。
|
||||
* @param {object} sourceProject 源项目(可为完整 SourceProject,或直接是 gameDefinition)。
|
||||
* @returns {{ok:boolean, errors:string[], gameDefinition:(object|null)}} 校验结果 + 剥净的 gameDefinition。
|
||||
*/
|
||||
export function validateSourceProject(sourceProject) {
|
||||
const errors = [];
|
||||
const sp = sourceProject || {};
|
||||
// 两路都收:完整 SourceProject{gameDefinition} 或直接传 gameDefinition。
|
||||
const gd = sp.gameDefinition && typeof sp.gameDefinition === 'object' ? sp.gameDefinition : sp;
|
||||
if (!gd || typeof gd !== 'object' || Array.isArray(gd)) {
|
||||
errors.push('gameDefinition 缺失或非对象');
|
||||
return { ok: false, errors, gameDefinition: null };
|
||||
}
|
||||
// strip top-level 未知键(模型常塞 schema 外键,如 title/instructions)。
|
||||
const cleaned = {};
|
||||
for (const k of GAMEDEF_KEYS) { if (gd[k] != null) cleaned[k] = gd[k]; }
|
||||
|
||||
// 数组类字段强制数组化(present-but-not-array=模型漂移:记错并降级为 [],杜绝后续 .map 裸 TypeError)。
|
||||
for (const k of ['entities', 'components', 'behaviors', 'scenes', 'rules']) {
|
||||
if (cleaned[k] != null && !Array.isArray(cleaned[k])) {
|
||||
errors.push(k + ' 非数组(应为数组,已忽略)');
|
||||
cleaned[k] = [];
|
||||
}
|
||||
}
|
||||
// 结构必需(schema gameDefinition.required = entities/scenes/rules;scenes/rules 容缺→运行时兜底)。
|
||||
if (!Array.isArray(cleaned.entities) || cleaned.entities.length === 0) errors.push('entities 缺失或为空');
|
||||
if (!Array.isArray(cleaned.scenes)) cleaned.scenes = [];
|
||||
if (!Array.isArray(cleaned.rules)) cleaned.rules = [];
|
||||
|
||||
// 引用完整性 + 唯一性(id 须非空;null/undefined id 不入集合,杜绝 'null'/'undefined' 假命中)。
|
||||
const comps = Array.isArray(cleaned.components) ? cleaned.components : [];
|
||||
const ents = Array.isArray(cleaned.entities) ? cleaned.entities : [];
|
||||
const compIds = new Set(comps.filter((c) => c && c.id != null).map((c) => String(c.id)));
|
||||
const entIds = new Set();
|
||||
for (const e of ents) {
|
||||
if (!e || e.id == null) { errors.push('实体缺 id'); continue; }
|
||||
const id = String(e.id);
|
||||
if (entIds.has(id)) errors.push('实体 id 重复: ' + id + '(须唯一)');
|
||||
entIds.add(id);
|
||||
for (const ref of (Array.isArray(e.components) ? e.components : [])) {
|
||||
if (typeof ref === 'string' && !compIds.has(ref)) errors.push('实体[' + id + '] 引用不存在组件: ' + ref);
|
||||
}
|
||||
}
|
||||
for (const s of (cleaned.scenes || [])) {
|
||||
for (const ref of (Array.isArray(s && s.entityRefs) ? s.entityRefs : [])) {
|
||||
if (!entIds.has(String(ref))) errors.push('场景[' + ((s && s.id) || '?') + '] 引用不存在实体: ' + ref);
|
||||
}
|
||||
}
|
||||
// behaviors:须带非空字符串逻辑 code/js + 静态扫描(边界拦截危险逻辑)。
|
||||
for (const b of (Array.isArray(cleaned.behaviors) ? cleaned.behaviors : [])) {
|
||||
const code = behaviorCode(b);
|
||||
const id = (b && b.id) || '?';
|
||||
if (code == null || code.trim() === '') {
|
||||
errors.push('behavior[' + id + '] 缺逻辑 code(须非空字符串)');
|
||||
continue;
|
||||
}
|
||||
errors.push(...scanLogic(code, 'behavior[' + id + ']', false));
|
||||
}
|
||||
// rules:condition 须无副作用布尔表达式 + 静态扫描。
|
||||
for (const r of (Array.isArray(cleaned.rules) ? cleaned.rules : [])) {
|
||||
const cond = r && r.condition;
|
||||
if (typeof cond === 'string' && cond.trim()) {
|
||||
errors.push(...scanLogic(cond, 'rule[' + ((r && r.id) || '?') + '].condition', true));
|
||||
}
|
||||
}
|
||||
return { ok: errors.length === 0, errors, gameDefinition: cleaned };
|
||||
}
|
||||
|
||||
/**
|
||||
* 装配 sourceProject → generated-factory.js 源文本。
|
||||
* @param {object} sourceProject 源项目。
|
||||
* @param {{runtimeImportPath?:string}} [opts] runtimeImportPath:gd-runtime.js 的 import 路径(缺省=生成目录相对路径)。
|
||||
* @returns {string} 对齐装载契约的工厂源文本。
|
||||
* @throws {Error} 校验失败(err.validationErrors 携带逐条错误,供 repair 回灌)。
|
||||
*/
|
||||
export function assembleFactoryFromSource(sourceProject, opts) {
|
||||
const o = opts || {};
|
||||
const { ok, errors, gameDefinition } = validateSourceProject(sourceProject);
|
||||
if (!ok) {
|
||||
const err = new Error('[build-from-source] gameDefinition 校验失败:\n - ' + errors.join('\n - '));
|
||||
err.validationErrors = errors;
|
||||
throw err;
|
||||
}
|
||||
const importPath = o.runtimeImportPath || DEFAULT_RUNTIME_IMPORT;
|
||||
// 序列化容错:循环引用/不可序列化 → fail-loud(带 validationErrors,供 repair),不抛裸 TypeError。
|
||||
let defJson;
|
||||
try {
|
||||
defJson = JSON.stringify(gameDefinition);
|
||||
} catch (e) {
|
||||
const err = new Error('[build-from-source] gameDefinition 不可序列化(疑循环引用): ' + e.message);
|
||||
err.validationErrors = ['gameDefinition 含循环引用/不可序列化结构'];
|
||||
throw err;
|
||||
}
|
||||
// inline-injection 安全:把 `<` 转 `<`(JSON/JS 合法转义,运行期串值不变),防 </script>/<!-- 提前闭合宿主内联 script。
|
||||
defJson = defJson.replace(/</g, '\\u003c');
|
||||
return factoryTemplate(importPath, defJson);
|
||||
}
|
||||
|
||||
/** 工厂源文本模板(对齐 game-host.d.ts 装载契约;GameInstance 委托 createRuntime)。 */
|
||||
function factoryTemplate(importPath, defJson) {
|
||||
return `'use strict';
|
||||
// generated-factory.js —— build-from-source 装配产物(U2)。源 = gameDefinition(改源不改打包产物)。
|
||||
// 工厂零引擎 import;一切经 boot.ctx(createRuntime 内部走受控面)。GameInstance 五法委托运行时基座。
|
||||
import { createRuntime } from '${importPath}';
|
||||
|
||||
const GAME_DEFINITION = ${defJson};
|
||||
|
||||
export default function createGame(opts) {
|
||||
const o = opts || {};
|
||||
let runtime = null;
|
||||
return {
|
||||
init(boot) { runtime = createRuntime(boot, GAME_DEFINITION); runtime.init(); },
|
||||
update(dt) { if (runtime) runtime.update(dt); },
|
||||
render(g) { if (runtime) runtime.render(g); },
|
||||
destroy() { if (runtime) { runtime.destroy(); runtime = null; } },
|
||||
_forensicsView() {
|
||||
return {
|
||||
seed: o.seed,
|
||||
state: () => (runtime ? runtime.state() : { phase: 'booting', result: null, score: 0, remaining: 0, progress: null }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
176
game-runtime/src/host/build-from-source.test.mjs
Normal file
176
game-runtime/src/host/build-from-source.test.mjs
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* build-from-source.test.mjs —— U2 装配器单测(plan 2026-06-18-001 U2 验收 · lili-mac 快走查)
|
||||
* 跑:cd game-runtime && node --test src/host/build-from-source.test.mjs(亦被 npm test 收)
|
||||
*
|
||||
* 钉死:① 校验+strip(产线容错);② 装配产物对齐装载契约 + node --check 过;
|
||||
* ③ 端到端功能证——装配文本经 createRuntime 真跑到 win(不需 esbuild/Chrome,本机即证装配链可玩)。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { validateSourceProject, assembleFactoryFromSource } from './build-from-source.mjs';
|
||||
import { createHostDevContext } from '../core/plugin.js';
|
||||
|
||||
/** 金样 gameDefinition:确定性自动胜(behavior 0.5s 后加分 → rule score≥1 胜),无需输入,可复现。 */
|
||||
function goldenSource() {
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
profile: { tickModel: 'realtime', inputModel: 'continuous', progressModel: 'metric' },
|
||||
gameDefinition: {
|
||||
components: [{ id: 'box', kind: 'render', shape: 'rect', color: '#00ffff', w: 30, h: 30 }],
|
||||
entities: [{ id: 'hero', transform: { position: { x: 195, y: 422 } }, components: ['box'] }],
|
||||
behaviors: [{
|
||||
id: 'tick', trigger: 'update',
|
||||
code: "self.t=(self.t||0)+dt; const h=rt.getEntity('hero'); if(h){ h.x = 195 + rt.clamp(self.t*50,0,80); } if(self.t>=0.5 && rt.score<1){ rt.addScore(1); rt.fx.burst(h?h.x:0, h?h.y:0, '#ff0'); }",
|
||||
}],
|
||||
scenes: [{ id: 's1', entityRefs: ['hero'] }],
|
||||
rules: [{ id: 'win', condition: 'rt.score >= 1', outcome: 'win' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ① 校验 + strip ──────────────────────────────────────────────────────── */
|
||||
test('U2-① validate 金样通过 + strip 顶层未知键', () => {
|
||||
const r = validateSourceProject(goldenSource());
|
||||
assert.equal(r.ok, true, '金样应通过: ' + r.errors.join('; '));
|
||||
assert.deepEqual(r.errors, []);
|
||||
// 顶层塞 schema 外键应被剥
|
||||
const dirty = { gameDefinition: { ...goldenSource().gameDefinition, title: '塞的外键', _hack: 1 } };
|
||||
const r2 = validateSourceProject(dirty);
|
||||
assert.equal(r2.gameDefinition.title, undefined, 'title 应被 strip');
|
||||
assert.equal(r2.gameDefinition._hack, undefined, '_hack 应被 strip');
|
||||
assert.equal(r2.ok, true);
|
||||
});
|
||||
|
||||
test('U2-① validate 揪结构/引用/缺逻辑错', () => {
|
||||
assert.equal(validateSourceProject({ gameDefinition: { entities: [] } }).ok, false, '空 entities 应失败');
|
||||
const badRef = validateSourceProject({ gameDefinition: { entities: [{ id: 'a', transform: { position: { x: 0, y: 0 } }, components: ['nope'] }] } });
|
||||
assert.ok(badRef.errors.some((e) => e.includes('不存在组件')), '应揪组件引用错');
|
||||
const badScene = validateSourceProject({ gameDefinition: { entities: [{ id: 'a', transform: { position: { x: 0, y: 0 } } }], scenes: [{ id: 's', entityRefs: ['ghost'] }] } });
|
||||
assert.ok(badScene.errors.some((e) => e.includes('不存在实体')), '应揪场景引用错');
|
||||
const noCode = validateSourceProject({ gameDefinition: { entities: [{ id: 'a', transform: { position: { x: 0, y: 0 } } }], behaviors: [{ id: 'b', trigger: 'update' }] } });
|
||||
assert.ok(noCode.errors.some((e) => e.includes('缺逻辑')), '应揪 behavior 缺 code');
|
||||
assert.equal(validateSourceProject(null).ok, false);
|
||||
});
|
||||
|
||||
/* ② 装配产物对齐装载契约 + node --check 语法过 ────────────────────────────── */
|
||||
test('U2-② assemble 产物含装载契约要件 + node --check 过', () => {
|
||||
const src = assembleFactoryFromSource(goldenSource());
|
||||
for (const marker of ['export default function createGame', 'createRuntime', 'init(boot)', 'update(dt)', 'render(g)', 'destroy()', '_forensicsView()', 'GAME_DEFINITION']) {
|
||||
assert.ok(src.includes(marker), '产物应含: ' + marker);
|
||||
}
|
||||
assert.ok(src.includes("from '../../../src/host/gd-runtime.js'"), '缺省 import 路径应为生成目录相对路径');
|
||||
// node --check:写临时文件 → 仅校语法(不解析 import)
|
||||
const f = join(tmpdir(), 'gd-bfs-syntax-' + process.pid + '.mjs');
|
||||
writeFileSync(f, src, 'utf8');
|
||||
try {
|
||||
const r = spawnSync(process.execPath, ['--check', f], { encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, 'node --check 应过: ' + (r.stderr || ''));
|
||||
} finally { rmSync(f, { force: true }); }
|
||||
});
|
||||
|
||||
test('U2-② assemble 校验失败 fail-loud(带 validationErrors)', () => {
|
||||
try {
|
||||
assembleFactoryFromSource({ gameDefinition: { entities: [] } });
|
||||
assert.fail('应抛错');
|
||||
} catch (err) {
|
||||
assert.ok(Array.isArray(err.validationErrors), '错误应带 validationErrors 供 repair 回灌');
|
||||
assert.ok(err.message.includes('校验失败'));
|
||||
}
|
||||
});
|
||||
|
||||
/* ③ 端到端功能证:装配文本经 createRuntime 真跑到 win(本机即证装配链可玩) ──────── */
|
||||
test('U2-③ 装配产物经 createRuntime 真跑到 win(功能证)', async () => {
|
||||
// runtimeImportPath 指向本仓真 gd-runtime.js(file:// 绝对),临时工厂可直接 import 运行。
|
||||
const runtimePath = new URL('./gd-runtime.js', import.meta.url).href;
|
||||
const src = assembleFactoryFromSource(goldenSource(), { runtimeImportPath: runtimePath });
|
||||
const f = join(tmpdir(), 'gd-bfs-fn-' + process.pid + '.mjs');
|
||||
writeFileSync(f, src, 'utf8');
|
||||
try {
|
||||
const mod = await import(pathToFileURL(f).href);
|
||||
const createGame = mod.default;
|
||||
// mock 引擎记 fx 调用(验 behavior 内 rt.fx.burst 真接线)
|
||||
const emitterCalls = [];
|
||||
const bundle = createHostDevContext({
|
||||
seed: 7,
|
||||
engineFactory: () => ({
|
||||
particles: { spawnEmitter: (s) => { emitterCalls.push(s); return { isActive: () => false, stop() {} }; } },
|
||||
audio: { synth: { synthSfx: () => [0], synthSong: () => null } },
|
||||
math: { lerp: (a, b, p) => a + (b - a) * p, smoothStep: (p) => p },
|
||||
}),
|
||||
});
|
||||
const game = createGame({ seed: 7 });
|
||||
game.init({ ctx: bundle.context, mainContext: null, canvas: null, seed: 7 });
|
||||
|
||||
const fv = game._forensicsView();
|
||||
assert.equal(typeof fv.state, 'function', '_forensicsView 应返 {state:fn}(取证契约)');
|
||||
assert.equal(fv.state().phase, 'playing', 'init 后应 playing');
|
||||
|
||||
// 推进帧(dt=0.2)直至 win(0.5s 后加分 → rule 胜)
|
||||
for (let i = 0; i < 10 && fv.state().phase === 'playing'; i++) game.update(0.2);
|
||||
const st = fv.state();
|
||||
assert.equal(st.phase, 'gameover', '应跑到终态');
|
||||
assert.equal(st.result, 'win', '应胜(rule score>=1)');
|
||||
assert.equal(st.score, 1);
|
||||
assert.ok(emitterCalls.length >= 1, 'behavior 内 rt.fx.burst 应真触发引擎粒子');
|
||||
|
||||
// 渲染不抛(mock g 记账)
|
||||
const calls = { fillRect: 0 };
|
||||
const g = { fillStyle: '', font: '', textAlign: '', fillRect: () => { calls.fillRect++; }, beginPath() {}, arc() {}, fill() {}, fillText() {} };
|
||||
assert.doesNotThrow(() => game.render(g));
|
||||
assert.ok(calls.fillRect >= 1, '声明式渲染应出 rect');
|
||||
assert.doesNotThrow(() => game.destroy());
|
||||
} finally { rmSync(f, { force: true }); }
|
||||
});
|
||||
|
||||
/* 工具:包一个最小合法 gameDefinition + 注入 behavior/rule 用于扫描测试。 */
|
||||
function withBehavior(code) {
|
||||
return { gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], behaviors: [{ id: 'b', trigger: 'update', code }] } };
|
||||
}
|
||||
|
||||
/* 评审修复(Med):非数组漂移转结构化 validationErrors(非裸 TypeError)。 */
|
||||
test('U2-修 非数组 components/entities 转结构化错', () => {
|
||||
const r = validateSourceProject({ gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], components: { bad: 1 } } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.ok(r.errors.some((e) => e.includes('非数组')));
|
||||
try { assembleFactoryFromSource({ gameDefinition: { entities: {}, components: {} } }); assert.fail('应抛'); }
|
||||
catch (err) { assert.ok(Array.isArray(err.validationErrors), '应带 validationErrors 非裸 TypeError'); }
|
||||
});
|
||||
|
||||
/* 评审修复(Med):behavior 非字符串/空 code + 重复实体 id。 */
|
||||
test('U2-修 非字符串/空 code + 重复 id 揪错', () => {
|
||||
assert.ok(validateSourceProject(withBehavior(12345)).errors.some((e) => e.includes('缺逻辑')), '数字 code 应揪');
|
||||
assert.ok(validateSourceProject(withBehavior(' ')).errors.some((e) => e.includes('缺逻辑')), '空白 code 应揪');
|
||||
assert.ok(validateSourceProject({ gameDefinition: { entities: [
|
||||
{ id: 'x', transform: { position: { x: 0, y: 0 } } }, { id: 'x', transform: { position: { x: 1, y: 1 } } },
|
||||
] } }).errors.some((e) => e.includes('重复')), '重复 id 应揪');
|
||||
});
|
||||
|
||||
/* 评审修复(keystone):静态扫描边界拦截危险逻辑 → repair 信号(治死循环/沙箱逃逸/确定性逃逸/condition 副作用)。 */
|
||||
test('U2-修 静态扫描拦截危险逻辑', () => {
|
||||
assert.ok(validateSourceProject(withBehavior('rt.addScore(Math.random())')).errors.some((e) => e.includes('Math.random')));
|
||||
assert.ok(validateSourceProject(withBehavior('while(true){ rt.addScore(1); }')).errors.some((e) => e.includes('while(true)')));
|
||||
assert.ok(validateSourceProject(withBehavior('process.exit(0)')).errors.some((e) => e.includes('process')));
|
||||
assert.ok(validateSourceProject(withBehavior('const f=(0).constructor.constructor("x")()')).errors.some((e) => e.includes('constructor')));
|
||||
// condition 副作用拦截 + 正常 condition 不误伤
|
||||
const condWin = { gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], rules: [{ id: 'r', condition: 'rt.win()', outcome: 'win' }] } };
|
||||
assert.ok(validateSourceProject(condWin).errors.some((e) => e.includes('win()')), 'condition 调 win() 应拦');
|
||||
const condStmt = { gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], rules: [{ id: 'r', condition: 'rt.setScore(9); true', outcome: 'win' }] } };
|
||||
assert.ok(validateSourceProject(condStmt).errors.length > 0, 'condition 含 ;/变更 应拦');
|
||||
assert.equal(validateSourceProject({ gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], rules: [{ id: 'r', condition: 'rt.score >= 3', outcome: 'win' }] } }).ok, true, 'rt.score>=3 不误伤');
|
||||
});
|
||||
|
||||
/* 评审修复(Med):循环引用 fail-loud + </script> inline 安全转义。 */
|
||||
test('U2-修 循环引用 fail-loud + </script> 转义', () => {
|
||||
const cyc = { gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }] } };
|
||||
cyc.gameDefinition.entities[0].self = cyc.gameDefinition.entities[0]; // 制造循环
|
||||
try { assembleFactoryFromSource(cyc); assert.fail('应抛'); }
|
||||
catch (err) { assert.ok(Array.isArray(err.validationErrors) && err.validationErrors.some((e) => e.includes('循环')), '循环引用应结构化失败'); }
|
||||
const src = assembleFactoryFromSource(withBehavior("rt.addScore(0); /* </script> */"));
|
||||
assert.ok(!src.includes('</script>'), '不应含裸 </script>(防内联 script 提前闭合)');
|
||||
assert.ok(src.includes('\\u003c/script>'), '应转义为 \\u003c');
|
||||
});
|
||||
197
game-runtime/src/host/gd-archetypes.test.mjs
Normal file
197
game-runtime/src/host/gd-archetypes.test.mjs
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* gd-archetypes.test.mjs —— U1 收敛判据:rt 约定跨品类表达力覆盖(plan 2026-06-18-001 U1 验收)
|
||||
* 跑:cd game-runtime && node --test src/host/gd-archetypes.test.mjs(亦被 npm test 收)
|
||||
*
|
||||
* 【为什么】plan U1 验收含「3-5 类游戏 behaviors 用约定全跑通、零约定外 helper 漂移(收敛判据)」。
|
||||
* 本文件手写 4 类原型 gameDefinition(覆盖 profile 谱:realtime-continuous / event-discrete /
|
||||
* realtime-spawn / realtime-physics),经 createRuntime 真驱动,钉死收敛判据:
|
||||
* **headline 断言 = runtime.errors() 为空**(零约定外 helper 漂移 / 零运行时抛错)+ 各原型出
|
||||
* 合理状态转移(移动 / 计分 / 终态 latch)。
|
||||
*
|
||||
* 【定位】此为「约定表达力」证(手写,非真实模型输出)——证 rt 面够表达目标品类谱;真实模型输出的
|
||||
* idiom 漂移收集随 U3(GAMEDEF_SYSTEM prompt + NEWAPI_KEY)落。这 4 份金样并作 U3 prompt few-shot 库。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHostDevContext } from '../core/plugin.js';
|
||||
import { createRuntime } from './gd-runtime.js';
|
||||
|
||||
/** 起受控运行时(注 mock 引擎记 fx)。 */
|
||||
function boot(seed) {
|
||||
const fx = [];
|
||||
const bundle = createHostDevContext({
|
||||
seed,
|
||||
engineFactory: () => ({
|
||||
particles: { spawnEmitter: (s) => { fx.push(s); return { isActive: () => false, stop() {} }; } },
|
||||
audio: { synth: { synthSfx: () => [0], synthSong: () => null } },
|
||||
math: { lerp: (a, b, p) => a + (b - a) * p, smoothStep: (p) => p },
|
||||
}),
|
||||
});
|
||||
return { bundle, boot: { ctx: bundle.context, mainContext: null, canvas: null, seed }, fx };
|
||||
}
|
||||
|
||||
/* ── 原型① paddle-intercept(打砖块/pong 谱:realtime + 连续输入 + 反弹 + lose latch) ── */
|
||||
test('原型① paddle-intercept 约定跑通', () => {
|
||||
const env = boot(11);
|
||||
const gdef = {
|
||||
profile: { tickModel: 'realtime', inputModel: 'continuous', progressModel: 'metric' },
|
||||
components: [
|
||||
{ id: 'r-paddle', kind: 'render', shape: 'rect', color: '#0ff', w: 80, h: 16 },
|
||||
{ id: 'r-ball', kind: 'render', shape: 'circle', color: '#fff', r: 10 },
|
||||
],
|
||||
entities: [
|
||||
{ id: 'paddle', transform: { position: { x: 195, y: 800 } }, components: ['r-paddle'] },
|
||||
{ id: 'ball', transform: { position: { x: 60, y: 400 } }, vx: 0, vy: 260, components: ['r-ball'] },
|
||||
],
|
||||
behaviors: [{
|
||||
id: 'play', trigger: 'update', code: `
|
||||
const paddle = rt.getEntity('paddle'), ball = rt.getEntity('ball');
|
||||
if (!paddle || !ball) return;
|
||||
// 连续输入:键盘左右 / 指针 x 控板
|
||||
if (rt.input.isDown('ArrowLeft')) paddle.x -= 300*dt;
|
||||
if (rt.input.isDown('ArrowRight')) paddle.x += 300*dt;
|
||||
if (rt.input.pointer.down) paddle.x = rt.input.pointer.x;
|
||||
paddle.x = rt.clamp(paddle.x, 40, rt.view.w-40);
|
||||
// 球积分 + 墙反弹
|
||||
ball.x += ball.vx*dt; ball.y += ball.vy*dt;
|
||||
if (ball.x < 10 || ball.x > rt.view.w-10) ball.vx = -ball.vx;
|
||||
if (ball.y < 10) ball.vy = Math.abs(ball.vy);
|
||||
// 板拦截
|
||||
if (ball.vy > 0 && rt.overlap({x:ball.x,y:ball.y,w:20,h:20}, {x:paddle.x,y:paddle.y,w:80,h:16})) {
|
||||
ball.vy = -Math.abs(ball.vy); rt.addScore(1); rt.fx.burst(ball.x, ball.y, '#0f0');
|
||||
}
|
||||
// 落底 → lose(置 latch 终态)
|
||||
if (ball.y > rt.view.h) rt.lose();
|
||||
`,
|
||||
}],
|
||||
scenes: [{ id: 's', entityRefs: ['paddle', 'ball'] }],
|
||||
rules: [],
|
||||
};
|
||||
const r = createRuntime(env.boot, gdef);
|
||||
r.init();
|
||||
// 不输入 → 球落底 → lose
|
||||
for (let i = 0; i < 60 && r.state().phase === 'playing'; i++) r.update(1 / 30);
|
||||
assert.deepEqual(r.errors(), [], '零约定外 helper 漂移 / 零抛错');
|
||||
assert.equal(r.state().phase, 'gameover');
|
||||
assert.equal(r.state().result, 'lose', '无输入应落底 lose');
|
||||
// 输入响应:另起一局,按右键 → 板右移
|
||||
const env2 = boot(11);
|
||||
const r2 = createRuntime(env2.boot, gdef);
|
||||
r2.init();
|
||||
const x0 = r2.rt.getEntity('paddle').x;
|
||||
env2.bundle.inputBridge._emit('keydown', { key: 'ArrowRight' });
|
||||
r2.update(0.1);
|
||||
assert.ok(r2.rt.getEntity('paddle').x > x0, '按右键板应右移(连续输入接线)');
|
||||
});
|
||||
|
||||
/* ── 原型② event-clicker(放置/点击器谱:event + 离散点击 + rule 胜) ── */
|
||||
test('原型② event-clicker 约定跑通', () => {
|
||||
const env = boot(2);
|
||||
const gdef = {
|
||||
profile: { tickModel: 'event', inputModel: 'discrete-choice', progressModel: 'metric' },
|
||||
components: [{ id: 'r-btn', kind: 'render', shape: 'rect', color: '#fa0', w: 200, h: 80 }],
|
||||
entities: [{ id: 'btn', transform: { position: { x: 195, y: 422 } }, components: ['r-btn'] }],
|
||||
behaviors: [{
|
||||
id: 'click', trigger: 'input', code: `
|
||||
if (rt.input.justTapped()) { rt.addScore(1); rt.fx.beep('score'); }
|
||||
`,
|
||||
}],
|
||||
scenes: [{ id: 's', entityRefs: ['btn'] }],
|
||||
rules: [{ id: 'win', condition: 'rt.score >= 3', outcome: 'win' }],
|
||||
};
|
||||
const r = createRuntime(env.boot, gdef);
|
||||
r.init();
|
||||
for (let i = 0; i < 3; i++) { env.bundle.inputBridge._emit('pointerdown', { x: 195, y: 422 }); r.update(0.05); }
|
||||
assert.deepEqual(r.errors(), []);
|
||||
assert.equal(r.state().score, 3);
|
||||
assert.equal(r.state().result, 'win', '点 3 次 → rule 胜');
|
||||
});
|
||||
|
||||
/* ── 原型③ dodge-spawn(躲避谱:realtime + spawn/query/random + overlap lose) ── */
|
||||
test('原型③ dodge-spawn 约定跑通', () => {
|
||||
const env = boot(3);
|
||||
const gdef = {
|
||||
profile: { tickModel: 'realtime', inputModel: 'continuous', progressModel: 'metric' },
|
||||
components: [
|
||||
{ id: 'r-player', kind: 'render', shape: 'rect', color: '#0f0', w: 30, h: 30 },
|
||||
{ id: 'r-enemy', kind: 'render', shape: 'circle', color: '#f33', r: 14 },
|
||||
],
|
||||
entities: [{ id: 'player', transform: { position: { x: 195, y: 700 } }, components: ['r-player'] }],
|
||||
behaviors: [{
|
||||
id: 'spawner', trigger: 'update', code: `
|
||||
self.t = (self.t||0) + dt;
|
||||
const player = rt.getEntity('player');
|
||||
if (player) { if (rt.input.isDown('ArrowLeft')) player.x -= 240*dt; if (rt.input.isDown('ArrowRight')) player.x += 240*dt; }
|
||||
// 定时随机生成下落敌人(rt.spawn + rt.random)
|
||||
if (self.t >= 0.25) { self.t = 0; rt.spawn({ x: rt.randRange(20, rt.view.w-20), y: -20, vy: 320, tags: ['enemy'], components: [{ kind:'render', shape:'circle', color:'#f33', r:14 }] }); }
|
||||
// 敌人下落 + 出界回收 + 撞玩家 lose(query/overlap)
|
||||
for (const e of rt.query('enemy')) {
|
||||
e.y += e.vy*dt;
|
||||
if (e.y > rt.view.h+30) rt.destroy(e);
|
||||
else if (player && rt.overlap({x:e.x,y:e.y,w:28,h:28}, {x:player.x,y:player.y,w:30,h:30})) { rt.fx.burst(e.x,e.y,'#f00'); rt.lose(); }
|
||||
}
|
||||
`,
|
||||
}],
|
||||
scenes: [{ id: 's', entityRefs: ['player'] }],
|
||||
rules: [],
|
||||
};
|
||||
const r = createRuntime(env.boot, gdef);
|
||||
r.init();
|
||||
// 玩家不动停在敌人流里 → 早晚被撞 lose;先确认 spawn 真发生
|
||||
let sawEnemies = false;
|
||||
for (let i = 0; i < 200 && r.state().phase === 'playing'; i++) { r.update(1 / 30); if (r.rt.query('enemy').length > 0) sawEnemies = true; }
|
||||
assert.deepEqual(r.errors(), [], '零约定外 helper 漂移 / 零抛错');
|
||||
assert.ok(sawEnemies, 'spawn 应真生成敌人');
|
||||
assert.equal(r.state().result, 'lose', '不动应被撞 lose');
|
||||
});
|
||||
|
||||
/* ── 原型④ runner(跑酷谱:realtime + physics 组件重力 + 跳跃输入 + 障碍碰撞 lose) ── */
|
||||
test('原型④ runner 约定跑通', () => {
|
||||
const env = boot(4);
|
||||
const gdef = {
|
||||
profile: { tickModel: 'realtime', inputModel: 'continuous', progressModel: 'metric' },
|
||||
components: [
|
||||
{ id: 'phys', kind: 'physics', gravity: 1600 },
|
||||
{ id: 'r-hero', kind: 'render', shape: 'rect', color: '#fff', w: 28, h: 28 },
|
||||
{ id: 'r-obs', kind: 'render', shape: 'rect', color: '#f80', w: 24, h: 40 },
|
||||
],
|
||||
entities: [{ id: 'hero', transform: { position: { x: 90, y: 700 } }, vx: 0, vy: 0, components: ['phys', 'r-hero'] }],
|
||||
behaviors: [{
|
||||
id: 'run', trigger: 'update', code: `
|
||||
const hero = rt.getEntity('hero');
|
||||
const GROUND = 700;
|
||||
if (hero) {
|
||||
// 跳跃(justTapped/space 起跳;physics 组件自动积分重力)
|
||||
if ((rt.input.justTapped() || rt.input.justPressed('Space')) && hero.y >= GROUND-1) hero.vy = -640;
|
||||
if (hero.y > GROUND) { hero.y = GROUND; hero.vy = 0; } // 落地
|
||||
}
|
||||
// 障碍流(rt.spawn + 计分)
|
||||
self.t = (self.t||0) + dt;
|
||||
if (self.t >= 0.7) { self.t = 0; rt.spawn({ x: rt.view.w+20, y: GROUND, vx: -260, tags: ['obs'], components: [{ kind:'render', shape:'rect', color:'#f80', w:24, h:40 }] }); }
|
||||
for (const o of rt.query('obs')) {
|
||||
o.x += o.vx*dt;
|
||||
if (o.x < -30) { rt.destroy(o); rt.addScore(1); }
|
||||
else if (hero && rt.overlap({x:o.x,y:o.y,w:24,h:40}, {x:hero.x,y:hero.y,w:28,h:28})) { rt.fx.burst(o.x,o.y,'#f00'); rt.lose(); }
|
||||
}
|
||||
`,
|
||||
}],
|
||||
scenes: [{ id: 's', entityRefs: ['hero'] }],
|
||||
rules: [],
|
||||
};
|
||||
const r = createRuntime(env.boot, gdef);
|
||||
r.init();
|
||||
const hero = r.rt.getEntity('hero');
|
||||
// 验 physics 重力积分:从空中起,落到地面
|
||||
hero.y = 600;
|
||||
r.update(0.1);
|
||||
assert.ok(hero.vy > 0, 'physics 组件应积分重力(vy 增)');
|
||||
// 跳跃响应
|
||||
hero.y = 700; hero.vy = 0;
|
||||
env.bundle.inputBridge._emit('pointerdown', { x: 90, y: 700 });
|
||||
r.update(1 / 60);
|
||||
assert.ok(r.rt.getEntity('hero').vy < 0, 'tap 应起跳(vy 转负)');
|
||||
// 长跑直至撞障碍 lose(不跳)
|
||||
for (let i = 0; i < 400 && r.state().phase === 'playing'; i++) r.update(1 / 60);
|
||||
assert.deepEqual(r.errors(), [], '零约定外 helper 漂移 / 零抛错');
|
||||
assert.equal(r.state().result, 'lose', '不跳应撞障碍 lose');
|
||||
});
|
||||
436
game-runtime/src/host/gd-runtime.js
Normal file
436
game-runtime/src/host/gd-runtime.js
Normal file
@ -0,0 +1,436 @@
|
||||
/**
|
||||
* gd-runtime.js —— 运行时访问约定基座(2D 适配器的 behavior 侧 API · core-runtime-v0)
|
||||
* owner:本 session(引擎线 · plan 2026-06-18-001 U1) | 消费方:U2 build-from-source 装配器 + U3 generate 真产 gameDefinition
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责】把已冻结的受控面 `boot.ctx`(PluginContext,api.d.ts)包装成「声明式 gameDefinition」
|
||||
* 友好的 2D 运行时:createRuntime(boot, gameDefinition) → 建可变世界(entities 实例)+ 产 `rt` 对象。
|
||||
* `rt` 即 U2 装配器编译每个 behavior 时 `new Function('rt','self','dt', code)` 注入的那个 rt——
|
||||
* behaviors 经 rt 操作世界(不写画、不触裸引擎/DOM),声明式渲染器逐实体按 render 组件出图。
|
||||
*
|
||||
* 【两阶段生命周期定位】gameDefinition = 开发态权威源(声明式 + behaviors 各带逻辑 JS);
|
||||
* 本运行时是「源 → 可玩」的运行期装配核(改源不改打包产物,见 source-project.schema.json)。
|
||||
*
|
||||
* 【受控面铁律(对齐 api.d.ts / game-host.d.ts)】
|
||||
* - 时间/随机一律走 boot.ctx(确定性可复现);rt 内部**禁 Math.random / Date.now**。
|
||||
* - 引擎能力(粒子/音频)一律经 boot.ctx.getEngine()——满 F 门「真接线」;无引擎时优雅 no-op。
|
||||
* - 输入一律经 boot.ctx.getInput() 订阅,rt 侧转成 behaviors 友好的**轮询面**(isDown/justTapped/pointer)。
|
||||
*
|
||||
* 【迭代状态·别冻早(plan U1)】本文件是运行时约定 **v0**;契约(runtime-api-2d.d.ts)待收 3-5 类真实
|
||||
* 模型输出补 idiom、1-2 轮稳定后再正式化。当前以「能跑能测」为准,接口可能随真实输出微调。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/** 视口常量(与 generic-host-config 的 390×844 移动竖屏基准同口径;behaviors 边界判断 / 渲染兜底用)。 */
|
||||
const VIEW_W = 390;
|
||||
const VIEW_H = 844;
|
||||
|
||||
/** rt.fx.beep 音效参数包(ZzFX 参数;按 kind 取,缺省走 default)。仅为「真调引擎合成核」满 F 门,不强求好听。 */
|
||||
const BEEP_PARAMS = {
|
||||
default: [1, 0.05, 400, , , 0.1, , 1.2],
|
||||
score: [1, 0.05, 800, , , 0.15, , 0.5],
|
||||
hit: [1, 0.05, 300, , , 0.1, , 1.0],
|
||||
lose: [1, 0.05, 150, 0.5, 0.3, 0.1, , 1.3],
|
||||
win: [1, 0.05, 600, , 0.2, 0.2, , 0.6],
|
||||
};
|
||||
|
||||
/* ── 小工具(纯函数,确定性) ───────────────────────────────────────────── */
|
||||
function num(v) { return typeof v === 'number' && isFinite(v) ? v : 0; }
|
||||
function toArray(v) { return Array.isArray(v) ? v : []; }
|
||||
/** 归一化颜色:接受 '#rrggbb' / {r,g,b,a}(0..1);产出引擎归一化 {r,g,b,a}。缺省不透明白。 */
|
||||
function toColor(c) {
|
||||
if (c && typeof c === 'object') {
|
||||
return { r: num(c.r), g: num(c.g), b: num(c.b), a: c.a == null ? 1 : num(c.a) };
|
||||
}
|
||||
if (typeof c === 'string' && c[0] === '#') {
|
||||
const h = c.length === 4
|
||||
? c.slice(1).split('').map(x => x + x).join('') // #abc → aabbcc
|
||||
: c.slice(1);
|
||||
const n = parseInt(h, 16);
|
||||
if (isFinite(n)) return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, a: 1 };
|
||||
}
|
||||
return { r: 1, g: 1, b: 1, a: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 建运行时:实例化 entities、编译 behaviors/rules、产 rt 对象。
|
||||
* @param {{ctx:object, mainContext?:object, canvas?:object, seed?:number}} boot 宿主注入的受控启动上下文(game-host.d.ts GameHostBootContext)。
|
||||
* @param {{entities?:Array, components?:Array, behaviors?:Array, scenes?:Array, rules?:Array}} gameDefinition 声明式领域模型(source-project.schema.json#/properties/gameDefinition)。
|
||||
* @returns {{rt:object, init:Function, update:Function, render:Function, state:Function, destroy:Function, errors:Function}}
|
||||
* 运行时句柄:U2 装配器产出的工厂经它实现 GameInstance 的 init/update/render/destroy/_forensicsView。
|
||||
*/
|
||||
export function createRuntime(boot, gameDefinition) {
|
||||
const ctx = boot && boot.ctx;
|
||||
// 错误路径必硬失败:绕过受控面即破「引擎可换」铁律,宁可 fail-loud 不静默降级。
|
||||
if (!ctx || typeof ctx.getInput !== 'function') {
|
||||
throw new Error('[gd-runtime] boot.ctx 缺失或非法:运行时必须经受控面(PluginContext)注入,不得绕过 ctx 触裸引擎/DOM');
|
||||
}
|
||||
const gdef = gameDefinition || {};
|
||||
// 组件定义按 id 索引(entity.components 是 id 引用数组,schema#/$defs/entity)。
|
||||
const componentDefs = new Map();
|
||||
for (const c of toArray(gdef.components)) { if (c && c.id != null) componentDefs.set(String(c.id), c); }
|
||||
|
||||
/* ── 世界状态 ───────────────────────────────────────────────────────── */
|
||||
let entities = []; // 当前世界实体(含已 spawn;reap 清 alive=false)
|
||||
const byId = new Map(); // id → entity
|
||||
let spawnSeq = 0; // 匿名 spawn 实体的自增序号
|
||||
let score = 0;
|
||||
let phase = 'booting'; // booting → playing → gameover(gameover 为 latch 终态,不可逆)
|
||||
let result = null; // 'win' | 'lose' | null
|
||||
let elapsed = 0; // 相对游戏时间(秒,累加 dt)——禁用绝对钟比较(取证可复现)
|
||||
let curDt = 0; // 本帧 dt(rt.dt 经 getter 读它)
|
||||
const subs = []; // 输入订阅句柄(destroy 时注销,防泄漏)
|
||||
const errors = []; // behavior/rule 运行错误(回灌为生成质量信号,供 repair 读)
|
||||
|
||||
/* ── 输入轮询态(订阅受控事件 → 维护,behaviors 经 rt.input 轮询) ──────── */
|
||||
const downKeys = new Set(); // 当前按住的键
|
||||
const justPressedKeys = new Set(); // 本帧刚按下的键(帧末清)
|
||||
let pointer = { x: 0, y: 0, down: false };
|
||||
let tappedThisFrame = false; // 本帧是否发生 pointerdown(帧末清)
|
||||
subs.push(ctx.getInput().on('keydown', (e) => { downKeys.add(e.key); justPressedKeys.add(e.key); }));
|
||||
subs.push(ctx.getInput().on('keyup', (e) => { downKeys.delete(e.key); }));
|
||||
subs.push(ctx.getInput().on('pointerdown', (e) => { pointer = { x: e.x, y: e.y, down: true }; tappedThisFrame = true; }));
|
||||
subs.push(ctx.getInput().on('pointermove', (e) => { pointer.x = e.x; pointer.y = e.y; }));
|
||||
subs.push(ctx.getInput().on('pointerup', () => { pointer.down = false; }));
|
||||
|
||||
/* ── 实体工厂 ───────────────────────────────────────────────────────── */
|
||||
/** 把组件引用/内联组件解析为组件对象数组(字符串 → 查 componentDefs;对象 → 内联用,spawn 走此路)。 */
|
||||
function resolveComponents(refs) {
|
||||
const out = [];
|
||||
for (const r of toArray(refs)) {
|
||||
if (typeof r === 'string') { const def = componentDefs.get(r); if (def) out.push(def); }
|
||||
else if (r && typeof r === 'object') { out.push(r); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/** 取实体首个指定 kind 的组件(无则 null)。 */
|
||||
function componentOf(e, kind) {
|
||||
for (const c of e.components) { if (c && c.kind === kind) return c; }
|
||||
return null;
|
||||
}
|
||||
/** 建实体实例:transform.position → x/y;vx/vy/tags/components 归一化;带 get/set/destroy。 */
|
||||
function makeEntity(spec) {
|
||||
const pos = (spec.transform && spec.transform.position) || { x: spec.x, y: spec.y };
|
||||
const e = {
|
||||
id: spec.id != null ? String(spec.id) : ('_e' + (spawnSeq++)),
|
||||
x: num(pos && pos.x), y: num(pos && pos.y),
|
||||
vx: num(spec.vx), vy: num(spec.vy),
|
||||
alive: true,
|
||||
tags: new Set(toArray(spec.tags).map(String)),
|
||||
components: resolveComponents(spec.components),
|
||||
get(k) { return this[k]; },
|
||||
set(k, v) { this[k] = v; return v; },
|
||||
destroy() { this.alive = false; },
|
||||
};
|
||||
return e;
|
||||
}
|
||||
/** 实体上限(防失控 spawn 静默膨胀;超限记错误信号并拒新增,转成 repair 可读信号)。 */
|
||||
const ENTITY_CAP = 5000;
|
||||
function addEntity(e) {
|
||||
if (entities.length >= ENTITY_CAP) {
|
||||
if (errors.length < 50) errors.push('实体数超上限 ' + ENTITY_CAP + '(疑失控 spawn),拒新增: ' + e.id);
|
||||
return e;
|
||||
}
|
||||
// 重复 id:保留先到者可寻址(byId 仅在无映射时写),记质量信号;杜绝「后者覆盖 byId、reap 误删致先到者失联」。
|
||||
if (byId.has(e.id)) { if (errors.length < 50) errors.push('实体 id 重复: ' + e.id + '(先到者保持可寻址)'); }
|
||||
else { byId.set(e.id, e); }
|
||||
entities.push(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
/* ── rt 对象(behavior 侧 API · 注入 new Function('rt','self','dt',code)) ── */
|
||||
/** latch 终态(win/lose 一旦置定不可逆——H 门「终态驻留」硬约束)。 */
|
||||
function latch(r) { if (phase === 'gameover') return; phase = 'gameover'; result = r; }
|
||||
/** 经 ctx.getEngine() 真调引擎粒子(满 F 门「真接线」;无引擎优雅 no-op,不留 sim 降级)。 */
|
||||
function fxBurst(x, y, color) {
|
||||
const eng = ctx.getEngine && ctx.getEngine();
|
||||
if (!eng || !eng.particles) return;
|
||||
const c = toColor(color);
|
||||
try {
|
||||
eng.particles.spawnEmitter({
|
||||
pos: { x: num(x), y: num(y) }, count: 20, speed: 120, particleTime: 0.4,
|
||||
colorStart: c, colorEnd: { r: c.r, g: c.g, b: c.b, a: 0 },
|
||||
});
|
||||
} catch (_) { /* 引擎调用失败不连坐游戏帧 */ }
|
||||
}
|
||||
/** 经 ctx.getEngine() 真调引擎合成核(满 F 门;无引擎 no-op)。 */
|
||||
function fxBeep(kind) {
|
||||
const eng = ctx.getEngine && ctx.getEngine();
|
||||
if (!eng || !eng.audio || !eng.audio.synth) return;
|
||||
try { eng.audio.synth.synthSfx(BEEP_PARAMS[kind] || BEEP_PARAMS.default); } catch (_) { /* no-op */ }
|
||||
}
|
||||
/** AABB 重叠(中心点 + 半宽半高;缺省全尺寸 16=半 8)。a/b = {x,y,w?,h?}。显式 0 须被尊重(不被 truthy 误当默认)。 */
|
||||
function overlap(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const sz = (v) => (v == null ? 16 : num(v));
|
||||
const ahw = sz(a.w) / 2, ahh = sz(a.h) / 2, bhw = sz(b.w) / 2, bhh = sz(b.h) / 2;
|
||||
return Math.abs(num(a.x) - num(b.x)) <= ahw + bhw && Math.abs(num(a.y) - num(b.y)) <= ahh + bhh;
|
||||
}
|
||||
|
||||
const rt = {
|
||||
/* 实体面 */
|
||||
getEntity(id) { const e = byId.get(String(id)); return e && e.alive ? e : null; },
|
||||
entities() { return entities.filter((e) => e.alive); },
|
||||
/** 按 tag 或组件 id/kind 查询活实体(gameplay 主用 tag)。 */
|
||||
query(name) {
|
||||
return entities.filter((e) => e.alive && (e.tags.has(name) || e.components.some((c) => c && (c.id === name || c.kind === name))));
|
||||
},
|
||||
/** 生成实体:{x,y,vx,vy,tags,components}(components 可内联对象)。返回实体引用。 */
|
||||
spawn(spec) { return addEntity(makeEntity(spec || {})); },
|
||||
destroy(e) { if (e) e.alive = false; },
|
||||
/* 输入面(轮询) */
|
||||
input: {
|
||||
isDown: (k) => downKeys.has(k),
|
||||
justPressed: (k) => justPressedKeys.has(k),
|
||||
justTapped: () => tappedThisFrame,
|
||||
get pointer() { return { x: pointer.x, y: pointer.y, down: pointer.down }; }, // 返副本:防 behavior 改内部 pointer 态
|
||||
},
|
||||
/* 时间/随机面(走 boot.ctx 受控种子,确定性;禁 Math.random/Date.now)。
|
||||
不暴露 nowMs(墙钟=非确定性)——behaviors 一律 now()(相对 elapsed)/dt,保取证可复现。 */
|
||||
time: { now: () => elapsed },
|
||||
get dt() { return curDt; },
|
||||
random: () => ctx.random.next(),
|
||||
randRange: (a, b) => ctx.random.range(a, b),
|
||||
randInt: (a, b) => Math.floor(ctx.random.range(a, b + 1)),
|
||||
/* 分数胜负面(win/lose 置 latch 终态;addScore 守 num 防 NaN/字符串污染 score 契约——score 是 behaviors/rules/取证共享面)。 */
|
||||
get score() { return score; },
|
||||
addScore: (n) => { score = num(score) + (n == null ? 1 : num(n)); return score; },
|
||||
setScore: (n) => { score = num(n); return score; },
|
||||
win: () => latch('win'),
|
||||
lose: () => latch('lose'),
|
||||
/* 工具/特效面 */
|
||||
clamp: (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v),
|
||||
dist: (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by),
|
||||
overlap,
|
||||
fx: { burst: (x, y, color) => fxBurst(x, y, color), beep: (kind) => fxBeep(kind) },
|
||||
/* 视口(behaviors 边界判断用) */
|
||||
view: { w: VIEW_W, h: VIEW_H },
|
||||
};
|
||||
// 冻结 rt 纯 API 面(input/fx/time/view + rt 本体)——防某 behavior 改写共享 API(如 rt.input.isDown)串味全局;
|
||||
// 实体仍可变(behaviors 合法写 e.x),只冻 API 命名空间。getter(dt/score)/方法仍可调;写入静默失效或抛,不腐蚀内部态。
|
||||
Object.freeze(rt.input);
|
||||
Object.freeze(rt.fx);
|
||||
Object.freeze(rt.time);
|
||||
Object.freeze(rt.view);
|
||||
Object.freeze(rt);
|
||||
|
||||
/* ── 编译 behaviors(各带逻辑 JS:behavior.code 规范名 / behavior.js 兼容别名) ── */
|
||||
// self = 本 behavior 的持久局部态(跨帧保留,存计时器/累加器等);dt = 本帧秒步。
|
||||
const initBehaviors = []; // trigger==='init':世界建好后跑一次
|
||||
const tickBehaviors = []; // 其余(update/input/collision/timer):每帧跑(behaviors 经 rt.input 轮询)
|
||||
for (const b of toArray(gdef.behaviors)) {
|
||||
const code = b && (b.code != null ? b.code : (b.js != null ? b.js : ''));
|
||||
if (!code || typeof code !== 'string') continue;
|
||||
let fn;
|
||||
try { fn = new Function('rt', 'self', 'dt', code); }
|
||||
catch (err) { errors.push('behavior[' + (b.id || '?') + '] 编译失败: ' + err.message); continue; }
|
||||
const slot = { id: b.id, trigger: b.trigger, fn, self: {} };
|
||||
(b.trigger === 'init' ? initBehaviors : tickBehaviors).push(slot);
|
||||
}
|
||||
function runBehavior(slot, dt) {
|
||||
// behavior 抛错隔离 + 回灌质量信号(plan U2 step6:喂 repair feedback);单 behavior 错不连坐整帧。
|
||||
try { slot.fn(rt, slot.self, dt); }
|
||||
catch (err) { errors.push('behavior[' + (slot.id || '?') + '] 运行抛错: ' + err.message); }
|
||||
}
|
||||
|
||||
/* ── 编译 rules(condition = JS 布尔表达式串,rt/self 在作用域内) ──────── */
|
||||
const compiledRules = [];
|
||||
for (const r of toArray(gdef.rules)) {
|
||||
let cond;
|
||||
try { cond = new Function('rt', 'self', 'return (' + (r.condition || 'false') + ');'); }
|
||||
catch (err) { errors.push('rule[' + (r.id || '?') + '] 编译失败: ' + err.message); continue; }
|
||||
compiledRules.push({ id: r.id, cond, outcome: r.outcome, self: {}, lastTrue: false });
|
||||
}
|
||||
function evalRules() {
|
||||
for (const cr of compiledRules) {
|
||||
let t = false;
|
||||
try { t = !!cr.cond(rt, cr.self); } catch (err) { errors.push('rule[' + (cr.id || '?') + '] 运行抛错: ' + err.message); }
|
||||
const rising = t && !cr.lastTrue; // 上升沿(score/advance 用,防每帧重复触发)
|
||||
cr.lastTrue = t;
|
||||
if (!t) continue;
|
||||
if (cr.outcome === 'win') latch('win');
|
||||
else if (cr.outcome === 'lose') latch('lose');
|
||||
else if (cr.outcome === 'score') { if (rising) score += 1; }
|
||||
// 'advance':多场景推进,v0 占位(单场景为主,后续迭代)。
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 物理积分(opt-in:含 kind=physics 组件的实体每帧按 vx/vy(+gravity) 推进) ── */
|
||||
function integratePhysics(dt) {
|
||||
for (const e of entities) {
|
||||
if (!e.alive) continue;
|
||||
const phys = componentOf(e, 'physics');
|
||||
if (!phys) continue;
|
||||
if (phys.gravity) e.vy += num(phys.gravity) * dt;
|
||||
e.x += e.vx * dt;
|
||||
e.y += e.vy * dt;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 内置点击派发(plan U3 tap-targets 救场基元):本帧有 tap 时,点中**带 clickable 组件且未 occupied** 的实体 →
|
||||
* 翻 occupied + spawn 标记(可见变化) + 计分 + fx。让离散点击类「声明 clickable 即可」,免模型写易错 tap-handler
|
||||
* (实测便宜/强模型均常把 tap 门控在分离 state/计时器上→点不中)。clickable 组件:{kind:'clickable',score?,mark?:{shape,color,r/w/h},hitW?,hitH?}。
|
||||
*/
|
||||
function dispatchClicks() {
|
||||
if (!tappedThisFrame) return;
|
||||
for (const e of entities) {
|
||||
if (!e.alive || e.occupied) continue;
|
||||
const clk = componentOf(e, 'clickable');
|
||||
if (!clk) continue;
|
||||
const rc = componentOf(e, 'render');
|
||||
const hw = num(clk.hitW != null ? clk.hitW : (rc && rc.w != null ? rc.w : (rc && rc.r != null ? rc.r * 2 : 80))) / 2;
|
||||
const hh = num(clk.hitH != null ? clk.hitH : (rc && rc.h != null ? rc.h : (rc && rc.r != null ? rc.r * 2 : 80))) / 2;
|
||||
if (Math.abs(pointer.x - e.x) <= hw && Math.abs(pointer.y - e.y) <= hh) {
|
||||
e.occupied = true;
|
||||
const mk = clk.mark;
|
||||
if (mk && typeof mk === 'object') {
|
||||
rt.spawn({ x: e.x, y: e.y, components: [{ kind: 'render', shape: mk.shape || 'circle', color: mk.color || '#ffcc00', r: num(mk.r != null ? mk.r : 28), w: mk.w, h: mk.h }] });
|
||||
}
|
||||
score = num(score) + num(clk.score != null ? clk.score : 1);
|
||||
fxBurst(e.x, e.y, (mk && mk.color) || '#ffcc00');
|
||||
fxBeep('score');
|
||||
break; // 一次 tap 只触一个目标
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 回收死实体(帧末统一清,避免迭代中改数组)。 */
|
||||
function reap() {
|
||||
let dirty = false;
|
||||
for (const e of entities) {
|
||||
if (!e.alive) {
|
||||
if (byId.get(e.id) === e) byId.delete(e.id); // 仅当 byId 指向该死亡实例才删(保同 id 存活者可寻址)
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (dirty) entities = entities.filter((e) => e.alive);
|
||||
}
|
||||
|
||||
/* ── 场景:v0 取 scenes[0].entityRefs 过滤初始实体(缺省全量实例化) ──────── */
|
||||
function sceneRefs() {
|
||||
const scenes = toArray(gdef.scenes);
|
||||
if (!scenes.length || !Array.isArray(scenes[0].entityRefs)) return null;
|
||||
return new Set(scenes[0].entityRefs.map(String));
|
||||
}
|
||||
|
||||
/* ── 生命周期:init / update / render / state / destroy ─────────────────── */
|
||||
function init() {
|
||||
const refs = sceneRefs();
|
||||
for (const espec of toArray(gdef.entities)) {
|
||||
if (refs && espec.id != null && !refs.has(String(espec.id))) continue;
|
||||
addEntity(makeEntity(espec));
|
||||
}
|
||||
for (const slot of initBehaviors) runBehavior(slot, 0);
|
||||
if (phase === 'booting') phase = 'playing';
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
curDt = num(dt);
|
||||
if (phase === 'playing') { // latch 终态后停摆(H 门:终态驻留);但输入沿仍每帧清(见下)
|
||||
for (const slot of tickBehaviors) { if (phase !== 'playing') break; runBehavior(slot, curDt); }
|
||||
dispatchClicks(); // 内置 clickable 点击派发(tap-targets 声明式基元,免模型写 tap-handler)
|
||||
integratePhysics(curDt);
|
||||
evalRules();
|
||||
reap();
|
||||
elapsed += curDt; // 帧末推进(首帧 behaviors 见 now()=0;latch 后不推进=时间冻结)
|
||||
}
|
||||
justPressedKeys.clear(); // 输入沿恒帧末清(含 latch 后,杜绝事件滞留——防未来 resume 路读到幻触)
|
||||
tappedThisFrame = false;
|
||||
}
|
||||
|
||||
/** 声明式渲染器:逐实体按 render 组件画 rect/circle/fill(behaviors 只逻辑不画)+ 极简 HUD。 */
|
||||
function render(g) {
|
||||
if (!g) return;
|
||||
// 背景 fill 实体先画(kind=render 且 shape=fill 铺满视口)。
|
||||
for (const e of entities) {
|
||||
if (!e.alive) continue;
|
||||
for (const c of e.components) {
|
||||
if (c && c.kind === 'render' && c.shape === 'fill') { g.fillStyle = c.color || '#000'; g.fillRect(0, 0, VIEW_W, VIEW_H); }
|
||||
}
|
||||
}
|
||||
for (const e of entities) { if (e.alive) drawEntity(g, e); }
|
||||
drawHud(g);
|
||||
}
|
||||
function drawEntity(g, e) {
|
||||
for (const c of e.components) {
|
||||
if (!c || c.kind !== 'render' || c.shape === 'fill') continue;
|
||||
g.fillStyle = c.color || '#ffffff';
|
||||
if (c.shape === 'circle') {
|
||||
const r = num(c.r != null ? c.r : (c.radius != null ? c.radius : 10));
|
||||
g.beginPath(); g.arc(e.x, e.y, r, 0, Math.PI * 2); g.fill();
|
||||
} else { // rect(缺省):以 transform 为中心
|
||||
const w = num(c.w != null ? c.w : (c.width != null ? c.width : 20));
|
||||
const h = num(c.h != null ? c.h : (c.height != null ? c.height : 20));
|
||||
g.fillRect(e.x - w / 2, e.y - h / 2, w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
function drawHud(g) {
|
||||
if (typeof g.fillText !== 'function') return; // mock ctx 容错
|
||||
try {
|
||||
g.fillStyle = '#ffffff';
|
||||
g.font = '24px monospace';
|
||||
g.textAlign = 'center';
|
||||
g.fillText('Score: ' + score, VIEW_W / 2, 36);
|
||||
if (phase === 'gameover') {
|
||||
g.font = '36px monospace';
|
||||
g.fillStyle = result === 'win' ? '#33dd66' : '#ff4444';
|
||||
g.fillText(result === 'win' ? 'YOU WIN' : 'GAME OVER', VIEW_W / 2, VIEW_H / 2);
|
||||
}
|
||||
} catch (_) { /* HUD 失败不连坐 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 取证快照(喂 _forensicsView().state())。除九门读的 phase/score/result/remaining 外,
|
||||
* 还**派生 harness driver 形状**(plan U1 step5,对齐 SaaPrompts gatespec driver 契约):
|
||||
* ① 逐实体按 id 投影为顶层命名位(ball.x/paddle.x/bird.vy… 让 driver 命名路径解析);
|
||||
* ② tag='target' 的实体 → targets[{x,y,idx,occupied,safe}](tap-targets driver 读)。
|
||||
* 故 gatespec 的 driver.ballPath='ball.x' 等须与 gameDefinition 实体 id 对齐(U3 prompt 喂此约定)。
|
||||
*/
|
||||
function state() {
|
||||
const alive = entities.filter((e) => e.alive);
|
||||
const snap = {
|
||||
phase, result, score,
|
||||
elapsed,
|
||||
remaining: alive.length,
|
||||
progress: null,
|
||||
errors: errors.slice(), // 生成质量信号(behavior/rule 抛错)
|
||||
};
|
||||
// ① 逐实体按 id 投影顶层命名位(不覆盖上面的保留键)。
|
||||
for (const e of alive) {
|
||||
if (e.id && !(e.id in snap)) snap[e.id] = entityView(e);
|
||||
}
|
||||
// ② tap-targets driver:tag='target' 的实体导出 targets 数组(idx/occupied/safe 供确定性逐格驱动)。
|
||||
const tnodes = alive.filter((e) => e.tags.has('target'));
|
||||
if (tnodes.length) {
|
||||
snap.targets = tnodes.map((e, i) => ({
|
||||
x: e.x, y: e.y, idx: (e.idx != null ? e.idx : i),
|
||||
occupied: !!e.occupied, safe: e.safe !== false,
|
||||
}));
|
||||
}
|
||||
snap.entities = alive.map((e) => ({ id: e.id, x: e.x, y: e.y, tags: Array.from(e.tags) }));
|
||||
return snap;
|
||||
}
|
||||
|
||||
/** 单实体取证投影:位置/速度 + 常见数值字段(w/h/r/angle/radius)供 gatespec 命名路径解析。 */
|
||||
function entityView(e) {
|
||||
const v = { x: e.x, y: e.y, vx: e.vx, vy: e.vy };
|
||||
for (const k of ['w', 'h', 'r', 'angle', 'radius']) {
|
||||
if (typeof e[k] === 'number') v[k] = e[k];
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const s of subs) { try { s && s.cancel && s.cancel(); } catch (_) { /* no-op */ } }
|
||||
subs.length = 0;
|
||||
entities = [];
|
||||
byId.clear();
|
||||
}
|
||||
|
||||
return { rt, init, update, render, state, destroy, errors: () => errors.slice() };
|
||||
}
|
||||
|
||||
export default createRuntime;
|
||||
276
game-runtime/src/host/gd-runtime.test.mjs
Normal file
276
game-runtime/src/host/gd-runtime.test.mjs
Normal file
@ -0,0 +1,276 @@
|
||||
/**
|
||||
* gd-runtime.test.mjs —— U1 运行时约定基座单测(plan 2026-06-18-001 U1 验收)
|
||||
* 跑:cd game-runtime && node --test src/host/gd-runtime.test.mjs(亦被 `npm test` 的 src/**\/*.test.mjs 收)
|
||||
*
|
||||
* 钉死 plan U1 三条必验 + 集成验证(证 behaviors/rules/spawn 真跑通):
|
||||
* ① 确定性:rt.random 同种子可复现;② win latch 驻留不可逆;③ rt.fx.burst 真触发 ctx.getEngine。
|
||||
* 受控 ctx 经 src/core/plugin.js 的 createHostDevContext(带 SeededRandom + 可注 engineFactory)建。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHostDevContext } from '../core/plugin.js';
|
||||
import { createRuntime } from './gd-runtime.js';
|
||||
|
||||
/** 建一个记账 mock 引擎(记 particles/audio 调用,验「真接线」),交 engineFactory。 */
|
||||
function makeMockEngine() {
|
||||
const emitterCalls = [];
|
||||
const synthCalls = [];
|
||||
const engine = {
|
||||
particles: { spawnEmitter: (spec) => { emitterCalls.push(spec); return { isActive: () => false, stop() {} }; } },
|
||||
audio: { synth: { synthSfx: (p) => { synthCalls.push(p); return [0]; }, synthSong: () => null } },
|
||||
math: { lerp: (a, b, p) => a + (b - a) * p, smoothStep: (p) => p },
|
||||
};
|
||||
return { engine, emitterCalls, synthCalls };
|
||||
}
|
||||
|
||||
/** 起一个受控 boot(注 seed + 可选 mock 引擎)。返回 {boot, bundle, mock}。 */
|
||||
function makeBoot(seed, withEngine) {
|
||||
const mock = withEngine ? makeMockEngine() : null;
|
||||
const bundle = createHostDevContext({ seed, engineFactory: mock ? () => mock.engine : null });
|
||||
const boot = { ctx: bundle.context, mainContext: null, canvas: null, seed };
|
||||
return { boot, bundle, mock };
|
||||
}
|
||||
|
||||
/* ① 确定性:同种子两运行时 rt.random 序列逐位相等(禁 Math.random 的硬证)。 */
|
||||
test('U1-① rt.random 同种子可复现(确定性)', () => {
|
||||
const a = createRuntime(makeBoot(20260618, false).boot, {});
|
||||
const b = createRuntime(makeBoot(20260618, false).boot, {});
|
||||
const seqA = Array.from({ length: 6 }, () => a.rt.random());
|
||||
const seqB = Array.from({ length: 6 }, () => b.rt.random());
|
||||
assert.deepEqual(seqA, seqB, 'rt.random 同种子序列应逐位相等');
|
||||
// randRange/randInt 同样确定
|
||||
assert.equal(a.rt.randInt(0, 100), b.rt.randInt(0, 100));
|
||||
// 不同种子应大概率不同(弱证非常数)
|
||||
const c = createRuntime(makeBoot(999, false).boot, {});
|
||||
assert.notDeepEqual(Array.from({ length: 6 }, () => c.rt.random()), seqA);
|
||||
});
|
||||
|
||||
/* ② win/lose latch:置定终态后不可逆(H 门「终态驻留」硬约束)。 */
|
||||
test('U1-② rt.win() 后 phase latch 驻留不可逆', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, {});
|
||||
r.init();
|
||||
assert.equal(r.state().phase, 'playing');
|
||||
r.rt.win();
|
||||
assert.equal(r.state().phase, 'gameover');
|
||||
assert.equal(r.state().result, 'win');
|
||||
// latch 不可逆:再调 lose 不应翻盘
|
||||
r.rt.lose();
|
||||
assert.equal(r.state().result, 'win', 'latch 后 result 不可被 lose 覆盖');
|
||||
// latch 后 update 停摆(不再推进 elapsed)
|
||||
const before = r.state().elapsed;
|
||||
r.update(0.5);
|
||||
assert.equal(r.state().elapsed, before, 'latch 后 update 应停摆');
|
||||
});
|
||||
|
||||
/* ③ rt.fx.burst / beep 真触发 ctx.getEngine() 调用(满 F 门「真接线」)。 */
|
||||
test('U1-③ rt.fx.burst/beep 真调引擎(满 F 门)', () => {
|
||||
const { boot, mock } = makeBoot(1, true);
|
||||
const r = createRuntime(boot, {});
|
||||
r.rt.fx.burst(100, 200, '#ff0000');
|
||||
assert.equal(mock.emitterCalls.length, 1, 'fx.burst 应触发一次 particles.spawnEmitter');
|
||||
assert.deepEqual(mock.emitterCalls[0].pos, { x: 100, y: 200 }, '发射原点应为受控面像素 {x,y}');
|
||||
assert.ok(Math.abs(mock.emitterCalls[0].colorStart.r - 1) < 1e-6, '#ff0000 → colorStart.r≈1');
|
||||
r.rt.fx.beep('score');
|
||||
assert.equal(mock.synthCalls.length, 1, 'fx.beep 应触发一次 audio.synth.synthSfx');
|
||||
// 无引擎时优雅 no-op(不抛)
|
||||
const noEng = createRuntime(makeBoot(1, false).boot, {});
|
||||
assert.doesNotThrow(() => { noEng.rt.fx.burst(0, 0, '#fff'); noEng.rt.fx.beep('hit'); });
|
||||
});
|
||||
|
||||
/* 集成①:behavior 带逻辑 JS 经 new Function 编译 + 每帧跑 + 操作世界(实体移动)。 */
|
||||
test('U1-集成 behavior 编译并每帧操作世界', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const gdef = {
|
||||
entities: [{ id: 'p', transform: { position: { x: 10, y: 10 } } }],
|
||||
behaviors: [{ id: 'mv', trigger: 'update', code: "const p=rt.getEntity('p'); if(p){ p.x += 100*dt; }" }],
|
||||
};
|
||||
const r = createRuntime(boot, gdef);
|
||||
r.init();
|
||||
assert.equal(r.rt.getEntity('p').x, 10);
|
||||
r.update(0.5);
|
||||
assert.ok(Math.abs(r.rt.getEntity('p').x - 60) < 1e-6, '0.5s @100px/s → x≈60');
|
||||
assert.deepEqual(r.errors(), [], '正常 behavior 不应产错误信号');
|
||||
});
|
||||
|
||||
/* 集成②:rule 布尔表达式编译 + 上升沿/latch(score≥3 → win)。 */
|
||||
test('U1-集成 rule 评估并 latch', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, { rules: [{ id: 'w', condition: 'rt.score >= 3', outcome: 'win' }] });
|
||||
r.init();
|
||||
r.rt.setScore(2);
|
||||
r.update(0.016);
|
||||
assert.equal(r.state().phase, 'playing', 'score<3 不应胜');
|
||||
r.rt.setScore(3);
|
||||
r.update(0.016);
|
||||
assert.equal(r.state().phase, 'gameover');
|
||||
assert.equal(r.state().result, 'win');
|
||||
});
|
||||
|
||||
/* 集成③:rt.spawn/destroy/query + 死实体回收。 */
|
||||
test('U1-集成 spawn/destroy/query 与回收', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, {});
|
||||
r.init();
|
||||
const b = r.rt.spawn({ x: 5, y: 5, tags: ['bullet'] });
|
||||
assert.equal(r.rt.query('bullet').length, 1);
|
||||
r.rt.destroy(b);
|
||||
r.update(0.016); // reap 在帧末
|
||||
assert.equal(r.rt.query('bullet').length, 0, 'destroy 后下一帧应回收');
|
||||
assert.equal(r.rt.getEntity(b.id), null);
|
||||
});
|
||||
|
||||
/* 集成④:输入轮询面(受控事件桥 _emit → rt.input 轮询)。 */
|
||||
test('U1-集成 输入轮询(isDown/justTapped)', () => {
|
||||
const { boot, bundle } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, {});
|
||||
r.init();
|
||||
bundle.inputBridge._emit('keydown', { key: 'ArrowLeft' });
|
||||
assert.equal(r.rt.input.isDown('ArrowLeft'), true);
|
||||
assert.equal(r.rt.input.justPressed('ArrowLeft'), true);
|
||||
bundle.inputBridge._emit('pointerdown', { x: 42, y: 99 });
|
||||
assert.equal(r.rt.input.justTapped(), true);
|
||||
assert.deepEqual({ x: r.rt.input.pointer.x, y: r.rt.input.pointer.y, down: r.rt.input.pointer.down }, { x: 42, y: 99, down: true });
|
||||
r.update(0.016); // 帧末清沿
|
||||
assert.equal(r.rt.input.justPressed('ArrowLeft'), false, '帧末应清 justPressed');
|
||||
assert.equal(r.rt.input.justTapped(), false, '帧末应清 justTapped');
|
||||
assert.equal(r.rt.input.isDown('ArrowLeft'), true, 'isDown 不随帧清(仍按住)');
|
||||
bundle.inputBridge._emit('keyup', { key: 'ArrowLeft' });
|
||||
assert.equal(r.rt.input.isDown('ArrowLeft'), false);
|
||||
});
|
||||
|
||||
/* 集成⑤:物理组件 opt-in 积分(gravity + vx/vy)。 */
|
||||
test('U1-集成 物理组件积分', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const gdef = {
|
||||
components: [{ id: 'phys', kind: 'physics', gravity: 100 }],
|
||||
entities: [{ id: 'ball', transform: { position: { x: 0, y: 0 } }, vx: 10, vy: 0, components: ['phys'] }],
|
||||
};
|
||||
const r = createRuntime(boot, gdef);
|
||||
r.init();
|
||||
r.update(1.0);
|
||||
const ball = r.rt.getEntity('ball');
|
||||
assert.ok(Math.abs(ball.vy - 100) < 1e-6, 'gravity 100 @1s → vy≈100');
|
||||
assert.ok(Math.abs(ball.x - 10) < 1e-6, 'vx 10 @1s → x≈10');
|
||||
});
|
||||
|
||||
/* 集成⑥:声明式渲染器不抛 + 真出绘制调用(mock g 记账)。 */
|
||||
test('U1-集成 声明式渲染器出图', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const calls = { fillRect: 0, arc: 0 };
|
||||
const g = {
|
||||
fillStyle: '', font: '', textAlign: '',
|
||||
fillRect: () => { calls.fillRect++; }, beginPath: () => {}, arc: () => { calls.arc++; }, fill: () => {},
|
||||
fillText: () => {},
|
||||
};
|
||||
const gdef = {
|
||||
components: [
|
||||
{ id: 'bg', kind: 'render', shape: 'fill', color: '#101020' },
|
||||
{ id: 'box', kind: 'render', shape: 'rect', color: '#fff', w: 20, h: 20 },
|
||||
{ id: 'dot', kind: 'render', shape: 'circle', color: '#0f0', r: 8 },
|
||||
],
|
||||
entities: [
|
||||
{ id: 'b', transform: { position: { x: 0, y: 0 } }, components: ['bg'] },
|
||||
{ id: 'box1', transform: { position: { x: 100, y: 100 } }, components: ['box'] },
|
||||
{ id: 'd1', transform: { position: { x: 50, y: 50 } }, components: ['dot'] },
|
||||
],
|
||||
};
|
||||
const r = createRuntime(boot, gdef);
|
||||
r.init();
|
||||
assert.doesNotThrow(() => r.render(g));
|
||||
assert.ok(calls.fillRect >= 2, 'fill 背景 + rect 实体应出 fillRect');
|
||||
assert.equal(calls.arc, 1, 'circle 实体应出 arc');
|
||||
});
|
||||
|
||||
/* 守卫:缺 boot.ctx 必 fail-loud(错误路径不静默)。 */
|
||||
test('U1-守卫 缺 ctx 硬失败', () => {
|
||||
assert.throws(() => createRuntime({}, {}), /boot\.ctx 缺失/);
|
||||
assert.throws(() => createRuntime(null, {}), /boot\.ctx 缺失/);
|
||||
});
|
||||
|
||||
/* 评审修复①(HIGH):state() 派生 harness driver 命名路径(ball.x/paddle.x/targets[])。 */
|
||||
test('U1-修 state 派生 driver 命名路径', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const gdef = {
|
||||
components: [{ id: 'rb', kind: 'render', shape: 'circle', color: '#fff', r: 8 }],
|
||||
entities: [
|
||||
{ id: 'ball', transform: { position: { x: 100, y: 200 } }, vx: 5, vy: -3, components: ['rb'] },
|
||||
{ id: 'paddle', transform: { position: { x: 195, y: 800 } } },
|
||||
{ id: 't0', transform: { position: { x: 50, y: 50 } }, tags: ['target'] },
|
||||
{ id: 't1', transform: { position: { x: 90, y: 50 } }, tags: ['target'] },
|
||||
],
|
||||
};
|
||||
const r = createRuntime(boot, gdef); r.init();
|
||||
const st = r.state();
|
||||
assert.equal(st.ball.x, 100); assert.equal(st.ball.vy, -3); // paddle-intercept driver 读 ball.x
|
||||
assert.equal(st.paddle.x, 195); // controlCheck 读 paddle.x
|
||||
assert.equal(st.targets.length, 2); // tap-targets driver 读 targets[]
|
||||
assert.equal(st.targets[0].idx, 0); assert.equal(st.targets[0].occupied, false);
|
||||
assert.equal(st.phase, 'playing'); // 保留键不被实体投影覆盖
|
||||
});
|
||||
|
||||
/* 评审修复②(High):addScore 守 num(NaN/字符串不污染 score 契约)。 */
|
||||
test('U1-修 addScore num 守卫', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, {}); r.init();
|
||||
r.rt.addScore(NaN); assert.equal(r.state().score, 0, 'NaN→0 不污染');
|
||||
r.rt.addScore('5'); assert.equal(r.state().score, 0, '非数值串→0(忽略,不拼接)');
|
||||
r.rt.addScore(3); assert.equal(r.state().score, 3, '数值正常 +3');
|
||||
r.rt.addScore(); assert.equal(r.state().score, 4, '缺省 +1');
|
||||
});
|
||||
|
||||
/* 评审修复③(High):rt 纯 API 面冻结(behavior 改写共享 API 不腐蚀全局)。 */
|
||||
test('U1-修 rt API 面冻结', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, {}); r.init();
|
||||
assert.throws(() => { r.rt.input.isDown = () => true; }); // ESM 严格模式写冻结对象抛
|
||||
assert.equal(r.rt.input.isDown('X'), false, 'isDown 未被改写');
|
||||
assert.throws(() => { r.rt.view.w = 0; });
|
||||
assert.equal(r.rt.view.w, 390);
|
||||
});
|
||||
|
||||
/* 评审修复(Med):重复实体 id——先到者保持可寻址 + 记错误信号(reap 不误删存活者)。 */
|
||||
test('U1-修 重复实体 id 先到者可寻址', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const gdef = { entities: [
|
||||
{ id: 'd', transform: { position: { x: 1, y: 1 } }, tags: ['first'] },
|
||||
{ id: 'd', transform: { position: { x: 2, y: 2 } }, tags: ['second'] },
|
||||
] };
|
||||
const r = createRuntime(boot, gdef); r.init();
|
||||
assert.ok(r.errors().some((e) => e.includes('重复')), '应记 id 重复信号');
|
||||
assert.equal(r.rt.entities().length, 2, '两实体都在世界');
|
||||
assert.equal(r.rt.getEntity('d').tags.has('first'), true, 'getEntity 返先到者');
|
||||
});
|
||||
|
||||
/* 评审修复(残留):rt.time 不暴露 nowMs(墙钟=非确定性已移除)。 */
|
||||
test('U1-修 rt.time 不暴露 nowMs', () => {
|
||||
const { boot } = makeBoot(1, false);
|
||||
const r = createRuntime(boot, {});
|
||||
assert.equal(typeof r.rt.time.now, 'function');
|
||||
assert.equal(r.rt.time.nowMs, undefined, 'nowMs 已移除(防非确定性逃逸)');
|
||||
});
|
||||
|
||||
/* U3 救场基元:clickable 内置点击派发(声明式 tap-targets,免模型写 tap-handler)。 */
|
||||
test('U3 clickable 内置点击派发', () => {
|
||||
const { boot, bundle, mock } = makeBoot(1, true);
|
||||
const gdef = {
|
||||
components: [
|
||||
{ id: 'cell', kind: 'render', shape: 'rect', color: '#333', w: 100, h: 100 },
|
||||
{ id: 'clk', kind: 'clickable', score: 1, mark: { shape: 'circle', color: '#ff0', r: 30 } },
|
||||
],
|
||||
entities: [{ id: 'c0', transform: { position: { x: 100, y: 300 } }, tags: ['target'], components: ['cell', 'clk'] }],
|
||||
};
|
||||
const r = createRuntime(boot, gdef); r.init();
|
||||
bundle.inputBridge._emit('pointerdown', { x: 100, y: 300 }); // 点中 c0
|
||||
r.update(0.016);
|
||||
assert.equal(r.state().score, 1, '点中 clickable +1 分(无需 tap-handler)');
|
||||
assert.equal(r.state().targets[0].occupied, true, '点中后 occupied 翻 true(driver 会跳过它)');
|
||||
assert.ok(r.state().entities.length >= 2, '应 spawn 标记实体(可见变化→E_live)');
|
||||
assert.ok(mock.emitterCalls.length >= 1, 'fx.burst 真触发引擎(F_wiring)');
|
||||
bundle.inputBridge._emit('pointerdown', { x: 100, y: 300 }); // 再点同一(已 occupied)
|
||||
r.update(0.016);
|
||||
assert.equal(r.state().score, 1, 'occupied 后再点不重复计分');
|
||||
bundle.inputBridge._emit('pointerdown', { x: 5, y: 5 }); // 点空白
|
||||
r.update(0.016);
|
||||
assert.equal(r.state().score, 1, '点空白不计分');
|
||||
});
|
||||
217
game-runtime/src/host/runtime-api-2d.d.ts
vendored
Normal file
217
game-runtime/src/host/runtime-api-2d.d.ts
vendored
Normal file
@ -0,0 +1,217 @@
|
||||
// runtime-api-2d.d.ts —— 运行时访问约定(2D 适配器 · behavior 侧 API)TS 契约 · core-runtime-v0
|
||||
// owner:本 session(引擎线 · plan 2026-06-18-001 U1 正式化产物) | 消费方:U2 build-from-source 装配器 + U3 GAMEDEF_SYSTEM prompt
|
||||
//
|
||||
// 【为什么手写 .d.ts、落 game-runtime/src/host/】
|
||||
// 与 game-host.d.ts(第 9 类契约)同理:发行版源 = ESM JS + JSDoc,对外「类型契约」手写。本约定是 game-runtime
|
||||
// 内部「宿主↔游戏 behavior」接线面(behaviors 经 rt 操作世界),与 api.d.ts(插件↔引擎面)正交、与 game-host.d.ts
|
||||
// (游戏工厂↔宿主面)相邻;故落 src/host/,不落 contracts/(避免跨仓 SSOT 反向 import)。是否升格 contracts/ 由 6c6g 定。
|
||||
//
|
||||
// 【与实现同形】本文件与 gd-runtime.js 的 createRuntime/rt 必须同形同步;人/模型可读版见同目录 runtime-api-2d.md。
|
||||
// 【状态】已过 4 轮迭代 + 跨品类 9/9 实证(含 clickable 基元),约定稳定,正式化为契约。后续演进只增不破已冻签名。
|
||||
|
||||
import type { PluginContext } from '../core/api.d.ts';
|
||||
import type { GameHostBootContext } from './game-host.d.ts';
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* gameDefinition 声明式领域模型(source-project.schema.json#/properties/gameDefinition 的运行时消费视图)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 归一化颜色:'#rgb'/'#rrggbb' 或 {r,g,b,a}(0..1)。 */
|
||||
export type Color = string | { r: number; g: number; b: number; a?: number };
|
||||
|
||||
/** 渲染组件:声明式出图(behaviors 只逻辑不画,渲染器据此画)。 */
|
||||
export interface RenderComponent {
|
||||
id?: string;
|
||||
kind: 'render';
|
||||
/** rect(缺省,以 transform 为中心)/ circle / fill(铺满 390×844 视口作背景)。 */
|
||||
shape?: 'rect' | 'circle' | 'fill';
|
||||
color?: Color;
|
||||
w?: number; h?: number; // rect
|
||||
r?: number; // circle
|
||||
}
|
||||
|
||||
/** 物理组件(opt-in):挂此组件的实体每帧 x+=vx*dt; y+=vy*dt(有 gravity 则先 vy+=gravity*dt)。 */
|
||||
export interface PhysicsComponent {
|
||||
id?: string;
|
||||
kind: 'physics';
|
||||
gravity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击组件(plan U3 离散点击声明式基元):运行时内置——本帧点中带此组件且 occupied!==true 的实体
|
||||
* → 自动翻 occupied=true + spawn mark 标记实体(可见变化) + 加 score + fx(粒子/音效)。
|
||||
* 离散点击类(井字棋/打地鼠/翻牌)声明它即可,免写易错 tap-handler。配 tags:["target"] 让九门 driver 逐个点。
|
||||
*/
|
||||
export interface ClickableComponent {
|
||||
id?: string;
|
||||
kind: 'clickable';
|
||||
/** 命中加分(缺省 1)。 */
|
||||
score?: number;
|
||||
/** 命中时 spawn 的标记实体的 render 形态(缺省黄色小圆)。 */
|
||||
mark?: { shape?: 'rect' | 'circle'; color?: Color; r?: number; w?: number; h?: number };
|
||||
/** 命中区半尺寸来源(缺省取 render 尺寸 / 80)。 */
|
||||
hitW?: number; hitH?: number;
|
||||
}
|
||||
|
||||
/** 碰撞/自定义组件:声明式标记,由 behavior 自行 rt.overlap 判定 / 读取。 */
|
||||
export interface MarkerComponent {
|
||||
id?: string;
|
||||
kind: 'collision' | 'custom';
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export type Component = RenderComponent | PhysicsComponent | ClickableComponent | MarkerComponent;
|
||||
|
||||
/** 实体声明(schema entity):transform.position → 运行时 x/y;可带 vx/vy/tags + 任意数值字段(idx/occupied 等)。 */
|
||||
export interface EntityDef {
|
||||
id: string;
|
||||
transform: { position: { x: number; y: number; z?: number }; rotation?: number; scale?: number };
|
||||
/** 组件引用(components[].id 字符串)或内联组件对象(spawn 用)。 */
|
||||
components?: Array<string | Component>;
|
||||
vx?: number; vy?: number;
|
||||
tags?: string[];
|
||||
[k: string]: unknown; // 运行时可用的自定义数值/布尔字段(idx/occupied/…)
|
||||
}
|
||||
|
||||
/** 行为模块:code 是一段 JS 逻辑串,运行期编译为 function(rt,self,dt)。init 跑一次,其余每帧。 */
|
||||
export interface BehaviorDef {
|
||||
id: string;
|
||||
trigger: 'init' | 'update' | 'input' | 'collision' | 'timer';
|
||||
/** 逻辑代码(规范字段 code;js 为兼容别名)。受校验边界静态扫描(禁 Math.random/Date/process/while(true)…)。 */
|
||||
code?: string;
|
||||
js?: string;
|
||||
}
|
||||
|
||||
/** 规则:condition 是无副作用 JS 布尔表达式(rt/self 在作用域),为真按 outcome。 */
|
||||
export interface RuleDef {
|
||||
id: string;
|
||||
condition: string;
|
||||
outcome: 'win' | 'lose' | 'score' | 'advance';
|
||||
}
|
||||
|
||||
/** 场景:scenes[0].entityRefs 决定初始实例化哪些实体(缺省全量)。 */
|
||||
export interface SceneDef {
|
||||
id: string;
|
||||
entityRefs: string[];
|
||||
}
|
||||
|
||||
export interface GameDefinition {
|
||||
entities: EntityDef[];
|
||||
components?: Component[];
|
||||
behaviors?: BehaviorDef[];
|
||||
scenes?: SceneDef[];
|
||||
rules?: RuleDef[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* rt —— behavior 侧 API(注入 new Function('rt','self','dt',code) 的那个 rt)
|
||||
* 受控面铁律:时间/随机走 boot.ctx 受控种子(确定性,禁 Math.random/Date.now);引擎能力经 ctx.getEngine()(满 F 门)。
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 运行时实体实例(rt.getEntity/entities/query/spawn 返回)。 */
|
||||
export interface Entity {
|
||||
id: string;
|
||||
x: number; y: number; vx: number; vy: number;
|
||||
alive: boolean;
|
||||
tags: Set<string>;
|
||||
/** 已解析的组件对象数组。 */
|
||||
components: Component[];
|
||||
get(k: string): unknown;
|
||||
set(k: string, v: unknown): unknown;
|
||||
destroy(): void;
|
||||
[k: string]: unknown; // behaviors 可直接读写自定义属性(e.hp=3、e.occupied 等)
|
||||
}
|
||||
|
||||
/** 受控输入轮询面(包 ctx.getInput() 受控事件为 behaviors 友好的轮询)。 */
|
||||
export interface RtInput {
|
||||
isDown(key: string): boolean;
|
||||
justPressed(key: string): boolean;
|
||||
justTapped(): boolean;
|
||||
/** 当前指针(副本,写它不腐蚀内部态)。 */
|
||||
readonly pointer: { x: number; y: number; down: boolean };
|
||||
}
|
||||
|
||||
/** 受控时间面(now=相对游戏时间秒,从 0;不暴露墙钟 nowMs,保确定性)。 */
|
||||
export interface RtTime {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
export interface RtFx {
|
||||
/** 经 ctx.getEngine().particles 真喷粒子(满 F 门;无引擎优雅 no-op)。 */
|
||||
burst(x: number, y: number, color?: Color): void;
|
||||
/** 经 ctx.getEngine().audio.synth 真合成音效(kind ∈ score/hit/lose/win/default)。 */
|
||||
beep(kind?: string): void;
|
||||
}
|
||||
|
||||
/** behavior/rule 注入的运行时访问面(全部能力的唯一入口;其纯 API 面被 Object.freeze 防改写)。 */
|
||||
export interface Rt {
|
||||
// 实体
|
||||
getEntity(id: string): Entity | null;
|
||||
entities(): Entity[];
|
||||
/** 按 tag 或组件 id/kind 查询活实体(gameplay 主用 tag)。 */
|
||||
query(name: string): Entity[];
|
||||
spawn(spec: Partial<EntityDef> & { x?: number; y?: number; vx?: number; vy?: number; tags?: string[]; components?: Array<string | Component> }): Entity;
|
||||
destroy(e: Entity): void;
|
||||
// 输入 / 时间 / 随机(确定性)
|
||||
input: RtInput;
|
||||
time: RtTime;
|
||||
readonly dt: number;
|
||||
random(): number;
|
||||
randRange(a: number, b: number): number;
|
||||
randInt(a: number, b: number): number;
|
||||
// 分数胜负(win/lose 置不可逆 latch 终态)
|
||||
readonly score: number;
|
||||
addScore(n?: number): number;
|
||||
setScore(n: number): number;
|
||||
win(): void;
|
||||
lose(): void;
|
||||
// 工具 / 特效 / 视口
|
||||
clamp(v: number, lo: number, hi: number): number;
|
||||
dist(ax: number, ay: number, bx: number, by: number): number;
|
||||
/** AABB 重叠({x,y,w?,h?},缺省全尺寸 16;显式 0 被尊重)。 */
|
||||
overlap(a: { x: number; y: number; w?: number; h?: number }, b: { x: number; y: number; w?: number; h?: number }): boolean;
|
||||
fx: RtFx;
|
||||
view: { w: number; h: number };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 取证快照(_forensicsView().state();九门据此判进展 + 派生 driver 命名路径)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** state() 快照:固定取证键 + 逐实体按 id 投影命名位(ball.x/paddle.x…)+ tag='target' → targets[]。 */
|
||||
export interface ForensicsState {
|
||||
phase: 'booting' | 'playing' | 'gameover';
|
||||
result: 'win' | 'lose' | null;
|
||||
score: number;
|
||||
elapsed: number;
|
||||
remaining: number;
|
||||
progress: number | null;
|
||||
errors: string[];
|
||||
entities: Array<{ id: string; x: number; y: number; tags: string[] }>;
|
||||
/** tap-targets driver 读:tag='target' 实体派生。 */
|
||||
targets?: Array<{ x: number; y: number; idx: number; occupied: boolean; safe: boolean }>;
|
||||
/** 逐实体 id 投影位(如 ball:{x,y,vx,vy,w?,h?,r?,angle?}),供 gatespec 命名路径解析。 */
|
||||
[entityId: string]: unknown;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* createRuntime —— 建可变世界 + 产 rt(U2 装配器产出的工厂 init 调它;GameInstance 五法委托其 init/update/render/state/destroy)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface Runtime {
|
||||
rt: Rt;
|
||||
init(): void;
|
||||
update(dt: number): void;
|
||||
render(g: CanvasRenderingContext2D): void;
|
||||
state(): ForensicsState;
|
||||
destroy(): void;
|
||||
errors(): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 建运行时:实例化 entities、编译 behaviors(new Function)/rules、内置 clickable 点击派发 + 声明式渲染器,产 rt。
|
||||
* @param boot 宿主注入的受控启动上下文(含 ctx:PluginContext 受控面)。
|
||||
* @param gameDefinition 声明式领域模型。
|
||||
*/
|
||||
export declare function createRuntime(boot: GameHostBootContext | { ctx: PluginContext; seed?: number; mainContext?: CanvasRenderingContext2D | null; canvas?: HTMLCanvasElement | null }, gameDefinition: GameDefinition): Runtime;
|
||||
export default createRuntime;
|
||||
136
game-runtime/src/host/runtime-api-2d.md
Normal file
136
game-runtime/src/host/runtime-api-2d.md
Normal file
@ -0,0 +1,136 @@
|
||||
# 运行时访问约定(2D 适配器 · behavior 侧 API)—— runtime-api-2d v0
|
||||
|
||||
> plan 2026-06-18-001 U1 交付物。本文件是「写 gameDefinition 的 behavior 逻辑时能用的全部 `rt` 面」的**人/模型可读契约**,
|
||||
> U3 的 `GAMEDEF_SYSTEM` prompt 直接嵌入本文(+ 末尾 few-shot)。实现见 `gd-runtime.js`,类型雏形随后正式化为 `runtime-api-2d.d.ts`。
|
||||
>
|
||||
> **状态 v0(plan「别冻早」)**:已过 4 类原型表达力证(见 `gd-archetypes.test.mjs`),接口稳定但保留随真实模型输出补 idiom 的余量。
|
||||
> **落点说明**:与 `game-host.d.ts`(第 9 类契约)同理,本约定是 game-runtime 内部「宿主↔游戏 behavior」接线面,故落 `game-runtime/src/host/`;
|
||||
> 是否升格为 `contracts/` 跨仓契约由 6c6g(大脑线/contracts owner)定。
|
||||
|
||||
---
|
||||
|
||||
## 1. 游戏 = 声明式 gameDefinition(改源不改打包产物)
|
||||
|
||||
一个游戏 = 一份 `gameDefinition` JSON(结构见 `contracts/agent-loop/source-project.schema.json`):
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `entities[]` | 实体:`{id, transform:{position:{x,y}}, components:[组件id...]}`(可带 `vx/vy/tags` 供运行时用) |
|
||||
| `components[]` | 组件定义:`{id, kind, ...}`,`kind ∈ render/collision/physics/custom` |
|
||||
| `behaviors[]` | 行为模块:`{id, trigger, code}`——`code` 是一段 **JS 逻辑字符串**,运行期编译执行 |
|
||||
| `scenes[]` | 场景:`{id, entityRefs:[实体id...]}`(v0 取 `scenes[0]` 决定初始实例化哪些实体;缺省全量) |
|
||||
| `rules[]` | 胜负规则:`{id, condition, outcome}`——`condition` 是 JS 布尔表达式串,`outcome ∈ win/lose/score/advance` |
|
||||
|
||||
**核心范式**:实体/组件/场景/规则是**声明式数据**;唯一的「逻辑代码」是 `behaviors[].code` 和 `rules[].condition`,
|
||||
它们经受控的 `rt` 面操作世界。**behaviors 只写逻辑、不写画**(出图由声明式渲染器据 render 组件自动完成)。
|
||||
|
||||
---
|
||||
|
||||
## 2. behavior 代码契约
|
||||
|
||||
每个 behavior 的 `code` 被编译为 `new Function('rt', 'self', 'dt', code)`,每次调用注入三参:
|
||||
|
||||
- **`rt`** — 运行时访问面(见 §4),操作世界的唯一入口。
|
||||
- **`self`** — 本 behavior 的**持久局部态**(普通对象 `{}`,跨帧保留)。存计时器/累加器:`self.t = (self.t||0) + dt;`
|
||||
- **`dt`** — 本帧时间步(秒)。
|
||||
|
||||
**trigger 语义**:
|
||||
- `init`:世界建好后**跑一次**(布初始实体/初值)。
|
||||
- `update` / `input` / `collision` / `timer`:**每帧跑一次**(输入经 `rt.input` 轮询;碰撞/计时在 behavior 内自查)。
|
||||
|
||||
**硬约束(违反 = 生成缺陷)**:
|
||||
1. **确定性**:随机一律 `rt.random/rt.randRange/rt.randInt`,时间一律 `rt.time.now()`/`dt`——**禁 `Math.random` / `Date.now` / `performance.now`**。
|
||||
2. **只用 `rt` 面**:禁自造约定外 helper(如 `rt._spawnFood`)、禁 `document`/`window`/裸引擎/`requestAnimationFrame`/`addEventListener`。要生成实体用 `rt.spawn`。
|
||||
3. **终态置 latch**:胜负用 `rt.win()`/`rt.lose()`(置不可逆终态),**不要**用计分或自定义 flag 表达「游戏结束」。
|
||||
4. **特效经 `rt.fx`**:碰撞/得分等关键事件调 `rt.fx.burst(...)` / `rt.fx.beep(...)`(真接引擎粒子/音频)。
|
||||
|
||||
> **边界硬拦截(非靠自觉)**:以上禁则在**校验边界静态扫描**——`behavior.code`/`rule.condition` 命中
|
||||
> `Math.random`/`Date`/`process`/`require`/`eval`/`.constructor`/`while(true)`/`document`/`window` 等,或
|
||||
> condition 含 `;`/赋值/世界变更调用,会被**拒绝并回灌 repair**(不进编译)。模型代码**只在浏览器执行**
|
||||
> (esbuild 仅静态打包),故确定性/安全靠此扫描兜底——别用约定外手段,会被打回。
|
||||
|
||||
---
|
||||
|
||||
## 3. 规则(rules)
|
||||
|
||||
`condition` 是 JS 布尔表达式串,作用域内有 `rt`(与 `self`)。每帧求值;为真时按 `outcome`:
|
||||
|
||||
- `win` / `lose` → 置 latch 终态(一次性,不可逆)。
|
||||
- `score` → 上升沿 +1 分(防每帧重复加)。
|
||||
- `advance` → 场景推进(v0 占位)。
|
||||
|
||||
例:`{ "id":"win", "condition":"rt.score >= 10", "outcome":"win" }`
|
||||
|
||||
---
|
||||
|
||||
## 4. `rt` 面参考(全部可用 API)
|
||||
|
||||
### 实体
|
||||
- `rt.getEntity(id)` → 实体或 null(仅活实体)
|
||||
- `rt.entities()` → 全部活实体数组
|
||||
- `rt.query(name)` → 按 tag 或组件 id/kind 过滤的活实体数组
|
||||
- `rt.spawn({x, y, vx?, vy?, tags?, components?})` → 新实体(`components` 可内联组件对象);返回实体引用
|
||||
- `rt.destroy(entity)` → 标记死亡(帧末回收)
|
||||
- 实体字段:`.x .y .vx .vy .alive .tags(Set) .components(数组)`;方法 `.get(k)` / `.set(k,v)` / `.destroy()`;可直接读写任意属性(`e.hp = 3`)
|
||||
|
||||
### 输入(轮询)
|
||||
- `rt.input.isDown(key)` → 是否按住(如 `'ArrowLeft'`/`'Space'`)
|
||||
- `rt.input.justPressed(key)` → 本帧是否刚按下
|
||||
- `rt.input.justTapped()` → 本帧是否发生指针按下
|
||||
- `rt.input.pointer` → `{x, y, down}`
|
||||
|
||||
### 时间 / 随机(确定性)
|
||||
- `rt.time.now()` → 相对游戏时间(秒,从 0 累加;**用这个**,非绝对钟)
|
||||
- `rt.dt` → 本帧步长(秒,= 注入的 `dt`)
|
||||
- `rt.random()` → `[0,1)`;`rt.randRange(a,b)` → `[a,b)`;`rt.randInt(a,b)` → `[a,b]` 整数
|
||||
|
||||
### 分数 / 胜负
|
||||
- `rt.score`(读)/ `rt.addScore(n=1)` / `rt.setScore(n)`
|
||||
- `rt.win()` / `rt.lose()` → 置 latch 终态
|
||||
|
||||
### 工具 / 特效
|
||||
- `rt.clamp(v,lo,hi)` / `rt.dist(ax,ay,bx,by)` / `rt.overlap(a,b)`(AABB,`a/b={x,y,w?,h?}`,缺省半尺寸 16)
|
||||
- `rt.fx.burst(x,y,color)` → 引擎粒子(color 接 `'#rrggbb'` 或 `{r,g,b,a}`)
|
||||
- `rt.fx.beep(kind)` → 引擎音效(kind ∈ `score/hit/lose/win/default`)
|
||||
- `rt.view` → `{w:390, h:844}`(视口;边界判断用)
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件(components)
|
||||
|
||||
- **render**:`{id, kind:'render', shape, color, ...}`
|
||||
- `shape:'rect'` + `w,h`(以实体 transform 为中心)
|
||||
- `shape:'circle'` + `r`
|
||||
- `shape:'fill'` + `color`(铺满视口,作背景)
|
||||
- **physics**:`{id, kind:'physics', gravity?}`——挂此组件的实体每帧自动 `x+=vx*dt; y+=vy*dt`(有 `gravity` 则先 `vy+=gravity*dt`)。不挂则位置全由 behavior 控制。
|
||||
- **clickable**(离散点击声明式基元):`{id, kind:'clickable', score?:1, mark?:{shape,color,r/w/h}, hitW?, hitH?}`——运行时**内置**:本帧点中带此组件且 `occupied!==true` 的实体(命中区取 hitW/hitH,缺省取 render 尺寸/80)→ 自动翻 `occupied=true` + spawn `mark` 标记实体(可见变化) + `+score` + fx 粒子/音效。**离散点击类(井字棋/打地鼠/翻牌)用它即可,无需写 tap-handler**(免模型把命中误门控在分离 state/计时器上)。配 `tags:["target"]` 让九门 driver 能逐个点。
|
||||
- **collision / custom**:声明式标记,由 behavior 自行 `rt.overlap` 判定 / 读取。
|
||||
|
||||
---
|
||||
|
||||
## 6. 原型范式(few-shot · 见 `gd-archetypes.test.mjs` 完整可跑版)
|
||||
|
||||
- **paddle-intercept(打砖块/pong)**:板 entity + 球 entity(`vx/vy`);update behavior 里键/指针控板、球积分+墙反弹、`rt.overlap` 板拦截 `rt.addScore`+`rt.fx.burst`、落底 `rt.lose()`。
|
||||
- **event-clicker(点击器/放置)**:按钮 entity;input behavior 里 `if(rt.input.justTapped()) rt.addScore(1)`;rule `rt.score>=N → win`。
|
||||
- **dodge-spawn(躲避)**:玩家 entity;update behavior 里键控玩家、定时 `rt.spawn` 随机位敌人(`rt.randRange`)、遍历 `rt.query('enemy')` 下落+出界 `rt.destroy`+`rt.overlap` 撞玩家 `rt.lose()`。
|
||||
- **runner(跑酷)**:玩家挂 `physics{gravity}`;update behavior 里 `rt.input.justTapped()` 起跳(置 `vy`)、落地复位、定时 `rt.spawn` 障碍(`vx`)、`rt.overlap` 撞障碍 `rt.lose()` / 出界 `rt.addScore`。
|
||||
|
||||
---
|
||||
|
||||
## 6.1 取证/driver 命名约定(让九门能驱动你的游戏 · 关键)
|
||||
|
||||
九门 harness 据 gatespec 的 driver 用**命名路径**读取证状态来真玩你的游戏。运行时自动把世界投影成取证形状,**但你必须命名/打标对齐**,否则 driver 读不到 → 机制门必挂:
|
||||
|
||||
- **实体按 id 投影为顶层取证位**:实体 id=`ball` → 取证可读 `ball.x/ball.y/ball.vx/ball.vy`(含 `w/h/r/angle`);id=`paddle` → `paddle.x`。
|
||||
故 **paddle-intercept 类**:把球实体命名 `ball`、挡板命名 `paddle`(与设计 agent 的 gatespec `driver.ballPath="ball.x"`/`controlCheck.paddlePath="paddle.x"` 对齐)。
|
||||
- **离散点击类(tap-targets)**:给每个可点目标实体打 `tags:["target"]`,并(可选)设 `idx`(序号)/`occupied`(是否已占)/`safe`(是否非致负);
|
||||
运行时自动汇成 `targets:[{x,y,idx,occupied,safe}]` 供 driver 逐个点。**不打 target 标 → driver 点不到 → H_progress 必挂。**
|
||||
- 保留取证键(`phase/score/result/remaining/progress/errors/entities/targets`)不会被实体投影覆盖(同名实体 id 不投影)。
|
||||
|
||||
## 7. 常见坑(治真实模型漂移)
|
||||
|
||||
- ❌ 自造 `rt._spawnFood` 等约定外 helper → ✅ 用 `rt.spawn(...)`。
|
||||
- ❌ `Math.random()` / `Date.now()` → ✅ `rt.random()` / `rt.time.now()`(确定性,否则取证不可复现 + 静态门拒)。
|
||||
- ❌ 用分数/flag 表达「结束」 → ✅ `rt.win()`/`rt.lose()` 置 latch。
|
||||
- ❌ 在 behavior 里画图(`ctx.fillRect`)→ ✅ 给实体加 render 组件,渲染器自动出图。
|
||||
- ❌ 关键事件无特效 → ✅ 碰撞/得分调 `rt.fx.burst`/`rt.fx.beep`(满「真接线」门)。
|
||||
183
wg1/gen-worker/gamedef_quickcheck.py
Normal file
183
wg1/gen-worker/gamedef_quickcheck.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""gamedef_quickcheck.py —— plan 2026-06-18-001 U3 gamedef 路 lili-mac 快走查(cutover 门早读)。
|
||||
|
||||
run.py 的 gamedef 版:把 generate(iife) → validate(静态) 换成
|
||||
generate(GAMEDEF_SYSTEM) → build-from-source.mjs(校验 schema/引用/静态扫描 + 装配出工厂),
|
||||
其余(scaffold/build/play 九门)复用 run.py(harness 零改)。
|
||||
|
||||
GAMEDEF_SYSTEM **从后端 SaaPrompts.java 抽取**(drift-free:跑的就是后端真用的 prompt)。
|
||||
模型/网关/代理旁路复用 _client;play-spec/brief 复用现有 briefs/*.json(gatespec driver 同口径)。
|
||||
|
||||
用法:.venv/bin/python gamedef_quickcheck.py runner flappy whack pong # 默认这 4
|
||||
仅 lili-mac 快走查(小样早读);authoritative ≥60% cutover 门在 mini-desktop(SaaFullGraphE2eTest)。
|
||||
密钥经 _client 读 .env,绝不打印。
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from worker import _client, run # noqa: E402 复用 chat + scaffold/build/play
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SAA_PROMPTS = (REPO_ROOT / "game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/"
|
||||
"com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java")
|
||||
GAME_RUNTIME = REPO_ROOT / "game-runtime"
|
||||
GEN_DIR = GAME_RUNTIME / "games" / "_wg1-gen"
|
||||
BRIEFS = Path(__file__).resolve().parent / "briefs"
|
||||
import os
|
||||
# 救场阶梯(mirror 真图 SaaStudioGraph):stage1 连续 STAGE1_REPAIRS 次失败 → 升档 stage2 再 STAGE2_EXTRA 次。
|
||||
STAGE1_MODEL = os.environ.get("QC_STAGE1", "deepseek-v4-flash")
|
||||
STAGE2_MODEL = os.environ.get("QC_STAGE2", "deepseek-v4-pro")
|
||||
STAGE1_REPAIRS = int(os.environ.get("QC_STAGE1_REPAIRS", "5")) # 真图 maxRepairs 默认 5
|
||||
STAGE2_EXTRA = int(os.environ.get("QC_STAGE2_EXTRA", "3")) # 真图 stage2ExtraRepairs 默认 3
|
||||
|
||||
|
||||
def extract_block(name):
|
||||
"""从 SaaPrompts.java 抽指定 SYSTEM 文本块(去 Java 文本块 12 空格缩进)。drift-free=跑后端真 prompt。"""
|
||||
java = SAA_PROMPTS.read_text(encoding="utf-8")
|
||||
m = re.search(name + r' = """\n(.*?)\n\s*""";', java, re.DOTALL)
|
||||
if not m:
|
||||
raise RuntimeError("未能从 SaaPrompts.java 抽取 " + name)
|
||||
lines = [(ln[12:] if ln.startswith(" " * 12) else ln) for ln in m.group(1).split("\n")]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def extract_gatespec(design_text):
|
||||
"""复刻 SaaPrompts.extractGatespec:抠 ```gatespec JSON 块 → 补 expectLatch=true + ballPath .y→.x。无则 None。"""
|
||||
m = re.search(r"```gatespec\s*(.+?)```", design_text or "", re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
gs = extract_json_obj(m.group(1))
|
||||
if not isinstance(gs, dict):
|
||||
return None
|
||||
if "assertAfterPlay" in gs and not isinstance(gs["assertAfterPlay"], list):
|
||||
gs["assertAfterPlay"] = []
|
||||
gs.setdefault("expectLatch", True)
|
||||
# gamedef 取证只暴露 score/remaining/progress/实体位置——把 assertAfterPlay 的顶层自定义标量字段(moves/totalRound 等)强制到 score
|
||||
# (设计 agent 常造取证读不到的字段→H_progress 必挂;不动含 '.' 的实体位置断言如 ball.x)。
|
||||
EXPOSED = ("score", "remaining", "progress")
|
||||
for a in (gs.get("assertAfterPlay") or []):
|
||||
if isinstance(a, dict) and isinstance(a.get("path"), str):
|
||||
p = a["path"].lstrip("/")
|
||||
top = p.split(".")[0].split("[")[0]
|
||||
if "." not in p and top not in EXPOSED:
|
||||
a["path"] = "score"
|
||||
a["op"] = a.get("op", "increased")
|
||||
drv = gs.get("driver")
|
||||
if isinstance(drv, dict) and drv.get("type") == "paddle-intercept":
|
||||
bp = drv.get("ballPath")
|
||||
if isinstance(bp, str) and bp.endswith(".y"):
|
||||
drv["ballPath"] = bp[:-2] + ".x"
|
||||
return gs
|
||||
|
||||
|
||||
def extract_json_obj(content):
|
||||
"""从模型输出抠最外层 {...}(去 ```json 围栏/前后说明)。"""
|
||||
s = content.strip()
|
||||
s = re.sub(r"^```(?:json)?\s*", "", s)
|
||||
s = re.sub(r"\s*```$", "", s)
|
||||
lo, hi = s.find("{"), s.rfind("}")
|
||||
if lo < 0 or hi <= lo:
|
||||
return None
|
||||
try:
|
||||
return json.loads(s[lo:hi + 1])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def assemble_via_cli(game_id, gamedef_obj):
|
||||
"""写 source.json → 跑 build-from-source.mjs 校验+装配 → generated-factory.js。返回 (ok, err)。"""
|
||||
gdir = GEN_DIR / game_id
|
||||
gdir.mkdir(parents=True, exist_ok=True)
|
||||
src = gdir / "source.json"
|
||||
src.write_text(json.dumps(gamedef_obj, ensure_ascii=False), encoding="utf-8")
|
||||
r = subprocess.run(
|
||||
["node", "src/host/build-from-source.mjs", str(src), str(gdir / "generated-factory.js")],
|
||||
cwd=str(GAME_RUNTIME), capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
return r.returncode == 0, (r.stdout + r.stderr).strip()
|
||||
|
||||
|
||||
def run_one(game_id, gamedef_system, design_system):
|
||||
brief_text, play_spec = run._load_brief(game_id)
|
||||
gid = "gd-" + game_id
|
||||
out = {"game_id": gid, "stage": None, "pass": False, "guards": {}, "err": "", "driver": None, "model": STAGE1_MODEL}
|
||||
# ① design 步(mirror 真图 design 节点):产 gatespec(driver/controlCheck/assert) 覆写 play_spec + enriched。
|
||||
enriched = brief_text
|
||||
try:
|
||||
dresp = _client.chat(STAGE1_MODEL, design_system, brief_text, max_tokens=16000)
|
||||
enriched = brief_text + "\n\n## 设计稿\n" + dresp["content"]
|
||||
gs = extract_gatespec(dresp["content"])
|
||||
if gs:
|
||||
for k in ("exportState", "driver", "controlCheck", "assertAfterPlay", "expectLatch"):
|
||||
if k in gs and gs[k] is not None:
|
||||
play_spec[k] = gs[k]
|
||||
out["driver"] = (gs.get("driver") or {}).get("type")
|
||||
except Exception as e:
|
||||
out["err"] = "design 降级:" + str(e)[:80] # design 失败不致命,凭 brief 继续 generate
|
||||
# ② generate→assemble→build→play **repair 回环**(mirror 真图:失败→verdict/错误回喂→重生成,≤max_retries 次)。
|
||||
# 这是 ≥60% cutover 门的真机制(单发首攻 ≠ 终率);design 只跑一次,repair 只重 generate。
|
||||
gdir = GEN_DIR / gid
|
||||
feedback = None
|
||||
total = STAGE1_REPAIRS + STAGE2_EXTRA
|
||||
for attempt in range(total):
|
||||
gen_model = STAGE2_MODEL if attempt >= STAGE1_REPAIRS else STAGE1_MODEL # 救场升档:stage1 耗尽→stage2 强档
|
||||
out["attempts"] = attempt + 1
|
||||
out["model"] = gen_model
|
||||
user = "请为下面这款游戏产出一份 gameDefinition(只输出一个 JSON 对象,不要 ```代码块、不要解释):\n\n" + enriched
|
||||
if feedback:
|
||||
user += "\n\n———\n上一次生成【未通过】,原因如下,请针对性修正后重新产出完整 gameDefinition JSON:\n" + feedback
|
||||
try:
|
||||
resp = _client.chat(gen_model, gamedef_system, user, max_tokens=16000)
|
||||
except Exception as e:
|
||||
out["stage"] = "generate"; feedback = "模型调用失败:" + str(e)[:160]; continue
|
||||
gd = extract_json_obj(resp["content"])
|
||||
if gd is None:
|
||||
out["stage"] = "parse"; feedback = "未能解析出 gameDefinition JSON。请只输出一个 JSON 对象(顶层 entities/components/behaviors/scenes/rules)。"; continue
|
||||
ok, err = assemble_via_cli(gid, gd)
|
||||
if not ok:
|
||||
out["stage"] = "validate/assemble"; feedback = "校验/装配未过:\n" + err[:500]; continue
|
||||
shutil.copyfile(GEN_DIR / "_shared" / "entry-bundle.template.js", gdir / "entry-bundle.js")
|
||||
shutil.copyfile(GEN_DIR / "_shared" / "index.template.html", gdir / "index.html")
|
||||
(gdir / "play-spec.json").write_text(json.dumps(play_spec, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
bok, blog = run.build(gid)
|
||||
if not bok:
|
||||
out["stage"] = "build"; feedback = "打包失败(esbuild):\n" + blog[:500]; continue
|
||||
rc, _, verdict = run.play(gid)
|
||||
out["stage"] = "play"
|
||||
out["guards"] = {k: g.get("pass") for k, g in ((verdict or {}).get("guards") or {}).items()}
|
||||
if rc == 0 and verdict and verdict.get("pass"):
|
||||
out["pass"] = True
|
||||
break
|
||||
feedback = run._verdict_feedback(verdict)
|
||||
out["err"] = feedback[:300]
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
games = sys.argv[1:] or ["runner", "flappy", "whack", "pong"]
|
||||
gds = extract_block("GAMEDEF_SYSTEM")
|
||||
dsys = extract_block("DESIGN_SYSTEM")
|
||||
print(f"[gamedef-qc] stage1={STAGE1_MODEL}({STAGE1_REPAIRS})→stage2={STAGE2_MODEL}({STAGE2_EXTRA}) games={games} (全图镜像+救场升档)\n")
|
||||
results = []
|
||||
for g in games:
|
||||
r = run_one(g, gds, dsys)
|
||||
results.append(r)
|
||||
mark = "✅ PASS" if r["pass"] else f"❌ {r['stage']}"
|
||||
gpass = sum(1 for v in r["guards"].values() if v)
|
||||
gtot = len(r["guards"])
|
||||
drv = r.get("driver") or "-"
|
||||
att = r.get("attempts", 1)
|
||||
print(f" {r['game_id']:14s} {mark:16s} drv={drv:14s} att={att} gates={gpass}/{gtot} {('' if r['pass'] else r['err'][:100])}")
|
||||
npass = sum(1 for r in results if r["pass"])
|
||||
print(f"\n[gamedef-qc] 过门 {npass}/{len(results)} (cutover 门基线=iife 60%; 本为 lili-mac 小样早读, authoritative=mini-desktop)")
|
||||
Path(__file__).resolve().parent.joinpath("results", "gamedef-quickcheck.json").write_text(
|
||||
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user