diff --git a/.agents/skills/ai-generation-pipeline.md b/.agents/skills/ai-generation-pipeline.md
index 0e946c2e..e25d8690 100644
--- a/.agents/skills/ai-generation-pipeline.md
+++ b/.agents/skills/ai-generation-pipeline.md
@@ -23,6 +23,16 @@
- **批跑前置铁律**:①浏览器侧 frontend 必须 `http://localhost:4173`(crypto.subtle 安全上下文,走 IP 真包也永 demo 兜底)②执行器与批跑互斥(批前关 `aigc.executor.enabled`)③首批前跑一次 `evalflow.py --seed-spike`(幂等)④抽检模型不可用按评审版 R8 降级(同模型异版本 prompt 重裁+告警),分歧>10% 冻结下一批发布开关。
- **回调写链**(真实,9bf3d54):唯一写入路径=`DifyCallbackService.handleCallback`(三表同事务:task/version/runtime_package;putManifest 未命中显式失败;checksum 两段序列化消解自指)——任何新生成来源(M-b 执行器/未来真 Dify)一律复用此语义,禁止旁路。
+### ⓪′ P3 派发面(generic 外置 worker 生成 engineBundle 入 feed,3b-A 实证 2026-06-14)
+
+**范式**:M-b「执行器进程内 LLM 出 GameConfig」之外,新增「**派发外置 worker 产 engineBundle**」路——仅 `templateId=generic` 走,旧四模板(若再开放)走旧进程内路逐行不变(**零回归硬约束**)。链路:执行器 `dispatchGeneric`(`AigcGenerateExecutor`)组 §6.1 job POST 给 `aigc.executor.worker-url` → worker 异步产 bundle → 把含 `engineBundle` 的 `DifyCallbackReqVO` POST 回**新开的内网回调子路由** `/admin-api/aigc/dify/callback-internal` → 复用 `handleCallback` 唯一写入路径建版本/组包/落包 → 发布翻转入 feed。执行器投递握手成功即留任务 RUNNING 等回调(超 deadline 由 watchdog 收尸兜底),**不在派发路写终态、不走进程内 `chatJson`**(日志 `executor-llm` 对该任务 0 命中即铁证)。
+
+**两条红线(门里逮过的真坑·复用必看)**:
+1. **🔴 非 RBAC 的 HTTP 回调路必须自注入系统身份(部署窗口#3 同根因,换路复发)**:worker 是内部服务无登录态,回调走 `@PermitAll` 内网子路由(仿 `SmsCallbackController`/pay-notify 范式解 §6.5 auth 孤儿);但 `@PermitAll` 路在 web 线程无 LoginUser → `DefaultDBFieldHandler` 取不到 userId → `handleCallback` 三表 UPDATE/INSERT 的 `updater/creator` 带 null 直出撞 NOT NULL(`DataIntegrityViolation`,任务卡 RUNNING)。**修法**:内网回调 controller 内自注入系统身份 `LoginUser().setId(0L).setUserType(ADMIN)` 入 `SecurityContextHolder`,`finally` 必 `clearContext()`(镜像 `AigcGenerateExecutor.executeWithSystemIdentity`)。RBAC 的 `/dify/callback` 由登录管理员提供身份故无此坑——**任何新免鉴权回调路(真 worker 3b-B/外部接入)都要补这条**。
+2. **🔴 @PermitAll 内网回调路上线前必补服务间签名校验**:本路由仅恃「内网不可外达」兜底,上线前若可被外网路由到,任意请求可伪造回调驱动落包(安全红线);3b-B 真 worker / 外部接入前补 HMAC/共享密钥或 mTLS(与 `/dify/callback`「真 Dify 接入补签名」TODO 同治)。
+
+**stub worker 配方(证管线用,3b-B 换真 worker)**:`game-cloud/scripts/wg1_stub_worker.py`(极简 HTTP,收 job→读固定 pong bundle `game-runtime/games/_wg1-gen/pong/bundle.iife.js` 作 engineBundle→回调;gameConfig 回 §5.2 最小占位 `{templateId,title,theme,engineDriven:true}`,不调 LLM 不打包)。e2e 脚手架:`p3b_scaffold_pong.sql`(建 gameId=9302 四态)→插 generic 任务→回调建新版本→`p3b_publish_flip.sql`(把 feed 翻到回调新版本)→ CDP 复用 `game-studio/evidence/p3-pong/p3-pong.driver.cjs ` 真玩。**配置**:`aigc.executor.worker-url`(空=派发面无端点→generic 判 failed(llm_error),不静默卡死);staging 经 `.env` 注 `AIGC_WORKER_URL`;stub 起 durable 单元 `systemd-run --unit=wg1-stub-worker`。
+
---
## 目标
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/controller/admin/task/AdminAigcTaskController.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/controller/admin/task/AdminAigcTaskController.java
index d1cce78f..67c5ddbe 100644
--- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/controller/admin/task/AdminAigcTaskController.java
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/controller/admin/task/AdminAigcTaskController.java
@@ -7,17 +7,25 @@ import cn.wanxiang.game.module.aigc.convert.task.AigcTaskConvert;
import cn.wanxiang.game.module.aigc.dal.dataobject.task.AigcTaskDO;
import cn.wanxiang.game.module.aigc.service.callback.DifyCallbackService;
import cn.wanxiang.game.module.aigc.service.task.AigcTaskService;
+import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
+import jakarta.annotation.security.PermitAll;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
+import java.util.Collections;
+
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
@@ -61,4 +69,49 @@ public class AdminAigcTaskController {
return success(difyCallbackService.handleCallback(reqVO));
}
+ /**
+ * 内网生成 worker 回调(P3 W-G1 派发面·3b-A,2026-06-14)。
+ *
+ * 【为何另开本路由(§6.5 auth 孤儿解法)】P3 generic 走「派发外置 worker」范式:worker 是【内部服务】、
+ * 无 RBAC 登录态 token,无法调上方带 {@code @PreAuthorize('aigc:dify:callback')} 的 /dify/callback。
+ * 故按本仓既有「外部/内部服务回调」范式(SmsCallbackController 的 {@code @PermitAll}、pay-notify 回调同款)
+ * 另开本 {@code @PermitAll} 内网回调子路由,复用同一 {@link DifyCallbackService#handleCallback} 唯一写入路径
+ * (建版本/组包/落包/回填全不变),把鉴权问题收敛为「仅内网可达」。worker 把 §6.1 result-out 经
+ * {@link DifyCallbackReqVO}(含 engineBundle 字段)POST 到本端点驱动任务终态。
+ *
+ * 【安全边界(诚实分界)】本路由跳过 admin RBAC,仅恃【内网不可外达】兜底(MVP staging:worker 与后端
+ * 同 mini-desktop localhost)。⚠️ 3b-B 真 worker / 未来外部接入前,必须补【服务间签名校验】
+ * (HMAC/共享密钥或 mTLS),与上方 /dify/callback 的「真 Dify 接入补签名」TODO 同治;上线前若本路由
+ * 可被外网路由到,须在网关层显式拦截或加签名门,否则任意外部请求可伪造回调驱动落包(安全红线)。
+ * 【TenantIgnore】worker 无租户上下文;与 SmsCallbackController 同款 {@code @TenantIgnore},任务定位经
+ * traceId 全局唯一(handleCallback 内 selectByTraceId 不依赖租户上下文)。
+ */
+ @PostMapping("/dify/callback-internal")
+ @PermitAll
+ @TenantIgnore
+ @Operation(summary = "内网生成 worker 完成回调(P3 派发面·@PermitAll 内网)",
+ description = "P3 generic 派发外置 worker 后,worker 把含 engineBundle 的 §6.1 result-out 经本内网免鉴权"
+ + "子路由 POST 回来,复用 handleCallback 唯一写入路径建版本/组包/落包。仅内网可达;真 worker/外部接入前补签名校验。")
+ public CommonResult difyCallbackInternal(@Valid @RequestBody DifyCallbackReqVO reqVO) {
+ // 复用唯一写入路径(与 /dify/callback 完全同款 handleCallback,仅鉴权门不同):worker 回调携 engineBundle,
+ // 经 DifyCallbackTxService.resolveEngineBundleText 取值后 put 进 GamePackage.engineBundle 落包入 feed。
+ log.info("[aigc-callback-internal] 收到内网 worker 回调 traceId={}, status={}, engineBundleLen={}",
+ reqVO.getTraceId(), reqVO.getStatus(),
+ reqVO.getEngineBundle() == null ? 0 : reqVO.getEngineBundle().length());
+ // 【根因·与部署窗口#3 同源】本路由 @PermitAll、无 RBAC 登录态 → DefaultDBFieldHandler 取不到 userId →
+ // handleCallback 三表写链(建版本/组包/落包/回填,含受理置 RUNNING)UPDATE/INSERT 的 updater/creator
+ // 带 null 直出撞 NOT NULL(DataIntegrityViolation,任务卡 RUNNING)。RBAC 的 /dify/callback 由登录管理员
+ // 提供身份故无此问题;本内网路由须自注入【系统身份 id=0】(审计列落 "0",镜像
+ // AigcGenerateExecutor.executeWithSystemIdentity 同款,finally 必清理防 web 线程池复用污染)。
+ LoginUser systemUser = new LoginUser().setId(0L).setUserType(UserTypeEnum.ADMIN.getValue());
+ SecurityContextHolder.getContext().setAuthentication(
+ new UsernamePasswordAuthenticationToken(systemUser, null, Collections.emptyList()));
+ try {
+ return success(difyCallbackService.handleCallback(reqVO));
+ } finally {
+ // web 线程池线程复用:处理完毕(含异常路径)立即清空安全上下文,防系统身份泄漏给同线程后续请求
+ SecurityContextHolder.clearContext();
+ }
+ }
+
}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java
index 90fd49fd..27fc30dc 100644
--- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java
@@ -7,6 +7,7 @@ import cn.wanxiang.game.module.aigc.service.executor.AigcGenerateExecutor;
import cn.wanxiang.game.module.aigc.service.executor.ExecutorLlmClient;
import cn.wanxiang.game.module.aigc.service.executor.GameConfigSchemaValidator;
import cn.wanxiang.game.module.aigc.service.executor.PromptResourceLoader;
+import cn.wanxiang.game.module.aigc.service.executor.WorkerDispatchClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -73,6 +74,17 @@ public class AigcExecutorConfiguration {
return new ExecutorLlmClient(properties);
}
+ /**
+ * 外置生成 worker 派发通道(P3 W-G1 派发面·3b-A:§6.5;JDK HttpClient 投递 §6.1 job,仅 generic 路用)
+ *
+ * @param properties 执行器配置(worker-url / 派发超时)
+ * @return 派发通道客户端
+ */
+ @Bean
+ public WorkerDispatchClient aigcWorkerDispatchClient(AigcExecutorProperties properties) {
+ return new WorkerDispatchClient(properties);
+ }
+
/**
* 生成执行器(§5:tick 触发面 + 执行体;构造时执行 §5.2 阈值不等式校验,误配置 fail-fast)
*
@@ -82,6 +94,7 @@ public class AigcExecutorConfiguration {
* @param promptResourceLoader prompt 装载器
* @param schemaValidator schema 校验器
* @param llmClient LLM 通道
+ * @param workerDispatchClient 外置生成 worker 派发通道(P3 generic 路)
* @return 执行器(@Scheduled 注解由全局调度后处理器消费,fixedDelay 单飞)
*/
@Bean
@@ -90,9 +103,10 @@ public class AigcExecutorConfiguration {
DifyCallbackService difyCallbackService,
PromptResourceLoader promptResourceLoader,
GameConfigSchemaValidator schemaValidator,
- ExecutorLlmClient llmClient) {
+ ExecutorLlmClient llmClient,
+ WorkerDispatchClient workerDispatchClient) {
return new AigcGenerateExecutor(properties, aigcTaskMapper, difyCallbackService,
- promptResourceLoader, schemaValidator, llmClient, Clock.systemDefaultZone());
+ promptResourceLoader, schemaValidator, llmClient, workerDispatchClient, Clock.systemDefaultZone());
}
}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcExecutorProperties.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcExecutorProperties.java
index 89a38b89..51b1b45d 100644
--- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcExecutorProperties.java
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcExecutorProperties.java
@@ -95,10 +95,43 @@ public class AigcExecutorProperties {
* 支持模板集(默认值取 {@link AigcTemplateConstants#SUPPORTED_TEMPLATE_IDS} 唯一同源,
* 与 AigcTaskServiceImpl.validateTemplateExists 口径一致,避免「提交通过、执行 no_template_match」割裂)。
* M-c 批① 已扩 merge(clicker+merge);批② 扩 idle/tycoon;dodge/runner/match 已降 P1 不进集合 → no_template_match。
- * 不在集合内 → failed + no_template_match(M1)。
+ * 不在集合内 → failed + no_template_match(M1)。P3 W-G1 点亮 generic(单一通用,默认即 ["generic"])。
*/
private List supportedTemplates = new ArrayList<>(AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS);
+ // ===== P3 W-G1 派发面(3b-A,2026-06-14):generic 模板走外置 worker 生成 =====
+
+ /**
+ * 触发派发外置 worker 的模板 id(P3 范式切换边界):仅此 templateId 走「派发 job 给 worker → worker 回调
+ * 携 engineBundle 入 feed」新范式({@link AigcGenerateExecutor#dispatchGeneric});其余 templateId 一律走旧
+ * 进程内 LLM 出 GameConfig 路(M-c clicker/merge 零回归硬约束)。默认 {@code "generic"}(O-P3-1 单一通用口径)。
+ * 空串 = 关闭派发(任何模板都走旧进程内路,回滚用)。
+ */
+ private String dispatchTemplateId = "generic";
+
+ /**
+ * 外置生成 worker 端点 URL(执行器 POST §6.1 job-in JSON 到此地址;worker 异步生成后把 §6.1 result-out
+ * 经回调入参 POST 回 /admin-api/aigc/dify/callback-internal)。staging 经 application-staging.yaml 占位符注入
+ * ({@code ${AIGC_WORKER_URL:}});默认空串 = 派发面无端点 → generic 任务 dispatch 即判 failed(llm_error)
+ * (不烧 LLM、可观测,不静默卡死)。3b-A 指 stub worker(回已知 pong bundle),3b-B 换 L1 真 worker。
+ */
+ private String workerUrl = "";
+
+ /**
+ * 派发 job 的 HTTP 单次超时秒数(仅「投递 job」这一步的连接/响应超时,非生成总时长——生成是 worker 侧
+ * 异步活、由 worker 回调驱动终态、超 deadline 由 watchdog 收尸兜底)。默认 30s(投递握手足够,避免投递
+ * 阶段长挂占 tick 线程)。
+ */
+ private Integer workerDispatchTimeoutSeconds = 30;
+
+ /**
+ * worker 回调本服务的 callback URL(写进 §6.1 job.callback.target,worker 据此把 result POST 回来)。
+ * 默认指向内网免鉴权回调子路由 /admin-api/aigc/dify/callback-internal(@PermitAll,§6.5 auth 孤儿解法:
+ * worker 是内部服务无 RBAC token,复用 SmsCallbackController/pay-notify 同款 @PermitAll 回调范式,
+ * 仅内网可达;真 worker/外部接入时另补服务间签名校验)。staging 经占位符 ${AIGC_CALLBACK_URL:...} 可覆盖。
+ */
+ private String callbackUrl = "http://localhost:48080/admin-api/aigc/dify/callback-internal";
+
/**
* 阈值关系铁律校验(HJ-AGENT-LOOP-EXEC-002 §5.2 精确式;执行器装配时调用,误配置 fail-fast)
*
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutor.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutor.java
index ce51c88c..6931c4e5 100644
--- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutor.java
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutor.java
@@ -22,6 +22,7 @@ import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
@@ -87,6 +88,8 @@ public class AigcGenerateExecutor {
private final GameConfigSchemaValidator schemaValidator;
/** LLM 通道(§6.3) */
private final ExecutorLlmClient llmClient;
+ /** 外置生成 worker 派发通道(P3 W-G1 派发面,仅 generic 模板走,§6.5) */
+ private final WorkerDispatchClient workerDispatchClient;
/** 墙钟(预算门用;单测注入假时钟验证 M5) */
private final Clock clock;
@@ -104,11 +107,13 @@ public class AigcGenerateExecutor {
* @param promptResourceLoader prompt 契约装载器
* @param schemaValidator schema 校验器
* @param llmClient LLM 通道
+ * @param workerDispatchClient 外置生成 worker 派发通道(P3 generic 路)
* @param clock 墙钟
*/
public AigcGenerateExecutor(AigcExecutorProperties properties, AigcTaskMapper aigcTaskMapper,
DifyCallbackService difyCallbackService, PromptResourceLoader promptResourceLoader,
- GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient, Clock clock) {
+ GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient,
+ WorkerDispatchClient workerDispatchClient, Clock clock) {
// §5.2 阈值关系铁律:stale×60 > budget + 273,装配期校验(误配置宁可启动失败也不带病收割在飞任务)
properties.validateThresholds();
this.properties = properties;
@@ -117,6 +122,7 @@ public class AigcGenerateExecutor {
this.promptResourceLoader = promptResourceLoader;
this.schemaValidator = schemaValidator;
this.llmClient = llmClient;
+ this.workerDispatchClient = workerDispatchClient;
this.clock = clock;
// 启动三行自检日志(§12-① 冒烟观察面:执行器已启用 / prompt 资源版本 / key 配置态;严禁打印密钥本身)
log.info("[executor-selfcheck] aigc 生成执行器已启用(poll={}ms, batch={}, budget={}s, stale={}min, maxAge={}h, templates={})",
@@ -329,7 +335,16 @@ public class AigcGenerateExecutor {
return callbackFailed(task, FailureReasonEnum.CONFIG_INVALID);
}
- // ----- §5.3-2~5 渲染 → LLM → 解析校验 →(失败重出 1 次)-----
+ // ----- §6.5 派发面分支(P3 W-G1·3b-A):仅 dispatchTemplateId(默认 generic)走「派发外置 worker + 异步等回调」-----
+ // ⚠️ 范式切换边界:generic 不在进程内 chatJson 出 GameConfig,改组 §6.1 job 投递 worker,worker 异步产
+ // engineBundle 后经回调入参 POST 回 /dify/callback-internal 驱动终态。其余 templateId(旧四模板若再开放)
+ // 一律走下方进程内 LLM 出 GameConfig 旧路逐行不变(M-c clicker/merge 零回归硬约束)。
+ if (StringUtils.hasText(properties.getDispatchTemplateId())
+ && properties.getDispatchTemplateId().equals(task.getTemplateId())) {
+ return dispatchGeneric(task);
+ }
+
+ // ----- §5.3-2~5 渲染 → LLM → 解析校验 →(失败重出 1 次)(进程内旧路:generic 以外模板)-----
String findings = ""; // 首轮空串;重出轮 = schema 校验错误文本灌 findings 槽(clicker-designer.md:23-24 明文)
JsonNode validConfig = null;
List lastErrors = Collections.emptyList();
@@ -399,6 +414,85 @@ public class AigcGenerateExecutor {
return invokeCallback(task, reqVO, "succeeded");
}
+ /**
+ * 派发面(P3 W-G1·3b-A,§6.5):组 §6.1 job-in 投递外置 worker,任务留 RUNNING 异步等回调。
+ *
+ * 【与进程内旧路的根本差异】不在进程内 chatJson 出 GameConfig;改为把 brief + 渲染上下文组进 §6.1 job
+ * POST 给 worker 端点({@link AigcExecutorProperties#getWorkerUrl()})。worker 异步写 GameHostFactory →
+ * esbuild 打 __GameBundle → 把 §6.1 result-out 经回调入参(含 engineBundle)POST 回
+ * /admin-api/aigc/dify/callback-internal(@PermitAll 内网回调子路由)→ 复用 handleCallback 唯一写入路径
+ * 建版本/组包/落包 → 任务终态。本方法只负责「投递」,不写终态(终态由 worker 回调驱动)。
+ *
+ * 【投递成功后任务状态】任务保持 RUNNING(processCandidate 的 CAS 已置 1)。worker 回调命中即终态化;
+ * worker 崩/回调丢/超 deadline → watchdog 收尸为 failed(timeout)(§6.5 deadline 处置,零僵尸)。
+ *
+ * 【投递失败处置】worker 端点未配置/HTTP 错/网络异常 → {@link WorkerDispatchClient#dispatch} 返回 false →
+ * 本方法按 M3 failed(llm_error) 落终态(不烧 LLM、可观测,不静默卡 RUNNING 让 watchdog 兜底超时)。
+ *
+ * @param task 已认领的 generic 任务(gameId 已由预检保证非空)
+ * @return true=投递成功(任务留 RUNNING 等回调,tick 汇总计入「派发」);false=投递失败已落 failed(llm_error)
+ */
+ private boolean dispatchGeneric(AigcTaskDO task) {
+ // 组渲染上下文(§6.1 renderCtx):template_schema 与服务端校验同源(generic schema 已就位);
+ // banned_list 用户路径无批内禁重语境置空;findings 首发空(worker 内部重出自管,不经后端重灌)。
+ String templateSchema;
+ try {
+ templateSchema = promptResourceLoader.getTemplateSchemaText(task.getTemplateId());
+ } catch (Exception e) {
+ // 资源未就绪/未知模板(自检/预检应已挡,防御):投递前置失败按 config_invalid 处置
+ log.error("[executor-dispatch] 取 generic schema 失败(按 config_invalid 处置)taskId={}, traceId={}",
+ task.getId(), task.getTraceId(), e);
+ return callbackFailed(task, FailureReasonEnum.CONFIG_INVALID);
+ }
+ Map renderCtx = new LinkedHashMap<>();
+ renderCtx.put("template_schema", templateSchema);
+ renderCtx.put("banned_list", "");
+ renderCtx.put("findings", "");
+
+ // 预算与幂等键(§6.1 budget/idempotency_key/deadline_ms):maxLlmCalls 对齐 ledger BudgetGuard 8 次;
+ // deadline_ms 取任务级墙钟预算(毫秒),与 watchdog 收尸阈值协同(§6.5 deadline 处置)。
+ Map budget = new LinkedHashMap<>();
+ budget.put("maxYuan", 0.15);
+ budget.put("maxLlmCalls", 8);
+ String idempotencyKey = task.getTraceId(); // traceId 全链路唯一,作幂等键(同 job 重发不重复产)
+
+ // 回调描述(§6.1 callback):type=http,target=内网免鉴权回调子路由(worker 把 result POST 回此地址)。
+ Map callback = new LinkedHashMap<>();
+ callback.put("type", "http");
+ callback.put("target", properties.getCallbackUrl());
+
+ // 组 §6.1 job-in(字段/语义逐字对齐 spec §6.1,供 L1/stub worker 不用猜)
+ Map job = new LinkedHashMap<>();
+ job.put("job_id", task.getTraceId()); // 贯穿 job↔result 的幂等键(与 idempotency_key 配合)
+ job.put("tier", "L1"); // 裸路(VERDICT §6-2 framework-light)
+ job.put("kind", "generate"); // 单局生成
+ job.put("brief", task.getPrompt() == null ? "" : task.getPrompt()); // 用户一句话创意
+ job.put("model", properties.getLlmModel()); // bake-off 时参数化(M3/M2.7/DS-V4),worker 调 new-api 用此 model
+ job.put("budget", budget);
+ job.put("gameId", task.getGameId()); // 组包元数据(必填,否则后端无法组包)
+ job.put("templateId", task.getTemplateId()); // generic
+ job.put("renderCtx", renderCtx);
+ job.put("idempotency_key", idempotencyKey);
+ job.put("deadline_ms", (long) properties.getTaskBudgetSeconds() * 1000L); // 任务级墙钟(ms)
+ job.put("callback", callback);
+ // traceId 显式带上(worker 回调 reqVO.traceId 必须与本任务一致,handleCallback 据此定位任务);
+ // 与 job_id 同值(traceId 即贯穿键),冗余携带便于 worker 直接取用、不依赖从 job_id 推。
+ job.put("traceId", task.getTraceId());
+
+ boolean dispatched = workerDispatchClient.dispatch(job);
+ if (dispatched) {
+ // 投递成功:任务留 RUNNING 等 worker 回调(终态由回调驱动,不在此写)。tick 汇总以「派发」计数(非 succeeded/failed)。
+ log.info("[executor-dispatch] generic 任务已派发 worker(留 RUNNING 等回调,终态由回调链驱动)"
+ + "taskId={}, traceId={}, gameId={}, model={}", task.getId(), task.getTraceId(),
+ task.getGameId(), properties.getLlmModel());
+ return true;
+ }
+ // 投递失败:按 M3 failed(llm_error) 落终态(worker 不可达/端点空,不静默卡 RUNNING)
+ log.error("[executor-dispatch] generic 任务投递失败(worker 不可达/端点未配置),按 llm_error 落终态 taskId={}, traceId={}",
+ task.getId(), task.getTraceId());
+ return callbackFailed(task, FailureReasonEnum.LLM_ERROR);
+ }
+
/**
* 解析 LLM content 并做 schema 校验(F2:非 JSON / 缺 config 视同 schema 失败,进重出轮)
*
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcTemplateConstants.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcTemplateConstants.java
index a530e30f..d0e01493 100644
--- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcTemplateConstants.java
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcTemplateConstants.java
@@ -42,8 +42,16 @@ public final class AigcTemplateConstants {
* 资源 Map 加行 + contracts/templates/<id>.schema.json 就位),白名单口径单点维护不变。
* 旧四模板的契约 schema(contracts/templates/{clicker,merge,idle,tycoon}.schema.json)与 prompt 注册表条目按
* W-CLEAN 范围(OUT)保留不动,仅本白名单清空使其「不可被生成」。
+ *
+ * 【P3 W-G1 点亮 generic(2026-06-14,3b-A 派发面 plumbing)】新模板体系落地:注册单一通用 templateId
+ * {@code "generic"}(O-P3-1 单一通用口径,品类区分留 prompt 层)。两处消费点(validateTemplateExists /
+ * 执行器预检)同步见 generic、不产生「提交通过、执行 no_template_match」割裂。配套:PromptResourceLoader
+ * .TEMPLATE_RESOURCES 加 generic 行(generic-coder.md + generic.schema.json,brick-2 已就位),
+ * AigcExecutorProperties.supportedTemplates 默认值随本常量同源。
+ * ⚠️ generic 走「派发外置 worker 产 engineBundle 经回调入 feed」新范式(执行器 generic 分支 dispatch,
+ * 见 AigcGenerateExecutor.dispatchGeneric);旧四模板若再开放仍走进程内 LLM 出 GameConfig 旧路,互不影响。
*/
- public static final List SUPPORTED_TEMPLATE_IDS = List.of();
+ public static final List SUPPORTED_TEMPLATE_IDS = List.of("generic");
private AigcTemplateConstants() {
// 纯常量持有类,禁止实例化
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/PromptResourceLoader.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/PromptResourceLoader.java
index 4db76081..fd6c8785 100644
--- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/PromptResourceLoader.java
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/PromptResourceLoader.java
@@ -76,6 +76,13 @@ public class PromptResourceLoader {
m.put("tycoon", new String[]{
"wanxiang-contracts/prompts/04-config/tycoon-designer.md",
"wanxiang-contracts/templates/tycoon.schema.json"});
+ // P3 W-G1(2026-06-14,3b-A):generic 通用模板资源——编码 prompt(出 GameHostFactory 源码,非策划出 GameConfig)+
+ // bundle 元数据 schema(校验 __GameBundle 全局名/体量,非 GameConfig 字段)。brick-2 已就位两文件。
+ // 注:generic 走派发外置 worker 路,后端不在进程内渲染/喂 LLM 该 prompt;本行的作用是
+ // ① 让 PromptResourceLoader.isReady() 通过(资源齐全)② schema 进 GameConfigSchemaValidator 缓存(同源就绪)。
+ m.put("generic", new String[]{
+ "wanxiang-contracts/prompts/04-config/generic-coder.md",
+ "wanxiang-contracts/templates/generic.schema.json"});
TEMPLATE_RESOURCES = Collections.unmodifiableMap(m);
}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/WorkerDispatchClient.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/WorkerDispatchClient.java
new file mode 100644
index 00000000..efd63afd
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/WorkerDispatchClient.java
@@ -0,0 +1,147 @@
+package cn.wanxiang.game.module.aigc.service.executor;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Map;
+
+/**
+ * 外置生成 worker 派发通道(HJ-GEN-001 P3 W-G1 派发面 · 3b-A,2026-06-14)
+ *
+ * 【职责】把执行器组好的 §6.1 job-in JSON POST 给外置 worker 端点({@code aigc.executor.worker-url})。
+ * 仅做「投递握手」——投递成功(HTTP 2xx)即返回 true,任务留 RUNNING 等 worker 异步回调;worker 异步
+ * 生成完把 §6.1 result-out 经回调入参 POST 回 /admin-api/aigc/dify/callback-internal 驱动终态。
+ * 投递失败(端点空/HTTP 错/网络异常)即返回 false,由调用方按 M3 failed(llm_error) 处置(不烧 LLM、可观测)。
+ *
+ * 【为何只投递不等结果(派发面收敛理由,§6.5)】生成是 worker 侧多步 agentic 活(写源码→esbuild 打包→
+ * 自检→失败重出),不宜让 tick 线程同步长挂;故执行器只「投 job + 异步等回调」,回调命中由现存
+ * handleCallback 唯一写入路径驱动,超 deadline 由 watchdog 收尸兜底(与 §5.3「下发 worker + 收 result」一致)。
+ *
+ * 【设计同源】HTTP 发送抽象复用 {@link ExecutorLlmClient.HttpJsonSender}(单测注入桩驱动、零真实网络,
+ * 与 ExecutorLlmClient 同款可测性范式);生产用 JDK java.net.http.HttpClient(零新 HTTP 依赖)。
+ *
+ * 【Bean 注册铁律(§3)】本类不标 @Component/@Service,只经 AigcExecutorConfiguration @Bean 注册,
+ * 防组件扫描绕过 aigc.executor.enabled 总开关。
+ *
+ * @author 造梦AI(P3 派发面)
+ */
+@Slf4j
+public class WorkerDispatchClient {
+
+ /** job JSON 编解码(实例私有,无共享可变状态) */
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** 执行器配置(worker-url / 派发超时) */
+ private final AigcExecutorProperties properties;
+ /** HTTP 发送实现(生产=JDK HttpClient;单测注入桩,验证投递而不走网络)——复用 ExecutorLlmClient 抽象 */
+ private final ExecutorLlmClient.HttpJsonSender sender;
+
+ /** 生产构造:JDK HttpClient 真实发送(经 AigcExecutorConfiguration @Bean 调用) */
+ public WorkerDispatchClient(AigcExecutorProperties properties) {
+ this(properties, defaultSender());
+ }
+
+ /**
+ * 可注入构造(单测以桩 sender 驱动,零真实网络)
+ *
+ * @param properties 执行器配置
+ * @param sender HTTP 发送实现
+ */
+ public WorkerDispatchClient(AigcExecutorProperties properties, ExecutorLlmClient.HttpJsonSender sender) {
+ this.properties = properties;
+ this.sender = sender;
+ }
+
+ /**
+ * 投递一个 §6.1 job-in 到 worker 端点(仅投递握手,成功即返回,不等生成结果)
+ *
+ * @param job §6.1 job-in 字段表(job_id/tier/kind/brief/model/budget/gameId/templateId/renderCtx/
+ * idempotency_key/deadline_ms/callback)
+ * @return true=投递成功(HTTP 2xx,任务留 RUNNING 等回调);false=投递失败(端点空/HTTP 错/网络异常,
+ * 调用方按 M3 failed(llm_error) 处置)
+ */
+ public boolean dispatch(Map job) {
+ String workerUrl = properties.getWorkerUrl();
+ if (workerUrl == null || workerUrl.isBlank()) {
+ // 端点未配置:派发面无落点 → 投递失败(不静默卡死,调用方判 failed 可观测)
+ log.error("[executor-dispatch] worker 端点未配置(aigc.executor.worker-url 为空),无法派发 generic 任务 job_id={}",
+ job.get("job_id"));
+ return false;
+ }
+ String body;
+ try {
+ body = MAPPER.writeValueAsString(job);
+ } catch (Exception e) {
+ // job 序列化理论不可达失败:防御留痕,按投递失败处置
+ log.error("[executor-dispatch] job 序列化失败 job_id={}", job.get("job_id"), e);
+ return false;
+ }
+ long start = System.currentTimeMillis();
+ try {
+ ExecutorLlmClient.SenderResponse resp = sender.send(workerUrl, /*apiKey 派发不带鉴权头*/ "",
+ body, properties.getWorkerDispatchTimeoutSeconds());
+ boolean ok = resp.statusCode() >= 200 && resp.statusCode() < 300;
+ if (ok) {
+ // 投递成功留痕(外部交互可追溯):job_id/gameId/model + 耗时;任务留 RUNNING 等 worker 回调
+ log.info("[executor-dispatch] job 投递成功(任务留 RUNNING 等回调)job_id={}, gameId={}, model={}, http={}, 耗时={}ms",
+ job.get("job_id"), job.get("gameId"), job.get("model"), resp.statusCode(),
+ System.currentTimeMillis() - start);
+ } else {
+ // HTTP 层错误:截响应片段入日志便于排障
+ log.error("[executor-dispatch] job 投递 HTTP 错误 job_id={}, http={}, body={}, 耗时={}ms",
+ job.get("job_id"), resp.statusCode(), brief(resp.body()), System.currentTimeMillis() - start);
+ }
+ return ok;
+ } catch (Exception e) {
+ // 网络超时/连接拒绝等:投递失败(worker 不可达),调用方判 failed(llm_error)
+ log.error("[executor-dispatch] job 投递通道异常 job_id={}, 耗时={}ms", job.get("job_id"),
+ System.currentTimeMillis() - start, e);
+ return false;
+ }
+ }
+
+ // ============================== 私有工具 ==============================
+
+ /**
+ * 截响应体片段(前 200 字符)入日志,防大响应刷屏
+ *
+ * @param body 响应体
+ * @return 片段文本
+ */
+ private static String brief(String body) {
+ if (body == null) {
+ return "";
+ }
+ return body.length() <= 200 ? body : body.substring(0, 200);
+ }
+
+ /**
+ * 生产默认 sender:JDK HttpClient(连接超时 10s;单次请求超时由调用传入派发超时秒数)。
+ * 与 ExecutorLlmClient.defaultSender 同款,但派发不带 Authorization 头(worker 端点内网、job 无鉴权)。
+ *
+ * @return HTTP 发送实现
+ */
+ private static ExecutorLlmClient.HttpJsonSender defaultSender() {
+ HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+ return (url, apiKey, jsonBody, timeoutSeconds) -> {
+ HttpRequest.Builder builder = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(timeoutSeconds))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8));
+ // 派发握手不带 Authorization(worker 端点内网,job 本身无鉴权语义);保留 apiKey 形参兼容抽象签名
+ if (apiKey != null && !apiKey.isBlank()) {
+ builder.header("Authorization", "Bearer " + apiKey);
+ }
+ HttpResponse response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
+ return new ExecutorLlmClient.SenderResponse(response.statusCode(), response.body());
+ };
+ }
+
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutorTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutorTest.java
index 22a5022a..82970709 100644
--- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutorTest.java
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutorTest.java
@@ -77,6 +77,10 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest {
@Mock
private ExecutorLlmClient.HttpJsonSender sender;
+ /** worker 派发 HTTP 发送桩(真实 WorkerDispatchClient 持有它验证「投 job 不走 in-process LLM」,P3 派发面) */
+ @Mock
+ private ExecutorLlmClient.HttpJsonSender dispatchSender;
+
// ============================== 组装与夹具 ==============================
/**
@@ -90,7 +94,12 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest {
ExecutorLlmClient llmClient = new ExecutorLlmClient(props, sender, millis -> {
// 单测空 sleeper:免真实等待(退避/节流逻辑仍真实走到)
});
- return new AigcGenerateExecutor(props, aigcTaskMapper, difyCallbackService, LOADER, VALIDATOR, llmClient, clock);
+ // P3 派发面:本组用例 supportedTemplates=["clicker"](非 generic),均走进程内旧路、不触发派发;
+ // 故注入「桩 sender 永不被调」的 WorkerDispatchClient 仅为满足构造签名(dispatch 零交互)。
+ // generic 派发分支的专项覆盖见 testDispatchGeneric_* 用例(显式 setSupportedTemplates(["generic"]))。
+ WorkerDispatchClient workerDispatchClient = new WorkerDispatchClient(props, dispatchSender);
+ return new AigcGenerateExecutor(props, aigcTaskMapper, difyCallbackService, LOADER, VALIDATOR,
+ llmClient, workerDispatchClient, clock);
}
/**
@@ -542,4 +551,112 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest {
assertNull(SecurityFrameworkUtils.getLoginUserId(), "异常路径结束后安全上下文应已清理");
}
+ // ============================== 用例17-19:P3 派发面(generic 走 dispatch + 零回归) ==============================
+
+ /** generic 就绪态配置:supportedTemplates=["generic"](点亮 generic)+ worker-url 已配 + dispatchTemplateId 默认 generic */
+ private static AigcExecutorProperties genericProps() {
+ AigcExecutorProperties props = new AigcExecutorProperties();
+ props.setApiKey("test-key");
+ props.setSupportedTemplates(java.util.List.of("generic")); // 点亮 generic(生产同源常量已 List.of("generic"))
+ props.setWorkerUrl("http://localhost:9999/wg1/generate"); // 桩 worker 端点(dispatchSender 拦截,零真实网络)
+ // dispatchTemplateId 默认即 "generic",callbackUrl 默认即内网回调子路由——本组用例用默认值
+ return props;
+ }
+
+ /** queued generic 任务夹具(generic 模板 + gameId 已挂 + 5 秒前入队) */
+ private static AigcTaskDO genericQueuedTask(Long id, String traceId) {
+ AigcTaskDO task = new AigcTaskDO();
+ task.setId(id);
+ task.setTraceId(traceId);
+ task.setTemplateId("generic");
+ task.setGameId(9302L);
+ task.setPrompt("做一个弹球小游戏");
+ task.setStatus(0);
+ task.setCreateTime(LocalDateTime.now().minusSeconds(5));
+ task.setTenantId(1L);
+ return task;
+ }
+
+ /** 把投递响应包成 SenderResponse(worker 投递握手成功=HTTP 202) */
+ private static ExecutorLlmClient.SenderResponse dispatchAccepted() {
+ return new ExecutorLlmClient.SenderResponse(202, "{\"accepted\":true}");
+ }
+
+ @Test
+ void testDispatchGeneric_postsJobNotInProcessLlm() throws Exception {
+ // 准备:一条 generic queued 任务;worker 投递桩返回 202(投递成功)
+ AigcTaskDO task = genericQueuedTask(701L, "aigc-gen-1");
+ when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task));
+ when(aigcTaskMapper.claimQueuedTask(701L)).thenReturn(true);
+ when(dispatchSender.send(anyString(), anyString(), anyString(), anyInt())).thenReturn(dispatchAccepted());
+
+ newExecutor(genericProps(), Clock.systemDefaultZone()).runOneTick();
+
+ // 断言①:走派发——POST job 给 worker 端点恰 1 次,且 body 是 §6.1 job-in 形态(含 brief/gameId/templateId/callback)
+ ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class);
+ verify(dispatchSender).send(urlCaptor.capture(), anyString(), bodyCaptor.capture(), anyInt());
+ assertEquals("http://localhost:9999/wg1/generate", urlCaptor.getValue());
+ com.fasterxml.jackson.databind.JsonNode job = JSON.readTree(bodyCaptor.getValue());
+ assertEquals("aigc-gen-1", job.path("job_id").asText());
+ assertEquals("generate", job.path("kind").asText());
+ assertEquals("做一个弹球小游戏", job.path("brief").asText());
+ assertEquals(9302, job.path("gameId").asInt());
+ assertEquals("generic", job.path("templateId").asText());
+ assertEquals("aigc-gen-1", job.path("traceId").asText());
+ assertEquals("http", job.path("callback").path("type").asText());
+ assertTrue(job.path("callback").path("target").asText().contains("/dify/callback-internal"),
+ "callback.target 应指向内网回调子路由");
+ assertTrue(job.path("renderCtx").path("template_schema").asText().contains("__GameBundle"),
+ "renderCtx.template_schema 应为 generic bundle 元数据 schema(含 __GameBundle 字面量)");
+ // 断言②:未走 in-process LLM(关键·范式切换)——sender(LLM 通道)零交互
+ verifyNoInteractions(sender);
+ // 断言③:任务留 RUNNING 等回调——执行器不在派发路写终态(零 handleCallback)
+ verifyNoInteractions(difyCallbackService);
+ }
+
+ @Test
+ void testDispatchGeneric_dispatchFailedMarksLlmError() throws Exception {
+ // 准备:worker 投递桩抛网络异常(worker 不可达)→ 投递失败
+ AigcTaskDO task = genericQueuedTask(702L, "aigc-gen-2");
+ when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task));
+ when(aigcTaskMapper.claimQueuedTask(702L)).thenReturn(true);
+ when(dispatchSender.send(anyString(), anyString(), anyString(), anyInt()))
+ .thenThrow(new IOException("连接拒绝(worker 不可达·单测桩)"));
+
+ newExecutor(genericProps(), Clock.systemDefaultZone()).runOneTick();
+
+ // 断言:投递失败 → failed(llm_error) 落终态(不静默卡 RUNNING);仍零 in-process LLM
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DifyCallbackReqVO.class);
+ verify(difyCallbackService).handleCallback(captor.capture());
+ assertEquals("failed", captor.getValue().getStatus());
+ assertEquals("llm_error", captor.getValue().getFailureReason());
+ verifyNoInteractions(sender);
+ }
+
+ @Test
+ void testZeroRegression_clickerStillInProcessNotDispatched() throws Exception {
+ // 零回归硬约束:dispatchTemplateId=generic 时,clicker 任务仍走进程内 LLM 旧路、绝不派发。
+ // 配置同时点亮 clicker+generic,提交一条 clicker 任务 → 必走 in-process(sender 被调),dispatchSender 零交互。
+ AigcExecutorProperties props = genericProps();
+ props.setSupportedTemplates(java.util.List.of("clicker", "generic")); // 同时点亮两者
+ AigcTaskDO task = queuedTask(703L, "aigc-clk-1"); // clicker 任务(夹具 templateId=clicker)
+ when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task));
+ when(aigcTaskMapper.claimQueuedTask(703L)).thenReturn(true);
+ when(sender.send(anyString(), anyString(), anyString(), anyInt()))
+ .thenReturn(llmOk(designContent(10, "奶茶店结账高峰")));
+ when(difyCallbackService.handleCallback(any(DifyCallbackReqVO.class))).thenReturn(true);
+
+ newExecutor(props, Clock.systemDefaultZone()).runOneTick();
+
+ // 断言①:clicker 走进程内 LLM(sender 被调 1 次)+ 正常成功回调(handleCallback succeeded)
+ verify(sender).send(anyString(), anyString(), anyString(), anyInt());
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DifyCallbackReqVO.class);
+ verify(difyCallbackService).handleCallback(captor.capture());
+ assertEquals("succeeded", captor.getValue().getStatus());
+ assertEquals("clicker", captor.getValue().getTemplateId());
+ // 断言②:clicker 绝不派发——worker 派发通道零交互(零回归核心证据)
+ verifyNoInteractions(dispatchSender);
+ }
+
}
diff --git a/game-cloud/scripts/p3_scaffold_pong.sql b/game-cloud/scripts/p3_scaffold_pong.sql
new file mode 100644
index 00000000..e451d762
--- /dev/null
+++ b/game-cloud/scripts/p3_scaffold_pong.sql
@@ -0,0 +1,70 @@
+-- ============================================================================
+-- P3 canary 3a·四态脚手架(pong 引擎游戏入 feed 真玩)—— adapt 自 p2_scaffold_4state.sql
+-- 缘由:P3 把 L1 便宜模型生成的 pong 游戏经后端入 feed、真人浏览器真玩(护城河可演示)。
+-- 复用 P2 灌库路,新建一款已发布游戏 gameId=9301 供 p3_seed_pong.mjs 覆写其运行包 engineBundle=pong bundle。
+-- (pong bundle 与 P2 ref bundle 同构:iife 全局 __GameBundle,含 bootGameHost/GameHostFactory,引擎真入包。)
+-- 四态:game_project.status=4(PUBLISHED) ∧ game_version.status=3 ∧ game_runtime_package.status=1 ∧ game_feed_rank 行
+-- ID:gameId=9301 / versionId=93010 / pkgId=93010 / feedRankId=93010(高位区,与 9201/9202 零碰撞)
+-- engineBundle 由后续 p3_seed_pong.mjs 9301 覆写 package_json(本脚本先建空 package_json 占位行)
+-- 幂等:先 DELETE 同 ID 行再 INSERT(纯重建,可重复执行)
+-- tenant:staging application-staging.yaml tenant.enable=false → tenant_id=0 即可
+-- 用法:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
+-- ============================================================================
+USE `ruoyi-vue-pro`;
+-- charset 防御(与 seed 脚本同口径):强制 utf8mb4,避免中文 title 被客户端默认 latin1 双编码
+SET NAMES utf8mb4;
+
+START TRANSACTION;
+
+-- 清旧(幂等重建)
+DELETE FROM game_feed_rank WHERE game_id = 9301;
+DELETE FROM game_runtime_package WHERE game_id = 9301;
+DELETE FROM game_version WHERE game_id = 9301;
+DELETE FROM game_project WHERE id = 9301;
+
+-- ① project:已发布(status=4),current_version_id 指向 93010,launch_zone_id 留 NULL(→feed zone 0)
+INSERT INTO game_project
+ (id, creator_user_id, title, summary, cover_url, tags, template_id, age_rating,
+ status, current_version_id, launch_zone_id, featured, play_count, like_count,
+ creator, create_time, updater, update_time, deleted, tenant_id)
+VALUES
+ (9301, 1, '弹球对战(Pong·引擎真渲)', 'P3 canary:便宜模型生成的 pong 经 bootGameHost 引擎真渲入 feed 真玩',
+ 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
+ 'P3,engine,pong,wg1-gen', 'pong', 'all',
+ 4, 93010, NULL, b'0', 0, 0,
+ 'p3-scaffold-pong', NOW(), 'p3-scaffold-pong', NOW(), b'0', 0);
+
+-- ② version:已发布(status=3),package_url 空(走 DB-manifest 端点),checksum 先占位(seed 脚本覆写运行包不动版本)
+INSERT INTO game_version
+ (id, game_id, version_no, gen_task_id, package_url, bundle_size, checksum,
+ status, creator, create_time, updater, update_time, deleted, tenant_id)
+VALUES
+ (93010, 9301, 1, NULL, '', 0, '',
+ 3, 'p3-scaffold-pong', NOW(), 'p3-scaffold-pong', NOW(), b'0', 0);
+
+-- ③ runtime_package:先建 status=1 行 + 空 package_json 占位(engineBundle 由 seed 脚本 UPDATE 覆写本行)
+-- sandbox_attr/allow_origins 取既有发布默认;package_url 空=DB-manifest 内嵌路
+INSERT INTO game_runtime_package
+ (id, game_id, version_id, template_id, package_url, entry, runtime_version, preload_policy,
+ bundle_size, checksum, sandbox_attr, allow_origins, status,
+ creator, create_time, updater, update_time, deleted, tenant_id, package_json)
+VALUES
+ (93010, 9301, 93010, 'pong', '', 'index.html', '1.0.0', 'eager',
+ 0, '', 'allow-scripts', '', 1,
+ 'p3-scaffold-pong', NOW(), 'p3-scaffold-pong', NOW(), b'0', 0, NULL);
+
+-- ④ feed_rank:zone_id=0(混合流) + status=1 入流 + sort_score 给正值(置流首便于 CDP 真玩定位)
+INSERT INTO game_feed_rank
+ (id, game_id, version_id, zone_id, quality_score, boost, sort_score, pinned, status,
+ creator, create_time, updater, update_time, deleted, tenant_id)
+VALUES
+ (93010, 9301, 93010, 0, 90.0000, 10.0000, 100.0000, 1, 1,
+ 'p3-scaffold-pong', NOW(), 'p3-scaffold-pong', NOW(), b'0', 0);
+
+COMMIT;
+
+-- 回读四态
+SELECT 'project' k, id, status, current_version_id FROM game_project WHERE id=9301
+UNION ALL SELECT 'version', id, status, game_id FROM game_version WHERE id=93010
+UNION ALL SELECT 'runtime_pkg', id, status, version_id FROM game_runtime_package WHERE id=93010
+UNION ALL SELECT 'feed_rank', id, status, zone_id FROM game_feed_rank WHERE id=93010;
diff --git a/game-cloud/scripts/p3_seed_pong.mjs b/game-cloud/scripts/p3_seed_pong.mjs
new file mode 100644
index 00000000..7e8d6b7f
--- /dev/null
+++ b/game-cloud/scripts/p3_seed_pong.mjs
@@ -0,0 +1,171 @@
+#!/usr/bin/env node
+/**
+ * P3 canary 3a 灌库桩 —— adapt 自 p2_seed_engine_bundle.mjs
+ * ============================================================================
+ * 【目标】把 L1 便宜模型生成的 pong 游戏 bundle 文本,灌进已发布 version(gameId=9301)的
+ * package_json.engineBundle,使 pong 经 feed 通用宿主 bootGameHost 引擎真渲、真人浏览器真玩。
+ * (护城河可演示:便宜模型生成的游戏在真 feed 被真人玩。)
+ *
+ * 【与 P2 ref 灌库桩的差异(adapt 点)】
+ * - gameId 缺省 9301(p3_scaffold_pong.sql 建的脚手架游戏;P2 是 9201/ref);
+ * - bundle 路径指向 pong(game-runtime/games/_wg1-gen/pong/bundle.iife.js;P2 是 wanglanmei-ref/dist/ref-bundle.iife.js);
+ * - templateId/title/gameConfig 改 pong(前端宿主分流只看 pkg.engineBundle 有无,不看 templateId,此处仅元数据标识)。
+ * 其余防坑逻辑(utf8mb4 charset / checksum 自洽 / versionId 占位回填 / __GameBundle 全局名硬校验)逐字保留。
+ *
+ * 【依赖的已发布游戏】本桩零新建、仅覆写某已发布 version 的 package_json,故须先跑同目录
+ * p3_scaffold_pong.sql 建四态脚手架(gameId=9301: project=4 ∧ version=3 ∧ pkg=1 ∧ feed_rank)。
+ *
+ * 【完整性校验链不变量(防 demo 门③依赖)】
+ * 宿主取包做 sha256(manifest 端点响应原文) == game_runtime_package.checksum 列。
+ * 故 UPDATE 用 checksum = LOWER(SHA2(package_json,256)) 对刚写入的同一文本求值 → 列值与所存文本字节恒一致,
+ * 宿主 crypto.subtle 校验必过、不触 demo 兜底。
+ *
+ * 【用法(mini-desktop staging)】
+ * 0) 建四态脚手架:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 \
+ * -uroot -p"$MYSQL_ROOT_PASSWORD" < game-cloud/scripts/p3_scaffold_pong.sql (建 gameId=9301)
+ * 1) 生成 SQL:node game-cloud/scripts/p3_seed_pong.mjs [gameId] > /tmp/p3_seed_pong.sql
+ * (gameId 缺省 9301;bundle 路径固定 games/_wg1-gen/pong/bundle.iife.js)
+ * 2) 应用:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < /tmp/p3_seed_pong.sql
+ * (⚠️ --default-character-set=utf8mb4 必带:客户端默认 latin1 会把 UTF-8 bundle/中文 title 双编码,
+ * 致列字节 ≠ checksum 计算字节 → manifest sha256 不一致 → 宿主校验失败 → demo 兜底;SQL 头亦自带 SET NAMES 双保险)
+ * 3) 重跑安全(幂等):再次执行步骤 1+2 即可(纯 UPDATE 覆写,无副作用)。
+ *
+ * @author 造梦AI(P3 canary)
+ */
+
+import { readFileSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+// ---------- 常量(与 P2 冻结契约逐字一致)----------
+/** 冻结:iife 顶层全局名,宿主从 window.__GameBundle 取 GameHostFactory/bootGameHost(三轨此名必须逐字一致) */
+const FROZEN_GLOBAL_NAME = '__GameBundle';
+/** pong bundle 固定产物路径——esbuild --global-name=__GameBundle 出(W-G1 生成 worker 产出,九门已验)。 */
+const PONG_BUNDLE_REL = '../../game-runtime/games/_wg1-gen/pong/bundle.iife.js';
+/** checksum 自指消解占位:64 位 '0'(与真实 sha256 hex 等长,对齐 DifyCallbackTxService.CHECKSUM_PLACEHOLDER) */
+const CHECKSUM_PLACEHOLDER = '0'.repeat(64);
+/** meta.cover 占位(1px 透明 gif data URI,沿后端组包同款,满足契约 format=uri) */
+const COVER_PLACEHOLDER =
+ 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
+/** pong 游戏标识(元数据;前端宿主分流不依赖此值,仅展示/契约 required 用) */
+const PONG_TITLE = '弹球对战(Pong·引擎真渲)';
+const PONG_TEMPLATE_ID = 'pong';
+
+// ---------- 入参 ----------
+const __dirname = dirname(fileURLToPath(import.meta.url));
+// gameId 缺省 9301(p3_scaffold_pong.sql 建的脚手架游戏);运行时可传第一个 CLI 参数覆盖
+const gameId = String(process.argv[2] || '9301').trim();
+
+// ---------- 步骤①:读 pong bundle 文本 + 校验全局名 ----------
+const bundlePath = resolve(__dirname, PONG_BUNDLE_REL);
+let engineBundle;
+try {
+ engineBundle = readFileSync(bundlePath, 'utf8');
+} catch (e) {
+ // 读不到产物即写链断裂:显式失败(不灌空桩)
+ console.error(`[p3-seed-pong][FATAL] 读不到 pong bundle:${bundlePath}\n` +
+ ` 请先在 game-runtime 跑 W-G1 生成 worker 产出该文件。原始错误:${e.message}`);
+ process.exit(1);
+}
+// 全局名硬校验:bundle 必须以 FROZEN_GLOBAL_NAME(__GameBundle)导出 iife,否则宿主取不到 factory、真接线必 fail。
+if (!engineBundle.includes(FROZEN_GLOBAL_NAME)) {
+ console.error(`[p3-seed-pong][FATAL] pong bundle 未含冻结全局名 ${FROZEN_GLOBAL_NAME}。\n` +
+ ` 须由 W-G1 产线以 esbuild --global-name=${FROZEN_GLOBAL_NAME} 重建 ${bundlePath}。\n` +
+ ` 当前不灌库(避免种入宿主取不到 factory 的死卡)。`);
+ process.exit(2);
+}
+
+// ---------- 步骤②:组 GamePackage JSON(含 engineBundle;键序对齐后端 buildGamePackage)----------
+// 用「保插入序」的对象逐键放入,序列化采用紧凑无空格(与后端 Jackson 默认无缩进口径一致,便于字节对齐)。
+function buildPackage(versionPlaceholder) {
+ const pkg = {};
+ pkg.schemaVersion = '1.0';
+ pkg.gameId = String(gameId);
+ pkg.versionId = versionPlaceholder; // SQL 端 REPLACE 回填真实 versionId(见下)
+ pkg.templateId = PONG_TEMPLATE_ID; // pong 标识(过渡口径,前端分流不依赖)
+ pkg.gameConfig = { templateId: PONG_TEMPLATE_ID, title: PONG_TITLE };
+ pkg.assets = [];
+ // manifest:bundleSize/checksum 先占位(内层自述,非宿主校验面);保留三键齐全满足契约 required
+ pkg.manifest = {
+ runtimeVersion: '1.0.0',
+ entry: 'index.html',
+ preloadPolicy: 'eager',
+ bundleSize: 0,
+ checksum: CHECKSUM_PLACEHOLDER,
+ };
+ pkg.meta = {
+ title: PONG_TITLE,
+ summary: 'P3 canary:便宜模型生成的 pong 经 bootGameHost 引擎真渲入 feed 真玩',
+ cover: COVER_PLACEHOLDER,
+ ageRating: 'all',
+ };
+ // engineBundle 末位放入(落 checksum 之内,由整包 sha256 覆盖;与后端 put 落点同义)
+ pkg.engineBundle = engineBundle;
+ return pkg;
+}
+
+// 内层 manifest.checksum 自指消解:先以占位序列化 T0 → C=sha256(T0) → 写回得 T1(包内自述用,宿主用外层列值)。
+const VID_PLACEHOLDER = '__VERSION_ID__';
+const t0 = JSON.stringify(buildPackage(VID_PLACEHOLDER));
+const innerChecksum = createHash('sha256').update(t0, 'utf8').digest('hex');
+const pkgT1 = buildPackage(VID_PLACEHOLDER);
+pkgT1.manifest.checksum = innerChecksum;
+const t1 = JSON.stringify(pkgT1);
+
+// ---------- 步骤③:转义为 SQL 字符串字面量(MySQL:转义 \ 和 ') ----------
+function sqlQuote(text) {
+ return "'" + text.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
+}
+const t1Sql = sqlQuote(t1);
+
+// ---------- 步骤④:产出幂等 UPDATE SQL ----------
+const sql = `-- ============================================================================
+-- P3 canary 3a 灌库桩 SQL(pong 引擎游戏)—— 由 game-cloud/scripts/p3_seed_pong.mjs 生成
+-- 目标 gameId=${gameId};灌入 pong 引擎 bundle 到其当前生效版本的 package_json.engineBundle
+-- 应用:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
+-- 幂等:纯 UPDATE,可重复执行
+-- ============================================================================
+USE \`ruoyi-vue-pro\`;
+-- charset 防御:强制连接 charset=utf8mb4,否则客户端默认 latin1 把 UTF-8 bundle/中文 title 双编码落 utf8mb4 列
+-- → 列字节 ≠ SET @pj 计算 checksum 的字节 → stored checksum 与 manifest 端点 sha256(原文) 不一致
+-- → 宿主 crypto.subtle 校验必失败 → demo 兜底 → 引擎永不渲。自带 SET NAMES 不依赖调用方加参数。
+SET NAMES utf8mb4;
+
+-- ① 解析该 gameId 当前生效版本 ID(game_project.current_version_id → game_version.id)
+SET @gid := ${Number(gameId)};
+SET @vid := (SELECT current_version_id FROM game_project WHERE id = @gid AND deleted = b'0' LIMIT 1);
+
+-- 防御:版本未解析(游戏不存在/未设当前版本)则报错中止,不盲写
+SET @ok := IF(@vid IS NULL, (SELECT CONCAT('FATAL: gameId=', @gid, ' 无 current_version_id,先跑 p3_scaffold_pong.sql') ), 'ok');
+SELECT @vid AS resolved_version_id, @ok AS precheck;
+
+-- ② 覆写该版本运行包行:package_json(versionId 占位回填真实 @vid)+ 自洽 checksum/bundle_size + status=1
+-- 把终文一次性装入会话变量 @pj,避免单 UPDATE 内联 3× bundle(~265KB) 撞 max_allowed_packet
+SET @pj := REPLACE(${t1Sql}, '${VID_PLACEHOLDER}', CAST(@vid AS CHAR));
+START TRANSACTION;
+UPDATE game_runtime_package
+SET
+ package_json = @pj,
+ checksum = LOWER(SHA2(@pj, 256)),
+ bundle_size = LENGTH(@pj),
+ status = 1,
+ updater = 'p3-seed-pong',
+ update_time = NOW()
+WHERE version_id = @vid AND deleted = b'0';
+COMMIT;
+
+-- ③ 回读核对(应见 status=1、checksum 64 位、package_json 含 engineBundle/__GameBundle)
+SELECT version_id, status, checksum, bundle_size,
+ (package_json LIKE '%"engineBundle"%') AS has_engine_bundle_key,
+ (package_json LIKE '%${FROZEN_GLOBAL_NAME}%') AS has_frozen_global_name
+FROM game_runtime_package WHERE version_id = @vid AND deleted = b'0';
+`;
+
+// 产出到 stdout(调用方重定向到 .sql 文件)
+process.stdout.write(sql);
+
+// 诊断信息走 stderr(不污染 stdout 的纯 SQL)
+console.error(`[p3-seed-pong] gameId=${gameId} bundle=${bundlePath} ` +
+ `bundleBytes=${Buffer.byteLength(engineBundle, 'utf8')} ` +
+ `pkgJsonBytes(占位态)=${Buffer.byteLength(t1, 'utf8')} innerChecksum=${innerChecksum}`);
diff --git a/game-cloud/scripts/p3b_publish_flip.sql b/game-cloud/scripts/p3b_publish_flip.sql
new file mode 100644
index 00000000..f4573d7e
--- /dev/null
+++ b/game-cloud/scripts/p3b_publish_flip.sql
@@ -0,0 +1,50 @@
+-- ============================================================================
+-- P3·3b-A 派发面 e2e·发布翻转(把 gameId=9302 的 feed 卡翻到【回调链新建的】version)
+-- 缘由:回调链 handleCallback 建【新】version(N)+runtime_package(engineBundle=stub 回的 pong),但
+-- 新 runtime_package 落库 status=0(未发布),且 project.current_version_id/feed_rank 仍指占位 93020。
+-- 本脚本把三处翻到回调新版本 N,使 feed 服务【回调链产物】(=派发面真产物),证 plumbing 闭环。
+-- 自动选 N:gameId=9302 下 gen_task_id 非空(=回调建的,占位 93020 的 gen_task_id 为 NULL)的最新 version。
+-- 幂等:纯 UPDATE 翻指针,可重复执行(再跑选同一 N)。
+-- 用法:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
+-- ============================================================================
+USE `ruoyi-vue-pro`;
+SET NAMES utf8mb4;
+
+START TRANSACTION;
+
+-- 选回调新建的最新 version N(gen_task_id 非空 = 回调链建的;占位 93020 gen_task_id 为 NULL 被排除)
+SET @vid := (SELECT id FROM game_version
+ WHERE game_id = 9302 AND gen_task_id IS NOT NULL AND deleted = b'0'
+ ORDER BY id DESC LIMIT 1);
+
+-- 防御:未找到回调新版本则不翻(保持占位态,避免把 feed 指向不存在版本)
+-- (@vid 为 NULL 时下方 UPDATE 的 WHERE 不命中任何行,安全空操作)
+
+-- ① 把回调新版本对应的 runtime_package 发布(status 0→1);占位 93020 包仍在但不再被 feed 指
+UPDATE game_runtime_package SET status = 1, updater = 'p3b-flip', update_time = NOW()
+ WHERE game_id = 9302 AND version_id = @vid AND @vid IS NOT NULL;
+
+-- ② project.current_version_id 指向回调新版本 N
+UPDATE game_project SET current_version_id = @vid, updater = 'p3b-flip', update_time = NOW()
+ WHERE id = 9302 AND @vid IS NOT NULL;
+
+-- ③ feed_rank.version_id 指向回调新版本 N(feed 卡 /play 取此 version 的包 = 回调产物 pong)
+UPDATE game_feed_rank SET version_id = @vid, updater = 'p3b-flip', update_time = NOW()
+ WHERE game_id = 9302 AND @vid IS NOT NULL;
+
+COMMIT;
+
+-- 回读:翻转后的指针 + 新版本运行包态(@vid 即 feed 现服务的 versionId,供 CDP driver 用)
+SELECT @vid AS flipped_version_id;
+SELECT 'project.current_version' k, current_version_id v FROM game_project WHERE id=9302
+UNION ALL SELECT 'feed_rank.version', version_id FROM game_feed_rank WHERE game_id=9302
+UNION ALL SELECT 'new_pkg.status', status FROM game_runtime_package WHERE game_id=9302 AND version_id=@vid
+UNION ALL SELECT 'new_pkg.bundleSize', bundle_size FROM game_runtime_package WHERE game_id=9302 AND version_id=@vid;
+-- 校验 engineBundle 真入包(LENGTH>0 = 回调把 pong bundle 落进 package_json)
+SELECT 'engineBundle_in_pkg_len' k,
+ CHAR_LENGTH(JSON_UNQUOTE(JSON_EXTRACT(package_json, '$.engineBundle'))) v
+ FROM game_runtime_package WHERE game_id=9302 AND version_id=@vid;
+-- 校验 checksum 自洽(列值 == 对所存 package_json 重算的 sha256)——宿主完整性校验链不变量
+SELECT 'checksum_selfconsistent' k,
+ (checksum = LOWER(SHA2(package_json, 256))) v
+ FROM game_runtime_package WHERE game_id=9302 AND version_id=@vid;
diff --git a/game-cloud/scripts/p3b_scaffold_pong.sql b/game-cloud/scripts/p3b_scaffold_pong.sql
new file mode 100644
index 00000000..4f14eec5
--- /dev/null
+++ b/game-cloud/scripts/p3b_scaffold_pong.sql
@@ -0,0 +1,71 @@
+-- ============================================================================
+-- P3·3b-A 派发面 e2e·四态脚手架(gameId=9302)—— adapt 自 p3_scaffold_pong.sql / p2_scaffold_4state.sql
+-- 缘由:3b-A 证「派发→stub worker→回调→buildGamePackage→新版本→入 feed」全 plumbing。
+-- 与 3a 直灌库桩(9301)不同:3b-A 的 engineBundle 由【回调链 handleCallback】产出(非 SQL 直写)。
+-- 本脚本只建一款【已发布 project(gameId=9302)+ 占位 version(93020)+ feed_rank】,供:
+-- ① 提交 generic 任务携 gameId=9302(project 须先存在);
+-- ② 回调链建【新】version(N)+runtime_package(engineBundle=stub 回的 pong);
+-- ③ 跑 p3b_publish_flip.sql 把 project.current_version_id/feed_rank 翻到新版本 N → feed 服务回调产物。
+-- 四态:game_project.status=4(PUBLISHED) ∧ game_version.status=3 ∧ game_runtime_package.status=1 ∧ game_feed_rank 行
+-- ID:gameId=9302 / 占位 versionId=93020 / pkgId=93020 / feedRankId=93020(高位区,与 9201/9301 零碰撞)
+-- 占位 version 93020 的 package_json 为空(feed 卡先有占位包,翻到回调新版本 N 后服务真 pong)。
+-- 幂等:先 DELETE 同 gameId/占位 ID 行再 INSERT(纯重建,可重复执行)。注意:不删回调新建的 version(N≠93020)。
+-- tenant:staging tenant.enable=false → tenant_id=0
+-- 用法:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
+-- ============================================================================
+USE `ruoyi-vue-pro`;
+SET NAMES utf8mb4;
+
+START TRANSACTION;
+
+-- 清旧(幂等重建;删该 gameId 全部 version/package/feed_rank,含上轮回调新建的——重跑前清场)
+DELETE FROM game_feed_rank WHERE game_id = 9302;
+DELETE FROM game_runtime_package WHERE game_id = 9302;
+DELETE FROM game_version WHERE game_id = 9302;
+DELETE FROM game_project WHERE id = 9302;
+
+-- ① project:已发布(status=4),current_version_id 先指占位 93020(flip 后改指回调新版本 N)
+INSERT INTO game_project
+ (id, creator_user_id, title, summary, cover_url, tags, template_id, age_rating,
+ status, current_version_id, launch_zone_id, featured, play_count, like_count,
+ creator, create_time, updater, update_time, deleted, tenant_id)
+VALUES
+ (9302, 1, '弹球(generic 派发·回调入feed)', 'P3 3b-A 派发面 e2e:generic 任务派发 stub worker 回 pong bundle 经回调链入 feed',
+ 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
+ 'P3,3b-A,dispatch,generic,pong', 'generic', 'all',
+ 4, 93020, NULL, b'0', 0, 0,
+ 'p3b-scaffold', NOW(), 'p3b-scaffold', NOW(), b'0', 0);
+
+-- ② 占位 version:已发布(status=3),仅供脚手架四态成立;回调会另建新 version(N)
+INSERT INTO game_version
+ (id, game_id, version_no, gen_task_id, package_url, bundle_size, checksum,
+ status, creator, create_time, updater, update_time, deleted, tenant_id)
+VALUES
+ (93020, 9302, 1, NULL, '', 0, '',
+ 3, 'p3b-scaffold', NOW(), 'p3b-scaffold', NOW(), b'0', 0);
+
+-- ③ 占位 runtime_package:status=1 + 空 package_json(占位包;flip 前 feed 卡指它,flip 后指回调新包)
+INSERT INTO game_runtime_package
+ (id, game_id, version_id, template_id, package_url, entry, runtime_version, preload_policy,
+ bundle_size, checksum, sandbox_attr, allow_origins, status,
+ creator, create_time, updater, update_time, deleted, tenant_id, package_json)
+VALUES
+ (93020, 9302, 93020, 'generic', '', 'index.html', '1.0.0', 'eager',
+ 0, '', 'allow-scripts', '', 1,
+ 'p3b-scaffold', NOW(), 'p3b-scaffold', NOW(), b'0', 0, NULL);
+
+-- ④ feed_rank:zone_id=0(混合流) + status=1 入流(flip 后把 version_id 改指回调新版本 N)
+INSERT INTO game_feed_rank
+ (id, game_id, version_id, zone_id, quality_score, boost, sort_score, pinned, status,
+ creator, create_time, updater, update_time, deleted, tenant_id)
+VALUES
+ (93020, 9302, 93020, 0, 0.0000, 0.0000, 0.0000, 0, 1,
+ 'p3b-scaffold', NOW(), 'p3b-scaffold', NOW(), b'0', 0);
+
+COMMIT;
+
+-- 回读四态
+SELECT 'project' k, id, status, current_version_id FROM game_project WHERE id=9302
+UNION ALL SELECT 'version', id, status, game_id FROM game_version WHERE id=93020
+UNION ALL SELECT 'runtime_pkg', id, status, version_id FROM game_runtime_package WHERE id=93020
+UNION ALL SELECT 'feed_rank', id, status, version_id FROM game_feed_rank WHERE id=93020;
diff --git a/game-cloud/scripts/wg1_stub_worker.py b/game-cloud/scripts/wg1_stub_worker.py
new file mode 100644
index 00000000..e7b926a0
--- /dev/null
+++ b/game-cloud/scripts/wg1_stub_worker.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+WG1 stub worker(P3 W-G1 派发面·3b-A,2026-06-14)——证全 plumbing 用的极简桩 worker。
+
+【用途】把 P3 派发面端到端打通:执行器 POST §6.1 job → 本 stub 收 job → 读已知 pong bundle 作 engineBundle →
+ 把 §6.1 result-out 经回调入参 POST 回 /admin-api/aigc/dify/callback-internal(@PermitAll 内网回调子路由)→
+ 后端 handleCallback 建版本/组包/落包入 feed。**不调任何 LLM、不打包**——只回固定 pong bundle,专证管线。
+ 真 worker(L1 run.py:裸 openai SDK 写 GameHostFactory → esbuild 打 __GameBundle → 自检)留 3b-B。
+
+【与 §6.1 接缝的关系】本 stub 实现 §6.1 job-in(收)/ result-out(回)两个 JSON 契约:
+ - 收 job:解 job_id/brief/gameId/templateId/callback.target(其余字段透传/忽略)。
+ - 回 result:但回调入参形态对齐【后端 DifyCallbackReqVO】(traceId/status/templateId/gameConfig/engineBundle),
+ 而非直接回 §6.1 result-out 全形态——因为 3b-A 复用 HTTP 回调路(worker→/dify/callback-internal),
+ 后端回调入参 VO 即落点;§6.1 result-out 的 selfCheck/costYuan 等字段是 worker↔pipeline「队列路」语义,
+ HTTP 回调路下由后端从 DifyCallbackReqVO 取 engineBundle(resolveEngineBundleText)落包,二者等价。
+
+【gameConfig 占位(§5.2 占位框)】engine 路下游戏逻辑全在 engineBundle 内,gameConfig 仍不可空(GamePackage
+ required + additionalProperties:false);故回最小合法占位 {templateId, title, theme, engineDriven:true}。
+
+【鉴权(§6.5 auth 孤儿解法)】worker 是内部服务无 RBAC token,回调走后端新开的 @PermitAll 内网回调子路由
+ /admin-api/aigc/dify/callback-internal(复用 handleCallback 唯一写入路径)。本 stub 因此【不带 Authorization 头】。
+
+【运行(mini-desktop)】
+ python3 game-cloud/scripts/wg1_stub_worker.py [--port 9301] \
+ [--bundle game-runtime/games/_wg1-gen/pong/bundle.iife.js] \
+ [--callback http://localhost:48080/admin-api/aigc/dify/callback-internal]
+ 执行器 application-staging.yaml 配 aigc.executor.worker-url=http://localhost:9301/wg1/generate 指向本 stub。
+
+@author 造梦AI(P3 派发面·stub)
+"""
+
+import argparse
+import json
+import os
+import sys
+import threading
+import urllib.request
+import urllib.error
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+# ---------- 默认常量(mini-desktop staging 口径)----------
+# pong bundle 固定产物(brick-2 已放此路径,267KB iife 顶层全局名 __GameBundle)
+DEFAULT_BUNDLE = "game-runtime/games/_wg1-gen/pong/bundle.iife.js"
+# 内网免鉴权回调子路由(后端 AdminAigcTaskController.difyCallbackInternal,@PermitAll)
+DEFAULT_CALLBACK = "http://localhost:48080/admin-api/aigc/dify/callback-internal"
+DEFAULT_PORT = 9301
+
+
+def log(msg):
+ """简单带前缀日志(stdout,便于 nohup 落盘排障)。"""
+ print(f"[wg1-stub-worker] {msg}", flush=True)
+
+
+class StubWorkerState:
+ """进程级状态(bundle 文本一次读入常驻 + 配置)。"""
+
+ def __init__(self, bundle_text, callback_url):
+ self.bundle_text = bundle_text # pong bundle 全文(engineBundle 真值)
+ self.callback_url = callback_url # 回调子路由 URL
+
+
+def build_callback_payload(job, bundle_text):
+ """
+ 据收到的 §6.1 job 组后端回调入参(DifyCallbackReqVO 形态)。
+
+ :param job: 收到的 §6.1 job-in(dict)
+ :param bundle_text: pong bundle 全文(engineBundle 真值)
+ :return: 回调入参 dict(traceId/status/templateId/gameConfig/engineBundle)
+ """
+ trace_id = job.get("traceId") or job.get("job_id") # traceId 贯穿全链(与 job_id 同值)
+ template_id = job.get("templateId") or "generic"
+ brief = job.get("brief") or ""
+ # gameConfig 最小合法占位(§5.2):engine 路承载 meta,非填参玩法。title 由 brief 派生(截断防超 META_TITLE_MAX)。
+ title = (brief[:20] if brief else "弹球(stub)")
+ game_config = {
+ "templateId": template_id, # 须与包级 templateId 一致(validateSucceededPayload 一致性校验)
+ "title": title, # 供 meta 段派生 cover/summary
+ "theme": "stub-pong", # 占位主题
+ "engineDriven": True, # 标记本款走引擎 bundle 路(非填参 GameConfig)
+ }
+ return {
+ "traceId": trace_id,
+ "status": "succeeded",
+ "templateId": template_id,
+ "gameConfig": game_config,
+ "engineBundle": bundle_text, # 回调入参携 pong bundle 全文 → 后端 resolveEngineBundleText 取值落包
+ "assets": [],
+ # qualityScore/failureReason 省略(succeeded 路不需要)
+ }
+
+
+def post_callback(callback_url, payload):
+ """
+ 把回调入参 POST 回后端内网回调子路由(不带 Authorization;@PermitAll 内网可达)。
+
+ :param callback_url: 回调 URL
+ :param payload: 回调入参 dict
+ :return: (http_status, body_text)
+ """
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ req = urllib.request.Request(
+ callback_url, data=data, method="POST",
+ headers={"Content-Type": "application/json", "tenant-id": "0"}, # tenant-id 头兜底(@TenantIgnore 已忽略,带上无害)
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ body = resp.read().decode("utf-8", "replace")
+ return resp.status, body
+ except urllib.error.HTTPError as e:
+ body = e.read().decode("utf-8", "replace") if e.fp else ""
+ return e.code, body
+ except Exception as e: # 网络异常:返回 -1 + 异常文本(不抛,便于日志记真因)
+ return -1, f"{type(e).__name__}: {e}"
+
+
+def handle_job_async(state, job):
+ """
+ 异步处理一个 job(读 bundle → 组回调入参 → POST 回调)。
+ 放后台线程,模拟「worker 异步生成、执行器投递握手后即返回、任务留 RUNNING 等回调」的真实时序。
+ """
+ trace_id = job.get("traceId") or job.get("job_id")
+ game_id = job.get("gameId")
+ log(f"开始处理 job trace_id={trace_id}, gameId={game_id}, bundleLen={len(state.bundle_text)}")
+ payload = build_callback_payload(job, state.bundle_text)
+ status, body = post_callback(state.callback_url, payload)
+ # 截断 body 防刷屏
+ body_brief = body[:300] if body else ""
+ log(f"回调完成 trace_id={trace_id}, callbackHttp={status}, body={body_brief}")
+
+
+def make_handler(state):
+ """工厂:绑定 state 的 HTTP handler 类。"""
+
+ class Handler(BaseHTTPRequestHandler):
+ # 静默默认访问日志(用自定义 log)
+ def log_message(self, fmt, *args):
+ return
+
+ def _send_json(self, code, obj):
+ data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def do_GET(self):
+ # 健康探针:GET /health → 200
+ if self.path.rstrip("/") in ("/health", "/wg1/health"):
+ self._send_json(200, {"ok": True, "bundleLen": len(state.bundle_text)})
+ else:
+ self._send_json(404, {"error": "not found"})
+
+ def do_POST(self):
+ # 派发入口:POST /wg1/generate ← 执行器投递 §6.1 job
+ if self.path.rstrip("/") != "/wg1/generate":
+ self._send_json(404, {"error": "not found", "path": self.path})
+ return
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ raw = self.rfile.read(length) if length > 0 else b""
+ try:
+ job = json.loads(raw.decode("utf-8")) if raw else {}
+ except Exception as e:
+ log(f"job 解析失败:{e}")
+ self._send_json(400, {"accepted": False, "error": "bad job json"})
+ return
+ trace_id = job.get("traceId") or job.get("job_id")
+ log(f"收到 job trace_id={trace_id}, templateId={job.get('templateId')}, gameId={job.get('gameId')}")
+ # 投递握手:立即回 202(投递成功),后台异步「生成」+回调(模拟真实 worker 异步时序)
+ threading.Thread(target=handle_job_async, args=(state, job), daemon=True).start()
+ self._send_json(202, {"accepted": True, "job_id": job.get("job_id"), "traceId": trace_id})
+
+ return Handler
+
+
+def main():
+ parser = argparse.ArgumentParser(description="WG1 stub worker(P3 派发面 3b-A:回固定 pong bundle 证管线)")
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"监听端口(默认 {DEFAULT_PORT})")
+ parser.add_argument("--bundle", default=DEFAULT_BUNDLE, help="engineBundle 真值文件(默认 pong bundle)")
+ parser.add_argument("--callback", default=DEFAULT_CALLBACK, help="后端内网回调子路由 URL")
+ args = parser.parse_args()
+
+ # 读 bundle(相对路径基于 cwd;建议在 repo 根跑)
+ bundle_path = args.bundle
+ if not os.path.isfile(bundle_path):
+ log(f"❌ bundle 文件不存在:{bundle_path}(cwd={os.getcwd()})")
+ sys.exit(2)
+ with open(bundle_path, "r", encoding="utf-8") as f:
+ bundle_text = f.read()
+ # 出厂自检:bundle 必含 __GameBundle 全局名(宿主据此取工厂;缺则后端落包后宿主取不到 → demo 兜底)
+ if "__GameBundle" not in bundle_text:
+ log(f"❌ bundle 缺 __GameBundle 全局名:{bundle_path}(宿主无法取 GameHostFactory)")
+ sys.exit(2)
+ log(f"bundle 已加载:{bundle_path}({len(bundle_text)} 字符,含 __GameBundle ✓)")
+ log(f"回调子路由:{args.callback}")
+
+ state = StubWorkerState(bundle_text, args.callback)
+ server = ThreadingHTTPServer(("0.0.0.0", args.port), make_handler(state))
+ log(f"stub worker 已启动 → http://0.0.0.0:{args.port}/wg1/generate(Ctrl-C 退出)")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ log("收到中断,退出")
+ server.shutdown()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/game-cloud/yudao-server/src/main/resources/application-staging.yaml b/game-cloud/yudao-server/src/main/resources/application-staging.yaml
index e00aad64..63c95b81 100644
--- a/game-cloud/yudao-server/src/main/resources/application-staging.yaml
+++ b/game-cloud/yudao-server/src/main/resources/application-staging.yaml
@@ -175,6 +175,11 @@ aigc:
executor:
enabled: ${AIGC_EXECUTOR_ENABLED:false} # 灰度开关:经 ~/game-staging/infra/.env 注入,默认关
api-key: ${NEWAPI_KEY:} # 密钥只走环境变量占位符,严禁写真值入 repo
+ # P3 W-G1 派发面(3b-A,2026-06-14):generic 模板派发外置 worker 生成 engineBundle 入 feed
+ worker-url: ${AIGC_WORKER_URL:} # 外置生成 worker 端点(空=派发面无端点→generic 任务判 failed;
+ # 3b-A 经 .env 注入 stub worker 地址,如 http://localhost:9301/wg1/generate)
+ # callback-url 默认即内网回调子路由(/admin-api/aigc/dify/callback-internal,@PermitAll);如需覆盖再放开:
+ # callback-url: ${AIGC_CALLBACK_URL:http://localhost:48080/admin-api/aigc/dify/callback-internal}
--- #################### passport 鉴权(真实鉴权与匿名玩家 HJ-PASSPORT-EXEC-001)####################
wanxiang:
diff --git a/game-studio/evidence/p3-pong/p3-pong.driver.cjs b/game-studio/evidence/p3-pong/p3-pong.driver.cjs
new file mode 100644
index 00000000..b240d59b
--- /dev/null
+++ b/game-studio/evidence/p3-pong/p3-pong.driver.cjs
@@ -0,0 +1,307 @@
+/**
+ * p3-pong.driver.cjs — P3 canary 3a「便宜模型生成的 pong 经 feed 引擎真渲真玩」CDP driver
+ * adapt 自 evidence/p2-feed/p2-feed.driver.cjs(仅 mini-desktop 跑;6c6g 禁 chrome)。
+ *
+ * 目标(母 spec 门4 + P2 门②/③/④):开真 staging studio(:4173·mock OFF) → feed 定位 pong 9301 卡 → /play
+ * → 断言:① 引擎真渲 pong 非「模板体系重构中」(截图+像素+活性) ② lifecycle game_loaded(via=bootGameHost,engine=real)
+ * ∧ game_start(经 bridge 上抛,top frame 监听捕获)③ 防 demo 兜底(无回退 demo warn ∧ 无 game_error
+ * ∧ sha256(manifest)==checksum 已端点侧实证 ∧ gameId/versionId 匹配)④ console 零未捕获错误
+ * ⑤ 真玩几步(pong 挡板/球响应:tap 底部挡板带 → 活性多帧哈希变化,证输入驱动引擎真玩)。
+ *
+ * 【pong 特化 adapt 点(与 P2 暖阳小卖部的差异)】
+ * - gameId/versionId 缺省 9301/93010;
+ * - 像素判据改 harness §6/§7 口径:亮像素 max(r,g,b)>80 计数 + maxCh + 活性(≥2 不同整帧哈希),
+ * 不用 P2 warm 暖色阈值(pong 黑底 + 白挡板/球/分数,非暖色;球在动故活性是更强的「真渲」证据);
+ * - 交互坐标取 pong play-spec:tap 底部挡板带 y≈740 不同 x(挡板触摸跟随),非小卖部 newGame/openShop。
+ * lifecycle 三锚(game_loaded via=bootGameHost / 无 game_error / 无重构文案)品类无关,逐字复用。
+ *
+ * 用法(cwd=game-studio):node evidence/p3-pong/p3-pong.driver.cjs [baseUrl] [gameId] [versionId]
+ * 缺省 baseUrl=http://localhost:4173 gameId=9301 versionId=93010
+ * ⚠️ baseUrl 必须 localhost(非 IP):宿主 manifest 校验用 crypto.subtle,仅安全上下文(localhost/https)可用;
+ * 经 IP 访问 → crypto.subtle 缺失 → sha256 抛 → 永走 demo 兜底(即使后端真包正确也渲占位)。
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const http = require('node:http');
+const WebSocket = require('ws');
+
+const BASE = process.argv[2] || 'http://localhost:4173';
+const GAME_ID = process.argv[3] || '9301';
+const VERSION_ID = process.argv[4] || '93010';
+const CDP_HTTP = process.env.CDP_HTTP || 'http://localhost:9222';
+const OUT_DIR = path.resolve('evidence/p3-pong');
+const EXPECT_CHECKSUM = process.env.EXPECT_CHECKSUM || '';
+
+const delay = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function httpJson(method, urlStr) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(urlStr);
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method }, (res) => {
+ let buf = '';
+ res.on('data', (c) => (buf += c));
+ res.on('end', () => { try { resolve(buf ? JSON.parse(buf) : {}); } catch { resolve({ raw: buf }); } });
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+/** 极简 CDP 会话(send/evaluate + 事件订阅:捕获 console / exceptionThrown)。 */
+class Cdp {
+ constructor(ws, targetId) {
+ this.ws = ws; this.targetId = targetId; this._id = 0; this._pending = new Map();
+ this.consoleMsgs = []; this.exceptions = [];
+ ws.on('message', (raw) => {
+ let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
+ if (msg.id != null && this._pending.has(msg.id)) {
+ const { resolve, reject } = this._pending.get(msg.id); this._pending.delete(msg.id);
+ if (msg.error) reject(new Error(JSON.stringify(msg.error))); else resolve(msg.result || {});
+ return;
+ }
+ if (msg.method === 'Runtime.consoleAPICalled') {
+ const a = (msg.params.args || []).map((x) => (x.value !== undefined ? x.value : x.description)).join(' ');
+ this.consoleMsgs.push({ type: msg.params.type, text: String(a) });
+ } else if (msg.method === 'Runtime.exceptionThrown') {
+ const d = msg.params.exceptionDetails || {};
+ const t = (d.exception && (d.exception.description || d.exception.value)) || d.text || 'unknown';
+ this.exceptions.push(String(t));
+ }
+ });
+ }
+ send(method, params, timeoutMs) {
+ const id = ++this._id;
+ return new Promise((resolve, reject) => {
+ const to = setTimeout(() => { this._pending.delete(id); reject(new Error('CDP 超时:' + method)); }, timeoutMs || 40000);
+ this._pending.set(id, { resolve: (r) => { clearTimeout(to); resolve(r); }, reject: (e) => { clearTimeout(to); reject(e); } });
+ this.ws.send(JSON.stringify({ id, method, params: params || {} }));
+ });
+ }
+ async evaluate(expression, timeoutMs) {
+ const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, timeoutMs);
+ if (r && r.exceptionDetails) throw new Error('求值异常:' + JSON.stringify(r.exceptionDetails).slice(0, 300));
+ return r && r.result ? r.result.value : undefined;
+ }
+ close() { try { this.ws.close(); } catch { /* ignore */ } }
+}
+
+async function connect() {
+ const created = await httpJson('PUT', `${CDP_HTTP}/json/new?${encodeURI('about:blank')}`);
+ if (!created.webSocketDebuggerUrl) throw new Error('建 target 失败:' + JSON.stringify(created).slice(0, 200));
+ const ws = await new Promise((resolve, reject) => {
+ const s = new WebSocket(created.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 });
+ s.on('open', () => resolve(s)); s.on('error', reject);
+ });
+ const cdp = new Cdp(ws, created.id);
+ await cdp.send('Page.enable');
+ await cdp.send('Runtime.enable');
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
+ try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch { /* old chrome */ }
+ // 文档创建即注入 lifecycle 捕获器(先于 SPA + 跨 reload 常驻),杜绝漏捕 game_loaded/start。
+ await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: INJECT_CAPTURE });
+ return cdp;
+}
+
+const INJECT_CAPTURE = `(function(){
+ if (window.__p3life) return 'already';
+ window.__p3life = [];
+ window.__p3telemetry = [];
+ window.addEventListener('message', function(e){
+ try {
+ var d = e.data;
+ if (d && d.channel === 'wanxiang-game-sdk' && d.type === 'lifecycle' && d.payload) {
+ window.__p3life.push({ event: d.payload.event, data: d.payload.data || {}, t: Date.now() });
+ }
+ if (d && d.channel === 'wanxiang-game-sdk' && d.type === 'telemetry' && d.payload) {
+ window.__p3telemetry.push({ event: d.payload.event, props: d.payload.props || {}, t: Date.now() });
+ }
+ } catch(_){}
+ }, true);
+ return 'installed';
+})()`;
+
+async function readLife(cdp) { return await cdp.evaluate('JSON.stringify(window.__p3life||[])').then((s) => JSON.parse(s || '[]')); }
+function hasLife(life, ev) { return life.find((x) => x.event === ev) || null; }
+
+async function saveShot(cdp, name) {
+ const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
+ if (!r || !r.data) throw new Error('截图无 data');
+ const abs = path.join(OUT_DIR, name);
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
+ fs.writeFileSync(abs, Buffer.from(r.data, 'base64'));
+ return abs;
+}
+
+/** 全页截图像素统计(pong 特化:亮像素 max(r,g,b)>80 计数 + maxCh + 全屏哈希用于活性)。
+ * pong 黑底 + 白挡板/球/分数 → 不用 warm 暖色;亮像素 + 活性(帧哈希变化) 共证「引擎真渲且在动」。 */
+async function screenshotPixelStats(cdp) {
+ const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
+ if (!r || !r.data) return { err: 'no shot' };
+ const dataUrl = 'data:image/png;base64,' + r.data;
+ const expr = `(async function(){
+ var img = new Image();
+ await new Promise(function(res,rej){ img.onload=res; img.onerror=rej; img.src=${JSON.stringify(dataUrl)}; });
+ var cv = document.createElement('canvas'); cv.width=img.naturalWidth; cv.height=img.naturalHeight;
+ var g = cv.getContext('2d'); g.drawImage(img,0,0);
+ var d = g.getImageData(0,0,cv.width,cv.height).data, n=d.length;
+ var bright=0, maxCh=0, nonblack=0, hash=0;
+ for (var i=0;imaxCh)maxCh=m;
+ if(m>40)nonblack++;
+ if(m>80)bright++; // harness §6/§7:任意亮像素(品类无关)
+ // 轻量整帧哈希(每 64 像素采一点,FNV-ish):用于活性「≥2 不同帧哈希」判据。
+ if((i % (64*4))===0){ hash=(hash*16777619 + (R<<16|G<<8|B))>>>0; }
+ }
+ return { px:(n/4), bright:bright, maxCh:maxCh, nonblack:nonblack, hash:hash>>>0, w:cv.width, h:cv.height };
+ })()`;
+ return await cdp.evaluate(expr, 60000);
+}
+
+/** 真触摸(iframe 充满视口,canvas 逻辑 390×844 映射到页面 CSS 坐标,直发 Input.dispatchTouchEvent)。 */
+async function tap(cdp, cssX, cssY) {
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: cssX, y: cssY }] });
+ await delay(60);
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
+}
+
+async function main() {
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+ const report = {
+ meta: { base: BASE, gameId: GAME_ID, versionId: VERSION_ID, startedAt: new Date().toISOString(), driver: 'p3-pong.driver.cjs' },
+ gates: {}, checks: [], lifecycle: [], console: { errors: [], demoWarns: [] }, screenshots: [], notes: [],
+ };
+ const check = (ok, msg) => { report.checks.push({ ok: !!ok, msg }); console.log((ok ? ' ✓ ' : ' ✗ ') + msg); return !!ok; };
+
+ let cdp;
+ try {
+ // ── 步骤1:feed 页 → 确认 pong 9301 卡在流(真后端,mock OFF)──
+ console.log('[1] feed 页加载 + 定位 pong 9301 卡');
+ cdp = await connect();
+ await cdp.send('Page.navigate', { url: `${BASE}/feed` });
+ await delay(4000); // 等 feed 拉流 + 渲染
+ const feedProbe = await cdp.evaluate(`(function(){
+ var html = document.body.innerText || '';
+ var hasTitle = html.indexOf('弹球') >= 0 || html.indexOf('Pong') >= 0 || html.indexOf('pong') >= 0;
+ var links = Array.prototype.map.call(document.querySelectorAll('a,[href]'), function(a){return a.getAttribute('href')||'';});
+ var playLink = links.filter(function(h){return h.indexOf('/play/${GAME_ID}')>=0;})[0] || null;
+ return { hasTitle: hasTitle, playLink: playLink, bodyLen: html.length, sample: html.slice(0,300) };
+ })()`);
+ report.notes.push({ feedProbe });
+ report.screenshots.push(await saveShot(cdp, '01-feed.png'));
+ check(feedProbe.hasTitle, `feed 出 pong 9301 卡(标题含「弹球/Pong」;sample=${(feedProbe.sample||'').slice(0,80)})`);
+ report.gates.gate1_feed_card = feedProbe.hasTitle;
+
+ // ── 步骤2:整页导航 /play/9301/93010(全量 reload → 捕获器文档创建即装)──
+ console.log('[2] /play 引擎包分支启动(全量 reload,捕获器文档级常驻)');
+ await cdp.send('Page.navigate', { url: `${BASE}/play/${GAME_ID}/${VERSION_ID}` });
+ await delay(800);
+ let life = [];
+ const deadline = Date.now() + 25000;
+ while (Date.now() < deadline) {
+ life = await readLife(cdp);
+ if (hasLife(life, 'game_loaded')) break;
+ if (hasLife(life, 'game_error')) break;
+ await delay(500);
+ }
+ report.lifecycle = life;
+ const loaded = hasLife(life, 'game_loaded');
+ const started = hasLife(life, 'game_start');
+ const errored = hasLife(life, 'game_error');
+ const loadedReal = !!(loaded && loaded.data && loaded.data.via === 'bootGameHost');
+ check(loadedReal, `lifecycle game_loaded(via=bootGameHost) 上抛(门② 就绪锚·引擎真渲非重构中)${loaded ? ' engine=' + (loaded.data && loaded.data.engine) + ' via=' + (loaded.data && loaded.data.via) : ' [缺]'}`);
+ check(!!started, `lifecycle game_start 上抛(会话/计数链)${started ? ' via=' + (started.data && started.data.via) : ''}`);
+ check(!errored, `无 game_error(引擎接管成功,未走兜底)${errored ? ' ✗错误:' + JSON.stringify(errored.data) : ''}`);
+ report.gates.gate2_game_loaded_real = loadedReal;
+ report.gates.gate2_game_start = !!started;
+ report.gates.gate3_no_game_error = !errored;
+
+ await delay(1000);
+ const telem = await cdp.evaluate('JSON.stringify(window.__p3telemetry||[])').then((s) => JSON.parse(s || '[]'));
+ report.notes.push({ telemetry: telem });
+ const playStart = telem.find((x) => x.event === 'game_play_start') || null;
+ report.gates.gate2_game_play_start_telem = !!playStart;
+ if (playStart) check(true, `game_play_start 遥测上报(play_count 权威源;DB session 行另证)`);
+
+ await delay(1200);
+ report.screenshots.push(await saveShot(cdp, '02-play-engine-render.png'));
+
+ // ── 步骤3:引擎真渲像素证据(pong 特化:亮像素 + 活性,非「模板体系重构中」占位)──
+ console.log('[3] 引擎真渲像素(亮像素+活性) + 防占位文案');
+ const stats = await screenshotPixelStats(cdp);
+ report.notes.push({ pixelStats: stats });
+ const rendered = !!(stats && stats.bright > 1500 && stats.maxCh > 120);
+ check(rendered, `引擎真渲非占位(bright=${stats && stats.bright} maxCh=${stats && stats.maxCh} nonblack=${stats && stats.nonblack})`);
+ report.gates.gate2_engine_rendered = rendered;
+ // 兜底文案检测:top frame 出现「模板体系重构中」即兜底触发(fail)
+ const reconText = await cdp.evaluate(`(document.body.innerText||'').indexOf('模板体系重构中')>=0`);
+ check(!reconText, `top frame 无「模板体系重构中」占位文案`);
+ report.gates.gate3_no_reconstruction_text = !reconText;
+
+ // ── 步骤4:防 demo 兜底假阳性(console 无回退 demo warn)+ checksum 三锚 ──
+ console.log('[4] 防 demo 兜底假阳性 console 扫描 + checksum 三锚');
+ const demoWarns = cdp.consoleMsgs.filter((m) => /回退\s*demo\s*兜底|manifest 校验失败|取包清单失败|engine_bundle_present_but_global_missing/.test(m.text));
+ report.console.demoWarns = demoWarns.map((m) => m.text);
+ check(demoWarns.length === 0, `无 demo 兜底告警(回退 demo/manifest 校验失败 计 ${demoWarns.length})`);
+ report.gates.gate3_no_demo_fallback = demoWarns.length === 0;
+ if (EXPECT_CHECKSUM) {
+ report.notes.push({ expectChecksum: EXPECT_CHECKSUM });
+ check(/^[0-9a-f]{64}$/.test(EXPECT_CHECKSUM) && EXPECT_CHECKSUM !== '0'.repeat(64),
+ `防 demo 锚①: checksum 64hex 非占位(端点 sha256==DB 已 shell 侧实证)= ${EXPECT_CHECKSUM.slice(0, 16)}…`);
+ }
+
+ // ── 步骤5:真玩几步(pong 挡板/球响应:tap 底部挡板带不同 x → 活性多帧哈希变化)──
+ // pong play-spec inputs:tap y≈740 底部挡板带,不同 x(100/300/195/130/270)→ 挡板触摸跟随。
+ // 判据=多采样整帧哈希 ≥2 不同(球在动 + 挡板响应触摸 → 画面必变;静止假死会同哈希)。
+ console.log('[5] 真玩几步(pong 挡板/球响应)+ 活性多帧哈希');
+ const PLAY_X = [100, 300, 195, 130, 270];
+ const PLAY_Y = 740;
+ const frameHashes = [stats && stats.hash];
+ await delay(600);
+ for (let k = 0; k < PLAY_X.length; k++) {
+ await tap(cdp, PLAY_X[k], PLAY_Y);
+ await delay(450);
+ const s = await screenshotPixelStats(cdp);
+ frameHashes.push(s && s.hash);
+ }
+ report.screenshots.push(await saveShot(cdp, '03-after-play.png'));
+ const uniqHashes = Array.from(new Set(frameHashes.filter((h) => h != null)));
+ report.notes.push({ frameHashes, uniqHashes });
+ const lively = uniqHashes.length >= 2;
+ check(lively, `活性:真玩中多采样 ≥2 不同整帧哈希(uniq=${uniqHashes.length}/${frameHashes.length},证球运动+挡板响应非静止假死)`);
+ report.gates.gate5_lively_interactive = lively;
+ // 真玩后像素再核:仍引擎真渲
+ const stats2 = await screenshotPixelStats(cdp);
+ report.notes.push({ pixelStatsAfterPlay: stats2 });
+ const stillRendered = !!(stats2 && stats2.bright > 1500 && stats2.maxCh > 120);
+ check(stillRendered, `真玩后引擎仍真渲(bright=${stats2 && stats2.bright} maxCh=${stats2 && stats2.maxCh})`);
+ report.gates.gate5_still_rendered = stillRendered;
+ report.lifecycle = await readLife(cdp);
+
+ // ── 步骤6:console 零未捕获错误 ──
+ console.log('[6] console 未捕获错误扫描');
+ const errs = cdp.consoleMsgs.filter((m) => m.type === 'error').map((m) => m.text).concat(cdp.exceptions);
+ report.console.errors = errs;
+ const realErrs = errs.filter((t) => !/favicon|ERR_|net::|404|Failed to load resource/i.test(t));
+ check(realErrs.length === 0, `console 零未捕获错误(real=${realErrs.length},total=${errs.length})`);
+ report.gates.gate4_console_clean = realErrs.length === 0;
+
+ } catch (e) {
+ report.fatal = String((e && e.stack) || e);
+ console.error('[FATAL]', report.fatal);
+ } finally {
+ if (cdp) { try { await httpJson('GET', `${CDP_HTTP}/json/close/${cdp.targetId}`); } catch { /* ignore */ } cdp.close(); }
+ }
+
+ report.finishedAt = new Date().toISOString();
+ const passCount = report.checks.filter((c) => c.ok).length;
+ report.summary = { pass: passCount, total: report.checks.length };
+ fs.writeFileSync(path.join(OUT_DIR, 'p3-evidence.json'), JSON.stringify(report, null, 2));
+ console.log(`\n[RESULT] ${passCount}/${report.checks.length} checks pass`);
+ console.log('[RESULT] gates=' + JSON.stringify(report.gates));
+ console.log('[DONE] evidence → ' + path.join(OUT_DIR, 'p3-evidence.json'));
+}
+
+main();