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:
parent
0cd1496399
commit
4e5a7bbfb9
@ -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<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 中出现的门,只对客观门要求 pass;driver 门跳过。无 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));
|
||||
|
||||
@ -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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -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:干净 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 预算全耗在 <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 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<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 大输出整体生成 >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 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<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();
|
||||
}
|
||||
|
||||
|
||||
@ -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)。 */
|
||||
|
||||
@ -230,6 +230,18 @@ public class AigcExecutorProperties {
|
||||
*/
|
||||
private String saaForceModel;
|
||||
|
||||
/**
|
||||
* SAA 全角色强制 maxTokens(Plan A U5;M3+thinking 思考+答案需大余量防截断,doc 荐 M3 128K=131072):
|
||||
* >0 → 包装 RoleModelFactory 覆盖各角色 maxTokens 为此值;<b>0(默认)= 各角色默认(字节零变)</b>。仅 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 字节零变)=====
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 可读信号)。 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user