feat(saa): M3 生成质量收敛——客观门解耦 + 流式 anthropic 思考 → e2e 11/12(91.7%)

rc 门解耦修复(SaaGenNodes.playNode):play.cdp.cjs 退出码=九门聚合(verdict.pass?0:1),driver 门(G/H/I)挂→rc=1 反向否决客观 pass,致 Phase 1 客观门解耦形同虚设、空跑救场烧 token;改 rc∈{0,1}+objectiveGatesPass,all9 旧口径保留可回退。

思考治理(SaaStudioGraph):关 openai 路 M3 adaptive thinking(内联污染 JSON+撞 max_tokens 截断);改走 Anthropic Messages 协议(原生 thinking 块分离)+ StreamingCallChatModel 流式收取(治阻塞整取 SocketTimeout;#4407 对 spring-ai 1.1.2+生成无工具调用不触发,n=1 实测确认)+ webClient NO_PROXY + max_tokens 256k。

配套:gd-runtime 去脚枪(Plan A·S2 自定义标量拷实体,治冻屏) + play.cdp seek-food 驱动 + Phase 1b prompt 解耦 + e2e 旋钮(protocol/maxTokens)。

实测 conc=12 gamedef:thinking-off 8/12(67%,E_live 哑火×4) → thinking-on 11/12(91.7%,哑火修 3/4,残留 #7 打地鼠);代价 ~10x 慢 + 1.6x token。按创始人拍板以 n=12/91.7% 宣告达标(放宽原 plan003 n>=30 门)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-20 11:26:26 -07:00
parent 0cd1496399
commit 4e5a7bbfb9
9 changed files with 264 additions and 48 deletions

View File

@ -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反向否决客观 passPhase 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<String> 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)不参与生成硬门
* <p>遍历 verdict.guards 中出现的门只对客观门要求 passdriver 门跳过 guards 明细或未见任何客观门
* 保守退回聚合 verdict.pass(避免误放空壳)<b>不改九门判定本身</b>只改生成环对其的消费口径
*/
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<Map.Entry<String, JsonNode>> it = guards.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> 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<Map.Entry<String, JsonNode>> it = guards.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> 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));

View File

@ -392,11 +392,17 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
} else {
// OpenAI 兼容路默认baseUrl host 剥末尾 /v1坑见 §7-1apiKey 经配置注入openAiFactory 逐字调 model(api,...)字节等价改造前
String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase());
// Plan A U5openai 路同 anthropic 旁路系统代理macOS clash Tailscale IP 走代理502无代理环境 no-op
OpenAiApi api = SaaStudioGraph.openAiApiNoProxy(baseUrl, properties.getApiKey());
// Plan A U5openai 路同 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 U5maxtoken 大余量saaForceMaxTokens>0 包装 mf 覆盖各角色 maxTokensM3+thinking 思考+答案需大余量防截断doc M3 128K0=各角色默认字节零变
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-19saaForceModel 非空 全角色统一该模型 stage2/fallback 单模型无跨家回退=stage1 默认deepseek 主力字节零变
String forceModel = properties.getSaaForceModel();
boolean singleModel = forceModel != null && !forceModel.isBlank();

View File

@ -14,6 +14,8 @@ import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
@ -158,10 +160,12 @@ public final class SaaStudioGraph {
if (temperature != null) {
opts.temperature(temperature); // null = 不下发用服务端默认对齐 Python design 角色
}
// Plan A U5 OpenAI 格式(创始人 2026-06-19)MiniMax (M3/M2.x) chat.completions 默认内联 <think> 污染结构化输出
// (探活 A 实证) OpenAiChatOptions.extraBody 注入厂商参 thinking:{type:disabled} 关推理(探活 B:干净 JSON1.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 预算全耗在 <think> 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.<String, Object>of("thinking", java.util.Map.of("type", "disabled")));
}
@ -175,15 +179,29 @@ public final class SaaStudioGraph {
* 建一个走 new-api {@link OpenAiApi}旁路系统代理Plan A U5macOS 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<java.net.Proxy> 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 大输出整体生成 &gt;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<ChatResponse> 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<ChatResponse> 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 U5M3+thinking 阻塞 .call() 单次(16K content+8K thinking24K token) >120s 读超时;
// anthropic 路恒阻塞(流式 thinking bug #4407 不用),故读超时放宽至 600s(单局总超时 900s 兜底)
rf.setReadTimeout(600_000);
// 流式收取避读超时创始人 2026-06-20B 实测阻塞整取在 adaptive+256k 下大面积 SocketTimeout慢局 20-31min
// 流式走 WebClient须同 openAiApiNoProxy 注入 NO_PROXY webClient否则 macOS clash Tailscale IP502
// 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<java.net.Proxy> 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();
}

View File

@ -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 SAAM3+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)。 */

View File

@ -230,6 +230,18 @@ public class AigcExecutorProperties {
*/
private String saaForceModel;
/**
* SAA 全角色强制 maxTokensPlan A U5M3+thinking 思考+答案需大余量防截断doc M3 128K=131072
* &gt;0 包装 RoleModelFactory 覆盖各角色 maxTokens 为此值<b>0(默认)= 各角色默认字节零变</b> dispatcher=saa 生效
*/
private Integer saaForceMaxTokens = 0;
/**
* SAA openai 兼容路读超时 msPlan A U5M3+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用对 M3flag 旁挂默认 openai 字节零变=====
/**

View File

@ -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 U5M3+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 U1anthropic=用对 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 U5openai 路读超时M3+thinking 量测 600000
if (forceMaxTokens != null) props.setSaaForceMaxTokens(forceMaxTokens); // Plan A U5全角色 maxTokensM3+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();

View File

@ -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→gameoversnake 真终态=死亡),满足 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。受控面只给原始 pointerswipe/拖拽手势须自合成(愤怒小鸟蓄力等)。 */
async function drag(cdp, from, to, ms) {
const f = from || { x: 195, y: 600 }, t = to || { x: 195, y: 700 };

View File

@ -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 可读信号)。 */