diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java index 768fdeab..8568e91d 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java @@ -484,7 +484,19 @@ final class SaaGenNodes { out.put("feedback", "verdict.json 解析失败:" + e.getMessage()); } } - boolean pass = rc == 0 && verdict != null && verdict.path("pass").asBoolean(false); + // Phase 1(关闭九门 driver 判定对生成的绑架;founder 2026-06-20「关闭九门判定·全面参考 OpenGame」): + // 生成硬门只认【客观健康门】(A-F build-health,对齐 OpenGame BH);driver 依赖门(G_input/H_progress/I_control) + // 降级为参考、不否决生成。九门 harness 仍照常跑并写 verdict.json(不动门判定本身)。双轨:-Dsaa.gen.gateMode=all9 退回旧聚合 pass。 + // play.cdp.cjs 退出码 = 九门聚合:verdict.pass(全 9 门 AND) ? 0 : 1——任一门(含 driver 门 G/H/I)挂即 rc=1。 + // 故客观门模式【绝不能】用 rc==0 当前置:否则 driver 门失败→rc=1→反向否决客观 pass,Phase 1 形同虚设 + //(n=3 实证:游戏过 A-E 仅挂 H_progress,却因 rc=1 判 false→空跑 8 轮救场→max_repairs/302K token)。 + // 修正:客观门模式只要 harness 完成判定(rc 0/1,排除 ≥2 用法/崩溃)且有可解析 verdict,即按客观门裁决;all9 保留 rc==0 旧口径。 + boolean pass; + if (GATE_ALL9) { + pass = rc == 0 && verdict != null && verdict.path("pass").asBoolean(false); + } else { + pass = (rc == 0 || rc == 1) && verdict != null && objectiveGatesPass(verdict); + } out.put("playPass", pass); if (verdict != null) { // 以字符串形态塞 state(避免 KeyStrategy 序列化 JsonNode 的额外约束;诊断读回即可)。 @@ -523,6 +535,47 @@ final class SaaGenNodes { } } + // ============================================================================ + // Phase 1(关闭九门 driver 判定对生成的绑架;founder 2026-06-20 + 全面参考 OpenGame build-health 门) + // 生成环硬门 = 客观健康门(build-health,对齐 OpenGame BH);driver 依赖门(G/H/I)不否决生成、不进救场回喂。 + // 九门 harness 仍照常跑并记录(verdict.json 不变),仅改「生成环如何消费 verdict」——可回退、不动门判定本身。 + // ============================================================================ + /** 客观健康门(build-health):能起动/无异常/有帧/有渲染/有动画/真接引擎;与 driver 能否玩动无关。 */ + private static final java.util.Set OBJECTIVE_GATES = java.util.Set.of( + "A_boot", "B_uncaught", "C_frame", "D_render", "E_live"); + // F_wiring(真接引擎) 已移出客观硬门:它靠游戏事件触发 rt.fx,无 driver 真玩→事件不触发→必挂(实测 n=2 即挂此门), + // 属 play-dependent。其"是否真用引擎/有特效"的语义改由 player 节点 VLM 看截图判(对齐 OpenGame VU 口径); + // 彻底解法见 Phase 4(VLM 升打分门 / 或 harness 通用 input-poker 触发事件)。 + + /** 双轨开关:all9=退回旧「九门聚合 pass」硬门;默认 objective=只认客观健康门(关闭 driver 门对生成的绑架)。 */ + private static final boolean GATE_ALL9 = + "all9".equalsIgnoreCase(System.getProperty("saa.gen.gateMode", "objective")); + + /** + * Phase 1:仅判【客观健康门】(A-F)是否全过;driver 依赖门(G_input/H_progress/I_control)不参与生成硬门。 + *

