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();