遍历 verdict.guards 中出现的门,只对客观门要求 pass;driver 门跳过。无 guards 明细或未见任何客观门→ + * 保守退回聚合 verdict.pass(避免误放空壳)。不改九门判定本身,只改生成环对其的消费口径。 + */ + private static boolean objectiveGatesPass(JsonNode verdict) { + JsonNode guards = verdict.path("guards"); + if (!guards.isObject() || guards.size() == 0) { + return verdict.path("pass").asBoolean(false); + } + boolean sawObjective = false; + Iterator> it = guards.fields(); + while (it.hasNext()) { + Map.Entry e = it.next(); + if (!OBJECTIVE_GATES.contains(e.getKey())) { + continue; // driver 依赖门(G/H/I)等:不参与生成硬门。 + } + sawObjective = true; + if (!e.getValue().path("pass").asBoolean(false)) { + return false; + } + } + return sawObjective || verdict.path("pass").asBoolean(false); + } + /** * 把失败的守卫摘成回喂文字(对齐 run.py:_verdict_feedback)。 */ @@ -536,6 +589,10 @@ final class SaaGenNodes { Iterator> it = guards.fields(); while (it.hasNext()) { Map.Entry e = it.next(); + // Phase 1:客观门模式下,只回喂客观健康门失败,不让 driver 门(G/H/I)驱动救场重生成。 + if (!GATE_ALL9 && !OBJECTIVE_GATES.contains(e.getKey())) { + continue; + } if (!e.getValue().path("pass").asBoolean(false)) { String g = e.getValue().toString(); lines.add("- 守卫 " + e.getKey() + " 未过:" + (g.length() > 200 ? g.substring(0, 200) : g)); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java index 4eea4f9a..08481ee3 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java @@ -392,11 +392,17 @@ public class SaaGraphDispatcher implements GenerationDispatcher { } else { // OpenAI 兼容路(默认):baseUrl 取 host 根(剥末尾 /v1,坑见 §7-1);apiKey 经配置注入。openAiFactory 逐字调 model(api,...),字节等价改造前。 String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase()); - // Plan A U5:openai 路同 anthropic 旁路系统代理(macOS clash 把 Tailscale IP 走代理→502;无代理环境 no-op)。 - OpenAiApi api = SaaStudioGraph.openAiApiNoProxy(baseUrl, properties.getApiKey()); + // Plan A U5:openai 路同 anthropic 旁路系统代理(macOS clash 把 Tailscale IP 走代理→502;无代理环境 no-op)+ 可配读超时(M3+thinking 慢)。 + OpenAiApi api = SaaStudioGraph.openAiApiNoProxy(baseUrl, properties.getApiKey(), properties.getSaaOpenAiReadTimeoutMs()); mf = SaaStudioGraph.openAiFactory(api); upstreamDesc = "openai(" + baseUrl + ")"; } + // Plan A U5「maxtoken 大余量」:saaForceMaxTokens>0 → 包装 mf 覆盖各角色 maxTokens(M3+thinking 思考+答案需大余量防截断,doc 荐 M3 128K);0=各角色默认(字节零变)。 + int forceMaxTokens = properties.getSaaForceMaxTokens(); + if (forceMaxTokens > 0) { + SaaStudioGraph.RoleModelFactory baseMf = mf; + mf = (name, temperature, mt) -> baseMf.create(name, temperature, forceMaxTokens); + } // Plan A U5「只用 M3」(创始人 2026-06-19):saaForceModel 非空 → 全角色统一该模型(含 stage2/fallback 位,单模型无跨家回退);空=stage1 默认(deepseek 主力,字节零变)。 String forceModel = properties.getSaaForceModel(); boolean singleModel = forceModel != null && !forceModel.isBlank(); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java index 5e037009..43968ab8 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java @@ -17,8 +17,8 @@ import java.util.regex.Pattern; *

@@ -153,17 +153,24 @@ final class SaaPrompts { {"entities":[...],"components":[...],"behaviors":[...],"scenes":[...],"rules":[...]} 【结构】 - - entities[]: {"id","transform":{"position":{"x","y"}},"components":[组件id...]}(可加 "vx","vy","tags":[...] 及任意数值字段如 idx) + - entities[]: {"id","transform":{"position":{"x","y"}},"components":[组件id...]}(可加 "vx","vy","tags":[...];⚠️自定义字段如 idx/gridX 写字面量里会被丢弃,须在 init behavior 里对实体引用赋值,见下 rt 面 ⚠️) - 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 持久态(跨帧存计时器/累加器) + code 编译为 function(rt,self,dt){...}: init 跑一次,其余每帧跑; self=本 behavior 私有持久态(跨帧存本 behavior 的计时器/累加器) + ⚠️【self 每个 behavior 各自独立——绝不跨 behavior 共享!】init 里写的 self.phase,别的 behavior 读到 undefined→分支永进不去→画面静止(实测最常见死法)。 + 跨 behavior 共享态(游戏阶段/计时/回合)必须挂【状态实体】:entities[] 声明一个固定 id 的状态实体(如 "gamestate",可无 render), + 各 behavior 一律 `const G=rt.getEntity('gamestate');` 再读写 G.phase/G.timeLeft(实体属性跨帧跨 behavior 持久,如 e.hp=3); 分数只用 rt.score/rt.addScore(全局唯一)。 + ⚠️【数组/对象态同样禁 self 跨享】最常见死法:init 写 self.active=[...]/self.moles=[...],别的 behavior 读 self.active[i]→空对象→抛 "Cannot read properties of undefined"→每帧夭折冻屏。共享数组挂状态实体(G.active=[...]),各 behavior 经 G 读写;或更优:打地鼠/接物类直接 rt.spawn 目标+clickable 内置计分+到点 rt.destroy,用"实体在不在"代替自管数组,免跨 behavior 态。 - scenes[]: {"id","entityRefs":[实体id...]}(scenes[0] 决定初始实例化哪些实体) - rules[]: {"id","condition":"","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) + ⚠️【spawn/entities 字面量只认 id/x/y/vx/vy/tags/components——写在 {...} 里的自定义字段(gridX/colorIdx/scored/w/h/idx 等)被静默丢弃,读到 undefined!】存自定义态须先拿引用再赋值:`const e=rt.spawn({x,y,tags:['t'],components:['c']}); e.gridX=col; e.colorIdx=ci;`(直接写实体对象才持久);声明实体则 init 里 `for(const e of rt.query('t')) e.idx=...`。 + ⚠️碰撞/命中尺寸 w/h/r 只能写进 render 组件,不能写实体顶层:rt.overlap 的框、内置 clickable 命中框都只读 render 组件的 w/h/r——实体顶层 e.w 永远 undefined→NaN→比较恒 false→永不命中/得分。 + ⚠️.tags 是 Set(有 .has(name),没有 .includes/数组方法):判 tag 用 rt.query('pipe') 或 e.tags.has('pipe');写 e.tags.includes(...) 每帧抛错冻死。 - 输入: 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()(置不可逆终态) @@ -172,30 +179,24 @@ final class SaaPrompts { 【硬约束(违反=校验拒绝→打回重做,非靠自觉)】 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 表达结束; 游戏必须"会输"(超时/碰撞/耗尽任一)。 + rt 是【唯一可调用面】——上面【behavior/condition 唯一能用的 rt 面】之外没有全局函数;裸调 getEngine()/getInput()/restart()/spawn()(漏 rt. 前缀)=每帧抛 ReferenceError→整个 behavior 当帧夭折→画面静止/不接线。特效只经 rt.fx;重开局由平台处理,游戏内别写 restart。 + 3. 终态: 胜负用 rt.win()/rt.lose()(不可逆), 别用分数/flag 表达结束; 游戏必须有能【真触发】的终态(九门要求真玩到 gameover 且驻留,光"分数能涨"不够)。 + 无天然失败态的(打地鼠/三消/接物)必须加【时间限或回合限】:init 在状态实体记 G.timeLeft=30,每帧 G.timeLeft-=dt; if(G.timeLeft<=0) rt.lose();(或回合耗尽→rt.win())。 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(别用取证读不到的自定义字段)。 - - **按设计稿 driver.type 对齐命名/进展**(九门据 driver 命名路径驱动你; 实体投影【只投 id→x/y/vx/vy/w/h/r/angle, 不投自定义常数】): - · `tap-pairs`(相邻交换消除/三消): 与离散点击同 — 每棋格实体打 tags:["target"]+idx(harness 点相邻两格=交换); 消除得分务必 rt.addScore(落到 score)。 - · `key-cycle`(纯按键: 方块/2048): 只靠键盘推进, 控制逻辑用 rt.input.isDown/justPressed(键)驱动; 进展落 score 或你导出的命名实体计数字段(如方块锁定数→建议直接用 rt.addScore 计入 score, 因取证只读 score/remaining/位置)。 - · `aim-fire`(旋转瞄准开火/Asteroids): 飞船建成 id="ship" 实体并把朝向写进 e.angle(投影含 angle→ship.angle 可读), 目标(陨石)打 tags:["target"](→targets[{x,y}]); 命中 rt.destroy 目标 + rt.addScore。 - · `drag-aiming`(抛射瞄准/愤怒小鸟): 目标打 tags:["target"]; 命门★—harness 想读 launch:{gravity,powerScale} 物理常数确定性命中, 但实体投影不投这类常数, gd 路【难显式导出 launch】→harness 退化力度扫描逐发兜(确定性尽力非保证); 故抛射类【优先用 iife 源(可手写 _forensicsView 导出 launch)】, gamedef 路至少保证目标 destroy/score 真变。 + - **可控体要跟手**: 挡板/玩家等可控实体随指针或方向键平滑移动,如 + `const p=rt.getEntity('player'); if(p && rt.input.pointer.down){ p.x = rt.input.pointer.x; }`——让玩家点哪动哪 / 按键有反应。 + - **离散点击玩法(井字棋/打地鼠/翻牌等)直接用 clickable 组件**: 每个可点目标实体挂 clickable 组件, + 运行时内置"点中→翻 occupied + spawn mark(可见变化) + 计分 + fx",**不必自己写 justTapped/overlap 点击逻辑** + (自己写命中常因把命中门控在分离 state 实体/计时器上而点不中)。 + - **进展落在运行时真暴露的字段**: 进展务必体现在 rt.addScore(分数涨)或 rt.destroy(目标/砖被清=剩余 remaining 降)上。 + ⚠️绝不把进展藏进自定义标量(score.player / snakeLength / hits / piecesLocked 这类)——运行时不暴露,验收读不到→判无进展;蛇变长/连击/收集一律计入 rt.addScore。 + ⚠️别把实体命名成 snakeLength/hits 等"像计数器"的名字——会被按 id 投成 {x,y} 对象、永不"增大"。 + - **会输 + 胜负演出**: 游戏要真能输(掉命/超时/资源耗尽任一),胜/负时画面有明确变化或文案(别只停住);无天然失败态的(打地鼠/三消/接物)加限时/限回合终态(见硬约束 3)。 - HUD/进展可见: 分数/剩余看得到; 前 30 秒能上手、有正反馈、看得到分数增长。 【参照(严格按此结构产 JSON, 但实现成题目要求的那款游戏; 这是个躲避类示例)】 @@ -257,18 +258,15 @@ final class SaaPrompts { 【约束】设计正文控制在 ~200 字内,聚焦"代码 agent 实现时需要的决策",不堆砌辞藻、不写引擎/契约细节(那是代码 agent 的事)。 - 【末尾必附 gate-spec(机器可读·harness 据此确定性验"能玩 + 手感"·这是 gate-H 推广到任意游戏的关键)】 - 设计正文之后另起一个 ```gatespec 代码块,块内为 JSON,声明本游戏怎样被确定性验证: + 【末尾必附 gate-spec(机器可读·供下游确定性验收用)】 + 设计正文之后另起一个 ```gatespec 代码块,块内为 JSON,声明本游戏的验收锚点: ```gatespec { - "exportState": ["phase","score","remaining","progress","<关键实体位置:如 ball:{x,y,vx,vy}、paddle:{x,y,w};纯点击类可省位置,但离散点击目标类必须导出 targets:[{x,y,idx,occupied[,safe]}]>"], - "driver": {"type":"paddle-intercept","ballPath":"ball.x","paddleY":800}, - "controlCheck": {"paddlePath":"paddle.x","tapXs":[50,340]}, - "assertAfterPlay": [{"path":"remaining","op":"decreased","why":"击碎砖块→剩余下降"}], - "expectLatch": true + "expectLatch": true, + "assertAfterPlay": [{"path":"score","op":"increased","why":"得分上升=有进展"}] } ``` - 规则:① `expectLatch` 恒 true(游戏必须能真玩到 gameover 且驻留);② 有"连续控制体(挡板/角色随手指平滑移动)"才给 `controlCheck`,纯点击/无连续控制给 null;③ 按【玩法形态】二分选 driver(这是判"机制是否真点亮"的命门,别一律给 none):(a)技巧类(挡板接球/接物,玩法=连续控制体随手指水平移动去拦截)`driver.type="paddle-intercept"`,`ballPath`=**球的水平 x 坐标字段**(挡板水平移动去对齐它,几乎总是 `ball.x`,**绝不要填 ball.y**),`paddleY`=挡板所在 y。(b)离散点击目标类(井字棋/五子棋/打地鼠/翻牌/Simon/扫雷/见缝插针:玩法=逐个点击网格或离散目标)**必须** `driver.type="tap-targets"`,并让代码 agent 在 `_forensicsView().state()` 导出 `targets:[{x,y,idx,occupied}]`(每个可点目标的逻辑坐标 x/y、序号 idx、是否已占用 occupied);**绝不可用 none**(否则 harness 不点目标→H_progress 必挂)。其中再按"有无致负目标"细分:——规避/推理族(存在"点了立即致负"的目标,如扫雷雷格):driver 加 `"safeOnly":true`,targets 每元素额外导出 `safe`(布尔=该目标非致负,如扫雷 `!mine`;**仅供测试·只现于 `_forensicsView`,渲染层与玩家界面绝不可显示、不得据此给避险提示**);`assertAfterPlay` 用 `{"path":"result","op":"==","value":"win"}`(确定性避负逐格揭至 win,把核心循环跑透)。——安全放置族(无致负目标,如井字棋/打地鼠:任一未占用目标都是合法推进招):不加 safeOnly,`assertAfterPlay` 给推进断言(如落子数/命中数 `increased`)。(c)**飞跃穿缝类**(Flappy 等:点击令控制体上升、松手重力下落、穿过上下障碍的缝隙)`driver.type="flap-to-gap"`,设 `entityPath`=控制体竖直位置(`bird.y`)、`vyPath`=其竖直速度(`bird.vy`)、`gapPath`=`nextGap.y`。(d)**纵向踩台/左右避让类**(Doodle 等:点屏左/右半令控制体水平移动去对齐下一落脚点)`driver.type="seek-x"`,设 `entityPath`=控制体水平位置(`bird.x`)、`targetPath`=`nextPlatform.x`。〔c/d 命门:代码 agent **必须把"控制体"与"下一障碍/落脚点"都建成命名实体**——控制体 `id="bird"`,下一缝隙 `id="nextGap"`(每帧把其 y 更新为下一对障碍的缝中心 y)、下一落脚台 `id="nextPlatform"`(每帧把其 x 更新为下一台水平位置);`_forensicsView` 按实体 id 投影出 `bird.y`/`bird.vy`/`nextGap.y`/`nextPlatform.x` 供 harness 预测出招。**漏建命名实体→driver 驱不动→机制点不亮**,绝不可因此退化 none〕(e)**相邻交换消除类**(三消/宝石:玩法=点两个相邻目标交换、凑≥3 连消除得分;区别于 tap-targets 的"点单格放置/揭示",这里是"成对点击触发交换")`driver.type="tap-pairs"`,让代码 agent 导出 `targets:[{x,y,idx,occupied}]`(每个棋格的逻辑坐标,与 tap-targets 同一约定,harness 每步点【相邻两格】=交换);`assertAfterPlay` 用 `{"path":"score","op":"increased"}`。〔反例:纯点单格无交换的打地鼠/翻牌→那是 tap-targets,不是 tap-pairs;命门:消除得分必须真落到 `score`(盲态轮换交换偶成三连即得分),别把得分藏在取证读不到的自定义字段〕(f)**纯按键推进类**(俄罗斯方块/2048/纯键盘射击:玩法=只靠方向键/空格驱动,无指针控制体、无离散点击目标)`driver.type="key-cycle"`,设 `keys`=要循环按的键数组(如方块 `["ArrowLeft","ArrowUp","Space","ArrowRight"]`、2048 `["ArrowUp","ArrowRight","ArrowDown","ArrowLeft"]`);`assertAfterPlay` 给推进断言落在【取证可读的进展字段】(方块用锁定块数 `{"path":"piecesLocked","op":"increased"}`、2048 用 `{"path":"score","op":"increased"}`)。〔反例:有指针控制体的→走 paddle-intercept/seek-x;命门:进展字段必须真在 `_forensicsView` 暴露且随按键真变(不下落/不合并=不升=暴露假接)〕(g)**抛射瞄准类**(愤怒小鸟/弹弓:玩法=从固定发射点拖拽蓄力、抛物线命中目标)`driver.type="drag-aiming"`,设 `origin`=发射点 `{x,y}`、`targetsPath="targets"`、`launchPath="launch"`;让代码 agent 把目标(猪/砖)打 `tags:["target"]` 汇成 `targets:[{x,y}]`。〔命门★:harness 优先读 `launch:{gravity,powerScale}` 物理常数做 45° 抛【解析反解】确定性命中——但实体投影【只投 x/y/vx/vy/w/h/r/angle,不投 gravity/powerScale】,故 `launch` 这类非位置常数必须由代码 agent 在 `_forensicsView().state()` 里【显式手动导出】成 `launch:{gravity:<重力>,powerScale:<拖拽距→初速系数>}`;漏导出→harness 退化"力度扫描"逐发兜(确定性尽力、非保证命中)→ 仍可能 0 分=driver 半接。反例:松手即飞无蓄力的→不是 drag-aiming〕(h)**旋转瞄准开火类**(小行星 Asteroids:玩法=旋转飞船朝向目标、对齐后开火击碎)`driver.type="aim-fire"`,设 `shipPath="ship"`、`targetsPath="targets"`、`fireKey="Space"`;命门★:代码 agent 必须把飞船建成 `id="ship"` 实体并导出朝向 `angle`(实体投影含 angle,故 `ship.angle` 可读),目标打 `tags:["target"]` 汇成 `targets:[{x,y}]`(harness 读坐标算方位旋向对齐);`assertAfterPlay` 用 `{"path":"score","op":"increased"}`。〔命门:飞船不导出 `angle` 或目标不打 target 标→harness 读不到方位=退化盲扫=可测性边界、driver 驱不动;反例:无旋转朝向、纯方向键移动射击的→走 key-cycle〕(i)其余既无连续控制体、又无可枚举离散目标、又非上述飞跃/纵向/交换/按键/抛射/旋射形态 → `driver.type="none"`;④ `assertAfterPlay` 至少一条"真有进展"断言(op ∈ increased/decreased/changed/>/>=/ 污染结构化输出 - // (探活 A 实证) → 经 OpenAiChatOptions.extraBody 注入厂商参 thinking:{type:disabled} 关推理(探活 B:干净 JSON、1.3s、零超时)。 - // 仅 MiniMax 注入(model 名含 minimax);deepseek 等不动(无此坑、字节零变)。对齐 Python worker 一贯 thinking:disabled。 - // 注:chat.completions 路 reasoning:{effort:none} 不被网关认(探活 C 仍内联),唯 thinking:{type:disabled} 有效。 + // 关闭思考(创始人 2026-06-20 拍板):M3 OpenAI 兼容路 thinking:adaptive 会把整段推理内联进 content、 + // 在写出 JSON 前就吃光 maxTokens → finish_reason=length 截断 → 节点侧无 length 检测、伪装成 parse 失败/llm_error。 + // 三子代理取证 + 活探针实证:默认 adaptive 4K 预算全耗在 上、JSON 一字未出(finish_reason=length); + // thinking:{type:disabled} 同题面当场吐干净紧凑 JSON(finish_reason=stop);worker 产线本就默认 disabled(13 种子可玩)。 + // 故对 MiniMax 系【全角色统一关思考】:生成角色保留 assemble 的 per-role maxTokens(16000,足装真 JSON ~8K),不再抬 60K。 + // 仅 MiniMax 注入;deepseek 等不动(无此坑、字节零变)。回退:要复开思考改回 {type:adaptive}+reasoning_split+抬上限即可。 if (modelName != null && modelName.toLowerCase().contains("minimax")) { opts.extraBody(java.util.Map.of("thinking", java.util.Map.of("type", "disabled"))); } @@ -175,15 +179,29 @@ public final class SaaStudioGraph { * 建一个走 new-api 的 {@link OpenAiApi},旁路系统代理(Plan A U5;macOS clash 等会把 Tailscale 内网 IP 走代理→502, * 同 anthropic 路 {@link Proxy#NO_PROXY} 处理)。本机/内网量测 openai 兼容路必经此旁路;无代理环境为 no-op(安全)。 */ - public static OpenAiApi openAiApiNoProxy(String baseUrl, String apiKey) { + public static OpenAiApi openAiApiNoProxy(String baseUrl, String apiKey, int readTimeoutMs) { SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory(); rf.setProxy(Proxy.NO_PROXY); rf.setConnectTimeout(10_000); - rf.setReadTimeout(120_000); + // Plan A U5:读超时可配(M3+thinking 慢、原 120s 在 K=10 下致超时;默认 120s,量测设 600s)。<=0 兜底 120s。 + rf.setReadTimeout(readTimeoutMs > 0 ? readTimeoutMs : 120_000); + // WebClient(流式 .stream() 用)也须旁路代理——否则 macOS clash 拦 Tailscale IP → 502(实测 WebClientResponseException 502 Bad Gateway)。 + // 用 JDK HttpClient + 显式 NO_PROXY ProxySelector(编译期可见、避 reactor.netty 非 main 编译依赖)+ JdkClientHttpConnector 注入 WebClient。 + java.net.http.HttpClient jdkHttp = java.net.http.HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(10)) + .proxy(new java.net.ProxySelector() { // 每个请求恒返 NO_PROXY = 直连,旁路 clash + @Override public java.util.List select(java.net.URI uri) { return java.util.List.of(java.net.Proxy.NO_PROXY); } + @Override public void connectFailed(java.net.URI uri, java.net.SocketAddress sa, java.io.IOException ioe) { } + }) + .build(); + org.springframework.web.reactive.function.client.WebClient.Builder wcb = + org.springframework.web.reactive.function.client.WebClient.builder() + .clientConnector(new org.springframework.http.client.reactive.JdkClientHttpConnector(jdkHttp)); return OpenAiApi.builder() .baseUrl(baseUrl) .apiKey(apiKey) .restClientBuilder(RestClient.builder().requestFactory(rf)) + .webClientBuilder(wcb) .build(); } @@ -212,7 +230,41 @@ public final class SaaStudioGraph { * @return openai per-role 工厂 */ public static RoleModelFactory openAiFactory(OpenAiApi api) { - return (name, temperature, maxTokens) -> model(api, name, temperature, maxTokens); + return (name, temperature, maxTokens) -> { + OpenAiChatModel m = model(api, name, temperature, maxTokens); + // Plan A U5「流式避读超时」(创始人 2026-06-19):MiniMax 系大输出(M3+thinking)非流式整体生成 >readTimeout 会读超时 + // → 包装成流式收取(块持续到达、不饿死读计时);deepseek 快、不变(返原 model 字节零变)。 + if (name != null && name.toLowerCase().contains("minimax")) { + return new StreamingCallChatModel(m); + } + return m; + }; + } + + /** + * 流式收取包装(Plan A U5「流式避读超时」创始人 2026-06-19):把底层 {@link ChatModel} 的阻塞 {@code call()} 改为 + * 「{@code stream()} 收块 → {@link org.springframework.ai.chat.model.MessageAggregator} 拼成整 {@link ChatResponse}」。 + * M3+thinking 大输出整体生成 >readTimeout 会读超时;流式下块持续到达、不饿死读计时 → 避超时。 + * 对调用方(节点)透明:仍 {@code call()} 返完整 ChatResponse(用量/finishReason 经 MessageAggregator 拼回)。 + */ + static final class StreamingCallChatModel implements ChatModel { + private final ChatModel delegate; + StreamingCallChatModel(ChatModel delegate) { this.delegate = delegate; } + @Override + public ChatResponse call(Prompt prompt) { + // 流式收块 + 官方 MessageAggregator 聚合为单 ChatResponse(含 content 拼接 + usage 末块);回调取聚合结果。 + java.util.concurrent.atomic.AtomicReference agg = new java.util.concurrent.atomic.AtomicReference<>(); + new org.springframework.ai.chat.model.MessageAggregator().aggregate(delegate.stream(prompt), agg::set).blockLast(); + return agg.get(); + } + @Override + public reactor.core.publisher.Flux stream(Prompt prompt) { + return delegate.stream(prompt); + } + @Override + public org.springframework.ai.chat.prompt.ChatOptions getDefaultOptions() { + return delegate.getDefaultOptions(); + } } /** @@ -237,7 +289,13 @@ public final class SaaStudioGraph { * @return anthropic per-role 工厂 */ public static RoleModelFactory anthropicFactory(String anthropicBase, String apiKey, int thinkingBudget) { - return (name, temperature, maxTokens) -> anthropicModel(anthropicBase, apiKey, name, temperature, maxTokens, thinkingBudget); + return (name, temperature, maxTokens) -> { + AnthropicChatModel m = anthropicModel(anthropicBase, apiKey, name, temperature, maxTokens, thinkingBudget); + // 流式收取(同 openai 路 StreamingCallChatModel):把阻塞 .call() 改 stream()+MessageAggregator 聚合, + // 避 M3+thinking 大输出阻塞整取读超时(B 实测阻塞在 adaptive+256k 下大面积 SocketTimeout)。 + // #4407(流式 thinking 缺陷)报在 spring-ai 1.0.2 且伴 tool_use、已 Closed;我们 1.1.2 + 生成无工具调用,n=1 实测确认流式可用。 + return new StreamingCallChatModel(m); + }; } /** @@ -258,11 +316,27 @@ public final class SaaStudioGraph { SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory(); rf.setProxy(Proxy.NO_PROXY); rf.setConnectTimeout(10_000); - rf.setReadTimeout(120_000); + // Plan A U5:M3+thinking 阻塞 .call() 单次(16K content+8K thinking≈24K token)易 >120s 读超时; + // anthropic 路恒阻塞(流式 thinking 有 bug #4407 不用),故读超时放宽至 600s(单局总超时 900s 兜底)。 + rf.setReadTimeout(600_000); + // 流式收取避读超时(创始人 2026-06-20:B 实测阻塞整取在 adaptive+256k 下大面积 SocketTimeout,慢局 20-31min): + // 流式走 WebClient,须同 openAiApiNoProxy 注入 NO_PROXY webClient(否则 macOS clash 拦 Tailscale IP→502)。 + // 用 JDK HttpClient + 显式 NO_PROXY ProxySelector + JdkClientHttpConnector(编译期可见、避 reactor.netty 非 main 依赖)。 + java.net.http.HttpClient jdkHttp = java.net.http.HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(10)) + .proxy(new java.net.ProxySelector() { // 每请求恒返 NO_PROXY = 直连,旁路 clash + @Override public java.util.List select(java.net.URI uri) { return java.util.List.of(java.net.Proxy.NO_PROXY); } + @Override public void connectFailed(java.net.URI uri, java.net.SocketAddress sa, java.io.IOException ioe) { } + }) + .build(); + org.springframework.web.reactive.function.client.WebClient.Builder wcb = + org.springframework.web.reactive.function.client.WebClient.builder() + .clientConnector(new org.springframework.http.client.reactive.JdkClientHttpConnector(jdkHttp)); AnthropicApi api = AnthropicApi.builder() .baseUrl(anthropicBase) // host 根;completionsPath 默认 /v1/messages(绝不加 /anthropic,勿用 stripV1Suffix) .apiKey(apiKey) // 发 x-api-key 头(勿用 Authorization Bearer) - .restClientBuilder(RestClient.builder().requestFactory(rf)) // 1.1.2 无 customHeaders,自定义只能经 restClientBuilder + .restClientBuilder(RestClient.builder().requestFactory(rf)) // 阻塞兜底(1.1.2 无 customHeaders,自定义只能经 restClientBuilder) + .webClientBuilder(wcb) // 流式路(StreamingCallChatModel 用):NO_PROXY 直连 new-api,避 clash 502 .build(); // P0-2 修:per-role thinking budget 自适应(替原 fail-fast 抛)。assemble 给判定类角色硬编码小 maxTokens // (playerVision=900/classify=1000/narrative=2000/playerText=4000),单一全局 budget(默认8192) 撞之即抛、anthropic 路 assemble 崩。 @@ -282,7 +356,7 @@ public final class SaaStudioGraph { } return AnthropicChatModel.builder() .anthropicApi(api) - .defaultOptions(opts.build()) // 生产保留默认 RetryTemplate(流式 thinking bug #4407 → 只阻塞 .call()) + .defaultOptions(opts.build()) // 默认 RetryTemplate 兜瞬时;流式收取在 anthropicFactory 包 StreamingCallChatModel(#4407 已 Closed/1.1.2+无工具,n=1 实测确认) .build(); } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java index 3c3419bf..e6237983 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java @@ -167,8 +167,10 @@ final class SaaStudioNodes { // ====================== LLM 调用:单次 180s 超时 + ≤3 次重试(仅 429/5xx/IO/timeout) ====================== - /** 单次 LLM 调用墙钟超时秒数(对齐 Python _client.py:57 timeout=180.0)。 */ - private static final long LLM_TIMEOUT_SECS = 180; + /** 单次 LLM 调用墙钟超时秒数。Python worker thinking:disabled 用 180s;但 SAA【M3+thinking 流式生成单次实测 ~300-400s】, + * 180s 会半途 Future.cancel → 假"挂死" + JDK HttpClient 非守护线程泄漏(Plan A U5 实证)。放宽至 600s: + * 慢生成首次即完成(不触发取消→不泄线程)、重试只对真错误(429/5xx/IO,快);600s < perBriefSec 900s 单局预算。 */ + private static final long LLM_TIMEOUT_SECS = 600; /** LLM 最大尝试次数(对齐 Python _client.py:60 tries=3)。 */ private static final int LLM_MAX_TRIES = 3; /** 重试间隔毫秒(对齐 Python _client.py:60 retry_delay=2.0)。 */ diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java index 1a656324..92d3f458 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java @@ -230,6 +230,18 @@ public class AigcExecutorProperties { */ private String saaForceModel; + /** + * SAA 全角色强制 maxTokens(Plan A U5;M3+thinking 思考+答案需大余量防截断,doc 荐 M3 128K=131072): + * >0 → 包装 RoleModelFactory 覆盖各角色 maxTokens 为此值;0(默认)= 各角色默认(字节零变)。仅 dispatcher=saa 生效。 + */ + private Integer saaForceMaxTokens = 0; + + /** + * SAA openai 兼容路读超时 ms(Plan A U5;M3+thinking 慢、原 120s 在 K=10 下致 SocketTimeout): + * 默认 120000;量 M3+thinking 设 600000。仅 openai 路({@link com.wanxiang.huijing.game.module.aigc.saa.SaaStudioGraph#openAiApiNoProxy})生效。 + */ + private Integer saaOpenAiReadTimeoutMs = 120000; + // ===== SAA 模型协议(Plan A · U1「用对 M3」;flag 旁挂,默认 openai 字节零变)===== /** diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java index f621142f..fa6bae99 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java @@ -222,6 +222,9 @@ class SaaFullGraphE2eTest { String protocol = System.getProperty("saa.e2e.protocol", "openai").trim(); // 强制单模型(Plan A U5「只用 M3」,创始人 2026-06-19):-Dsaa.e2e.model 非空 → 全角色统一该模型(如 MiniMax-M3);未设=stage1 默认(deepseek 主力)。 String forceModel = System.getProperty("saa.e2e.model", "").trim(); + // Plan A U5:M3+thinking 量测旋钮——openai 读超时(慢)+ 全角色 maxTokens(防截断)。未设=props 默认(120s/各角色默认)。 + Integer readTimeoutMs = Integer.getInteger("saa.e2e.readTimeoutMs"); + Integer forceMaxTokens = Integer.getInteger("saa.e2e.maxTokens"); // 端口基址(Plan A U3 暴露;与 superpowers-chrome 等本机进程共存防撞):未设=props 默认 4320/9222(字节零变)。 // 本机 9222 常被 superpowers-chrome 占用 → 量测可 -Dsaa.e2e.cdpPortBase=9322 避撞(serve-and-play 净场只杀本槽端口,不误杀他者)。 Integer playPortBase = Integer.getInteger("saa.e2e.playPortBase"); @@ -242,9 +245,11 @@ class SaaFullGraphE2eTest { props.setSaaConcurrency(concurrency); // 003-U1:后台并发度(默认 1=串行基线;>1 多 job 并行真玩各占错开端口) props.setSaaModelProtocol(protocol); // Plan A U1:anthropic=用对 M3 真路(R1 量==发);默认 openai 字节零变。base/budget 走 props 默认(saaAnthropicBase / saaThinkingBudget) if (!forceModel.isEmpty()) props.setSaaForceModel(forceModel); // Plan A U5:全角色统一模型(如 MiniMax-M3);未设=stage1 默认(deepseek 主力) + if (readTimeoutMs != null) props.setSaaOpenAiReadTimeoutMs(readTimeoutMs); // Plan A U5:openai 路读超时(M3+thinking 慢,量测 600000) + if (forceMaxTokens != null) props.setSaaForceMaxTokens(forceMaxTokens); // Plan A U5:全角色 maxTokens(M3+thinking 防截断,如 128000) if (playPortBase != null) props.setSaaPlayPortBase(playPortBase); // 未设=props 默认 4320(字节零变) if (cdpPortBase != null) props.setSaaCdpPortBase(cdpPortBase); // 未设=props 默认 9222;本机避 superpowers-chrome 可 -Dsaa.e2e.cdpPortBase=9322 - System.out.println("[config] sourceMode=" + sourceMode + "(factory=iife旧路/gamedef=真结构化), concurrency=" + concurrency + ", protocol=" + protocol + ", forceModel=" + (forceModel.isEmpty() ? "(stage1默认/deepseek主力)" : forceModel) + ", playPortBase=" + props.getSaaPlayPortBase() + ", cdpPortBase=" + props.getSaaCdpPortBase()); + System.out.println("[config] sourceMode=" + sourceMode + "(factory=iife旧路/gamedef=真结构化), concurrency=" + concurrency + ", protocol=" + protocol + ", forceModel=" + (forceModel.isEmpty() ? "(stage1默认/deepseek主力)" : forceModel) + ", forceMaxTokens=" + props.getSaaForceMaxTokens() + ", openaiReadTimeoutMs=" + props.getSaaOpenAiReadTimeoutMs() + ", playPortBase=" + props.getSaaPlayPortBase() + ", cdpPortBase=" + props.getSaaCdpPortBase()); // ── 2) 结果容器 + stub 回调(共享 latch(N) + byTrace 精确归位)+ 派发器(5 参,sourceProjectApi=null=create 路)── long runStart = System.currentTimeMillis(); diff --git a/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs b/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs index e5797772..c1da0281 100644 --- a/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs +++ b/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs @@ -207,6 +207,7 @@ async function runDriver(cdp, driver, hashes) { if (driver.type === 'tap-targets') return runTapTargets(cdp, driver, hashes); if (driver.type === 'flap-to-gap') return runFlapToGap(cdp, driver, hashes); if (driver.type === 'seek-x') return runSeekX(cdp, driver, hashes); + if (driver.type === 'seek-food') return runSeekFood(cdp, driver, hashes); if (driver.type === 'tap-pairs') return runTapPairs(cdp, driver, hashes); if (driver.type === 'key-cycle') return runKeyCycle(cdp, driver, hashes); if (driver.type === 'drag-aiming') return runDragAiming(cdp, driver, hashes); @@ -308,6 +309,57 @@ async function runSeekX(cdp, driver, hashes) { return { drove, steps }; } +/** type='seek-food':读蛇头 head.x/head.y 与食物 food.x/food.y,贪心选向(先消较大轴差)发方向键朝食物走; + * 防 180° 反向(snake 反向键被运行时忽略)→记上一步方向、其反向时改走另一轴。把贪吃蛇的盲循环换成朝食物 steer + * (类比 flap-to-gap 读 nextGap);零放水——只把"盲走"换"朝食物走",score/latch 仍照判。 */ +async function runSeekFood(cdp, driver, hashes) { + const hxPath = driver.headXPath || 'head.x'; + const hyPath = driver.headYPath || 'head.y'; + const fxPath = driver.foodXPath || 'food.x'; + const fyPath = driver.foodYPath || 'food.y'; + const steps = driver.steps || 70; + const stepMs = driver.stepMs != null ? driver.stepMs : 110; + const scoreTarget = driver.scoreTarget != null ? driver.scoreTarget : 30; // 得分够即转终态阶段 + const OPP = { ArrowLeft: 'ArrowRight', ArrowRight: 'ArrowLeft', ArrowUp: 'ArrowDown', ArrowDown: 'ArrowUp' }; + let drove = 0, phx = null, phy = null, actualDir = null; + // 阶段1:朝食物 steer 得分(防反向基于蛇头位移推断的实际方向)。 + for (let i = 0; i < steps; i++) { + const s = await readGameState(cdp); + if (s && s.phase === 'gameover') return { drove, steps }; + if (s && typeof s.score === 'number' && s.score >= scoreTarget) break; // 得分够→转终态阶段 + const hx = getPath(s, hxPath), hy = getPath(s, hyPath); + const fx = getPath(s, fxPath), fy = getPath(s, fyPath); + if (typeof hx === 'number' && typeof hy === 'number' && typeof fx === 'number' && typeof fy === 'number') { + // 从蛇头位移推断【实际行进方向】(仅在真移动时更新)——防反向须基于实际方向, + // 而非已发的键(发的反向键会被蛇忽略→驱动误以为转了→继续直行撞墙)。 + if (phx != null && (hx !== phx || hy !== phy)) { + if (Math.abs(hx - phx) >= Math.abs(hy - phy)) actualDir = hx > phx ? 'ArrowRight' : 'ArrowLeft'; + else actualDir = hy > phy ? 'ArrowDown' : 'ArrowUp'; + } + phx = hx; phy = hy; + const dx = fx - hx, dy = fy - hy; + const horiz = Math.abs(dx) >= Math.abs(dy); + // 主选(消较大轴差) + 次选(另一轴);选第一个「非空且非实际方向反向」的(反向会被忽略→撞墙)。 + const cands = horiz + ? [dx > 0 ? 'ArrowRight' : (dx < 0 ? 'ArrowLeft' : null), dy > 0 ? 'ArrowDown' : (dy < 0 ? 'ArrowUp' : null)] + : [dy > 0 ? 'ArrowDown' : (dy < 0 ? 'ArrowUp' : null), dx > 0 ? 'ArrowRight' : (dx < 0 ? 'ArrowLeft' : null)]; + const dir = cands.find((d) => d && d !== OPP[actualDir]) || cands.find(Boolean); + if (dir) { await key(cdp, dir, driver.downMs || 60); drove++; } + } else { await delay(stepMs); } + try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {} + await delay(stepMs); + } + // 阶段2:终态——已得分但蛇太稳不死 → 保持直行撞墙触发 lose→gameover(snake 真终态=死亡),满足 latch(不放水门:用游戏真有的失败态)。 + for (let i = 0; i < 30; i++) { + const s = await readGameState(cdp); + if (s && s.phase === 'gameover') break; + if (actualDir) { await key(cdp, actualDir, driver.downMs || 60); } + try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {} + await delay(stepMs); + } + return { drove, steps }; +} + /** 触屏拖拽:touchStart(from) → 多帧 touchMove 插值到 to → touchEnd。受控面只给原始 pointer,swipe/拖拽手势须自合成(愤怒小鸟蓄力等)。 */ async function drag(cdp, from, to, ms) { const f = from || { x: 195, y: 600 }, t = to || { x: 195, y: 700 }; diff --git a/game-runtime/src/host/gd-runtime.js b/game-runtime/src/host/gd-runtime.js index cd50b176..6c9e6102 100644 --- a/game-runtime/src/host/gd-runtime.js +++ b/game-runtime/src/host/gd-runtime.js @@ -133,6 +133,16 @@ export function createRuntime(boot, gameDefinition) { set(k, v) { this[k] = v; return v; }, destroy() { this.alive = false; }, }; + // 去脚枪(Plan A·S2):把 spec/entities 字面量上的自定义标量字段(gridX/colorIdx/scored/idx/hp/w/h 等) + // 拷到实体,使「spec 带自定义字段」的模型自然假设成真(原仅留 id/x/y/vx/vy/tags/components → §0 + // 跨款最大失败簇:自定义状态读到 undefined→逻辑空转/冻屏)。仅标量(num/str/bool)、不覆盖保留键/已设键; + // 取证 entityView 白名单投影,故自定义字段不外泄取证。对象/数组不拷(避免共享引用意外)。 + const RESERVED = { id: 1, transform: 1, x: 1, y: 1, vx: 1, vy: 1, tags: 1, components: 1, alive: 1, get: 1, set: 1, destroy: 1 }; + for (const k in spec) { + if (RESERVED[k] || e[k] !== undefined) continue; + const t = typeof spec[k]; + if (t === 'number' || t === 'string' || t === 'boolean') e[k] = spec[k]; + } return e; } /** 实体上限(防失控 spawn 静默膨胀;超限记错误信号并拒新增,转成 repair 可读信号)。 */