diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 947fe063..636fd197 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-06-08 微信能力按领域收口 + +- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。 +- 决策:`api-server` 内微信相关 HTTP/BFF 适配统一收在 `server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*`;`platform-wechat` 负责微信订阅消息、微信支付 V3、虚拟支付消息推送的协议 client、header、签名、验签、解密、mock 和 payload 解析;`api-server::wechat` 只负责 AppConfig 映射、Axum handler、用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。微信 OAuth / 小程序登录 provider 暂继续在 `platform-auth`,通过 `api-server::wechat::provider` 作为组合根 adapter 接入。 +- 影响范围:`server-rs/crates/api-server/src/wechat.rs`、`server-rs/crates/api-server/src/wechat/*`、`server-rs/crates/platform-wechat/src/*`、微信支付 / 订阅消息 / 小程序消息推送文档。 +- 验证方式:执行 `cargo check --manifest-path server-rs/Cargo.toml -p platform-wechat`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server`、微信相关定向测试和编码检查;新增微信协议细节优先落到 `platform-wechat`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 + +## 2026-06-08 后端创作 / 游玩流程先统一主干再领域分发 + +- 背景:前端平台入口、作品架、公开详情和推荐运行态已经持续收口,但 `api-server` 仍在 `app.rs` 逐玩法合并创作 / 运行态路由,入口开关路径判断也独立维护,新增玩法容易复制出平行链路。 +- 决策:后端所有创作 / 游玩相关 HTTP 路由先进入 `server-rs/crates/api-server/src/modules/play_flow.rs` 统一主干;主干注册 `playId`、领域模块 key、创作路由前缀、运行态路由前缀和新建创作入口开关匹配规则,并在进入领域 handler 前统一挂载 `PlayFlowRequestContext`,再在最后一步分发到各玩法领域 HTTP Adapter。创作入口配置、AI task、runtime chat、运行态设置 / 存档、运行态库存、游玩历史、存档归档、游玩统计、历史素材、角色资产工坊、角色图像 / 动画生成和 Hyper3D 代理也作为创作 / 游玩支撑能力从 `play_flow` 进入;`modules/platform.rs` 只保留通用 LLM / 语音代理。`app.rs` 只合并 `modules::play_flow::router(state)`,不再逐玩法 merge;`creation_entry_config.rs` 复用 `play_flow` 的入口开关解析,不维护第二份路径表。 +- 影响范围:`api-server` 路由组织、入口开关、玩法接入 SOP、后端契约文档、后续新增 / 迁移玩法。 +- 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并确认旧 `/api/creation//*`、历史 `/api/runtime//agent/*` 与公开 runtime 路由外部契约不变。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-08 PlatformUiKit 弹窗与复制反馈收口 - 背景:前端已有 `UnifiedModal` 统一遮罩和无障碍外壳,但业务页面仍反复手写“知道了”“确认 / 取消”“危险确认”的 footer 按钮和关闭禁用逻辑。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 9fad4640..f34cf07f 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -101,6 +101,8 @@ npm run dev:admin-web 生产 `Genarrative-Stdb-Module-Publish` 的备份默认使用 `DATABASE_BACKUP_MODE=async`:流水线在 publish 前先生成本地冷备份,随后继续 publish,并把同一份发布前备份交给后台 Node 进程上传 OSS,避免低带宽 OSS 上传长时间占住部署窗口。需要强制在 publish 前等待打包和上传并让失败阻断发布时,手动选择 `DATABASE_BACKUP_MODE=sync`;已有其他备份窗口且明确接受风险时才选择 `skip`。 +生产 API / Web / Stdb 发布流水线不在目标机器 checkout Git。对应 Build 流水线必须把发布产物、校验文件、`release-manifest.json` 和部署 / 发布脚本一起归档;Deploy / Publish 流水线只通过 `copyArtifacts` 复制上游构建归档并执行随产物归档的脚本,避免目标机器 Git 访问和产物 commit 与部署脚本 commit 漂移。 + 查看本地 Rust/SpacetimeDB 日志: ```bash diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index bb648e65..2972bbf6 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -71,13 +71,13 @@ - 验证:`CreationAgentWorkspace` 测试应断言进度标题、百分比和提示文本带专属 class;`src/index.test.ts` 应断言这些 class 在 remap surface 内有白色覆盖规则;移动端截图中暗色卡片文字应保持可读。 - 关联:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/components/creation-agent/CreationAgentWorkspace.test.tsx`、`src/index.css`、`src/index.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## VectorEngine 图片生成 SendRequest 超时要按传输失败排查 +## VectorEngine 图片生成 request_send 传输错误要按可重试网络抖动排查 -- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`timeout=true`、`statusCode=null`,`errorSource` 可能是 `client error (SendRequest)` 或更完整的 reqwest 底层错误链,前端只知道图片生成失败。 -- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。 -- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout=true` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。当前 `platform-image` 对 `request_send` 的 `timeout` / `connect` 错误最多重试 3 次,multipart `/v1/images/edits` 每次重试都必须重建 form;看到 `VectorEngine 图片请求发送失败,准备重试` 只是单次 attempt 失败,最终 `external_api_call_failure` 才代表该用户请求整体失败。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。 +- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`statusCode=null`,`errorSource` 可能是 `client error (SendRequest)`、`[35] SSL connect error (Recv failure: Connection reset by peer)`、`[56] Failure when receiving data from the peer (... unexpected eof while reading ...)`;也可能看到 `failureStage=upstream_status`、`statusCode=502`、错误体是 Nginx HTML `502 Bad Gateway`。前端只知道图片生成失败。 +- 原因:`request_send` 表示请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体;`upstream_status=502/5xx/429/408` 表示拿到了上游错误响应但仍属于可重试的过载 / 网关抖动。`timeout=true` 来自超时判定,`connect=true` 会同时覆盖 DNS / connect 失败以及 libcurl 35 SSL 握手、libcurl 56 收包提前 EOF、connection reset 这类临时传输错误。 +- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout/connect=true` 或 `upstream_status + statusCode=408/429/5xx` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。当前 `platform-image` 对 request_send 的 timeout / connect / SSL connect reset / recv error / unexpected eof / send error,以及 upstream_status 的 408 / 429 / 5xx 最多发送 5 次,multipart `/v1/images/edits` 每次重试都会重新构造 form;看到 `VectorEngine 图片请求发送失败,准备重试` 或 `VectorEngine 图片上游状态可重试,准备重试` 只是单次 attempt 失败,最终 `external_api_call_failure` 才代表该用户请求整体失败。若记录有 `429 moderation_blocked` 或明确审核错误,按审核失败另行处理,不要归到网络抖动。 - 拼图关卡资产生成按 `level_scene -> ui_spritesheet -> level_background` 顺序执行,每个资产会输出 `slot`、`asset_kind`、`elapsed_ms`;排查拼图草稿失败时优先看同一 request_id 下最后一个失败 slot。 -- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。 +- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_send_retry_policy -- --nocapture`、`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。 - 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态 @@ -457,6 +457,14 @@ - 验证:未登录推荐页可以直接进入跳一跳运行态,且 `work_play_start` 事件仍会落库或出现在 outbox 中,metadata 含匿名标记。 - 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/api-server/src/auth.rs`、`server-rs/crates/api-server/src/work_play_tracking.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。 +## 跳一跳直接打开空 runtime 路由不能停在加载态 + +- 现象:直接访问 `/runtime/jump-hop` 时页面看起来一直停在“正在载入游戏 / 正在加载内容”,DOM 内部只有空的跳一跳运行态,没有平台、地块或 run 数据。 +- 原因:`appPageRoutes` 会把该路径解析为 `jump-hop-runtime`,但裸路径没有 `work=JH-*` 公开作品码,也没有从详情页启动后写入的 `jumpHopRun`,平台壳仍挂载 `JumpHopRuntimeShell`。 +- 处理:平台壳在 `jump-hop-runtime` 且缺少 run 时先看 `work` 参数;有 `JH-*` 则通过公开 gallery detail 回读 profile 并启动 published run,没有则回到平台首页。全局作品码恢复 effect 在跳一跳 runtime 阶段要跳过,避免和运行态恢复互相抢路由。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop runtime route"`;浏览器 smoke 分别打开 `/`、`/runtime/jump-hop` 和 `/runtime/jump-hop?work=JH-*`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/routing/appPageRoutes.ts`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 + ## release tracking outbox 权限错误先查 env 缺失 - 现象:release 机器 `journalctl -u genarrative-api.service` 每秒刷 `tracking outbox 定时封存 active 文件失败 error=Permission denied (os error 13)` 和 `tracking outbox 批量写入 SpacetimeDB 失败`。 @@ -1740,18 +1748,18 @@ - 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。 - 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 跳一跳地块图集固定走 5x5 地块池 +## 跳一跳地块图集固定走 18 个 UV 大单元 - 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者生成完成后只有 atlas 预览路径,地块切片没有真正落盘。 -- 原因:旧模板先后尝试过通用系列素材 helper 和 `2x3` 六格固定 tileType,但当前跳一跳已经重设计为“主题 -> 5x5 地块图集 -> 25 个等权地块池 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。 -- 处理:跳一跳地块固定生成一张 `5x5` 主题图集,后端按均匀网格切出 25 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind;不要再恢复 `2行*3列`、`start / normal / target / finish / bonus / accent` 六格口径。 -- 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 25 个独立 `JumpHopTileAsset`,运行态无限路径从地块池随机取材。 +- 原因:旧模板先后尝试过通用系列素材 helper、`2x3` 六格固定 tileType 和 `5x5` 单贴图池,但当前跳一跳已经重设计为“主题 -> 一张 `1024x1536` 图集 -> 18 个 `3列*6行` UV 大单元 -> 每格 `4列*3行` 六面贴图 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。 +- 处理:跳一跳地块固定只生成一张 `1024x1536` 主题 UV 展开图集,后端先切出 18 个大单元,再从每格固定 UV 网切出 top/front/right/back/left/bottom 六张 `256x256` 不透明 PNG,并对 108 张面贴图各自走 OSS 上传、asset_object 确认和 entity bind;不要再恢复 `2行*3列`、`5x5` 单贴图、`start / normal / target / finish / bonus / accent` 六格口径。 +- 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 18 个独立 `JumpHopTileAsset` 且每个新资产包含 `faceAssets` 六面贴图,运行态无限路径从地块池随机取材;旧资产没有 `faceAssets` 时仍能用 `imageSrc` 单贴图 fallback。 - 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 ## 跳一跳宝可梦主题地块图集 safety rejection 只做专项改写 - 现象:跳一跳草稿使用“宝可梦 / Pokemon / 皮卡丘 / 精灵球”等主题时,背景底图和返回按钮可能已生成成功,但地块图集的 VectorEngine 请求返回 `Your request was rejected by the safety system`,日志里 `failure_context="跳一跳地块图集生成失败"`、`status=429`、`code="invalid_prompt"`。 -- 原因:25 个落点图集 prompt 会把这些词放进“主题物体图集”语境,容易被上游理解为要求生成具体宝可梦角色或标志道具,触发安全拦截;这不是普通平台造型词、抠图或超时问题。 +- 原因:18 个立方体主题物体 UV 展开图集 prompt 会把这些词放进“主题物体图集”语境,容易被上游理解为要求生成具体宝可梦角色或标志道具,触发安全拦截;这不是普通平台造型词、抠图或超时问题。 - 处理:仅在跳一跳图片生成 prompt 文本命中宝可梦相关词时做生成侧替换,把 `宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon` 改为“原创幻想萌宠冒险道具”,把 `精灵球` 改为“彩色冒险能量球”,把 `皮卡丘 / Pikachu` 改为“黄色闪电萌宠符号”;不要把所有主题都加全局 IP 禁止约束,用户草稿标题和主题展示也不改。 - 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml` 应覆盖宝可梦词专项替换;真实联调时同一草稿重试后,地块图集请求的 prompt 不再包含宝可梦相关词。 - 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -1759,9 +1767,9 @@ ## 跳一跳地块切片不要按 tileType 复用资产槽位 - 现象:跳一跳生成完成后,运行态看起来仍像在显示默认几何地块,或者地块图片在加载时频闪;结果页地块池也可能只看到少量重复素材。 -- 原因:`tileType` 只是路径平台的玩法类型标签,25 个 atlas 切片里会重复出现 `normal / target / bonus / accent` 等类型。若后端持久化时用 `tileType` 生成 slot/path,同类型切片会写入同一个 `/generated-jump-hop-assets///image.png`,后上传的切片覆盖先上传的切片,前端换签缓存也会读到重复或旧对象。 -- 处理:后端切图后必须按 atlas 单元格写入 `tile-01` 到 `tile-25` 的唯一 slot/path;前端结果页和运行态展示生成图时用 `assetObjectId` 作为 `refreshKey`,避免重生成后复用旧签名或旧图片缓存。 -- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 应包含 `jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices`;前端运行态测试应断言地块换签带 `assetObjectId` 刷新键。 +- 原因:`tileType` 只是路径平台的玩法类型标签,18 个 atlas 大单元里会重复出现 `normal / target / bonus / accent` 等类型。若后端持久化时用 `tileType` 生成 slot/path,同类型切片会写入同一个 `/generated-jump-hop-assets///image.png`,后上传的切片覆盖先上传的切片,前端换签缓存也会读到重复或旧对象。 +- 处理:后端切图后必须按 atlas 单元格写入 `tile-01` 到 `tile-18` 的唯一 tile slot,并把六面贴图写入 `tile-XX-top/front/right/back/left/bottom` 唯一 face slot;前端结果页和运行态展示生成图时用 `assetObjectId` 作为 `refreshKey`,避免重生成后复用旧签名或旧图片缓存。 +- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 应包含 `jump_hop_tile_asset_slots_are_unique_for_eighteen_slices`;前端运行态测试应断言地块换签带 `assetObjectId` 刷新键,并覆盖新 UV 资产会解析六张面贴图。 - 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`。 ## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影 @@ -1772,12 +1780,12 @@ - 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。 - 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`。 -## 跳一跳落点辅助和后端裁决必须统一坐标换算 +## 跳一跳长按蓄力不能再消费拖拽方向 -- 现象:落点辅助标识已经压在目标地块图片上,松手后后端仍判定失败,玩家看到的是“明明瞄准了却没落上去”。 -- 原因:前端辅助标识使用屏幕像素坐标绘制,而后端裁决使用世界坐标。屏幕 y 轴向下为正、世界 y 轴向上为正;同时屏幕 x/y 每个世界单位对应的像素比例不同。若前端直接把屏幕像素拖拽向量发给后端,辅助点和后端落点方向会不一致。 -- 处理:前端运行态保留原始屏幕拖拽向量用于画弹弓和辅助点,但提交后端前必须按当前地块到目标地块的屏幕跨度 / 世界跨度把 x、y 分别换算成世界尺度一致的向量;后端继续只负责反向弹射和落点裁决。 -- 验证:前端回归测试要同时覆盖辅助点完整拖拽到目标地块,以及提交给后端的向量已完成世界尺度换算;后端领域测试覆盖屏幕向后下拉时应向世界 y 正方向跳出并命中。 +- 现象:跳一跳改成长按蓄力后,如果前端或后端仍消费 `dragVectorX/dragVectorY`,玩家手指轻微移动就会改变跳跃方向,和“始终朝下一块中心跳”的体验不一致。 +- 原因:历史弹弓拖拽版本把屏幕拖拽方向作为正式裁决输入,契约字段仍为兼容旧客户端保留,容易被误认为仍是当前玩法规则。 +- 处理:前端运行态只用长按时长提交 `dragDistance` 兼容字段,不再发送方向字段;落点预测按当前地块中心到下一块地块中心的方向投影。后端 `module-jump-hop` 即使收到旧客户端 `dragVectorX/dragVectorY` 也必须忽略,只按当前地块到下一块地块中心的单位向量裁决。 +- 验证:前端回归测试覆盖手指移动不改变提交方向、预测落点忽略旧方向字段;后端领域测试覆盖旧客户端传错误方向时仍按下一块中心命中。 - 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`server-rs/crates/module-jump-hop/src/application.rs`。 ## 跳一跳创作入口旧文案先查 SpacetimeDB 配置 @@ -2055,24 +2063,32 @@ - 现象:跳一跳松手后如果后端很快返回下一帧 run,地块窗口会立刻前移,角色翻腾动画看起来像没播放;若同时刷新图片资产,还可能被误认为地块频闪。 - 原因:后端 run 是规则真相,前端 runtime 又需要低延迟表现。如果 DOM 平台层直接用最新 `run.currentPlatformIndex` 渲染,后端回包会抢在动画前完成视觉切换。 -- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前窗口内插值飞向目标地块;约 `300ms` 后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。推进期间地块 DOM 层和 Three.js 角色层必须统一包在同一个 camera layer 下移动,旧当前地块用相机偏移自然离开视野,新预览地块从上方露出;不要再让 p1/p2 各自 top/left 过渡。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,从旧目标地块位置斜向滑到新当前地块聚焦位置,避免先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。相机推进期间角色自身也不能保留 `left/top` transition,否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回;角色推进期只允许 transform / opacity transition。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。 -- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,动画结束后进入 `data-platform-advancing=true`,Three 角色层与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态角色 transition 不包含 `left/top`。 +- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前显示窗口内飞向前端预测真实落点;视觉预测必须用当前显示窗口的 current/next 地块作为方向来源,不能拿已经提前返回的后端新 run 目标配旧窗口角色,否则下一跳会朝实际目标反方向飞。飞行动画完成后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。成功后的角色显示必须使用 `lastJump.landedX/landedY` 映射出的真实偏移,不要吸附到目标地块中心。推进期间地块 DOM 层和 DOM 角色层必须统一包在同一个 camera layer 下移动,旧当前地块先跟随相机偏移离开主视野,之后只保留在屏幕后方;不要给旧地块加独立向上 / 向下飞走 keyframes,也不要因为旧地块还在保留列表里阻塞下一跳。玩家继续向前跳时,已完成旧地块继续被新的相机推进自然带离屏幕,超过离屏阈值后销毁。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,并以旧窗口真实落点和新窗口真实落点为锚点,避免先横向瞬切居中再纵向推进;运行态相机层当前为约 `1.3x` 近距缩放。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。相机推进期间角色自身也不能保留 `left/top` transition,否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回;角色推进期只允许 transform / opacity transition。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。 +- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,成功落地保留真实落点偏移,动画结束后进入 `data-platform-advancing=true`,DOM 角色层与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态角色 transition 不包含 `left/top`、旧地块没有独立 `jump-hop-platform-exit-drift` keyframes 且下一跳不会被旧地块保留态阻塞。 - 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`server-rs/crates/module-jump-hop/src/application.rs`。 ## 跳一跳相机推进不要让地块图片回退到原型方块 - 现象:角色落到下一块后,相机推进时旧地块图片突然消失,或新预览地块先露出浅色原型方块,随后真实 image2 切片才出现。 -- 原因:旧地块进入 exiting 状态时如果 React key 从 `platformId` 变成 `platformId-exiting`,图片组件会重新挂载并丢失已加载状态;同时 `JumpHopTileImage` 曾在真实图片 URL 已存在但 `onLoad` 尚未触发时显示 fallback 原型地块。 -- 处理:exiting 地块继续使用稳定 `platformId` key,让旧图片组件在推进期复用;有真实 `resolvedUrl` 且未错误时直接保留真实 ``,只在无 URL 或加载失败时显示 fallback;当前 3 块之外的后续地块通过隐藏预加载图片提前解析签名 URL 和浏览器缓存。 +- 原因:旧地块进入 exiting 状态时如果 React key 从 `platformId` 变成 `platformId-exiting`,图片组件会重新挂载并丢失已加载状态;同时 `JumpHopTileImage` 曾在真实图片 URL 已存在但 `onLoad` 尚未触发时显示 fallback 原型地块。Three.js 平台层接入后,如果隐藏预加载只让浏览器缓存 ``,但没有把未来 `platformId` 的纹理 URL 写入 `platformTextureUrlsByRenderKey`,相机推进时新预览地块会短暂缺 Three 贴图;若旧 blob 贴图在空 URL 回调时先被 revoke,再继续保留在 state 中,也会留下一个看似 ready、实际已失效的贴图地址。 +- 处理:exiting 地块继续使用稳定 `platformId` key,让旧图片组件在推进期复用;有真实 `resolvedUrl` 且未错误时直接保留真实 ``,只在无 URL 或加载失败时显示 fallback;当前 3 块之外的后续地块通过隐藏预加载图片提前解析签名 URL 和浏览器缓存,并同步按未来 `platformId` 发布 Three 纹理 URL。Three 平台层在当前 render items 全部有贴图 URL 后继续承接包含 exiting 地块在内的 3D 渲染;退出地块只随相机推进自然离屏,不播放独立飞走动画,避免退出期露出被放大的平面贴图或重复飞多次;贴图 URL 替换必须等新 URL 到达后再释放旧 parent-owned blob,空 URL 回调不得清空或 revoke 仍在活跃 / 预加载 key 上的旧贴图。 - 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖真实 tile URL 不露出 `.jump-hop-runtime__fallback-tile`,并存在 `jump-hop-tile-preload-image`。 - 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。 -## 跳一跳地块抠图不要用绿幕或近白底识别 +## 跳一跳 Three.js 平台层不能左右镜像 DOM 坐标 -- 现象:跳一跳生成草地、花、雪地、白石或云朵地块时,透明化会把绿色 / 白色主体局部扣掉,运行态看到平台缺口、变薄或主体消失。 -- 原因:通用图集默认按绿幕和近白底做透明化,适合 UI / 普通物品,但跳一跳地块天然高频包含绿色和白色;如果继续用 `#00FF00` 绿幕或近白背景识别,素材本体会落入背景分数。旧逻辑还会清理非边缘连通的高置信 key 色块,遇到主体内部撞色时也可能误伤。 -- 处理:跳一跳地块图集 prompt 固定要求单一纯洋红 `#FF00FF` key 背景;切片前后透明化调用 `GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen()`,只扣洋红 key,关闭近白扣除,并且不清理非边缘连通 key 色像素。通用绿幕函数保持默认绿幕 / 近白兼容,避免影响拼图、抓大鹅和敲木鱼。 -- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet -- --nocapture` 覆盖洋红 key 保留绿色、白色和非边缘连通 key 色主体;`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳洋红 prompt 与绿 / 白地块切片。 +- 现象:视觉上下一块地块在角色右侧,但蓄力引导和角色飞行动画朝左侧;后端回包后地块窗口又闪现摆回正确位置,像是先按反方向飞、再由快照刷新纠正。 +- 原因:Three.js 平台层如果把相机 `up` 设置成反向,或在 Three 容器上做左右镜像,会让 WebGL 地块的屏幕 X 轴和 DOM 角色 / 落点预测的屏幕 X 轴相反。规则层仍沿当前地块中心到下一块中心裁决,所以后端快照会把状态纠正回来,表现为跳后刷新。 +- 处理:Three 相机保持 `up=(0, 1, 0)`,再用内部投影公式抵消 45° 下压导致的 Y 轴压缩;不要通过反向 `camera.up` 解决上下方向。DOM 角色、蓄力引导、落点预测和 Three 平台层必须共用同向屏幕坐标。 +- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖 `JUMP_HOP_THREE_CAMERA_UP_Y=1`,并断言 Three 投影与 DOM 屏幕坐标同向。 +- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。 + +## 跳一跳立方体贴图不要走透明主体切片 + +- 现象:水果等主题生成成功后,运行态地块看起来像薄的纯水果 PNG、果切贴纸、透明 cutout;或者反过来六个面都是同一张平铺果皮 / 果肉材质,无法组合成方块苹果 / 方块香蕉这类完整主题对象表达。 +- 原因:跳一跳地板已经改为 Three.js 标准 `1x1x1` 等比极小倒角立方体复用几何体,运行态视角固定为近距相机和 45° 下压视角;image2 应生成 `1024x1536` 的 18 个 cube object UV unwrap,每个大单元内的 top/front/right/back/left/bottom 六面要共同包装同一个主题物体。只强调 full-bleed 容易让水果主题退化成果皮、果肉、叶脉等表面纹理;如果仍把一张图贴给六个面,模型也不需要理解正反和跨面连续特征。旧切图链路若把洋红 key 转 alpha、裁边、只保留最大 alpha 连通主体并补透明安全边,会把整格贴图重新抠成苹果 / 香蕉 / 果切等居中主体,贴到立方体上后四角和侧面都变透明。 +- 处理:跳一跳地板图集 prompt 固定要求 `cube object UV unwrap atlas / 立方体主题物体六面展开图集`,一张图只生成 18 个大单元,每个大单元固定 `4列*3行` UV 网:第 1 行第 2 列 top,第 2 行 left/front/right/back,第 3 行第 2 列 bottom;水果主题要明确生成能一眼说出名称的方块苹果、方块香蕉、方块橙子、方块西瓜等可识别对象,并要求果柄叶片、剥皮条带、放射切面、红瓤黑籽等身份特征跨面连续。禁止自然圆形水果、自然长条香蕉、非方块化完整水果、果切小贴纸、居中小物体、透明背景和留白,同时也禁止“单纯平铺材质 / 抽象纹理 / 只铺主题颜色 / 纯果皮材质 / 纯果肉纹理 / 纯叶脉纹理”。后端按 3x6 大单元和 4x3 UV 网切出 108 张 `256x256` 不透明面贴图,不再调用透明化、最大 alpha 连通主体保留或透明补边。洋红 `#FF00FF` 只作为图集安全缝 / UV 空位 / 外圈 key 色,裁切后若仍有极少残留则转成不透明材质底色;绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色必须完整保留。 +- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳 UV unwrap prompt、18 个大单元、108 张不透明面贴图、绿色 / 白色材质不被透明化、洋红 key 残留不作为透明洞;前端 `JumpHopRuntimeShell` 测试覆盖新 UV 资产会解析六张面贴图,旧单贴图资产仍可 fallback。 - 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/jump_hop.rs`。 ## 含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码 @@ -2138,3 +2154,12 @@ - 处理:`api-server` 构造生成结果订阅消息时,`time4` 固定格式化为北京时间 `YYYY-MM-DD HH:mm`;不要复用 `shared_kernel::format_timestamp_micros`。 - 验证:`cargo test --manifest-path server-rs\Cargo.toml -p api-server generation_result_template -- --nocapture`;dev 日志中不应再出现 `data.time4.value invalid`。 - 关联:`server-rs/crates/api-server/src/wechat_subscribe_message.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 待解决:跳一跳生成超时后可能后台继续成功 + +- 风险程度:高。 +- 现象:跳一跳生成页可能在 `98% 写入正式草稿` 后报“请求超时,请稍后重试”,但后端仍在继续生成,稍后才把同一 session 写成 `DraftCompiled=100`。2026-06-08 排查 `jump-hop-session-6db8fa7af57c4fa2a71e6430cc808412` 时,背景底图 image2 成功但耗时约 `18分25秒`,返回按钮约 `2分44秒`,地板图集约 `1分46秒`,总耗时超过前端 20 分钟等待窗口,最终在前端超时后约 3 分钟写草稿成功。 +- 原因:跳一跳创作链路仍把背景、返回按钮、地板图集、切片和 OSS 写入串在一次 HTTP 请求里;VectorEngine image2 单步 timeout/connect 失败会在后端重试,单步耗时可能超过前端总等待窗口。中间资产和真实阶段没有落库,session 在完成前仍显示 `Collecting`、`progress_percent=0`,前端只能按时间显示假进度;超时后重试同一 session 时,后端还可能因为 session 没有中间素材而重新从背景开始生成。 +- 待处理:将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,按背景、返回按钮、图集、切片、持久化、写草稿分阶段落库;统一后端全局生成 deadline、VectorEngine 重试预算、前端等待窗口和失败态回写。超时后再次进入同一 session 应优先恢复正在运行或已完成的任务,不应重复生图。 +- 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。 +- 关联:`src/services/jump-hop/jumpHopClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/deploy/systemd/genarrative-database-backup.service b/deploy/systemd/genarrative-database-backup.service index cde294e2..a19481b6 100644 --- a/deploy/systemd/genarrative-database-backup.service +++ b/deploy/systemd/genarrative-database-backup.service @@ -9,10 +9,9 @@ User=root Group=root WorkingDirectory=/opt/genarrative/current EnvironmentFile=/etc/genarrative/api-server.env -ExecStart=/usr/bin/node /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service +ExecStart=/usr/bin/node /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service # 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。 PrivateTmp=true ProtectSystem=full ReadWritePaths=/stdb /var/lib/genarrative - diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index 07751c9e..34f39319 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -2,15 +2,15 @@ ## 1. 目标 -`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `5x5` 地块资源图集,切成 25 个 2D 地块素材;运行态使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色,并和这些 2D 地块资产组成无限平台流。 +`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块再按固定 `4列*3行` UV 网切成 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。 首版目标: 1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生; -2. image2 只生成一张 `5x5` 地块图集,后端均匀切成 25 张 PNG; +2. image2 只生成一张 `1024x1536` 地板 UV 展开图集,后端切成 18 组、共 108 张面贴图 PNG; 3. 角色不再单独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG; 4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块; -5. 操作方式为按住屏幕向后拖动蓄力,松手后角色向拖拽反方向弹出; +5. 操作方式为长按屏幕蓄力并按拖拽方向起跳,松手后角色按前端提交的后端方向向量弹出; 6. 只要落点未命中下一个地块,本局立即失败并冻结计时; 7. 成绩记录成功跳跃次数和游戏时长; 8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。 @@ -21,10 +21,10 @@ - 展示名:`跳一跳` - 工程域:`jump-hop` - 创作入口卡:`subtitle = 主题驱动平台跳跃`,`imageSrc = /creation-type-references/jump-hop.webp` -- 运行态:`DOM 平台 / DOM 角色 + Three.js 透明扩展层 + DOM HUD` +- 运行态:`Three.js 标准 1x1x1 等比极小倒角立方体地板 + DOM 角色 + DOM HUD` - 画面比例:移动端竖屏优先,桌面端居中承载 `9:16` -- 素材策略:2D 地块图集 + 陶泥儿 logo 透明角色 -- 渲染分层:生成地块切片必须由 DOM 平台层直接渲染为图片;角色必须由 DOM 透明 PNG 层渲染并保持最高层级,Three.js 透明画布只作为后续扩展层,不能把地块图片或角色回退为 WebGL 占位材质 +- 素材策略:18 个立方体主题物体 UV 展开包装 + Three.js 复用标准 1x1x1 等比立方体几何 + 陶泥儿 logo 透明角色 +- 渲染分层:Three.js 平台层复用一份标准 `1x1x1` 等比极小倒角立方体几何体,`tileAssets[]` 切片只作为主题身份方块包装贴图;单块立方体必须正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,也不得用不同 x/y/z scale 压成扁盒子;运行态视角采用约 `1.3x` 近距相机和 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,后续两块向上展开且保持紧凑的纵向 / 横向间距;Three.js 平台层与 DOM 角色层必须保持屏幕 X 轴同向,禁止通过反向相机 `up` 或镜像容器把平台左右翻转;DOM 地块图片层只用于换签、预加载、WebGL 不可用和测试 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,退出地块只随相机推进自然离屏,不播放独立飞走动画,超过屏幕后再销毁,避免旧地块退出期露出被放大的平面 DOM 贴图;角色必须由 DOM 透明 PNG 层渲染并保持在 Three.js 平台层之上 本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块在当前地块上方不同 x 轴位置随机出现。 @@ -35,12 +35,12 @@ - 单图资产槽位:无独立角色图槽位;v1 固定使用陶泥儿 logo 透明 PNG 角色 - 系列素材槽位: - `batchId = jump-hop-tile-atlas` - - `sheetSpec = 5x5 / 1:1 / PNG / 纯绿色绿幕背景 / 后端切图透明化` - - `slotSpecs = tile-01 ... tile-25`,每个 slot 必须对应唯一 OSS path / `assetObjectId` - - 切图规则:按原图宽高均分为 5 行 5 列,从上到下、从左到右切出 25 张 PNG;每格透明化后只保留最大的 alpha 连通主体,再裁边并补透明安全边,避免相邻格越界碎片或方形杂边进入 tile - - 透明化规则:生成时要求绿幕背景,后端上传 OSS 前抠成透明 PNG,并清理与主体分离的小型残片 + - `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格4列*3行UV网 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG` + - `slotSpecs = tile-01 ... tile-18`,每个 tile 再包含 `top/front/right/back/left/bottom` 六个面 slot,所有 slot 必须对应唯一 OSS path / `assetObjectId` + - 切图规则:先按原图宽高均分为 3 列 6 行,从上到下、从左到右得到 18 个大单元;每个大单元内部固定 4 列 3 行 UV 网,`top` 在第 1 行第 2 列,`left/front/right/back` 在第 2 行第 1-4 列,`bottom` 在第 3 行第 2 列;每个面输出 `256x256` 不透明 PNG + - 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位,后端不做透明化抠图,只把裁切后残留的洋红 key 色转为不透明材质底色,保留绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题纹理 - 失败回写:生成失败时 session 保持 failed,可从生成页重试 - - 局部重生成:结果页允许重生成地块图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存 + - 局部重生成:结果页允许重生成地板贴图图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存 - API 命名空间:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*` - 业务真相:后端裁决落点、失败、成功跳跃次数、冻结时长和排行榜 - 创作工具模式例外:无 @@ -55,33 +55,35 @@ 1. 作品标题:主题为空白修剪后的短标题,默认前缀不外露; 2. 作品简介:基于主题生成一句短简介; 3. 标签:`跳一跳`、`休闲` 和主题关键词; -4. 地块提示词:围绕主题生成 25 个风格一致的俯视角清爽游戏化 2D 平台素材,每一块都是符合主题的单独可跳跃平台;实际 image2 prompt 使用“独立可落脚平台素材 / 平台裸素材 / 完整平台”措辞,不再把正向主体描述成图标集或游戏界面资源; +4. 地板贴图提示词:围绕主题生成 18 个风格一致的立方体主题物体 UV 展开包装,每个包装由 top/front/right/back/left/bottom 六面组成,供 Three.js 标准 1x1x1 等比极小倒角立方体地板复用;实际 image2 prompt 使用“立方体主题物体 UV 展开包装图集 / cube object UV unwrap atlas”措辞,要求六面共同表达同一个完整方块化主题物体,例如水果主题要生成可一眼辨认的方块苹果、方块香蕉、方块橙子、方块西瓜等,而不是单纯生成平铺材质、抽象纹理、平台、跳台、地块成品、单张图重复六面或游戏界面资源; 5. 初始平台流参数:固定 v1 标准参数,不让创作者手工调规则。 -## 5. 地块图集 +## 5. 地板贴图图集 -image2 只生成一张 `1:1` 图片,画面为 `5x5` 均匀分布平台裸素材;实际提示词必须先约束“画面只包含 25 个独立跳一跳可落脚平台素材”,并明确不是游戏界面、棋盘、背包、装备栏或图标集页面。 +image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体,并在固定 `4列*3行` UV 网中提供六张面贴图;不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。 图集要求: -1. 每格只放一个完整地块资源; -2. 资源为纯 2D 平面素材,但要表现为符合主题且有设计感的俯视角清爽游戏化立体感平台,有顶面、主体内部明暗和清晰轮廓;主题元素必须直接成为平台主体,例如“水果”应生成苹果切片、橙子切片、西瓜块、草莓、菠萝、香蕉等水果造型平台; -3. 25 个地块来自同一主题、同一光向和同一材质体系; -4. 背景为纯绿色绿幕,方便后端透明化; -5. 不包含角色、文字、水印、UI、游戏面板、棋盘、背包、装备栏、按钮、标题、外层边框、网格线、场景背景、落地投影、接触阴影、方形阴影、方形底板、白底、灰底或黑底; -6. 地块不能跨格、贴边或进入相邻格,主体必须居中并保留至少 18% 纯绿色安全留白;每个平台之间只能是纯绿色空白,不画容器框或棋盘格。 +1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`; +2. 每个面都是 full-bleed 不透明正方形贴图,四角、边缘和中心都要有可识别内容;六个面共同组成同一个完整方块化主题物体,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标; +3. 贴图不生成已经渲染好的透视 3D 块体成品,不包含摄像机角度、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或烘焙高光;真实倒角、侧壁、透视和阴影由运行态 Three.js 生成; +4. 18 个方块来自同一主题、同一哑光手绘包装体系,但应表达不同方块化主题物体或明显不同的包装识别特征;水果主题要混排方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴,不要 18 个方块都只是同一种果皮、果肉或叶脉纹理; +5. 大单元之间、UV 空位、六面之间和画布外圈为纯洋红 `#FF00FF`,方便后端安全切图; +6. 不包含角色、文字、水印、UI、游戏面板、棋盘、背包、装备栏、按钮、标题、外层边框、可见网格线、场景背景、落地投影、接触阴影、方形阴影、方形底板、白底、灰底或黑底; +7. 贴图不能跨格、贴边串色或进入相邻格;每个面贴图应尽量铺满自己的 UV 面,纯洋红只作为安全缝、UV 空位和外圈 key 色。 -切片顺序固定为: +大单元切片顺序固定为: ```text -tile-01 tile-02 tile-03 tile-04 tile-05 -tile-06 tile-07 tile-08 tile-09 tile-10 -tile-11 tile-12 tile-13 tile-14 tile-15 -tile-16 tile-17 tile-18 tile-19 tile-20 -tile-21 tile-22 tile-23 tile-24 tile-25 +tile-01 tile-02 tile-03 +tile-04 tile-05 tile-06 +tile-07 tile-08 tile-09 +tile-10 tile-11 tile-12 +tile-13 tile-14 tile-15 +tile-16 tile-17 tile-18 ``` -运行态随机使用这 25 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。 +每个 `tile-XX` 再切出 `top/front/right/back/left/bottom` 六个面贴图并写入 `tileAssets[].faceAssets`。历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 保存 top 面,旧作品没有 `faceAssets` 时运行态仍可把单张旧贴图应用到立方体所有面。运行态随机使用这 18 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。 ## 6. 运行态规则 @@ -97,23 +99,24 @@ tile-21 tile-22 tile-23 tile-24 tile-25 ### 6.2 操作 -1. 用户按住当前地块或画面; -2. 向后拖动形成蓄力向量; -3. 松手后角色沿拖拽反方向弹出; -4. 拖拽距离决定力度,拖拽方向决定落点方向; -5. 力度和方向都由前端提交给后端裁决。 +1. 用户按住当前地块或画面开始蓄力; +2. 长按时长形成蓄力值,达到 `maxChargeMs` 后封顶; +3. 松手后角色按本次输入方向弹出; +4. 蓄力值决定跳跃距离,拖拽方向决定跳跃方向; +5. 前端必须同时提交 `dragDistance` 与换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端以这两个方向字段裁决真实落点;旧客户端缺失方向或方向非法时,后端才 fallback 到当前地块中心指向下一块地块中心。 -手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.008`。该值表示同等世界跳跃距离只需要旧版 `0.004` 配置的一半屏幕拖动距离;旧作品运行时若仍携带 `0.004`,开局归一化为 `0.008`。 +手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.004`。该值表示蓄力时长到世界跳跃距离的换算系数;旧作品运行时若仍携带其它系数,开局归一化为 `0.004`。契约中的 `dragDistance` 语义是前端提交的蓄力值;`dragVectorX/dragVectorY` 是正式方向输入契约,不能在前端提交或后端裁决中丢弃。 -松手后前端必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画;角色从当前地块弹向预测落点,蓄力阶段角色应沿拖拽方向明显拉长,落地后再向反方向回弹两次。动画期间 DOM 地块窗口保持在本次起跳前的 3 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端返回的最新 run,并进入约 `1440ms` 的相机推进过渡。推进过渡中,地块 DOM 层和 DOM 角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块随相机推进自然离开视野,新预览地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块可以保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用同一 `1440ms` 缓动过渡;不得通过直接改宽高造成瞬切变大。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。 +松手后前端必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画;视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色从当前地块沿下一块地块中心方向弹向预测真实落点,蓄力阶段角色只做垂直压缩,不沿目标方向拉长。成功落地后必须保留 `lastJump.landedX/landedY` 对应的真实落点偏移,不得强制吸附回目标地块中心;落地后可以轻量回弹,但不能把角色位置拉离真实落点。动画期间 DOM 地块窗口保持在本次起跳前的 3 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应先使用后端真实落点对齐显示态,再进入约 `1440ms` 的相机推进过渡,避免角色先飞过很远再瞬间拉回地块。推进过渡中,地块 DOM 层和 DOM 角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块只随相机推进保留在屏幕后方,不单独执行飞走动画,玩家继续向前跳时再被新的相机推进自然带出屏幕并销毁,新预览地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块可以保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用同一 `1440ms` 缓动过渡;不得通过直接改宽高造成瞬切变大。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。 ### 6.3 判定 1. 目标永远是当前地块后的下一个地块; -2. 落点进入下一个地块落地半径,则成功; -3. 落点未进入下一个地块落地半径,则失败; -4. 失败后状态改为 `failed`,计时冻结; -5. v1 没有通关状态、combo、perfect 或生命数。 +2. 真实落点沿前端提交的 `dragVectorX/dragVectorY` 归一化方向计算;仅当方向缺失、非有限数或长度过小时,才沿当前地块中心到下一块地块中心方向兼容计算; +3. 落点进入下一个地块可见顶面 footprint,则成功;footprint 使用当前路径里该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%; +4. 落点未进入下一个地块可见顶面 footprint,则失败;旧 `landingRadius/perfectRadius` 字段仅保留兼容读写,不再作为当前 v1 成功判定; +5. 失败后状态改为 `failed`,计时冻结; +6. v1 没有通关状态、combo、perfect 或生命数。 ### 6.4 计分与时间 @@ -149,7 +152,7 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc 结果页展示: 1. 陶泥儿 logo 透明角色预览; -2. 25 个地块资源池预览; +2. 18 个地块资源池预览; 3. 首屏 3 块平台预览; 4. 试玩; 5. 发布; @@ -183,14 +186,14 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc ## 10. 验收 1. 创作页只显示主题输入; -2. 生成链路只调用一次地块图集 image2,不再调用角色生图; -3. 地块图集为 `5x5`,后端切出 25 个地块 PNG; +2. 生成链路只调用一次地板贴图图集 image2,不再调用角色生图; +3. 地板贴图图集为 `1024x1536 / 3列*6行 / 每格4列*3行UV网`,后端切出 18 组、共 108 张面贴图 PNG; 4. 结果页不依赖旧角色图片槽; 5. 运行态为竖屏俯视角,首屏保持 3 个地块可见; -6. 拖拽方向和力度会影响落点; +6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向; 7. 未落到下一个地块立即失败; 8. 成功跳跃次数累加,失败后计时冻结; 9. 排行榜按成功跳跃次数优先排序; 10. 作品可保存、发布、分享并从公开入口启动。 -11. 运行态地块必须显示 `tileAssets[]` 中的生成切片图片;拖拽蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台图片层或 DOM 角色层。 -12. 同等跳跃距离的拖动距离必须比旧 `0.004` 系数缩短一半,松手后必须先看到角色飞行动画,再看到地块窗口前移。 +11. 运行态 Three.js 地板必须优先把 `tileAssets[].faceAssets` 六面贴图按 right/left/top/bottom/front/back 材质顺序贴到标准 `1x1x1` 等比立方体上;旧作品没有 `faceAssets` 时才使用 `tileAssets[].imageSrc` 单贴图 fallback。六面贴图通过换签或 blob 异步解析时,Three.js 平台 mesh 的刷新签名必须包含 top/front/right/back/left/bottom 六个 texture URL,任一面 URL 变化都要触发材质重建,不能只监听旧单图 `imageSrc`。立方体正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,不得把 x/y/z 缩放成扁盒子;相机保持近距 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,可见三块地板之间的屏幕间距必须偏紧凑;长按蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台贴图预加载层或 DOM 角色层。 +12. 同等世界距离的蓄力换算必须使用 `0.004` 系数,松手后必须先看到角色飞行动画,再看到地块窗口前移;成功落地显示必须保留真实落点偏移。 diff --git a/docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md b/docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md index 6502fcdc..4a32ceac 100644 --- a/docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md +++ b/docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md @@ -194,7 +194,7 @@ cargo test -p api-server app --manifest-path server-rs/Cargo.toml 后续建议继续拆分: -- `match3d`: `draft.rs`、`background_and_cover.rs`、`material_sheet.rs`、`apimart_image.rs`。 +- `match3d`: `draft.rs`、`background_and_cover.rs`、`material_sheet.rs`。 - `puzzle`: `session_form.rs`、`draft_compile.rs`、`image_provider.rs`、`errors.rs`。 - `custom_world`: `publish_gate.rs`、`foundation_job.rs`、`foundation_assets.rs`、`errors.rs`。 - `square_hole`: `config.rs`、`errors.rs`。 diff --git a/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md b/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md index 6ace57c1..17597a32 100644 --- a/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md +++ b/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md @@ -215,7 +215,7 @@ Handler 主要在 `story.rs`、`combat.rs`、`runtime_inventory.rs`: | Square Hole 图片重生成 | OpenAI/VectorEngine GPT image helper | URL 下载或 base64/data URL 解码 | `LegacyAssetPrefix::SquareHoleAssets` | 方洞作品图片槽位相关 kind | profile/work + image slot | 调用方包裹 | 生成成功但入库失败保留 Data URL 回包 | | Custom World 场景/封面 | VectorEngine GPT image 2 / OpenAI helper | URL 下载或 base64 解码 | `LegacyAssetPrefix::CustomWorldScenes` 等 | scene/cover/opening storyboard | `custom_world_profile` 或 profile/landmark/scene slot | `custom_world_ai.rs` 调用方包裹 | entity/scene 生成存在 LLM fallback;资产持久化失败按当前错误口径返回 | | Puzzle 图片 | GPT image 2 generations/edits | 无参考图 JSON 创建;有参考图 multipart 编辑;base64/URL 结果归一 | `LegacyAssetPrefix::PuzzleAssets` | puzzle level/background/generated image,另有 `puzzle_background_music` | puzzle profile/run/level slot | `puzzle.rs` 调用方包裹 | connectivity 可按既有规则跳过部分计费;运行态 fallback 保持原逻辑 | -| Match3D 图片 | APIMart/VectorEngine/OpenAI image helper | 下载、切图、透明化、校准后入库 | `LegacyAssetPrefix::Match3DAssets` | cover/background/item material sheet,音频 kind 另列 | match3d profile/session slot | `match3d.rs` 调用方包裹 | 新草稿不回退 Rodin/GLB;部分连接错误按现有计费跳过规则处理 | +| Match3D 图片 | VectorEngine/OpenAI image helper | 下载、切图、透明化、校准后入库 | `LegacyAssetPrefix::Match3DAssets` | cover/background/item material sheet,音频 kind 另列 | match3d profile/session slot | `match3d.rs` 调用方包裹 | 新草稿不回退 Rodin/GLB;部分连接错误按现有计费跳过规则处理 | | Visual Novel 音频 | VectorEngine Suno/Vidu | 任务提交后按 task publish 下载音频 | 视觉小说/creation audio scope | `visual_novel_music`、`visual_novel_ambient_sound` | `visual_novel_scene` + scene id + `music`/`ambient_sound` | `vector_engine_audio_generation.rs` 调用方包裹 | 上游/下载失败显式错误,不混入图片 Adapter | | 通用音频 | VectorEngine Suno/Vidu | 同上 | creation audio scope | background_music/sound_effect 由调用方目标指定 | creation target entity/slot | 调用方包裹 | 不与 VN 场景语义混用 | | 视频 Opening CG | Ark/火山视频 + storyboard | 先生 storyboard,再图生视频,下载 remote video | Custom World 相关 prefix | `custom_world_opening_cg_storyboard`、`custom_world_opening_cg_video` | `custom_world_profile` + opening cg slots | `execute_billable_asset_operation_with_cost` 固定点数 | 配置缺失/超时显式错误,不应静默降级 | diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index aa61d3c8..aaf9ff20 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -22,7 +22,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` - HTTP 服务:`api-server`。 - 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。 -- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-speech`。 +- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-wechat`、`platform-speech`。 - 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。 - SpacetimeDB:`spacetime-client`、`spacetime-module`。 - 测试支撑:`tests-support`。 @@ -35,6 +35,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` 4. 后端访问 SpacetimeDB 必须经 `spacetime-client` facade。 5. HTTP 鉴权、BFF 聚合、SSE、外部模型编排、OSS 上传和第三方回调在 `api-server`。 6. 前端共享 DTO 通过 `shared-contracts` 和 `packages/shared` 对齐,不在页面内重新发明旧接口。 +7. 微信能力按两层收口:`server-rs/crates/platform-wechat` 承载微信协议 client、订阅消息 `stable_token` / `subscribeMessage.send`、微信支付 V3 / 虚拟支付消息推送的 HTTP header、签名、验签、解密、mock 响应和协议 payload 解析;`server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*` 承载 Axum handler、AppConfig 到平台配置的映射、Genarrative 用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。`platform-auth` 当前仍承载微信 OAuth / 小程序登录 provider 协议,`api-server::wechat::provider` 只作为组合根 adapter,不在业务 handler 内散落 provider 构造。 验证: @@ -55,10 +56,11 @@ npm run check:server-rs-ddd - 健康检查:`GET /healthz`。 - 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品可见性、兑换码、邀请码、任务配置和充值商品配置。 - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 -- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请、兑换、存档、历史浏览和游玩统计。 -- LLM 与语音:`/api/llm/*`、`/api/speech/volcengine/*`。 -- 资产:`/api/assets/*`,包括直传票据、STS、对象确认、实体绑定、读签名、读 bytes、历史资产、角色图像/动画和 Hyper3D 代理。 -- 创作入口配置:`/api/creation-entry/config`,后台 `/admin/api/creation-entry/config` 和 `/admin/api/creation-entry/config/banners`。 +- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 +- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 +- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-*`,负责直传、确认、绑定和读取。 +- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`。 +- 后台入口配置:`/admin/api/creation-entry/config` 和 `/admin/api/creation-entry/config/banners`。 - 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。 - 拼图:`/api/runtime/puzzle/*`。 - 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。 @@ -69,9 +71,20 @@ npm run check:server-rs-ddd - 跳一跳:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`。 - 汪汪声浪:`/api/runtime/bark-battle/*`。 - 儿童向创作:`/api/creation/edutainment/*`。 -- AI task:`/api/ai/tasks*`。 -需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。 +需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。涉及创作、生成、作品、公开详情、试玩、正式运行态、运行态库存、运行态设置 / 存档、游玩历史、存档归档、游玩统计、AI task、角色资产工坊或玩法生成支撑资产的路由,不再直接在 `app.rs` 逐玩法 `.merge(...)`,也不挂到 `modules/platform.rs`;必须先进入 `server-rs/crates/api-server/src/modules/play_flow.rs` 的统一玩法流程主干,再由主干注册表分发到各领域 HTTP Adapter 或支撑能力 handler。 + +### 创作 / 游玩统一流程主干 + +`modules/play_flow.rs` 是后端创作与游玩流程的统一入口。现有外部 URL、DTO、错误 envelope、鉴权方式、入口开关语义和 SpacetimeDB schema 默认不变,但路由组织必须遵循: + +1. `app.rs` 只合并 `modules::play_flow::router(state)`,不直接合并 RPG、拼图、抓大鹅、跳一跳、敲木鱼、拼消消、汪汪声浪、视觉小说或儿童向创作等逐玩法模块。 +2. `play_flow` 统一注册每个玩法的 `playId`、领域模块 key、创作路由前缀和运行态路由前缀;后续新增玩法或迁移旧玩法时,先补这个注册表,再挂具体领域模块路由。 +3. 新建创作、首次生成和 Remix 成草稿等会产生新创作的入口开关匹配规则同样归 `play_flow` 管理;`creation_entry_config.rs` 只复用该规则执行 `open=false` 熔断,不再维护第二份路径判断。 +4. `play_flow` 在进入领域 handler 前先解析并挂载 `PlayFlowRequestContext`,统一标记请求处于 `Creation`、`Runtime`、`CreationEntryConfig`、`CreationSupport`、`RuntimeSupport`、`AiTask`、`PublicReadModel` 或 `RuntimeInventory` 阶段,并记录目标 `playId` / 领域模块 key;领域 handler 可以读取该上下文做后续收口,但不能绕过主干自建平行流程。 +5. `play_flow` 只做平台共性编排和领域 Adapter 组合,不下沉玩法规则;最后一步的草稿编译、资产生成、发布、运行态 start/action/finish、计分和排行榜仍交给对应 `module-*`、`spacetime-module` procedure 和玩法 HTTP handler 处理。 +6. 公开作品聚合、作品详情、运行态库存、运行态设置 / 存档、游玩历史、存档归档、游玩统计、历史素材、AI task、runtime chat、文档解析、角色资产工坊、角色图像 / 动画生成和 Hyper3D 代理属于跨玩法或玩法支撑流程,也从 `play_flow` 主干挂入;`modules/platform.rs` 只保留通用 LLM / 语音代理,不再承接创作 / 游玩支撑路由。 +7. 如果某个旧玩法仍使用历史 `/api/runtime//agent/*` 作为创作命名空间,只保留外部兼容路径;新增实现和文档仍按“统一主干 -> 领域 Adapter”的语义描述,不把历史路径当新架构模板。 ### 认证态用户与会话摘要下发口径 @@ -86,8 +99,8 @@ npm run check:server-rs-ddd 路由模块化规则: -1. 每个能力 Module 只暴露 `router(state) -> Router`,由 `app.rs` 统一 `.merge(...)`。 -2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。 +1. 每个能力 Module 只暴露 `router(state) -> Router`;平台创作 / 游玩相关 Module 和支撑能力由 `modules/play_flow.rs` 统一 `.merge(...)` 或在支撑 router 内挂载,其它账号、资产基础、后台和平台基础能力再由 `app.rs` 直接合并。 +2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue;不得重新恢复逐玩法 creation/runtime merge 列表。 3. 能力 Module 可在路由内部用 `FromRef` 派生自己的 Feature State,例如 `PuzzleApiState`。全局 `AppState` 仍作为进程组合根、鉴权层和全局中间件状态,但业务 handler 优先只提取对应 Feature State,不直接暴露完整 `AppState`。 4. Feature State 只暴露该能力实际需要的 facade / adapter / 配置快照;若必须复用仍要求 `AppState` 的横切 helper(例如计费、外部失败审计或通用 tracking),应通过 Feature State 的窄方法或显式 `root_state()` 过渡,并在后续继续收窄。 5. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现,再收窄 handler 可见状态。 @@ -177,8 +190,8 @@ npm run check:server-rs-ddd ## 外部服务与资产 -- LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。 -- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 +- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 +- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 @@ -253,7 +266,7 @@ npm run check:server-rs-ddd - Rust 结构体:`AuthStoreSnapshot` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;运行中若 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中,会先从 SpacetimeDB 正式认证表按需刷新一次认证工作集再复查,避免多实例或滚动重启时新登录设备只被签发它的进程认识。`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。 +认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;运行中若 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中,会先从 SpacetimeDB 正式认证表按需刷新一次认证工作集再复查,避免多实例或滚动重启时新登录设备只被签发它的进程认识。`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 `auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/`、`phone/`、`session/`、`session_hash/`、`wechat/`、`union/`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 14db7a20..c45c23ec 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ -# 本地开发验证与生产运维 +# 本地开发验证与生产运维 -更新时间:`2026-06-05` +更新时间:`2026-06-09` ## 标准开发流程 @@ -55,7 +55,7 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 -微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED`、`WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID` 和 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;H5 在拼图 `compile_puzzle_draft` 生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在拼图资产生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。模板 `time4` 字段固定发送北京时间 `YYYY-MM-DD HH:mm`,不要使用内部微秒时间戳、秒级时间戳或带时区后缀的 RFC3339 字符串,否则微信会返回 `argument invalid! data.time4.value invalid`。 +微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED`、`WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID` 和 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;H5 在生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在玩法草稿生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。模板 `thing1` 字段发送玩法模板名,例如 `拼图`、`敲木鱼`、`抓大鹅`;`number6` 字段发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`。模板 `time4` 字段固定发送北京时间 `YYYY-MM-DD HH:mm`,不要使用内部微秒时间戳、秒级时间戳或带时区后缀的 RFC3339 字符串,否则微信会返回 `argument invalid! data.time4.value invalid`。当前已接入拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说的草稿生成终态;分槽素材生成或发布动作不得直接复用生成结果通知,避免一次作品生成产生多条订阅消息。 如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。 @@ -73,7 +73,7 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv 本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。 -VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout` 或 `connect` 错误时,`platform-image` 会对同一请求最多发送 3 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。 +VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会对同一请求最多发送 5 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 或 `VectorEngine 图片上游状态可重试,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504 / 502。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。 拼图入口直创的 `compile_puzzle_draft` 是长耗时链路:后端会先快速编译草稿并返回 `image_refining` / `generating` 快照,然后在 api-server 后台任务中完成首图、UI 资产、OSS 持久化、作品投影、计费退款和失败态回写。生产排查小程序 `Failed to fetch` 时,若 Nginx access log 里 action POST 是 `499`、`upstream_status=-`,说明客户端或 WebView 先断开;此时不应再把长 POST 是否返回作为生成成败依据,而应继续按实际 `session_id` 查后台任务日志、VectorEngine provider 日志、`external_api_call_failure` 和后续 GET 轮询结果。同一用户可能先轮询旧的 `puzzle-session-*`,随后 POST 新建实际生成 session;必须用 action POST 的 `request_id` 和 `/api/runtime/puzzle/agent/sessions//actions` 路径对齐真实失败请求,避免被前端显示的“来源草稿”误导。 @@ -214,10 +214,10 @@ UI 相关修改要重点验证: 数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为;生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份: ```bash -npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service +npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service ``` -脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node ... --upload-archive ` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 +脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service` 依赖 `spacetimedb.service`,生产定时冷备份还必须传入 `--restart-service-after genarrative-api.service`,确保备份后 API 随数据库一起恢复。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node ... --upload-archive ` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 生产环境变量模板在 `deploy/env/api-server.env.example`: @@ -246,18 +246,22 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 `Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts` 的 `buildPublicWorkCode` 与 `isSamePublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。 -`Genarrative-Web-Build` 会把 `build//web.tar.gz`、`web.tar.gz.sha256` 和 `release-manifest.json` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物,再执行 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。 +`Genarrative-Web-Build` 会把 `build//web.tar.gz`、`web.tar.gz.sha256`、`release-manifest.json` 和 `scripts/deploy/production-web-deploy.sh` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物和部署脚本,不再在目标机器 checkout Git,再执行随构建归档的 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。 + +`Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build//api-server`、`api-server.sha256`、`release-manifest.json`、`scripts/database-backup-to-oss.mjs`、`scripts/deploy/production-api-deploy.sh`、`scripts/deploy/maintenance-on.sh` 和 `scripts/deploy/maintenance-off.sh`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`Genarrative-Api-Deploy` 会从上游 API 构建产物复制部署脚本和备份脚本,不再在目标机器 checkout Git;如果 API 发布后 current release 中缺少该脚本,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build//scripts/database-backup-to-oss.mjs`,不要只在部署机工作区手工补文件。 + +`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build//spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh` 和 `scripts/database-backup-to-oss.mjs`。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制这些产物和发布脚本,不再在目标机器 checkout Git;如果 publish 前备份脚本缺失,应先检查 Stdb Build 的归档列表和 Stdb Publish 的复制过滤器。 `Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。 -生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。其它构建 / 发布流水线仍按各自 Jenkinsfile 的 checkout 口径执行;所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。 +生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。构建类流水线仍按各自 Jenkinsfile 的 checkout 口径执行;所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。API / Web / Stdb 发布类流水线不在目标机器 checkout Git,统一执行上游构建归档里的部署脚本,避免产物 commit 与部署脚本 commit 漂移。 -`scripts/jenkins-checkout-source.sh` 是生产 Jenkinsfile 内部二次确认源码的统一入口。构建和发布流水线传入 `COMMIT_HASH` 时,脚本必须先保持 `depth=1` 浅拉,若上游 commit 已在浅历史内则直接校验并 checkout;只有浅历史无法证明 commit 属于目标分支时,才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS`(默认 `50 200 1000 5000`)逐步加深,最后才尝试展开完整历史。`Genarrative-Api-Deploy`、`Genarrative-Web-Deploy` 和 `Genarrative-Stdb-Module-Publish` 都必须保留上游构建传入的 `COMMIT_HASH`,不得为了缩短 checkout 时间改为空值或改用目标分支最新提交。 +`scripts/jenkins-checkout-source.sh` 是生产 Jenkinsfile 内部二次确认源码的统一入口。构建流水线和服务器初始化流水线传入 `COMMIT_HASH` 时,脚本必须先保持 `depth=1` 浅拉,若上游 commit 已在浅历史内则直接校验并 checkout;只有浅历史无法证明 commit 属于目标分支时,才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS`(默认 `50 200 1000 5000`)逐步加深,最后才尝试展开完整历史。`Genarrative-Api-Deploy`、`Genarrative-Web-Deploy` 和 `Genarrative-Stdb-Module-Publish` 仍保留上游构建传入的 `COMMIT_HASH` 作为通知和追溯字段,但不再用它在目标机器重新 checkout 部署脚本。 `Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM,只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。 -`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。 +`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash;Stdb module 的 `CARGO_HOME`、`CARGO_TARGET_DIR` 和 `SCCACHE_DIR` 默认落在稳定缓存根 `~/caches/genarrative-jenkins/stdb-module` 下,可用 `GENARRATIVE_STDB_CACHE_ROOT` 覆盖,避免 `WORKSPACE@tmp` 被清理后无改动也触发近似冷构建。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。 `Genarrative-Server-Provision` 只做服务器初始化,不再承担构建职责。流水线全程运行在目标服务器 agent:`DEPLOY_TARGET=development` 使用 `linux && genarrative-dev-deploy`,`DEPLOY_TARGET=release` 使用 `linux && genarrative-release-deploy`;`Prepare Provision Tools` 也在同一个目标 agent 工作区内准备 SpacetimeDB 与 `otelcol-contrib` 交付件,不再切到 `linux && genarrative-build`,也不再 stash 给后续阶段。`SOURCE_GIT_REMOTE_URL` 必须显式填写为目标 agent 可访问的本机路径、`file:///` 地址、localhost / 127.0.0.1、RFC1918 内网 HTTP Git 地址、单标签内网主机名或 `.local` / `.lan` / `.internal` 地址;这条流水线不配置公网 Git 备用地址,目标 agent 拉不到内网源就应直接失败。真实初始化会写入 `/etc` / systemd / Nginx、创建系统用户并修改服务,目标 dev / release agent 非 dry-run 时都必须具备 root 权限。 @@ -317,8 +321,9 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - `GENARRATIVE_SPACETIME_TOKEN` - `GENARRATIVE_DATABASE_BACKUP_*` - `GENARRATIVE_LLM_*` -- `APIMART_*` - `VECTOR_ENGINE_*` +- ~~`APIMART_*`~~(已弃用,LLM 文本调用统一迁移到 VectorEngine) +- `APIMART_*`(历史残留,创意 Agent LLM 已迁移到 VectorEngine) - `HYPER3D_*` - `VOLCENGINE_SPEECH_*` - `DASHSCOPE_*` @@ -328,6 +333,14 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 结构化创作 / RPG 的 Responses JSON 链路默认不打开 `web_search`;本地和生产如需联网增强,必须显式配置 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses 可能先吐自然语言再返回 `ToolNotOpen`,这类报错应按工具不可用排查,不要先当成 JSON 解析 bug。 +创意 Agent `gpt-5` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于 Responses 协议。排查或切换密钥后,可在本地运行: + +```bash +node scripts/test-ve-llm.mjs +``` + +该脚本读取仓库根目录 `.env.secrets.local` 中的 `VECTOR_ENGINE_BASE_URL` 和 `VECTOR_ENGINE_API_KEY`,依次探测 `/v1/models`、`/v1/chat/completions`、`/v1/responses`、`gpt-5` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。 + ### 手机验证码短信 手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。 @@ -414,7 +427,7 @@ systemctl restart genarrative-api.service journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13' ``` -`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会等待启动恢复,超时后继续监听但进入依赖不可用模式,所有请求统一返回 `503 SERVICE_UNAVAILABLE`,错误详情包含 `reason=spacetime_startup_unavailable`,以避免用空本地状态或旧快照覆盖认证表。 +`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。 常用检查思路: diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index af959025..61057acd 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -17,7 +17,9 @@ - 充值入口:`src/components/rpg-entry/RpgEntryHomeView.tsx` - 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js` - API 契约:`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts/src/runtime.rs` -- 后端下单与签名:`server-rs/crates/api-server/src/runtime_profile.rs` +- 后端下单与订单编排:`server-rs/crates/api-server/src/runtime_profile.rs`、`server-rs/crates/api-server/src/wechat/pay.rs` +- 微信支付 / 虚拟支付协议适配:`server-rs/crates/platform-wechat/src/pay.rs` +- 微信订阅消息协议适配:`server-rs/crates/platform-wechat/src/subscribe_message.rs` - WebView 回流确认:`GET /api/profile/recharge/orders/{orderId}/wechat/events`、`POST /api/profile/recharge/orders/{orderId}/wechat/confirm` - 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs`、`server-rs/crates/module-auth/src/lib.rs` @@ -72,5 +74,5 @@ npm run check:encoding - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 - Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 - WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底;后端收到虚拟支付消息推送并入账后会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 `done`。 -- 小程序订阅消息用于拼图 AI 创作生成结果通知:H5 在拼图 `compile_puzzle_draft` 生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在拼图后台首图 / UI 资产生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm`。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 +- 小程序订阅消息用于 AI 创作生成结果通知:H5 在生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在玩法草稿生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。模板 `thing1` 发送玩法模板名,`number6` 发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`;模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm`。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 31ea9b3d..308b4178 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -56,6 +56,8 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 创作入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态 ``` +后端链路也按同一条平台主干组织:所有创作、生成、作品回读、发布、试玩、正式 runtime、公开详情、作品架、运行态设置 / 存档、游玩历史、存档归档、游玩统计、历史素材、AI task、runtime chat、文档解析、角色资产工坊和玩法生成支撑资产相关 HTTP 路由,先注册到 `server-rs/crates/api-server/src/modules/play_flow.rs`,由主干在进入领域 handler 前统一解析 `PlayFlowRequestContext`,再在最后一步分发给对应领域模块或支撑能力 handler 处理。`app.rs` 不再逐玩法挂载创作 / 运行态路由,`modules/platform.rs` 只保留通用 LLM / 语音代理;新增玩法、补齐旧玩法或迁移旧路径时,必须先补 `play_flow` 的 `playId`、领域模块 key、创作路由前缀、运行态路由前缀和入口开关匹配规则,再补具体 handler。领域规则、胜负裁决、计分、发布状态、资产完整性和排行榜仍留在各自 `module-*` 与 SpacetimeDB procedure 中,不把平台主干写成某个玩法的新业务真相。 + 默认工作台只提交结构化表单、图片槽位和配置 payload,不默认增加聊天输入区、流式消息区或轻输入 Agent。确需偏离该模式时,必须先在 PRD 和本文档写明例外原因、影响范围和回退方式,再进入编码。 单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图、主图预览和删除确认;新玩法页面不得重复手写这些交互。主图已有图片时,默认点击图片打开全屏预览,上传 / 更换收口到右下角 `ImagePlus` 图标按钮;无图时仍允许点击空图卡上传。调用方只能通过 `canUploadMainImage`、`canUseImageHistory` 等受控参数开关上传和历史入口,不得用复制组件或样式遮挡改行为。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec`、`slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。 @@ -170,21 +172,25 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生; 2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色; -3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key,不启用近白底扣除,也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制;地块造型提示词要求以主题物体本身外轮廓为准,允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异,只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装;所有地块素材必须保持统一正面30度视角,相机位于物体正前方略高位置、镜头向下约30度,必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面,主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶;避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标;主题物体本身必须是唯一可落脚体,只能用自身切面、边缘厚度、花瓣层或果皮边表现承重,禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板;前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集,物体本身作为跳跃落点”的口径,不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改; +3. 地板贴图只调用一次 image2,输出一张 `1024x1536` 竖版、`3列*6行`、单一纯洋红 `#FF00FF` key 安全缝 / 外圈背景的立方体主题物体 UV 展开图集;image2 要生成 18 个完整 `1x1x1` 立方体主题物体包装,每个大单元格内部固定为 `4列*3行` UV 网:第 1 行第 2 列为 `top`,第 2 行依次为 `left / front / right / back`,第 3 行第 2 列为 `bottom`,其它 UV 空位保持纯洋红。每个大单元格的六个面必须属于同一个方块化主题物体,top/front/right/back/left/bottom 之间的果皮、切面、籽点、条纹、果柄、叶片等身份特征要连续一致,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标。水果主题应生成 18 种可一眼辨认的方块水果 UV,例如方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。禁止文字、UI、底座、托盘、圆台、地板垫层、落地投影、接触阴影、方形阴影、洋红描边、紫色底边、粉色脏边、彩色光晕、发光边、透明背景、留白、自然圆形水果、自然长条香蕉、孤立水果照片、小型贴纸、纯果皮材质、纯果肉纹理、纯叶脉纹理和无法分辨具体物体的抽象纹理;真实透视、极小倒角、侧壁厚度和阴影统一由运行态 Three.js 标准 `1x1x1` 等比立方体生成。后端只把洋红 key 作为图集安全边界处理,先按 3x6 大单元格切出 18 个方块,再按每格 4x3 UV 网切出 108 张 `256x256` 不透明面贴图,不再运行透明化抠图、最大 alpha 连通主体保留或透明安全边补白;若裁切后仍残留极少洋红 key 色,会转成不透明材质底色。前端和后端默认 `tilePrompt` 都必须使用“立方体主题物体 UV 展开包装图集 / cube object UV unwrap atlas”的口径,不再提交“正面30度主题物体 / 平台素材 / 跳台 / 地块成品 / 地砖 / 材质贴片 / 平铺纹理”等会把模型拉回 2D 地块、平台或单纯材质的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改; 4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段,OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感,两侧允许更强立体层次和行进感;背景只作为底图,禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版;左上角返回按钮不允许画进背景,而是单独生成 `backButtonAsset` 透明 PNG,OSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile; -5. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-25` 的透明 PNG,每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位; -6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽; -7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、地块图集、切片、抠图和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。 +5. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-18`,每个方块再持久化 `tile-XX-top/front/right/back/left/bottom` 六个独立 slot/path,不能按重复的 `tileType` 复用槽位;`tileAssets[].faceAssets` 保存六面贴图,历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 写 top 面作为旧单贴图 fallback,运行态对旧作品没有 `faceAssets` 时仍可把单张贴图应用到立方体所有面; +6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽;移动端结果页必须由结果页根容器承接纵向滚动并保留底部安全区,确保素材预览较长时仍能下滑到返回编辑、试玩和发布按钮; +7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、返回按钮去绿、地板贴图图集切片和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。 -运行态规则真相必须沉到 `module-jump-hop`,前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。 +待解决问题(风险程度:高):跳一跳创作链路目前仍是一次 HTTP 请求内串行生成背景底图、返回按钮、地板贴图图集、切片和 OSS 写入;VectorEngine image2 单步 timeout/connect 失败会在后端最多重试 5 次,而前端只有 20 分钟总等待窗口。若某次背景底图生成接近或超过 18 分钟,前端会先报“请求超时,请稍后重试”,但后端可能继续跑完并在数分钟后写入草稿;同时因为背景、返回按钮和图集等中间资产未按阶段落库,同一 session 超时后重试会重新从背景图开始生成,存在重复生图、重复计费、用户误以为失败、作品架状态短时间不一致的风险。后续应将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,并在每个素材阶段成功后写入可恢复状态;同时收口后端全局生成 deadline、前端等待策略和失败态回写,确保超时、重试和最终成功不会互相打架。 + +生成页“当前跳一跳信息”只展示实际参与创作提示词的主题、地块提示词等用户可理解信息;`stylePreset` 等未参与当前 image2 提示词组装的内部风格枚举不得作为兜底内容展示,避免把 `minimal-blocks`、`paper-toy` 等工程值暴露给创作者。 + +运行态规则真相必须沉到 `module-jump-hop`,前端只做长按蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。 每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数,不展示计时器或右上角重开按钮;生成背景和游戏舞台必须覆盖整个运行态视口,HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌,以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长;每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。 -运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图,图片读取继续走平台资产换签,没有背景时才回退到内置渐变;DOM 平台层直接使用 `tileAssets[]` 的生成切片图片显示地块,图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存;每个地块下方的统一软椭圆阴影来自运行态 DOM 的 `.jump-hop-runtime__platform-shadow`,不是 image2 地块切片的必需内容,调整阴影优先改运行态 CSS;有真实地块图片 URL 时不得在加载空档显示 fallback 原型地块,下一屏预览地块必须在进入相机视野前隐藏预加载;DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持最高层级;Three.js 透明画布仅作为后续扩展层。拖拽蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景或平台图片层,否则会造成背景、地块和角色层频闪。 +运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图,图片读取继续走平台资产换签,没有背景时才回退到内置渐变;Three.js 平台层复用同一份标准 `1x1x1` 等比极小倒角立方体几何体,只按单一 side 等比缩放当前 / 目标 / 预览地块,并把 `tileAssets[]` 的生成切片作为主题身份方块包装贴图加载到立方体表面;单块地板保持正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转;`tileAssets[].faceAssets` 存在时,Three.js 材质刷新签名必须纳入 top/front/right/back/left/bottom 六面 texture URL,任一面异步换签或 blob URL 变化都要重建平台材质,不能只监听旧单图 `imageSrc` 或基础 render key;运行态采用约 `1.3x` 近距相机、45° 下压视角和更紧凑的可见地板间距,当前脚下地块基准位于屏幕中线略下方,目标和预览地块向上展开,侧壁、倒角、透视和软椭圆阴影均由 Three.js 统一表现;Three.js 相机和 DOM 角色层必须保持屏幕 X 轴同向,不得通过反向 `camera.up` 或镜像 wrapper 把平台层左右翻转,否则会出现地块显示在右侧但蓄力与飞行动画朝左侧的反向错觉;DOM 地块图片层只作为资产换签、预加载、WebGL 不可用和测试环境 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,避免露出旧原型方块或双层闪现;推进期存在旧地块退出保留时,Three 平台层必须继续承接 3D 地块渲染,旧地块只跟随后续相机推进逐步离屏,不播放独立飞走动画,超过屏幕后自然销毁;图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存。DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持在 Three.js 平台层之上。长按蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景、平台贴图预加载层或 DOM 角色层,否则会造成背景、地块和角色层频闪。 -跳一跳当前拖拽手感统一采用 `chargeToDistanceRatio=0.008`,用于把同等跳跃距离所需拖拽距离缩短到旧 `0.004` 的一半;如果历史路径仍保存旧系数,`start_run` 会在开局归一化到新系数。拖拽中只显示弹弓拉线,不显示落点辅助点、投影圈或其它命中提示。松手后运行态必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画:蓄力时角色沿拖拽方向明显拉长,角色弹向预测落点,落地后向反方向回弹两次;动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端最新 run,并用约 `1440ms` 的相机层推进过渡承接新窗口。推进时地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块允许保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用同一 `1440ms` 节奏;不要直接修改宽高造成瞬切,也不要再给当前态额外叠 CSS scale。相机推进期间角色自身必须禁用 `left/top` transition,只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。 +跳一跳当前长按蓄力手感统一采用 `chargeToDistanceRatio=0.004`,用于把长按时长换算成世界跳跃距离;如果历史路径仍保存其它系数,`start_run` 会在开局归一化到新系数。用户按住画面开始蓄力,松手立即起跳;前端必须提交 `dragDistance` 以及换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端正式裁决用该方向向量计算真实落点,只有旧客户端缺失方向、方向非有限数或向量长度过小时,才 fallback 到当前地块中心指向下一块地块中心。成功判定使用下一块地块可见顶面 footprint:后端以该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%,落点进入该视觉顶面则成功,未进入则失败;旧 `landingRadius/perfectRadius` 只保留兼容读写,不再作为当前命中真相。蓄力中角色只做垂直压缩,不沿目标方向拉伸;蓄力反馈可显示朝向当前目标方向的轻量引导,但不显示落点辅助点、投影圈或其它命中提示。松手后运行态必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画:视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色沿本次提交方向弹向预测真实落点,成功也不得强制吸附回目标地块中心。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应优先用 `lastJump.landedX/landedY` 映射出的真实落点显示角色,再把显示态切到后端最新 run,并用约 `1440ms` 的相机层推进过渡承接新窗口,避免先飞过很远再瞬间拉回地块造成闪现。推进时地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块只随相机推进保留在屏幕后方,不单独执行向上 / 向下飞走动画;玩家继续向前跳时,旧地块继续被新的相机推进带离视口,超过离屏阈值后自然销毁,新预览地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块允许保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用同一 `1440ms` 节奏;不要直接修改宽高造成瞬切,也不要再给当前态额外叠 CSS scale。相机推进期间角色自身必须禁用 `left/top` transition,只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。 -平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 +平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地板贴图图集和路径配置,必须先补读完整 work profile 再传入运行态。`/runtime/jump-hop?work=JH-*` 这类正式深链必须先通过公开作品号回读 gallery detail,再以 profileId 启动 published run;直接打开没有 `work` 参数的 `/runtime/jump-hop` 时不能停留在空运行态或“正在加载内容”,应回到平台首页。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。 diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 609866ca..d399ed2f 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -104,7 +104,7 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/database-backup-to-oss.mjs,scripts/deploy/production-api-deploy.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh", fingerprint: true } } diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index dcb824b6..db7809ce 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -7,16 +7,11 @@ pipeline { buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } - environment { - GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' - } - parameters { choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') - string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') - string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '上游构建源码分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '上游构建源码 commit') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') @@ -62,48 +57,6 @@ pipeline { } } - stage('Checkout Deploy Scripts') { - agent { - label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}" - } - steps { - script { - def checkoutFromRemote = { String remoteUrl -> - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [ - [$class: 'CleanBeforeCheckout'], - [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], - ], - userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], - ]) - } - try { - checkoutFromRemote(env.GIT_REMOTE_URL) - env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL - } catch (error) { - echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" - checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) - env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL - } - } - sh ''' - bash -lc ' - set -euo pipefail - chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ - COMMIT_HASH="${COMMIT_HASH:-}" \ - GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ - GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ - SOURCE_COMMIT_FILE=".jenkins-source-commit" \ - scripts/jenkins-checkout-source.sh - ' - ''' - } - } - stage('Fetch Artifact') { agent { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}" @@ -112,7 +65,7 @@ pipeline { copyArtifacts( projectName: params.BUILD_JOB_NAME, selector: specific(params.BUILD_NUMBER_TO_DEPLOY), - filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json", + filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json,build/${params.BUILD_VERSION}/scripts/database-backup-to-oss.mjs,scripts/deploy/production-api-deploy.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh", target: '.', fingerprintArtifacts: true ) diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 6b6a964a..992c5c68 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -12,6 +12,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' + GENARRATIVE_STDB_CACHE_ROOT = 'caches/genarrative-jenkins/stdb-module' CARGO_INCREMENTAL = '0' RUSTC_WRAPPER = 'sccache' SCCACHE_CACHE_SIZE = '30G' @@ -81,12 +82,15 @@ pipeline { sh ''' bash -lc ' set -euo pipefail - workspace_tmp="${WORKSPACE_TMP:-${WORKSPACE}@tmp}" - export CARGO_HOME="${workspace_tmp}/cargo-home" - export CARGO_TARGET_DIR="${workspace_tmp}/cargo-target/prod-release" + stdb_cache_root="${GENARRATIVE_STDB_CACHE_ROOT:-caches/genarrative-jenkins/stdb-module}" + if [[ "${stdb_cache_root}" != /* ]]; then + stdb_cache_root="${HOME:?HOME 不能为空}/${stdb_cache_root}" + fi + export CARGO_HOME="${stdb_cache_root}/cargo-home" + export CARGO_TARGET_DIR="${stdb_cache_root}/cargo-target/prod-release" export CARGO_INCREMENTAL=0 export RUSTC_WRAPPER=sccache - export SCCACHE_DIR="${workspace_tmp}/sccache-stdb-module" + export SCCACHE_DIR="${stdb_cache_root}/sccache" export SCCACHE_CACHE_SIZE=30G mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" chmod +x scripts/jenkins-prepare-cargo-env.sh @@ -115,7 +119,7 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/database-backup-to-oss.mjs", fingerprint: true archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/migration-bootstrap-secret.txt", fingerprint: false } } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index a543ce04..78aa250b 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -7,16 +7,11 @@ pipeline { buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } - environment { - GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' - } - parameters { choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') - string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') - string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '上游构建源码分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '上游构建源码 commit') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb module 构建流水线作业名') @@ -75,48 +70,6 @@ pipeline { } } - stage('Checkout Publish Scripts') { - agent { - label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}" - } - steps { - script { - def checkoutFromRemote = { String remoteUrl -> - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [ - [$class: 'CleanBeforeCheckout'], - [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], - ], - userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], - ]) - } - try { - checkoutFromRemote(env.GIT_REMOTE_URL) - env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL - } catch (error) { - echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" - checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) - env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL - } - } - sh ''' - bash -lc ' - set -euo pipefail - chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ - COMMIT_HASH="${COMMIT_HASH:-}" \ - GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ - GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ - SOURCE_COMMIT_FILE=".jenkins-source-commit" \ - scripts/jenkins-checkout-source.sh - ' - ''' - } - } - stage('Fetch Artifact') { agent { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}" @@ -125,7 +78,7 @@ pipeline { copyArtifacts( projectName: params.BUILD_JOB_NAME, selector: specific(params.BUILD_NUMBER_TO_DEPLOY), - filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json", + filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/database-backup-to-oss.mjs", target: '.', fingerprintArtifacts: true ) diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index d1a61b6f..cc8a2356 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -89,7 +89,7 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz,build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz,build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-web-deploy.sh", fingerprint: true } } diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index 81baf9cd..36bc9571 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -7,16 +7,11 @@ pipeline { buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } - environment { - GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' - } - parameters { choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') - string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') - string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '上游构建源码分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '上游构建源码 commit') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名') @@ -49,48 +44,6 @@ pipeline { } } - stage('Checkout Deploy Scripts') { - agent { - label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}" - } - steps { - script { - def checkoutFromRemote = { String remoteUrl -> - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [ - [$class: 'CleanBeforeCheckout'], - [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], - ], - userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], - ]) - } - try { - checkoutFromRemote(env.GIT_REMOTE_URL) - env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL - } catch (error) { - echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" - checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) - env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL - } - } - sh ''' - bash -lc ' - set -euo pipefail - chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ - COMMIT_HASH="${COMMIT_HASH:-}" \ - GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ - GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ - SOURCE_COMMIT_FILE=".jenkins-source-commit" \ - scripts/jenkins-checkout-source.sh - ' - ''' - } - } - stage('Fetch Artifact') { agent { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}" @@ -99,7 +52,7 @@ pipeline { copyArtifacts( projectName: params.BUILD_JOB_NAME, selector: specific(params.BUILD_NUMBER_TO_DEPLOY), - filter: "build/${params.BUILD_VERSION}/web.tar.gz,build/${params.BUILD_VERSION}/web.tar.gz.sha256,build/${params.BUILD_VERSION}/release-manifest.json", + filter: "build/${params.BUILD_VERSION}/web.tar.gz,build/${params.BUILD_VERSION}/web.tar.gz.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-web-deploy.sh", target: '.', fingerprintArtifacts: true ) diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 8b2621e1..9379baa0 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -96,6 +96,37 @@ export interface JumpHopTileAsset { visualHeight: number; topSurfaceRadius: number; landingRadius: number; + faceAssets?: JumpHopTileFaceAssets | null; +} + +export type JumpHopTileFaceKey = + | 'top' + | 'front' + | 'right' + | 'back' + | 'left' + | 'bottom'; + +export interface JumpHopTileFaceAsset { + face: JumpHopTileFaceKey; + assetId: string; + imageSrc: string; + imageObjectKey: string; + assetObjectId: string; + generationProvider: string; + prompt: string; + width: number; + height: number; + sourceAtlasCell: string; +} + +export interface JumpHopTileFaceAssets { + top: JumpHopTileFaceAsset; + front: JumpHopTileFaceAsset; + right: JumpHopTileFaceAsset; + back: JumpHopTileFaceAsset; + left: JumpHopTileFaceAsset; + bottom: JumpHopTileFaceAsset; } export interface JumpHopScoring { diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index 5eac405b..b01cf557 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -20,7 +20,7 @@ const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'; function usage() { console.log(`用法: npm run database:backup:oss -- [--data-dir ] [--work-dir ] [--bucket ] [--object-prefix ] [--keep-local] - node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] [--defer-upload] + node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] [--restart-service-after genarrative-api.service] [--defer-upload] node scripts/database-backup-to-oss.mjs --upload-archive 说明: @@ -100,6 +100,7 @@ function parseArgs(argv) { envFiles: [], keepLocal: false, stopService: '', + restartServicesAfter: [], database: '', dryRun: false, deferUpload: false, @@ -159,6 +160,9 @@ function parseArgs(argv) { case '--stop-service': options.stopService = readValue(); break; + case '--restart-service-after': + options.restartServicesAfter.push(readValue()); + break; case '--keep-local': options.keepLocal = true; break; @@ -266,6 +270,16 @@ function startServiceIfNeeded(serviceName, wasStopped) { runCommand('systemctl', ['start', serviceName], {stdio: 'inherit'}); } +function restartServicesAfterBackup(serviceNames) { + for (const serviceName of serviceNames) { + if (!serviceName) { + continue; + } + console.log(`[database-backup] 冷备份后重启依赖服务: ${serviceName}`); + runCommand('systemctl', ['restart', serviceName], {stdio: 'inherit'}); + } +} + function createArchive({dataDir, workDir, fileName}) { if (!existsSync(dataDir)) { throw new Error(`数据库数据目录不存在: ${dataDir}`); @@ -510,6 +524,13 @@ async function main() { } finally { startServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE), serviceStopped); } + restartServicesAfterBackup([ + ...String(env.GENARRATIVE_DATABASE_BACKUP_RESTART_SERVICE_AFTER ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + ...args.restartServicesAfter, + ]); const manifestPath = `${archivePath}.manifest.json`; writeManifest({ diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 0f861923..6fd2c121 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -332,14 +332,20 @@ mkdir -p "${RELEASE_DIR}" cp "${SOURCE_DIR}/api-server" "${RELEASE_DIR}/api-server" chmod +x "${RELEASE_DIR}/api-server" -SCRIPT_SOURCE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts" +BACKUP_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" +WORKSPACE_BACKUP_SCRIPT_SOURCE="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts/database-backup-to-oss.mjs" mkdir -p "${RELEASE_DIR}/scripts" -if [[ -f "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" ]]; then - cp "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" - chmod 0644 "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" -else - echo "[production-api-deploy] 未找到数据库备份脚本,release 目录不会包含 scripts/database-backup-to-oss.mjs" >&2 +if [[ ! -f "${BACKUP_SCRIPT_SOURCE}" ]]; then + if [[ -f "${WORKSPACE_BACKUP_SCRIPT_SOURCE}" ]]; then + echo "[production-api-deploy] 发布产物缺少 scripts/database-backup-to-oss.mjs,回退使用部署工作区脚本;请重新触发包含该脚本的 API 构建。" >&2 + BACKUP_SCRIPT_SOURCE="${WORKSPACE_BACKUP_SCRIPT_SOURCE}" + else + echo "[production-api-deploy] 缺少数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2 + exit 1 + fi fi +cp "${BACKUP_SCRIPT_SOURCE}" "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" +chmod 0644 "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.api-server.json" diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index 64f4e9dd..cdd60d41 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -179,6 +179,7 @@ prepare_async_backup() { --data-dir "${SPACETIME_ROOT_DIR}" \ --database "${DATABASE}" \ --stop-service spacetimedb.service \ + --restart-service-after genarrative-api.service \ --defer-upload \ --result-file "${ASYNC_BACKUP_STATUS_FILE}" } @@ -257,7 +258,8 @@ case "${BACKUP_MODE}" in --env-file /etc/genarrative/api-server.env \ --data-dir "${SPACETIME_ROOT_DIR}" \ --database "${DATABASE}" \ - --stop-service spacetimedb.service + --stop-service spacetimedb.service \ + --restart-service-after genarrative-api.service ;; skip) echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份" diff --git a/scripts/test-ve-llm.mjs b/scripts/test-ve-llm.mjs new file mode 100644 index 00000000..764a5e55 --- /dev/null +++ b/scripts/test-ve-llm.mjs @@ -0,0 +1,163 @@ +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +function loadEnv(path) { + const content = readFileSync(path, 'utf-8'); + const env = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) continue; + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +const env = loadEnv(resolve(root, '.env.secrets.local')); +const BASE = env.VECTOR_ENGINE_BASE_URL?.replace(/\/+$/, '') || 'https://api.vectorengine.cn'; +const KEY = env.VECTOR_ENGINE_API_KEY || ''; + +if (!KEY) { + console.error('未找到 VECTOR_ENGINE_API_KEY'); + process.exit(1); +} + +const TIMEOUT_MS = 60_000; + +async function test(name, method, path, body = null) { + const url = `${BASE}${path}`; + const start = Date.now(); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + const headers = { + 'Authorization': `Bearer ${KEY}`, + 'Content-Type': 'application/json', + }; + const options = { method, headers, signal: controller.signal }; + if (body) options.body = JSON.stringify(body); + + const resp = await fetch(url, options); + clearTimeout(timer); + const elapsed = Date.now() - start; + const text = await resp.text(); + let json = null; + try { json = JSON.parse(text); } catch {} + + if (resp.ok) { + const model = json?.model || json?.data?.[0]?.id || '?'; + const summary = json?.choices?.[0] ? `choices[0]: ${json.choices[0].message?.content?.slice(0, 80)}` : + json?.output_text ? `output_text: ${json.output_text.slice(0, 80)}` : + json?.data ? `${json.data.length} models` : JSON.stringify(json).slice(0, 120); + return { ok: true, elapsed, code: resp.status, model, summary }; + } else { + const errMsg = json?.error?.message || json?.message || text.slice(0, 200); + return { ok: false, elapsed, code: resp.status, error: errMsg }; + } + } catch (e) { + const elapsed = Date.now() - start; + return { ok: false, elapsed, code: 0, error: e.name === 'AbortError' ? `超时(${TIMEOUT_MS / 1000}s)` : e.message }; + } +} + +console.log(`VectorEngine LLM 能力探测`); +console.log(`目标: ${BASE}\n`); + +const tests = [ + // 1. 探测 /v1/models - 基础连通性 + 列出可用模型 + { name: 'GET /v1/models (列出可用模型)', method: 'GET', path: '/v1/models' }, + + // 2. Chat Completions - 最标准协议,项目已有 LlmProvider::OpenAiCompatible 支持 + { + name: 'POST /v1/chat/completions (Chat)', + method: 'POST', + path: '/v1/chat/completions', + body: { + model: 'gpt-4o', + messages: [{ role: 'user', content: '回复 ok,不要解释' }], + max_tokens: 10, + }, + }, + + // 3. Responses - Apimart 当前使用的协议 + { + name: 'POST /v1/responses (Responses)', + method: 'POST', + path: '/v1/responses', + body: { + model: 'gpt-4o', + input: [ + { role: 'user', content: [{ type: 'input_text', text: '回复 ok,不要解释' }] }, + ], + }, + }, + + // 4. 测试 gpt-5 (creative_agent 模型) + { + name: 'POST /v1/chat/completions (gpt-5, Chat)', + method: 'POST', + path: '/v1/chat/completions', + body: { + model: 'gpt-5', + messages: [{ role: 'user', content: '回复 ok' }], + max_tokens: 10, + }, + }, + + // 5. 抓大鹅生成需要的 JSON 输出能力验证 + { + name: 'POST /v1/chat/completions (JSON 输出: 抓大鹅物品)', + method: 'POST', + path: '/v1/chat/completions', + body: { + model: 'gpt-4o', + messages: [ + { role: 'system', content: '你是抓大鹅游戏编辑,只返回 JSON。' }, + { role: 'user', content: '题材:水果。请生成 JSON:{"gameName":"水果切切乐","items":[{"name":"苹果","itemSize":"中"},{"name":"西瓜","itemSize":"大"}]}' }, + ], + max_tokens: 200, + }, + }, +]; + +let pass = 0; +let fail = 0; + +for (let i = 0; i < tests.length; i++) { + const t = tests[i]; + console.log(`[${i + 1}/${tests.length}] ${t.name}`); + const result = await test(t.name, t.method, t.path, t.body); + + if (result.ok) { + console.log(` ✅ HTTP ${result.code} ${result.elapsed}ms model: ${result.model}`); + console.log(` ${result.summary}`); + pass++; + } else { + const codeStr = result.code === 0 ? 'NET' : `HTTP ${result.code}`; + console.log(` ❌ ${codeStr} ${result.elapsed}ms ${result.error}`); + fail++; + } + console.log(); +} + +console.log(`=== 结果: ${pass}/${tests.length} 通过, ${fail}/${tests.length} 失败 ===`); + +// 结论 +if (pass >= 3) { + console.log('\n✅ VectorEngine 支持 LLM 文本调用,可替代 Apimart。'); + console.log(' 将 .env.secrets.local 中 APIMART_BASE_URL 改为 VectorEngine 地址即可。'); +} else if (pass <= 1) { + console.log('\n❌ VectorEngine 不支持 LLM 文本调用。'); +} else { + console.log('\n⚠️ 部分支持,需进一步评估。'); +} diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 1f661268..f285faaf 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2513,11 +2513,21 @@ dependencies = [ name = "platform-wechat" version = "0.1.0" dependencies = [ + "aes", + "base64 0.22.1", + "cbc", + "hex", "reqwest 0.12.28", + "ring", "serde", "serde_json", + "sha1", + "sha2", + "shared-contracts", + "time", "tracing", "url", + "urlencoding", ] [[package]] diff --git a/server-rs/crates/api-server/src/ai_tasks.rs b/server-rs/crates/api-server/src/ai_tasks.rs index 42a446df..d2c176d0 100644 --- a/server-rs/crates/api-server/src/ai_tasks.rs +++ b/server-rs/crates/api-server/src/ai_tasks.rs @@ -542,8 +542,8 @@ mod tests { #[tokio::test] async fn create_ai_task_returns_bad_gateway_when_spacetime_not_published() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -605,8 +605,8 @@ mod tests { #[tokio::test] async fn start_ai_task_returns_bad_gateway_when_spacetime_not_published() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -652,8 +652,8 @@ mod tests { #[tokio::test] async fn ai_task_mutation_routes_return_bad_gateway_when_spacetime_not_published() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); for route in ai_task_mutation_route_cases() { @@ -763,21 +763,20 @@ mod tests { (status, payload) } - async fn seed_authenticated_state() -> AppState { + async fn seed_authenticated_state() -> (AppState, String) { let state = AppState::new(AppConfig::default()).expect("state should build"); - state + let user_id = state .seed_test_phone_user_with_password("13800138100", "secret123") .await .id; - state + (state, user_id) } - fn issue_access_token(state: &AppState) -> String { + fn issue_access_token(state: &AppState, user_id: &str) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), - session_id: state - .seed_test_refresh_session_for_user_id("user_00000001", "sess_ai_tasks"), + user_id: user_id.to_string(), + session_id: state.seed_test_refresh_session_for_user_id(user_id, "sess_ai_tasks"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 05f4f6d1..a68f6db5 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -15,7 +15,7 @@ use tower_http::{ use tracing::{Level, Span, error, info_span}; use crate::{ - auth::{AuthenticatedAccessToken, require_bearer_auth}, + auth::AuthenticatedAccessToken, backpressure::limit_concurrent_requests, creation_entry_config::require_creation_entry_route_enabled, error_middleware::normalize_error_response, @@ -23,25 +23,10 @@ use crate::{ modules, request_context::{RequestContext, attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, - runtime_inventory::get_runtime_inventory_state, state::{AppState, BackpressureState}, telemetry::record_http_observability, tracking::record_route_tracking_event_after_success, - vector_engine_audio_generation::{ - create_background_music_task, create_sound_effect_task, - create_visual_novel_background_music_task, create_visual_novel_sound_effect_task, - publish_background_music_asset, publish_sound_effect_asset, - publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset, - }, - visual_novel::{ - compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work, - execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session, - get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history, - list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run, - start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message, - submit_visual_novel_message, update_visual_novel_work, - }, - wechat_pay::{ + wechat::pay::{ handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify, handle_wechat_virtual_payment_notify, }, @@ -57,19 +42,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::profile::router(state.clone())) .merge(modules::assets::router(state.clone())) .merge(modules::platform::router(state.clone())) - .merge(modules::story::router(state.clone())) - .merge(modules::edutainment::router(state.clone())) - .merge(modules::custom_world::router(state.clone())) - .merge(modules::big_fish::router(state.clone())) - .merge(modules::bark_battle::router(state.clone())) - .merge(modules::match3d::router(state.clone())) - .merge(modules::square_hole::router(state.clone())) - .merge(modules::jump_hop::router(state.clone())) - .merge(modules::wooden_fish::router(state.clone())) - .merge(modules::public_work::router(state.clone())) - .merge(modules::puzzle_clear::router(state.clone())) - .merge(modules::puzzle::router(state.clone())) - .merge(visual_novel_router(state.clone())) + .merge(modules::play_flow::router(state.clone())) .route( "/api/profile/recharge/wechat/notify", post(handle_wechat_pay_notify), @@ -79,13 +52,6 @@ pub fn build_router(state: AppState) -> Router { get(handle_wechat_virtual_payment_message_push_verify) .post(handle_wechat_virtual_payment_notify), ) - .route( - "/api/runtime/sessions/{runtime_session_id}/inventory", - get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) // 后端创作/运行态 API 路由只按 open 做熔断;visible 仅控制创作页入口展示。 .layer(middleware::from_fn_with_state( state.clone(), @@ -290,166 +256,6 @@ async fn record_api_tracking_after_success( response } -fn visual_novel_router(state: AppState) -> Router { - Router::new() - .route( - "/api/creation/visual-novel/sessions", - post(create_visual_novel_session).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/visual-novel/sessions/{session_id}", - get(get_visual_novel_session).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/visual-novel/sessions/{session_id}/messages", - post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/visual-novel/sessions/{session_id}/messages/stream", - post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/visual-novel/sessions/{session_id}/actions", - post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/visual-novel/sessions/{session_id}/compile", - post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/visual-novel/works", - get(list_visual_novel_works).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/visual-novel/works/{profile_id}", - get(get_visual_novel_work) - .put(update_visual_novel_work) - .patch(update_visual_novel_work) - .delete(delete_visual_novel_work) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/visual-novel/works/{profile_id}/publish", - post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/visual-novel/audio/background-music", - post(create_visual_novel_background_music_task).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), - ) - .route( - "/api/creation/visual-novel/audio/background-music/{task_id}/asset", - post(publish_visual_novel_background_music_asset).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), - ) - .route( - "/api/creation/visual-novel/audio/sound-effect", - post(create_visual_novel_sound_effect_task).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), - ) - .route( - "/api/creation/visual-novel/audio/sound-effect/{task_id}/asset", - post(publish_visual_novel_sound_effect_asset).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), - ) - .route( - "/api/creation/audio/background-music", - post(create_background_music_task).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/audio/background-music/{task_id}/asset", - post(publish_background_music_asset).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/audio/sound-effect", - post(create_sound_effect_task).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation/audio/sound-effect/{task_id}/asset", - post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/visual-novel/gallery", - get(list_visual_novel_gallery), - ) - .route( - "/api/runtime/visual-novel/works/{profile_id}/runs", - post(start_visual_novel_run).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/visual-novel/runs/{run_id}", - get(get_visual_novel_run).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/visual-novel/runs/{run_id}/actions/stream", - post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/visual-novel/runs/{run_id}/history", - get(list_visual_novel_history).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/visual-novel/runs/{run_id}/regenerate", - post(regenerate_visual_novel_run) - .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)), - ) -} - #[cfg(test)] mod tests { use axum::{ @@ -1507,8 +1313,7 @@ mod tests { #[tokio::test] async fn wooden_fish_session_creation_accepts_legacy_audio_body_above_default_limit() { let state = AppState::new(AppConfig::default()).expect("state should build"); - let seed_user = - seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await; + let seed_user = seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_audio_body"); let app = build_router(state); let request_body = format!( @@ -1548,8 +1353,7 @@ mod tests { #[tokio::test] async fn wooden_fish_actions_accept_legacy_audio_body_above_default_limit() { let state = AppState::new(AppConfig::default()).expect("state should build"); - let seed_user = - seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; + let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_action_body"); let app = build_router(state); let request_body = format!( diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs index bab8c434..32cdc1a0 100644 --- a/server-rs/crates/api-server/src/auth_me.rs +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Json, extract::{Extension, State}, http::StatusCode, diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 6ca7896d..46a5f9f0 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1,4 +1,4 @@ -use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; +use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; use platform_llm::{ DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, @@ -133,9 +133,10 @@ pub struct AppConfig { pub dashscope_reference_image_model: String, pub dashscope_cover_image_model: String, pub dashscope_image_request_timeout_ms: u64, - pub apimart_base_url: String, - pub apimart_api_key: Option, - pub apimart_image_request_timeout_ms: u64, + // 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine(同时支持 Chat Completions / Responses 协议)。 + // pub apimart_base_url: String, + // pub apimart_api_key: Option, + // pub apimart_image_request_timeout_ms: u64, pub vector_engine_base_url: String, pub vector_engine_api_key: Option, pub vector_engine_image_request_timeout_ms: u64, @@ -293,9 +294,9 @@ impl Default for AppConfig { dashscope_reference_image_model: String::new(), dashscope_cover_image_model: String::new(), dashscope_image_request_timeout_ms: 150_000, - apimart_base_url: String::new(), - apimart_api_key: None, - apimart_image_request_timeout_ms: 180_000, + // apimart_base_url: String::new(), + // apimart_api_key: None, + // apimart_image_request_timeout_ms: 180_000, vector_engine_base_url: String::new(), vector_engine_api_key: None, vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, @@ -791,17 +792,17 @@ impl AppConfig { config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms; } - if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) { - config.apimart_base_url = apimart_base_url; - } - - config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]); - - if let Some(apimart_image_request_timeout_ms) = - read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"]) - { - config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms; - } + // 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine。 + // 保留以下历史加载代码,后续删除: + // if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) { + // config.apimart_base_url = apimart_base_url; + // } + // config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]); + // if let Some(apimart_image_request_timeout_ms) = + // read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"]) + // { + // config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms; + // } if let Some(vector_engine_base_url) = read_first_non_empty_env(&["VECTOR_ENGINE_BASE_URL"]) { @@ -1189,7 +1190,7 @@ mod tests { assert!(config.llm_model.is_empty()); assert!(config.llm_base_url.is_empty()); - assert!(config.apimart_base_url.is_empty()); + // assert!(config.apimart_base_url.is_empty()); assert!(config.vector_engine_base_url.is_empty()); assert!(config.ark_character_video_base_url.is_empty()); assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2"); @@ -1285,11 +1286,11 @@ mod tests { assert_eq!(config.llm_provider, LlmProvider::OpenAiCompatible); assert_eq!(config.llm_base_url, "https://llm.internal.example/v1"); assert_eq!(config.llm_model, "internal-text-model"); - assert_eq!( - config.apimart_base_url, - "https://responses.internal.example/v1" - ); - assert_eq!(config.apimart_image_request_timeout_ms, 190_000); + // assert_eq!( + // config.apimart_base_url, + // "https://responses.internal.example/v1" + // ); + // assert_eq!(config.apimart_image_request_timeout_ms, 190_000); assert_eq!( config.vector_engine_base_url, "https://vector.internal.example" diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 9804bf83..36639108 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use module_runtime::build_creation_entry_config_response; use shared_contracts::creation_entry_config::CreationEntryConfigResponse; +pub use crate::modules::play_flow::resolve_creation_entry_route_id; use crate::{ api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, @@ -70,62 +71,6 @@ pub async fn require_creation_entry_route_enabled( next.run(request).await } -pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { - let normalized = path.trim_end_matches('/'); - if normalized == "/api/runtime/puzzle/agent/sessions" - || normalized == "/api/runtime/puzzle/onboarding/generate" - { - return Some("puzzle"); - } - if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") { - return Some("puzzle"); - } - if normalized == "/api/runtime/big-fish/agent/sessions" { - return Some("big-fish"); - } - if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") { - return Some("big-fish"); - } - if normalized == "/api/runtime/custom-world/agent/sessions" - || normalized == "/api/runtime/custom-world/profile" - { - return Some("rpg"); - } - if normalized.starts_with("/api/runtime/custom-world-gallery/") - && normalized.ends_with("/remix") - { - return Some("rpg"); - } - if normalized == "/api/creation/match3d/sessions" { - return Some("match3d"); - } - if normalized == "/api/creation/square-hole/sessions" { - return Some("square-hole"); - } - if normalized == "/api/creation/bark-battle/drafts" { - return Some("bark-battle"); - } - if normalized == "/api/creation/wooden-fish/sessions" { - return Some("wooden-fish"); - } - if normalized == "/api/creation/jump-hop/sessions" { - return Some("jump-hop"); - } - if normalized == "/api/creation/puzzle-clear/sessions" { - return Some("puzzle-clear"); - } - if normalized == "/api/creation/visual-novel/sessions" { - return Some("visual-novel"); - } - if normalized == "/api/creation/edutainment/baby-object-match/assets" { - return Some("baby-object-match"); - } - if normalized == "/api/creation/edutainment/baby-love-drawing/magic" { - return Some("baby-love-drawing"); - } - None -} - pub(crate) fn resolve_creation_entry_mud_point_cost_from_config( config: &CreationEntryConfigResponse, creation_type_id: &str, diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index cd6d3240..634ece0e 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -37,11 +37,11 @@ use spacetime_client::{ CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, - CustomWorldLibraryEntryRecord, - CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput, - CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput, - CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord, - CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError, + CustomWorldLibraryEntryRecord, CustomWorldProfileLikeReportRecordInput, + CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, + CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, + CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, + CustomWorldWorkSummaryRecord, SpacetimeClientError, }; use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 74b93c70..d235f028 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -10,9 +10,9 @@ use axum::{ response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType}; #[cfg(test)] use image::ImageFormat; +use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs index 5c800414..953de5a5 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -9,8 +9,8 @@ use crate::{ #[allow(unused_imports)] pub(crate) use generated_asset_sheets_impl::{ GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor, - GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetSliceImage, - GeneratedAssetSheetUpload, + GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, + GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload, apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte, crop_generated_asset_sheet_view_edge_matte_with_options, diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 55914d7e..015a510e 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -14,25 +14,21 @@ use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest, - JumpHopRunResponse, - JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, - JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, + JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileFaceAsset, JumpHopTileFaceAssets, + JumpHopTileFaceKey, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use std::{ - collections::{BTreeMap, VecDeque}, + collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, }; use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, RuntimePrincipal}, - generated_asset_sheets::{ - GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options, - crop_generated_asset_sheet_view_edge_matte_with_options, - }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, @@ -45,10 +41,14 @@ use crate::{ }, request_context::RequestContext, state::AppState, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; -const JUMP_HOP_TILE_ITEM_COUNT: usize = 25; +const JUMP_HOP_TILE_ITEM_COUNT: usize = 18; const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; @@ -56,9 +56,15 @@ const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs"; -const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 5; -const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5; +const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 6; +const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; +const JUMP_HOP_TILE_UV_FACE_ROWS: u32 = 3; +const JUMP_HOP_TILE_UV_FACE_COLS: u32 = 4; const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF"; +const JUMP_HOP_TILE_ATLAS_IMAGE_SIZE: &str = "1024*1536"; +const JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH: u32 = 1024; +const JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT: u32 = 1536; +const JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE: u32 = 256; const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536"; const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024; const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536; @@ -70,9 +76,26 @@ const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024; struct JumpHopTileAtlasSlice { tile_type: JumpHopTileType, source_atlas_cell: String, + faces: JumpHopTileFaceSlices, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct JumpHopTileFaceSlice { + face: JumpHopTileFaceKey, + source_atlas_cell: String, bytes: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct JumpHopTileFaceSlices { + top: JumpHopTileFaceSlice, + front: JumpHopTileFaceSlice, + right: JumpHopTileFaceSlice, + back: JumpHopTileFaceSlice, + left: JumpHopTileFaceSlice, + bottom: JumpHopTileFaceSlice, +} + pub async fn create_jump_hop_session( State(state): State, Extension(request_context): Extension, @@ -150,27 +173,86 @@ pub async fn execute_jump_hop_action( let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let mut payload = payload; - maybe_generate_jump_hop_assets( - &state, - &request_context, - session_id.as_str(), - owner_user_id.as_str(), - &mut payload, - ) - .await?; - let response = state - .spacetime_client() - .execute_jump_hop_action(session_id, owner_user_id, payload) - .await - .map_err(|error| { - jump_hop_error_response( - &request_context, - JUMP_HOP_CREATION_PROVIDER, - map_jump_hop_client_error(error), - ) - })?; + let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft); + let generation_points_cost = if is_compile_draft { + resolve_jump_hop_generation_points_cost(&state).await + } else { + 0 + }; + let result = async { + maybe_generate_jump_hop_assets( + &state, + &request_context, + session_id.as_str(), + owner_user_id.as_str(), + &mut payload, + ) + .await?; + state + .spacetime_client() + .execute_jump_hop_action(session_id, owner_user_id.clone(), payload) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + }) + } + .await; - Ok(json_success_body(Some(&request_context), response)) + match result { + Ok(response) => { + if is_compile_draft && response.session.status == JumpHopGenerationStatus::Ready { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()), + work_name: response + .session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + Ok(json_success_body(Some(&request_context), response)) + } + Err(response) => { + if is_compile_draft && response.status().is_server_error() { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()), + work_name: None, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + Err(response) + } + } +} + +async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 { + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + JUMP_HOP_TEMPLATE_ID, + u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ) + .await } pub async fn publish_jump_hop_work( @@ -232,10 +314,7 @@ pub async fn get_jump_hop_work_detail( ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() - .get_jump_hop_work_profile( - profile_id, - authenticated.claims().user_id().to_string(), - ) + .get_jump_hop_work_profile(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { jump_hop_error_response( @@ -260,10 +339,7 @@ pub async fn delete_jump_hop_work( ensure_non_empty(&request_context, &profile_id, "profileId")?; let works = state .spacetime_client() - .delete_jump_hop_work( - profile_id, - authenticated.claims().user_id().to_string(), - ) + .delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { jump_hop_error_response( @@ -664,10 +740,10 @@ async fn maybe_generate_jump_hop_assets( &settings, sheet_prompt.as_str(), Some(build_jump_hop_tile_atlas_negative_prompt()), - "1024*1024", + JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, 1, &[], - "跳一跳地块图集生成失败", + "跳一跳地板贴图图集生成失败", ) .await .map_err(|error| { @@ -679,7 +755,7 @@ async fn maybe_generate_jump_hop_assets( JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", - "message": "跳一跳地块图集生成成功但未返回图片。", + "message": "跳一跳地板贴图图集生成成功但未返回图片。", })), ) })?; @@ -694,8 +770,8 @@ async fn maybe_generate_jump_hop_assets( tile_prompt.as_str(), tile_image, LegacyAssetPrefix::JumpHopAssets, - 1024, - 1024, + JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH, + JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT, request_context, ) .await?; @@ -780,7 +856,7 @@ fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String { return value; } - // 中文注释:仅对宝可梦相关词做生成侧脱敏,避免地块图集触发上游安全拦截。 + // 中文注释:仅对宝可梦相关词做生成侧脱敏,避免贴图图集触发上游安全拦截。 const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [ ("宝可梦", "原创幻想萌宠冒险道具"), ("神奇宝贝", "原创幻想萌宠冒险道具"), @@ -840,12 +916,12 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri }; format!( - "生成一张1:1图片,主题为“{theme_text}”。\n画面只包含25个独立的跳跃落点主题物体,按五行五列均匀摆放在纯洋红抠图画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为正面30度视角的跳跃游戏素材,画面内容是{subject_text}。所有落点素材都必须保持统一的正面30度视角:相机位于物体正前方略高位置,镜头向下约30度,能看到清晰正面、侧壁、下沿和少量上表面。\n构图验收标准:主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;不要让顶面占据主要视觉,不要画成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标。\n水果主题尤其要避免俯拍:橙瓣必须看到橙皮正面外侧和果肉厚度,椰子必须看到壳的正面侧壁和切口厚度,浆果不能只是一个从上往下看的圆形球顶。\n每一个落点都必须直接使用主题物体或合理发散物体做主体造型,主题要一眼可见;例如主题为水果时,可以是苹果切片、橙瓣、西瓜块、草莓、菠萝块、香蕉、葡萄串等水果物体,苹果可近似圆,香蕉可近似长条或长方形,西瓜可近似扇形,造型以物体本身外轮廓为准。\n主题物体本身就是唯一可落脚体:雪花落点就是一枚带厚度的雪花,向日葵落点就是一朵带厚度的向日葵,水果落点就是水果切片或水果本体;不要在主题物体下面再垫任何石头、土块、木板、圆台、底盘、托盘、岛屿、花盆、地面块或通用承托物。\n只画主题物体裸素材,不画外层面板、棋盘底座、菜单、UI按钮、标题、文字、角标、装饰边框、工具栏、装备栏、图标卡、角色或游戏界面。\n整体风格为清爽自然的休闲手游主题物体素材,偏2D/2.5D手绘质感,哑光材质,干净色块,轻微主体内部明暗,避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n每个落点都是符合主题且有设计感的立体感物体,有清晰轮廓和明显自身厚度;不要把不同主题物体强行改造成统一地砖、统一按钮或统一抽象图标。\n造型规则完全由物体本身决定:允许圆形、长条、弧形、三角、扇形、块状、枝叶状、多件组合、轻微夸张和一定程度发散;只在同一2D/2.5D手绘风格、正面30度视角、材质包装、清晰轮廓、单格规格和安全留白上保持一致。\n25个落点应尽量选择不同主题物体或相关发散物体,差异主要来自物体种类和原生轮廓,不使用固定形状脚本;相邻格可以形状相似,只要物体不同且主题清楚。\n允许用主题物体自身的切面、边缘厚度、花瓣层、果皮边、雪花厚边或云朵体积表现可落脚感;禁止额外支撑层、承托底座、脚下地板、下方石台、下方土墩、下方圆盘、下方托盘或“物体摆在平台上”的画法。\n每个落点必须居中,视觉尺寸只占单格56%-64%,四周至少保留18%纯洋红安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个落点只保留主体内部明暗、外轮廓和自身厚度,不绘制落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、彩色光晕、发光底边、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个落点同一材质体系、同一光向和同一正面30度视角,但物体类别、外轮廓和细节有变化;每个落点之间只能是纯洋红空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX},背景平整无纹理、无渐变、无阴影、无黑底;主体允许使用绿色、白色、雪地、云朵、草地和花朵,但主体自身不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的洋红色,主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影。\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板、图标集页面、物体下方额外底座或物体摆在地板上。\nEnglish guardrail: isolated front-facing 30-degree camera-pitch theme-object assets only, camera slightly above the object and looking down about 30 degrees from the front; every object must show a clear front face, side wall, lower rim, object thickness, and only a small top surface; visible front/side area must be close to or larger than the top area; never produce top-down, overhead, bird's-eye, flat icon, round top-view disk assets; the theme object itself is the only landing object, each object's native silhouette decides the shape, no extra base under the object, no pedestal, no plinth, no floor slab, no colored shadow or magenta fringe around objects, consistent 2D/2.5D style wrapper, solid magenta chroma key background {JUMP_HOP_TILE_ATLAS_KEY_HEX}, no text, no poster, no UI screen, no inventory icons." + "生成一张1024x1536竖版图片,主题为“{theme_text}”。\n画面只包含18个用于跳一跳地板的立方体主题物体 UV 展开包装图,按三列六行均匀排布;每个大单元格代表一个完整的 1x1x1 立方体方块物体,运行态会把该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上。\n画面内容是{subject_text}。这是一张 cube object UV unwrap atlas / 立方体主题物体六面展开图集,不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满,也不是已经渲染好的 3D 方块成品、游戏界面或图标集页面。\n每个大单元格内部必须使用固定 4列x3行 UV 展开结构,只有以下六个位置有贴图,其它位置保持纯洋红安全色:第1行第2列是 top;第2行第1列是 left;第2行第2列是 front;第2行第3列是 right;第2行第4列是 back;第3行第2列是 bottom。不要改变顺序,不要旋转面,不要把六个面画成一张连续透视图。\n每个方块都必须表现为“一个完整主题物体被塑造成 1x1x1 立方体后的六面包装”,六个面要属于同一个物体并能组合成完整方块造型;top/front/right/back/left/bottom 之间的颜色、边缘纹理、切面、果皮、籽点、条纹、果柄和叶片必须连续一致,不能六面各画互不相关的图案,也不能把同一张纹理重复六次。\n水果主题要生成18种可一眼辨认的方块水果 UV:方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴等;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质。\n每个面都是满版不透明正方形贴图 / full-bleed opaque square face texture:四角、边缘和中心都要有可识别内容,不留透明、不留空白、不留实底背景;允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点,但不要把一个小水果、小叶片、小石头或小物体放在面中央,也不要画小贴纸、小图标、徽章或孤立主体。\n这不是透视渲染图:不要画摄像机视角、透视块、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或高光光斑;真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成。每个面贴图在运行态会以约45度下压视角和较小尺寸显示,所以必须使用大色块、高对比、粗线条和简单图形,保证在64x64缩略图里仍能分辨主题物体身份。\n排布必须安全:18个大单元格必须完整落在自己的三列六行网格内,不能跨格、贴边串色或进入相邻方块;大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 作为切图安全色,允许极细纯洋红安全缝,但不要画可见网格线、边框、编号、face label 或裁切标记。\n贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色,但不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的纯洋红色;贴图边缘不得有洋红描边、紫色底边、粉色脏边、彩色光晕或发光边。\n禁止文字、Logo、水印、UI按钮、标题、角标、装饰边框、face label、top/front/right/back/left/bottom文字、背包、装备栏、菜单、角色、完整场景、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、小型果切贴纸、小型橙片贴纸、小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理、纯叶脉纹理、纯颜色块、透明背景、留白、3D平台、圆台、底座、托盘、物体摆在平台上、透视地块、正面30度物体图、鸟瞰地图块、落地投影、接触阴影、方形阴影、白底、灰底、黑底。\nEnglish guardrail: one vertical 1024x1536 image, exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas; each large cell is one complete cube object skin with a fixed 4x3 UV net: row1 col2 top, row2 col1 left, row2 col2 front, row2 col3 right, row2 col4 back, row3 col2 bottom; empty UV cells and gutters are solid magenta {JUMP_HOP_TILE_ATLAS_KEY_HEX}; generate six different face textures that stitch into one recognizable cubified theme object, not one repeated texture and not unrelated icons; fruit theme must create 18 distinct cubified fruits with continuous identity marks across faces; no text labels, no perspective cube render, no baked lighting, no baked shadows, no pedestal, no floor slab, no small centered stickers, no generic flat material; every face is full-bleed opaque square texture and remains recognizable at 64x64 in a 45-degree game camera." ) } fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str { - "文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、纯俯视角、正上方视角、鸟瞰视角、平铺俯拍、顶面占主画面、只看顶面、圆形顶视图、扁平图标、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界" + "文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、单纯平铺材质、抽象纹理、随机斑点、只铺主题颜色、纯果皮材质、纯果肉纹理、纯叶脉纹理、无法分辨具体物体、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、果切小贴纸、橙片小贴纸、小水果居中、苹果小贴纸、香蕉小贴纸、小贴纸图标、小物体居中、透明背景、留白、3D平台、跳板成品、地块成品、物体剪影、正面30度物体图、纯俯视地图块、鸟瞰地图块、透视地块、已经画好的侧壁、已经画好的厚度、烘焙高光、烘焙阴影、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界、可见网格线、编号、裁切标记" } fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { @@ -855,32 +931,41 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { } value = replace_jump_hop_pokemon_prompt_terms(value.as_str()); - const REPLACEMENTS: [(&str, &str); 18] = [ - ("俯视角", "正面30度视角"), - ("正上方视角", "正面30度视角"), - ("鸟瞰视角", "正面30度视角"), - ("平铺俯拍", "正面30度视角"), - ("可落脚平台素材", "跳跃落点主题物体"), - ("清爽游戏化立体感平台素材", "清爽游戏化立体感主题物体"), - ("平台裸素材", "主题物体裸素材"), - ("每格一个完整平台", "每格一个完整主题物体"), - ("平台素材", "主题物体"), - ("可落脚平台", "跳跃落点"), - ("可落脚", "落点"), - ("平台", "主题物体"), - ("跳台", "落点"), - ("地块", "主题物体"), - ("地砖", "主题物体"), + const REPLACEMENTS: [(&str, &str); 24] = [ + ("正面30度视角主题物体图集", "3D立方体主题身份方块包装图集"), + ("物体本身作为跳跃落点", "主题物体方块化后作为立方体包装"), + ("3D立方体主题方块包装图集", "3D立方体主题身份方块包装图集"), + ("立方体主题方块包装图集", "立方体主题身份方块包装图集"), + ("俯视角", "正交平面"), + ("正上方视角", "正交平面"), + ("鸟瞰视角", "正交平面"), + ("平铺俯拍", "正交平面"), + ("可落脚平台素材", "立方体主题身份方块包装贴图"), + ( + "清爽游戏化立体感平台素材", + "清爽游戏化立方体主题身份方块包装贴图", + ), + ("平台裸素材", "立方体主题身份方块包装贴图"), + ("每格一个完整平台", "每格一张完整身份方块包装贴图"), + ("主题物体图集", "立方体主题身份方块包装图集"), + ("主题物体", "主题身份方块包装"), + ("平台素材", "立方体身份方块包装贴图"), + ("可落脚平台", "立方体主题身份方块包装"), + ("可落脚", "可贴图"), + ("平台", "立方体地板"), + ("跳台", "立方体地板"), + ("地块", "身份方块包装贴图"), + ("地砖", "身份方块包装贴图"), ("底座", "承托物"), ("底盘", "承托物"), - ("地板", "承托物"), + ("地板", "立方体地板"), ]; for (from, to) in REPLACEMENTS { value = value.replace(from, to); } - while value.contains("正面30度视角正面30度视角") { - value = value.replace("正面30度视角正面30度视角", "正面30度视角"); + while value.contains("立方体立方体") { + value = value.replace("立方体立方体", "立方体"); } value @@ -889,14 +974,14 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { fn slice_jump_hop_tile_atlas( image: &crate::openai_image_generation::DownloadedOpenAiImage, ) -> Result, AppError> { - let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": JUMP_HOP_CREATION_PROVIDER, - "message": format!("跳一跳地块图集解码失败:{error}"), - })) - })?; - let alpha_options = GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(); - let source = apply_generated_asset_sheet_alpha_with_options(source, alpha_options); + let source = image::load_from_memory(image.bytes.as_slice()) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地板贴图图集解码失败:{error}"), + })) + })? + .to_rgba8(); let width = source.width(); let height = source.height(); let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS; @@ -905,7 +990,7 @@ fn slice_jump_hop_tile_atlas( return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": JUMP_HOP_CREATION_PROVIDER, - "message": "跳一跳地块图集尺寸过小,无法切割。", + "message": "跳一跳地板贴图图集尺寸过小,无法切割。", })), ); } @@ -918,133 +1003,187 @@ fn slice_jump_hop_tile_atlas( let x1 = (col.saturating_add(1)).saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS; let y0 = row.saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; - let cropped = source.crop_imm( + let tile_width = x1.saturating_sub(x0).max(1); + let tile_height = y1.saturating_sub(y0).max(1); + let faces = slice_jump_hop_tile_uv_faces( + &source, x0, y0, - x1.saturating_sub(x0).max(1), - y1.saturating_sub(y0).max(1), - ); - let cleaned = - crop_generated_asset_sheet_view_edge_matte_with_options(cropped, alpha_options); - let cleaned = keep_jump_hop_largest_alpha_component(cleaned); - let cleaned = - crop_generated_asset_sheet_view_edge_matte_with_options(cleaned, alpha_options); - let cleaned = pad_jump_hop_tile_slice_image(cleaned); - let mut cursor = std::io::Cursor::new(Vec::new()); - cleaned - .write_to(&mut cursor, image::ImageFormat::Png) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": JUMP_HOP_CREATION_PROVIDER, - "message": format!("跳一跳地块图集切割失败:{error}"), - })) - })?; + tile_width, + tile_height, + row, + col, + )?; slices.push(JumpHopTileAtlasSlice { tile_type: jump_hop_tile_type_by_index(index), source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), - bytes: cursor.into_inner(), + faces, }); } Ok(slices) } -fn pad_jump_hop_tile_slice_image(image: image::DynamicImage) -> image::DynamicImage { - let source = image.to_rgba8(); - let (width, height) = source.dimensions(); - if width == 0 || height == 0 { - return image::DynamicImage::ImageRgba8(source); - } +fn slice_jump_hop_tile_uv_faces( + source: &image::RgbaImage, + tile_x: u32, + tile_y: u32, + tile_width: u32, + tile_height: u32, + atlas_row: u32, + atlas_col: u32, +) -> Result { + let face_side = (tile_width / JUMP_HOP_TILE_UV_FACE_COLS) + .min(tile_height / JUMP_HOP_TILE_UV_FACE_ROWS) + .max(1); + let uv_width = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_COLS); + let uv_height = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_ROWS); + let uv_x = tile_x.saturating_add(tile_width.saturating_sub(uv_width) / 2); + let uv_y = tile_y.saturating_add(tile_height.saturating_sub(uv_height) / 2); - // 中文注释:生图偶尔会让主体贴近单元格边缘;切片入库前补透明安全边, - // 避免运行态缩放或滤镜让主体看起来被裁掉。 - let pad_x = (width / 12).clamp(8, 24); - let pad_y = (height / 12).clamp(8, 24); - let mut padded = image::RgbaImage::from_pixel( - width.saturating_add(pad_x.saturating_mul(2)), - height.saturating_add(pad_y.saturating_mul(2)), - image::Rgba([0, 0, 0, 0]), - ); - image::imageops::overlay(&mut padded, &source, pad_x.into(), pad_y.into()); - image::DynamicImage::ImageRgba8(padded) + Ok(JumpHopTileFaceSlices { + top: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0, + )?, + front: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1, + )?, + right: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1, + )?, + back: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1, + )?, + left: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1, + )?, + bottom: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2, + )?, + }) } -fn keep_jump_hop_largest_alpha_component(image: image::DynamicImage) -> image::DynamicImage { - let mut source = image.to_rgba8(); - let (width, height) = source.dimensions(); - if width == 0 || height == 0 { - return image::DynamicImage::ImageRgba8(source); +#[allow(clippy::too_many_arguments)] +fn slice_jump_hop_tile_uv_face( + source: &image::RgbaImage, + uv_x: u32, + uv_y: u32, + face_side: u32, + atlas_row: u32, + atlas_col: u32, + face: JumpHopTileFaceKey, + face_col: u32, + face_row: u32, +) -> Result { + let cleaned = crop_jump_hop_tile_texture_cell( + source, + uv_x.saturating_add(face_col.saturating_mul(face_side)), + uv_y.saturating_add(face_row.saturating_mul(face_side)), + face_side, + face_side, + ); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地板 UV 面贴图切割失败:{error}"), + })) + })?; + let face_label = jump_hop_tile_face_key_label(&face); + + Ok(JumpHopTileFaceSlice { + face, + source_atlas_cell: format!( + "row-{}-col-{}/{}", + atlas_row + 1, + atlas_col + 1, + face_label + ), + bytes: cursor.into_inner(), + }) +} + +fn crop_jump_hop_tile_texture_cell( + source: &image::RgbaImage, + x0: u32, + y0: u32, + width: u32, + height: u32, +) -> image::DynamicImage { + let min_side = width.min(height).max(1); + let safe_inset = (min_side / 32).clamp(2, 12); + let inset_x = safe_inset.min(width.saturating_sub(1) / 2); + let inset_y = safe_inset.min(height.saturating_sub(1) / 2); + let crop_width = width.saturating_sub(inset_x.saturating_mul(2)).max(1); + let crop_height = height.saturating_sub(inset_y.saturating_mul(2)).max(1); + let cropped = image::imageops::crop_imm( + source, + x0.saturating_add(inset_x), + y0.saturating_add(inset_y), + crop_width, + crop_height, + ) + .to_image(); + let mut resized = image::imageops::resize( + &cropped, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + image::imageops::FilterType::Lanczos3, + ); + normalize_jump_hop_tile_texture_pixels(&mut resized); + image::DynamicImage::ImageRgba8(resized) +} + +fn normalize_jump_hop_tile_texture_pixels(image: &mut image::RgbaImage) { + let fallback = average_jump_hop_tile_texture_color(image); + for pixel in image.pixels_mut() { + if is_jump_hop_tile_texture_key_pixel(*pixel) { + *pixel = fallback; + } + pixel.0[3] = 255; } +} - // 中文注释:模型偶尔会让相邻格的叶片、果梗或阴影越界进当前格; - // 每格只保留最大的 alpha 连通主体,能去掉这些小碎片再入库。 - let width_usize = width as usize; - let height_usize = height as usize; - let pixel_count = width_usize.saturating_mul(height_usize); - let mut visited = vec![false; pixel_count]; - let mut best_component = Vec::::new(); +fn average_jump_hop_tile_texture_color(image: &image::RgbaImage) -> image::Rgba { + let mut total_r = 0u64; + let mut total_g = 0u64; + let mut total_b = 0u64; + let mut count = 0u64; - for start in 0..pixel_count { - if visited[start] || source.as_raw()[start * 4 + 3] <= 16 { - visited[start] = true; + for pixel in image.pixels() { + if is_jump_hop_tile_texture_key_pixel(*pixel) { continue; } - - let mut queue = VecDeque::from([start]); - let mut component = Vec::::new(); - visited[start] = true; - - while let Some(index) = queue.pop_front() { - component.push(index); - let x = index % width_usize; - let y = index / width_usize; - - for offset_y in -1i32..=1 { - for offset_x in -1i32..=1 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 - { - continue; - } - let next = next_y as usize * width_usize + next_x as usize; - if visited[next] { - continue; - } - visited[next] = true; - if source.as_raw()[next * 4 + 3] > 16 { - queue.push_back(next); - } - } - } - } - - if component.len() > best_component.len() { - best_component = component; - } + total_r += pixel.0[0] as u64; + total_g += pixel.0[1] as u64; + total_b += pixel.0[2] as u64; + count += 1; } - if best_component.is_empty() { - return image::DynamicImage::ImageRgba8(source); + if count == 0 { + return image::Rgba([148, 163, 184, 255]); } - let mut keep = vec![false; pixel_count]; - for index in best_component { - keep[index] = true; - } - for index in 0..pixel_count { - if keep[index] { - continue; - } - let pixel = - source.get_pixel_mut((index % width_usize) as u32, (index / width_usize) as u32); - pixel.0[3] = 0; - } + image::Rgba([ + (total_r / count) as u8, + (total_g / count) as u8, + (total_b / count) as u8, + 255, + ]) +} - image::DynamicImage::ImageRgba8(source) +fn is_jump_hop_tile_texture_key_pixel(pixel: image::Rgba) -> bool { + let [red, green, blue, _] = pixel.0; + let red_delta = red.abs_diff(255) as u32; + let green_delta = green as u32; + let blue_delta = blue.abs_diff(255) as u32; + + red_delta.saturating_mul(red_delta) + + green_delta.saturating_mul(green_delta) + + blue_delta.saturating_mul(blue_delta) + <= 24u32.saturating_mul(24) } fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { @@ -1061,6 +1200,25 @@ fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String { format!("tile-{:02}", tile_index + 1) } +fn jump_hop_tile_face_key_label(face: &JumpHopTileFaceKey) -> &'static str { + match face { + JumpHopTileFaceKey::Top => "top", + JumpHopTileFaceKey::Front => "front", + JumpHopTileFaceKey::Right => "right", + JumpHopTileFaceKey::Back => "back", + JumpHopTileFaceKey::Left => "left", + JumpHopTileFaceKey::Bottom => "bottom", + } +} + +fn jump_hop_tile_face_asset_slot_name(tile_index: usize, face: &JumpHopTileFaceKey) -> String { + format!( + "{}-{}", + jump_hop_tile_asset_slot_name(tile_index), + jump_hop_tile_face_key_label(face) + ) +} + #[allow(clippy::too_many_arguments)] async fn persist_jump_hop_tile_asset( state: &AppState, @@ -1071,8 +1229,97 @@ async fn persist_jump_hop_tile_asset( request_context: &RequestContext, ) -> Result { let slot = jump_hop_tile_asset_slot_name(tile_index); + let top = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.top, + request_context, + ) + .await?; + let front = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.front, + request_context, + ) + .await?; + let right = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.right, + request_context, + ) + .await?; + let back = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.back, + request_context, + ) + .await?; + let left = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.left, + request_context, + ) + .await?; + let bottom = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.bottom, + request_context, + ) + .await?; + let primary = top.clone(); + + Ok(JumpHopTileAsset { + tile_type: tile_slice.tile_type, + tile_id: Some(slot), + image_src: primary.image_src.clone(), + image_object_key: primary.image_object_key.clone(), + asset_object_id: primary.asset_object_id.clone(), + source_atlas_cell: tile_slice.source_atlas_cell, + atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1), + atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1), + visual_width: JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + visual_height: JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + top_surface_radius: 42.0, + landing_radius: 34.0, + face_assets: Some(JumpHopTileFaceAssets { + top, + front, + right, + back, + left, + bottom, + }), + }) +} + +async fn persist_jump_hop_tile_face_asset( + state: &AppState, + owner_user_id: &str, + profile_id: &str, + tile_index: usize, + face_slice: JumpHopTileFaceSlice, + request_context: &RequestContext, +) -> Result { + let slot = jump_hop_tile_face_asset_slot_name(tile_index, &face_slice.face); let image = crate::openai_image_generation::DownloadedOpenAiImage { - bytes: tile_slice.bytes, + bytes: face_slice.bytes, mime_type: "image/png".to_string(), extension: "png".to_string(), }; @@ -1082,31 +1329,29 @@ async fn persist_jump_hop_tile_asset( profile_id, slot.as_str(), &format!( - "跳一跳地块切片 {}:{}", + "跳一跳地板 UV 面贴图 {}:{}", tile_index + 1, - tile_slice.source_atlas_cell + face_slice.source_atlas_cell ), image, LegacyAssetPrefix::JumpHopAssets, - 256, - 192, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, request_context, ) .await?; - Ok(JumpHopTileAsset { - tile_type: tile_slice.tile_type, - tile_id: Some(slot), + Ok(JumpHopTileFaceAsset { + face: face_slice.face, + asset_id: persisted.asset_id, image_src: persisted.image_src, image_object_key: persisted.image_object_key, asset_object_id: persisted.asset_object_id, - source_atlas_cell: tile_slice.source_atlas_cell, - atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1), - atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1), - visual_width: 256, - visual_height: 192, - top_surface_radius: 42.0, - landing_radius: 34.0, + generation_provider: persisted.generation_provider, + prompt: persisted.prompt, + width: persisted.width, + height: persisted.height, + source_atlas_cell: face_slice.source_atlas_cell, }) } @@ -1376,7 +1621,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"), tile_prompt: clean_or_default( &payload.tile_prompt, - &format!("{theme_text}主题的正面30度视角主题物体图集,物体本身作为跳跃落点"), + &format!("{theme_text}主题的3D立方体主题身份方块包装图集"), ), end_mood_prompt: payload .end_mood_prompt @@ -1575,68 +1820,80 @@ mod tests { } #[test] - fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() { + fn jump_hop_tile_atlas_prompt_uses_dedicated_uv_unwrap_floor_layout() { let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台"); - assert!(prompt.contains("五行五列")); - assert!(prompt.contains("25个独立")); - assert!(prompt.contains("跳跃落点主题物体")); - assert!(prompt.contains("不要画成游戏界面")); - assert!(prompt.contains("视觉方向为正面30度视角")); - assert!(prompt.contains("所有落点素材都必须保持统一的正面30度视角")); - assert!(prompt.contains("相机位于物体正前方略高位置")); - assert!(prompt.contains("镜头向下约30度")); - assert!(prompt.contains("能看到清晰正面、侧壁、下沿和少量上表面")); - assert!(prompt.contains("主体正面或侧壁可见面积必须接近或大于顶面面积")); - assert!(prompt.contains("顶面只能作为辅助可见面")); - assert!(prompt.contains("不要让顶面占据主要视觉")); - assert!(prompt.contains("不要画成纯俯视、正上方俯拍、鸟瞰地图块")); - assert!(prompt.contains("水果主题尤其要避免俯拍")); - assert!(prompt.contains("橙瓣必须看到橙皮正面外侧和果肉厚度")); - assert!(prompt.contains("浆果不能只是一个从上往下看的圆形球顶")); - assert!(prompt.contains("主题要一眼可见")); - assert!(prompt.contains("每个落点都是符合主题且有设计感的立体感物体")); - assert!(prompt.contains("清爽自然的休闲手游主题物体素材")); - assert!(prompt.contains("符合主题且有设计感的立体感物体")); - assert!(prompt.contains("每一个落点都必须直接使用主题物体或合理发散物体")); - assert!(prompt.contains("苹果可近似圆")); - assert!(prompt.contains("香蕉可近似长条或长方形")); - assert!(prompt.contains("主题物体本身就是唯一可落脚体")); - assert!(prompt.contains("雪花落点就是一枚带厚度的雪花")); - assert!(prompt.contains("不要在主题物体下面再垫任何石头、土块、木板")); - assert!(prompt.contains("造型规则完全由物体本身决定")); - assert!(prompt.contains("允许圆形、长条、弧形、三角、扇形、块状")); - assert!(prompt.contains("只在同一2D/2.5D手绘风格")); - assert!(prompt.contains("同一正面30度视角")); - assert!(prompt.contains("不使用固定形状脚本")); - assert!(prompt.contains("允许用主题物体自身的切面、边缘厚度")); - assert!(prompt.contains("禁止额外支撑层、承托底座、脚下地板")); - assert!(prompt.contains("四周至少保留18%纯洋红安全留白")); + assert!(prompt.contains("生成一张1024x1536竖版图片")); + assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图")); + assert!(prompt.contains("按三列六行均匀排布")); + assert!(prompt.contains("每个大单元格代表一个完整的 1x1x1 立方体方块物体")); + assert!(prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上")); + assert!(prompt.contains("cube object UV unwrap atlas / 立方体主题物体六面展开图集")); + assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满")); + assert!(prompt.contains("游戏界面或图标集页面")); + assert!(prompt.contains("固定 4列x3行 UV 展开结构")); + assert!(prompt.contains("第1行第2列是 top")); + assert!(prompt.contains("第2行第1列是 left")); + assert!(prompt.contains("第2行第2列是 front")); + assert!(prompt.contains("第2行第3列是 right")); + assert!(prompt.contains("第2行第4列是 back")); + assert!(prompt.contains("第3行第2列是 bottom")); + assert!(prompt.contains("不要改变顺序,不要旋转面")); + assert!(prompt.contains("六个面要属于同一个物体并能组合成完整方块造型")); + assert!(prompt.contains("不能六面各画互不相关的图案,也不能把同一张纹理重复六次")); + assert!(prompt.contains("水果主题要生成18种可一眼辨认的方块水果 UV")); + assert!(prompt.contains("方块苹果、方块香蕉、方块橙子、方块西瓜")); + assert!(prompt.contains("苹果需要果柄叶片跨 top/front")); + assert!(prompt.contains("香蕉需要剥皮条带跨 front/right")); + assert!(prompt.contains("西瓜需要红瓤黑籽和绿皮条纹在各面连续")); + assert!(prompt.contains("不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质")); + assert!(prompt.contains("full-bleed opaque square face texture")); + assert!(prompt.contains("四角、边缘和中心都要有可识别内容")); + assert!(prompt.contains("不留透明、不留空白、不留实底背景")); + assert!(prompt.contains("允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点")); + assert!(prompt.contains("不要把一个小水果、小叶片、小石头或小物体放在面中央")); + assert!(prompt.contains("这不是透视渲染图")); + assert!(prompt.contains("不要画摄像机视角、透视块、已烘焙侧壁")); + assert!(prompt.contains("真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成")); + assert!(prompt.contains("64x64缩略图里仍能分辨主题物体身份")); + assert!(prompt.contains("18个大单元格必须完整落在自己的三列六行网格内")); + assert!(prompt.contains("大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红")); assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX)); - assert!(prompt.contains("主体允许使用绿色、白色、雪地、云朵、草地和花朵")); - assert!(prompt.contains("不绘制落地投影")); - assert!(prompt.contains("不绘制落地投影、接触阴影、方形阴影、洋红阴影")); - assert!(prompt.contains("紫色底边、彩色光晕、发光底边")); - assert!(prompt.contains("不画分隔线、网格线、容器框或棋盘格")); - assert!(prompt.contains("主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影")); - assert!(prompt.contains("English guardrail")); - assert!(prompt.contains("front-facing 30-degree camera-pitch")); - assert!(prompt.contains("camera slightly above the object")); assert!( - prompt.contains("visible front/side area must be close to or larger than the top area") + prompt.contains("贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色") ); - assert!(prompt.contains("never produce top-down")); - assert!(prompt.contains("each object's native silhouette decides the shape")); - assert!(prompt.contains("no extra base under the object")); + assert!(prompt.contains("不得使用接近")); + assert!(prompt.contains("贴图边缘不得有洋红描边、紫色底边、粉色脏边")); + assert!(prompt.contains("自然圆形水果、自然长条香蕉、非方块化完整水果")); + assert!(prompt.contains("小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理")); + assert!(prompt.contains("English guardrail")); + assert!(prompt.contains("one vertical 1024x1536 image")); + assert!(prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas")); + assert!(prompt.contains("row1 col2 top")); + assert!(prompt.contains("row2 col1 left")); + assert!(prompt.contains("row2 col2 front")); + assert!(prompt.contains("row2 col3 right")); + assert!(prompt.contains("row2 col4 back")); + assert!(prompt.contains("row3 col2 bottom")); + assert!(prompt.contains("six different face textures that stitch into one recognizable cubified theme object")); + assert!(prompt.contains("no generic flat material")); + assert!(prompt.contains("no small centered stickers")); + assert!(prompt.contains("every face is full-bleed opaque square texture")); + assert!(prompt.contains("no perspective cube render")); + assert!(prompt.contains("no baked shadows")); assert!(prompt.contains("no pedestal")); assert!(prompt.contains("no floor slab")); - assert!(prompt.contains("no colored shadow or magenta fringe around objects")); + assert!(prompt.contains("empty UV cells and gutters are solid magenta")); assert!(!prompt.contains("可落脚平台素材")); assert!(!prompt.contains("平台裸素材")); assert!(!prompt.contains("每格一个完整平台")); assert!(!prompt.contains("25个平台")); - assert!(!prompt.contains("platform, each")); - assert!(!prompt.contains("only platform")); + assert!(!prompt.contains("跳跃落点主题物体")); + assert!(!prompt.contains("正面30度视角")); + assert!(!prompt.contains("五行五列")); + assert!(!prompt.contains("25张用于跳一跳地板")); + assert!(!prompt.contains("25 full-bleed")); + assert!(!prompt.contains("one square 5x5")); assert!(!prompt.contains("基础轮廓优先做不规则主题剪影")); assert!(!prompt.contains("25格造型要混排")); assert!(!prompt.contains("no simple circles")); @@ -1755,7 +2012,7 @@ mod tests { let normal_prompt = build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集"); assert!(normal_prompt.contains("主题为“水果”")); - assert!(normal_prompt.contains("画面内容是水果主题的正面30度视角主题物体图集")); + assert!(normal_prompt.contains("画面内容是水果主题的3D立方体主题身份方块包装图集")); } #[test] @@ -1765,14 +2022,15 @@ mod tests { "科幻芯片主题的俯视角清爽游戏化立体感平台素材", ); - assert!(prompt.contains("画面内容是科幻芯片主题的正面30度视角清爽游戏化立体感主题物体")); + assert!(prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")); assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材")); assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角")); let top_down_prompt = build_jump_hop_tile_atlas_prompt("水果", "水果主题鸟瞰视角平铺俯拍圆形平台"); - assert!(top_down_prompt.contains("画面内容是水果主题正面30度视角圆形主题物体")); + assert!(top_down_prompt.contains("画面内容是水果主题正交平面")); + assert!(top_down_prompt.contains("圆形立方体地板")); assert!(!top_down_prompt.contains("画面内容是水果主题鸟瞰视角")); assert!(!top_down_prompt.contains("画面内容是水果主题平铺俯拍")); @@ -1781,8 +2039,8 @@ mod tests { "雪花主题可落脚平台素材,每格一个完整平台,不要底座", ); - assert!(legacy_prompt.contains("雪花主题跳跃落点主题物体")); - assert!(legacy_prompt.contains("每格一个完整主题物体")); + assert!(legacy_prompt.contains("雪花主题立方体主题身份方块包装贴图")); + assert!(legacy_prompt.contains("每格一张完整身份方块包装贴图")); assert!(legacy_prompt.contains("不要承托物")); assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚平台素材")); assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚")); @@ -1798,13 +2056,28 @@ mod tests { assert!(negative_prompt.contains("厚重CG渲染")); assert!(negative_prompt.contains("游戏界面")); assert!(negative_prompt.contains("图标集页面")); - assert!(negative_prompt.contains("纯俯视角")); - assert!(negative_prompt.contains("正上方视角")); - assert!(negative_prompt.contains("鸟瞰视角")); - assert!(negative_prompt.contains("顶面占主画面")); - assert!(negative_prompt.contains("只看顶面")); - assert!(negative_prompt.contains("圆形顶视图")); - assert!(negative_prompt.contains("扁平图标")); + assert!(negative_prompt.contains("完整水果")); + assert!(negative_prompt.contains("孤立水果")); + assert!(negative_prompt.contains("果切")); + assert!(negative_prompt.contains("橙片")); + assert!(negative_prompt.contains("苹果小贴纸")); + assert!(negative_prompt.contains("香蕉小贴纸")); + assert!(negative_prompt.contains("小贴纸图标")); + assert!(negative_prompt.contains("纯果皮材质")); + assert!(negative_prompt.contains("无法分辨具体物体")); + assert!(negative_prompt.contains("小物体居中")); + assert!(negative_prompt.contains("透明背景")); + assert!(negative_prompt.contains("留白")); + assert!(negative_prompt.contains("3D平台")); + assert!(negative_prompt.contains("跳板成品")); + assert!(negative_prompt.contains("地块成品")); + assert!(negative_prompt.contains("物体剪影")); + assert!(negative_prompt.contains("正面30度物体图")); + assert!(negative_prompt.contains("透视地块")); + assert!(negative_prompt.contains("已经画好的侧壁")); + assert!(negative_prompt.contains("已经画好的厚度")); + assert!(negative_prompt.contains("烘焙高光")); + assert!(negative_prompt.contains("烘焙阴影")); assert!(negative_prompt.contains("方形阴影")); assert!(negative_prompt.contains("洋红阴影")); assert!(negative_prompt.contains("紫色底边")); @@ -1818,6 +2091,8 @@ mod tests { assert!(negative_prompt.contains("台座")); assert!(negative_prompt.contains("物体摆在平台上")); assert!(negative_prompt.contains("物体下方垫地板")); + assert!(negative_prompt.contains("可见网格线")); + assert!(negative_prompt.contains("裁切标记")); assert!(!negative_prompt.contains("规则圆盘")); assert!(!negative_prompt.contains("正圆平台")); assert!(!negative_prompt.contains("规则方块")); @@ -1828,100 +2103,195 @@ mod tests { assert!(!negative_prompt.contains("楼房")); } - #[test] - fn jump_hop_tile_slice_keeps_largest_alpha_component() { - let mut image = image::RgbaImage::from_pixel(80, 80, image::Rgba([0, 0, 0, 0])); - for y in 12..52 { - for x in 12..52 { - image.put_pixel(x, y, image::Rgba([220, 70, 50, 255])); - } - } - for y in 68..74 { - for x in 36..42 { - image.put_pixel(x, y, image::Rgba([40, 190, 80, 255])); - } - } - - let cleaned = keep_jump_hop_largest_alpha_component(image::DynamicImage::ImageRgba8(image)) - .to_rgba8(); - - assert_eq!(cleaned.get_pixel(20, 20).0[3], 255); - assert_eq!( - cleaned.get_pixel(38, 70).0[3], - 0, - "相邻格侵入的小碎片不应扩大当前地块切片边界" + fn paint_test_uv_face( + atlas: &mut image::RgbaImage, + atlas_col: u32, + atlas_row: u32, + face_col: u32, + face_row: u32, + color: image::Rgba, + ) { + let cell_width = atlas.width() / JUMP_HOP_TILE_ATLAS_COLS; + let cell_height = atlas.height() / JUMP_HOP_TILE_ATLAS_ROWS; + let face_side = (cell_width / JUMP_HOP_TILE_UV_FACE_COLS) + .min(cell_height / JUMP_HOP_TILE_UV_FACE_ROWS) + .max(1); + let tile_x = atlas_col.saturating_mul(cell_width); + let tile_y = atlas_row.saturating_mul(cell_height); + let uv_x = tile_x.saturating_add( + cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2, ); + let uv_y = tile_y.saturating_add( + cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2, + ); + for y in uv_y + face_row * face_side..uv_y + (face_row + 1) * face_side { + for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side { + atlas.put_pixel(x, y, color); + } + } } - #[test] - fn jump_hop_tile_atlas_slices_twenty_five_png_tiles() { - let width = 500; - let height = 500; - let mut atlas = image::RgbaImage::new(width, height); - for row in 0..5 { - for col in 0..5 { - let index = row * 5 + col; - let color = image::Rgba([ - 40 + index as u8 * 3, - 24 + index as u8 * 5, - 120 + index as u8 * 2, - 255, - ]); - for y in row as u32 * 100..(row as u32 + 1) * 100 { - for x in col as u32 * 100..(col as u32 + 1) * 100 { - atlas.put_pixel(x, y, color); - } - } - } + fn load_test_png(bytes: Vec) -> crate::openai_image_generation::DownloadedOpenAiImage { + crate::openai_image_generation::DownloadedOpenAiImage { + bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), } + } + + fn encode_test_atlas(atlas: image::RgbaImage) -> Vec { let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(atlas) .write_to(&mut encoded, image::ImageFormat::Png) .expect("atlas should encode"); - let image = crate::openai_image_generation::DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; + encoded.into_inner() + } + + fn assert_png_contains_color(bytes: &[u8], color: [u8; 4], message: &str) { + let decoded = image::load_from_memory(bytes) + .expect("tile face slice should decode") + .to_rgba8(); + assert_eq!( + decoded.dimensions(), + ( + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE + ), + "{message}" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == color), + "{message}" + ); + assert!( + decoded.pixels().all(|pixel| pixel.0[3] == 255), + "{message}" + ); + } + + #[test] + fn jump_hop_tile_atlas_slices_eighteen_cube_uv_unwrap_tiles() { + let width = 384; + let height = 576; + let mut atlas = + image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255])); + for row in 0..JUMP_HOP_TILE_ATLAS_ROWS { + for col in 0..JUMP_HOP_TILE_ATLAS_COLS { + let index = row * JUMP_HOP_TILE_ATLAS_COLS + col; + let base = index as u8; + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 0, + image::Rgba([40 + base * 3, 24 + base * 2, 100, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 1, + image::Rgba([50 + base * 3, 34 + base * 2, 110, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 2, + 1, + image::Rgba([60 + base * 3, 44 + base * 2, 120, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 3, + 1, + image::Rgba([70 + base * 3, 54 + base * 2, 130, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 0, + 1, + image::Rgba([80 + base * 3, 64 + base * 2, 140, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 2, + image::Rgba([90 + base * 3, 74 + base * 2, 150, 255]), + ); + } + } + let image = load_test_png(encode_test_atlas(atlas)); let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT); for (index, slice) in slices.iter().enumerate() { + let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; + let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; + let base = index as u8; assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index)); assert_eq!( slice.source_atlas_cell, - format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1) + format!("row-{}-col-{}", row + 1, col + 1) ); - let decoded = image::load_from_memory(slice.bytes.as_slice()) - .expect("tile slice should decode") - .to_rgba8(); assert_eq!( - decoded.dimensions(), - (116, 116), - "跳一跳地块切片应在 100x100 单元格外补透明安全边" + slice.faces.top.source_atlas_cell, + format!("row-{}-col-{}/top", row + 1, col + 1) ); - let color = [ - 40 + index as u8 * 3, - 24 + index as u8 * 5, - 120 + index as u8 * 2, - 255, - ]; - assert!( - decoded.pixels().any(|pixel| pixel.0 == color), - "第 {index} 个地块切片应保留对应格子的主体颜色" + assert_eq!( + slice.faces.front.source_atlas_cell, + format!("row-{}-col-{}/front", row + 1, col + 1) + ); + assert_png_contains_color( + slice.faces.top.bytes.as_slice(), + [40 + base * 3, 24 + base * 2, 100, 255], + "top 面应从每格第1行第2列切出", + ); + assert_png_contains_color( + slice.faces.front.bytes.as_slice(), + [50 + base * 3, 34 + base * 2, 110, 255], + "front 面应从每格第2行第2列切出", + ); + assert_png_contains_color( + slice.faces.right.bytes.as_slice(), + [60 + base * 3, 44 + base * 2, 120, 255], + "right 面应从每格第2行第3列切出", + ); + assert_png_contains_color( + slice.faces.back.bytes.as_slice(), + [70 + base * 3, 54 + base * 2, 130, 255], + "back 面应从每格第2行第4列切出", + ); + assert_png_contains_color( + slice.faces.left.bytes.as_slice(), + [80 + base * 3, 64 + base * 2, 140, 255], + "left 面应从每格第2行第1列切出", + ); + assert_png_contains_color( + slice.faces.bottom.bytes.as_slice(), + [90 + base * 3, 74 + base * 2, 150, 255], + "bottom 面应从每格第3行第2列切出", ); } } #[test] fn jump_hop_tile_atlas_slicing_preserves_green_and_white_tile_materials() { - let width = 500; - let height = 500; + let width = 384; + let height = 576; let mut atlas = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255])); - for row in 0..5 { - for col in 0..5 { + for row in 0..JUMP_HOP_TILE_ATLAS_ROWS { + for col in 0..JUMP_HOP_TILE_ATLAS_COLS { let color = if row == 0 && col == 0 { image::Rgba([62, 188, 74, 255]) } else if row == 0 && col == 1 { @@ -1929,30 +2299,16 @@ mod tests { } else { image::Rgba([120, 96, 72, 255]) }; - let center_x = col as u32 * 100 + 50; - let center_y = row as u32 * 100 + 50; - for y in center_y - 24..center_y + 24 { - for x in center_x - 28..center_x + 28 { - atlas.put_pixel(x, y, color); - } - } + paint_test_uv_face(&mut atlas, col, row, 1, 0, color); } } - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(atlas) - .write_to(&mut encoded, image::ImageFormat::Png) - .expect("atlas should encode"); - let image = crate::openai_image_generation::DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; + let image = load_test_png(encode_test_atlas(atlas)); let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); - let green_tile = image::load_from_memory(slices[0].bytes.as_slice()) + let green_tile = image::load_from_memory(slices[0].faces.top.bytes.as_slice()) .expect("green tile should decode") .to_rgba8(); - let white_tile = image::load_from_memory(slices[1].bytes.as_slice()) + let white_tile = image::load_from_memory(slices[1].faces.top.bytes.as_slice()) .expect("white tile should decode") .to_rgba8(); @@ -1966,12 +2322,32 @@ mod tests { .pixels() .any(|pixel| pixel.0 == [246, 246, 238, 255]) ); - assert_eq!(green_tile.get_pixel(0, 0).0[3], 0); - assert_eq!(white_tile.get_pixel(0, 0).0[3], 0); + assert_eq!(green_tile.get_pixel(0, 0).0[3], 255); + assert_eq!(white_tile.get_pixel(0, 0).0[3], 255); + assert!( + green_tile.pixels().all(|pixel| pixel.0[3] == 255), + "绿色主题材质不能被透明化扣掉" + ); + assert!( + white_tile.pixels().all(|pixel| pixel.0[3] == 255), + "白色主题材质不能被透明化扣掉" + ); + assert!( + green_tile + .pixels() + .all(|pixel| pixel.0 != [255, 0, 255, 255]), + "残留洋红 key 色应被转成不透明材质底色,不能留成可见边" + ); + assert!( + white_tile + .pixels() + .all(|pixel| pixel.0 != [255, 0, 255, 255]), + "残留洋红 key 色应被转成不透明材质底色,不能留成可见边" + ); } #[test] - fn jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices() { + fn jump_hop_tile_asset_slots_are_unique_for_eighteen_slices() { let slots = (0..JUMP_HOP_TILE_ITEM_COUNT) .map(jump_hop_tile_asset_slot_name) .collect::>(); @@ -1983,7 +2359,31 @@ mod tests { assert_eq!( unique_slots.len(), JUMP_HOP_TILE_ITEM_COUNT, - "25 个地块切片必须写入 25 个独立 slot/path,不能按重复的 tile_type 互相覆盖" + "18 个地板 UV 大单元必须写入 18 个独立 slot/path,不能按重复的 tile_type 互相覆盖" + ); + + let face_slots = (0..JUMP_HOP_TILE_ITEM_COUNT) + .flat_map(|index| { + [ + JumpHopTileFaceKey::Top, + JumpHopTileFaceKey::Front, + JumpHopTileFaceKey::Right, + JumpHopTileFaceKey::Back, + JumpHopTileFaceKey::Left, + JumpHopTileFaceKey::Bottom, + ] + .into_iter() + .map(move |face| jump_hop_tile_face_asset_slot_name(index, &face)) + }) + .collect::>(); + let unique_face_slots = face_slots + .iter() + .cloned() + .collect::>(); + assert_eq!( + unique_face_slots.len(), + JUMP_HOP_TILE_ITEM_COUNT * 6, + "18 个地板 UV 大单元的 108 张面贴图必须写入独立 slot/path" ); } } diff --git a/server-rs/crates/api-server/src/login_options.rs b/server-rs/crates/api-server/src/login_options.rs index f71f4d51..19fe4bde 100644 --- a/server-rs/crates/api-server/src/login_options.rs +++ b/server-rs/crates/api-server/src/login_options.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Json, extract::{Extension, State}, }; diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index aaecd923..1867c754 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -89,10 +89,7 @@ mod tracking_outbox; mod vector_engine_audio_generation; mod visual_novel; mod volcengine_speech; -mod wechat_auth; -mod wechat_pay; -mod wechat_provider; -mod wechat_subscribe_message; +mod wechat; mod wooden_fish; mod work_author; mod work_play_tracking; @@ -122,6 +119,7 @@ use crate::{ const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8); +const AUTH_STORE_STARTUP_RETRY_INTERVAL: Duration = Duration::from_secs(5); #[derive(Clone)] struct ShutdownContext { @@ -321,6 +319,25 @@ fn build_tcp_listener( async fn restore_app_state_for_startup( config: AppConfig, +) -> Result { + loop { + match try_restore_app_state_for_startup(config.clone()).await { + Ok(state) => return Ok(state), + Err(state::AppStateInitError::DependencyUnavailable(message)) => { + warn!( + retry_after_seconds = AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), + error = %message, + "启动恢复 SpacetimeDB 认证快照暂不可用,api-server 将继续重试" + ); + tokio::time::sleep(AUTH_STORE_STARTUP_RETRY_INTERVAL).await; + } + Err(error) => return Err(error), + } + } +} + +async fn try_restore_app_state_for_startup( + config: AppConfig, ) -> Result { match timeout( AUTH_STORE_STARTUP_RESTORE_TIMEOUT, @@ -332,7 +349,7 @@ async fn restore_app_state_for_startup( Err(_) => { error!( timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(), - "启动等待 SpacetimeDB 恢复认证快照超时,api-server 将进入依赖不可用模式" + "启动等待 SpacetimeDB 恢复认证快照超时" ); Err(state::AppStateInitError::DependencyUnavailable( "SpacetimeDB 启动恢复认证快照超时".to_string(), @@ -415,7 +432,10 @@ fn is_valid_env_key(key: &str) -> bool { #[cfg(test)] mod tests { - use super::{is_valid_env_key, protected_env_keys_from, strip_env_value}; + use super::{ + AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from, + strip_env_value, + }; #[test] fn strip_env_value_removes_wrapping_quotes() { @@ -456,4 +476,9 @@ mod tests { assert!(!protected.contains("ALIYUN_OSS_ENDPOINT")); assert!(protected.contains("ALIYUN_OSS_ACCESS_KEY_ID")); } + + #[test] + fn startup_dependency_retry_interval_is_short_enough_for_service_recovery() { + assert_eq!(AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), 5); + } } diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 5f4f2e22..5517c6bd 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::BTreeMap, convert::Infallible, future::Future, @@ -84,6 +84,10 @@ use crate::{ vector_engine_audio_generation::{ GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation, }, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent"; diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index 103bf594..eb99ec38 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -323,27 +323,56 @@ pub(super) async fn compile_match3d_draft_for_session( ) .await; - if let Err(response) = result.as_ref() - && response.status().is_server_error() - { - let failure_message = match3d_response_failure_message(response); - persist_failed_match3d_draft_generation( - state, - request_context, - authenticated, - compile_session_id, - compile_owner_user_id, - compile_profile_id, - compile_initial_game_name, - compile_requested_summary, - compile_initial_tags, - compile_requested_cover_image_src, - failure_message, - ) - .await; + match result { + Ok((session, generated_item_assets)) => { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id: compile_owner_user_id.clone(), + task_name: Some("抓大鹅".to_string()), + work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + Ok((session, generated_item_assets)) + } + Err(response) if response.status().is_server_error() => { + let failure_message = match3d_response_failure_message(&response); + persist_failed_match3d_draft_generation( + state, + request_context, + authenticated, + compile_session_id, + compile_owner_user_id.clone(), + compile_profile_id, + compile_initial_game_name.clone(), + compile_requested_summary, + compile_initial_tags, + compile_requested_cover_image_src, + failure_message, + ) + .await; + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id: compile_owner_user_id, + task_name: Some("抓大鹅".to_string()), + work_name: Some(compile_initial_game_name), + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + Err(response) + } + Err(response) => Err(response), } - - result } #[allow(clippy::too_many_arguments)] diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index 1cac08d9..88caf30d 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -10,10 +10,12 @@ pub mod internal; pub mod jump_hop; pub mod match3d; pub mod platform; +pub mod play_flow; pub mod profile; pub mod public_work; pub mod puzzle; pub mod puzzle_clear; pub mod square_hole; pub mod story; +pub mod visual_novel; pub mod wooden_fish; diff --git a/server-rs/crates/api-server/src/modules/assets.rs b/server-rs/crates/api-server/src/modules/assets.rs index a03e8372..73da2d27 100644 --- a/server-rs/crates/api-server/src/modules/assets.rs +++ b/server-rs/crates/api-server/src/modules/assets.rs @@ -6,7 +6,7 @@ use axum::{ use crate::{ assets::{ bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket, - create_sts_upload_credentials, get_asset_history, get_asset_read_bytes, get_asset_read_url, + create_sts_upload_credentials, get_asset_read_bytes, get_asset_read_url, }, auth::require_bearer_auth, state::AppState, @@ -44,11 +44,4 @@ pub fn router(state: AppState) -> Router { ) .route("/api/assets/read-url", get(get_asset_read_url)) .route("/api/assets/read-bytes", get(get_asset_read_bytes)) - .route( - "/api/assets/history", - get(get_asset_history).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) } diff --git a/server-rs/crates/api-server/src/modules/auth.rs b/server-rs/crates/api-server/src/modules/auth.rs index 784cdad2..5cb67df2 100644 --- a/server-rs/crates/api-server/src/modules/auth.rs +++ b/server-rs/crates/api-server/src/modules/auth.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Router, middleware, routing::{get, post}, }; @@ -16,7 +16,7 @@ use crate::{ phone_auth::{phone_login, send_phone_code}, refresh_session::refresh_session, state::AppState, - wechat_auth::{ + wechat::auth::{ bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login, }, }; diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index 2ed65a3b..26cd3ab8 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -1,7 +1,6 @@ use axum::{ - middleware, + Router, middleware, routing::{get, post}, - Router, }; use crate::{ @@ -9,9 +8,8 @@ use crate::{ jump_hop::{ create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action, get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work, - get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump, - list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, - start_jump_hop_run, + get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump, list_jump_hop_gallery, + list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run, }, state::AppState, }; diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs index 12efa9f0..ce399eec 100644 --- a/server-rs/crates/api-server/src/modules/platform.rs +++ b/server-rs/crates/api-server/src/modules/platform.rs @@ -1,40 +1,11 @@ use axum::{ - Router, - extract::DefaultBodyLimit, - middleware, + Router, middleware, routing::{get, post}, }; use crate::{ - ai_tasks::{ - append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, - complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage, - }, auth::require_bearer_auth, - character_animation_assets::{ - generate_character_animation, get_character_animation_job, get_character_workflow_cache, - import_character_animation_video, list_character_animation_templates, - publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow, - save_character_workflow_cache, - }, - character_visual_assets::{ - generate_character_visual, get_character_visual_job, publish_character_visual, - }, - creation_agent_document_input::parse_creation_agent_document_input, - creation_entry_config::get_creation_entry_config_handler, - hyper3d_generation::{ - get_hyper3d_downloads, get_hyper3d_task_status, submit_hyper3d_image_to_model, - submit_hyper3d_text_to_model, - }, llm::proxy_llm_chat_completions, - runtime_chat::stream_runtime_npc_chat_turn, - runtime_chat_plain::{ - generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary, - stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue, - stream_runtime_npc_recruit_dialogue, - }, - runtime_save::{delete_runtime_snapshot, get_runtime_snapshot, put_runtime_snapshot}, - runtime_settings::{get_runtime_settings, put_runtime_settings}, state::AppState, volcengine_speech::{ get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection, @@ -42,8 +13,6 @@ use crate::{ }, }; -const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024; - pub fn router(state: AppState) -> Router { Router::new() .route( @@ -81,213 +50,4 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) - .route( - "/api/runtime/chat/character/suggestions", - post(generate_runtime_character_chat_suggestions).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), - ) - .route( - "/api/runtime/chat/character/summary", - post(generate_runtime_character_chat_summary).route_layer( - middleware::from_fn_with_state(state.clone(), require_bearer_auth), - ), - ) - .route( - "/api/runtime/chat/character/reply/stream", - post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/chat/npc/dialogue/stream", - post(stream_runtime_npc_chat_dialogue).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/chat/npc/turn/stream", - post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/chat/npc/recruit/stream", - post(stream_runtime_npc_recruit_dialogue).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/creation-agent/document-inputs/parse", - post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/ai/tasks", - post(create_ai_task).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/ai/tasks/{task_id}/start", - post(start_ai_task).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/ai/tasks/{task_id}/stages/{stage_kind}/start", - post(start_ai_task_stage).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/ai/tasks/{task_id}/chunks", - post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/ai/tasks/{task_id}/stages/{stage_kind}/complete", - post(complete_ai_stage).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/ai/tasks/{task_id}/references", - post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/ai/tasks/{task_id}/complete", - post(complete_ai_task).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/ai/tasks/{task_id}/fail", - post(fail_ai_task).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/ai/tasks/{task_id}/cancel", - post(cancel_ai_task).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/assets/character-visual/generate", - post(generate_character_visual), - ) - .route( - "/api/assets/character-visual/jobs/{task_id}", - get(get_character_visual_job), - ) - .route( - "/api/assets/character-visual/publish", - post(publish_character_visual), - ) - .route( - "/api/assets/character-animation/generate", - post(generate_character_animation), - ) - .route( - "/api/assets/character-animation/jobs/{task_id}", - get(get_character_animation_job), - ) - .route( - "/api/assets/character-animation/publish", - post(publish_character_animation), - ) - .route( - "/api/assets/character-animation/import-video", - post(import_character_animation_video), - ) - .route( - "/api/assets/character-animation/templates", - get(list_character_animation_templates), - ) - .route( - "/api/assets/character-workflow-cache", - post(save_character_workflow_cache), - ) - .route( - "/api/assets/character-workflow-cache/{character_id}", - get(get_character_workflow_cache), - ) - .route( - "/api/runtime/custom-world/asset-studio/role/{character_id}/workflow", - post(resolve_role_asset_workflow).put(put_role_asset_workflow), - ) - .route( - "/api/assets/hyper3d/text-to-model", - post(submit_hyper3d_text_to_model).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/assets/hyper3d/image-to-model", - post(submit_hyper3d_image_to_model) - .layer(DefaultBodyLimit::max( - HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/assets/hyper3d/status", - post(get_hyper3d_task_status).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/assets/hyper3d/download", - post(get_hyper3d_downloads).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/creation-entry/config", - get(get_creation_entry_config_handler), - ) - .route( - "/api/runtime/settings", - get(get_runtime_settings) - .put(put_runtime_settings) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/save/snapshot", - get(get_runtime_snapshot) - .put(put_runtime_snapshot) - .delete(delete_runtime_snapshot) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) } diff --git a/server-rs/crates/api-server/src/modules/play_flow.rs b/server-rs/crates/api-server/src/modules/play_flow.rs new file mode 100644 index 00000000..be85f260 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/play_flow.rs @@ -0,0 +1,1028 @@ +use axum::{ + Router, + body::Body, + extract::DefaultBodyLimit, + http::Request, + middleware::{self, Next}, + response::Response, + routing::{get, post}, +}; + +use crate::{ + ai_tasks::{ + append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, + complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage, + }, + assets::get_asset_history, + auth::require_bearer_auth, + character_animation_assets::{ + generate_character_animation, get_character_animation_job, get_character_workflow_cache, + import_character_animation_video, list_character_animation_templates, + publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow, + save_character_workflow_cache, + }, + character_visual_assets::{ + generate_character_visual, get_character_visual_job, publish_character_visual, + }, + creation_agent_document_input::parse_creation_agent_document_input, + creation_entry_config::get_creation_entry_config_handler, + hyper3d_generation::{ + get_hyper3d_downloads, get_hyper3d_task_status, submit_hyper3d_image_to_model, + submit_hyper3d_text_to_model, + }, + runtime_browse_history::{ + delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history, + }, + runtime_chat::stream_runtime_npc_chat_turn, + runtime_chat_plain::{ + generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary, + stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue, + stream_runtime_npc_recruit_dialogue, + }, + runtime_inventory::get_runtime_inventory_state, + runtime_profile::get_profile_play_stats, + runtime_save::{delete_runtime_snapshot, get_runtime_snapshot, put_runtime_snapshot}, + runtime_save::{list_profile_save_archives, resume_profile_save_archive}, + runtime_settings::{get_runtime_settings, put_runtime_settings}, + state::AppState, +}; + +const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PlayFlowDomainAdapter { + pub play_id: &'static str, + pub module_key: &'static str, + pub creation_route_prefixes: &'static [&'static str], + pub runtime_route_prefixes: &'static [&'static str], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PlayFlowStage { + Creation, + Runtime, + CreationEntryConfig, + CreationSupport, + RuntimeSupport, + AiTask, + PublicReadModel, + RuntimeInventory, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PlayFlowRequestContext { + pub stage: PlayFlowStage, + pub play_id: Option<&'static str>, + pub module_key: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CreationEntryRouteMatcher { + Exact(&'static str), + PrefixAndSuffix { + prefix: &'static str, + suffix: &'static str, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CreationEntryRouteRule { + play_id: &'static str, + matcher: CreationEntryRouteMatcher, +} + +impl CreationEntryRouteMatcher { + fn matches(self, normalized_path: &str) -> bool { + match self { + Self::Exact(path) => normalized_path == path, + Self::PrefixAndSuffix { prefix, suffix } => { + normalized_path.starts_with(prefix) && normalized_path.ends_with(suffix) + } + } + } +} + +impl PlayFlowDomainAdapter { + fn creation_matches(self, normalized_path: &str) -> bool { + self.creation_route_prefixes + .iter() + .any(|prefix| normalized_path.starts_with(prefix)) + } + + fn runtime_matches(self, normalized_path: &str) -> bool { + self.runtime_route_prefixes + .iter() + .any(|prefix| normalized_path.starts_with(prefix)) + } +} + +// 中文注释:平台玩法流程先在这里统一注册;HTTP handler 仍在最后一步分发到各领域模块执行。 +pub(crate) const PLAY_FLOW_DOMAIN_ADAPTERS: &[PlayFlowDomainAdapter] = &[ + PlayFlowDomainAdapter { + play_id: "rpg", + module_key: "custom_world", + creation_route_prefixes: &[ + "/api/runtime/custom-world/agent", + "/api/runtime/custom-world/profile", + "/api/runtime/custom-world-gallery/", + ], + runtime_route_prefixes: &[ + "/api/runtime/custom-world-library", + "/api/runtime/custom-world-gallery", + "/api/runtime/custom-world/", + ], + }, + PlayFlowDomainAdapter { + play_id: "creative-agent", + module_key: "story", + creation_route_prefixes: &["/api/runtime/creative-agent"], + runtime_route_prefixes: &["/api/story"], + }, + PlayFlowDomainAdapter { + play_id: "big-fish", + module_key: "big_fish", + creation_route_prefixes: &["/api/runtime/big-fish/agent"], + runtime_route_prefixes: &["/api/runtime/big-fish"], + }, + PlayFlowDomainAdapter { + play_id: "puzzle", + module_key: "puzzle", + creation_route_prefixes: &[ + "/api/runtime/puzzle/agent", + "/api/runtime/puzzle/onboarding", + "/api/runtime/puzzle/gallery/", + ], + runtime_route_prefixes: &["/api/runtime/puzzle"], + }, + PlayFlowDomainAdapter { + play_id: "match3d", + module_key: "match3d", + creation_route_prefixes: &["/api/creation/match3d"], + runtime_route_prefixes: &["/api/runtime/match3d"], + }, + PlayFlowDomainAdapter { + play_id: "square-hole", + module_key: "square_hole", + creation_route_prefixes: &["/api/creation/square-hole"], + runtime_route_prefixes: &["/api/runtime/square-hole"], + }, + PlayFlowDomainAdapter { + play_id: "jump-hop", + module_key: "jump_hop", + creation_route_prefixes: &["/api/creation/jump-hop"], + runtime_route_prefixes: &["/api/runtime/jump-hop"], + }, + PlayFlowDomainAdapter { + play_id: "wooden-fish", + module_key: "wooden_fish", + creation_route_prefixes: &["/api/creation/wooden-fish"], + runtime_route_prefixes: &["/api/runtime/wooden-fish"], + }, + PlayFlowDomainAdapter { + play_id: "puzzle-clear", + module_key: "puzzle_clear", + creation_route_prefixes: &["/api/creation/puzzle-clear"], + runtime_route_prefixes: &["/api/runtime/puzzle-clear"], + }, + PlayFlowDomainAdapter { + play_id: "bark-battle", + module_key: "bark_battle", + creation_route_prefixes: &["/api/creation/bark-battle"], + runtime_route_prefixes: &["/api/runtime/bark-battle"], + }, + PlayFlowDomainAdapter { + play_id: "visual-novel", + module_key: "visual_novel", + creation_route_prefixes: &["/api/creation/visual-novel", "/api/creation/audio"], + runtime_route_prefixes: &["/api/runtime/visual-novel"], + }, + PlayFlowDomainAdapter { + play_id: "baby-object-match", + module_key: "edutainment", + creation_route_prefixes: &["/api/creation/edutainment/baby-object-match"], + runtime_route_prefixes: &[], + }, + PlayFlowDomainAdapter { + play_id: "baby-love-drawing", + module_key: "edutainment", + creation_route_prefixes: &["/api/creation/edutainment/baby-love-drawing"], + runtime_route_prefixes: &[], + }, +]; + +const NEW_CREATION_ROUTE_RULES: &[CreationEntryRouteRule] = &[ + CreationEntryRouteRule { + play_id: "puzzle", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/puzzle/agent/sessions"), + }, + CreationEntryRouteRule { + play_id: "puzzle", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/puzzle/onboarding/generate"), + }, + CreationEntryRouteRule { + play_id: "puzzle", + matcher: CreationEntryRouteMatcher::PrefixAndSuffix { + prefix: "/api/runtime/puzzle/gallery/", + suffix: "/remix", + }, + }, + CreationEntryRouteRule { + play_id: "big-fish", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/big-fish/agent/sessions"), + }, + CreationEntryRouteRule { + play_id: "big-fish", + matcher: CreationEntryRouteMatcher::PrefixAndSuffix { + prefix: "/api/runtime/big-fish/gallery/", + suffix: "/remix", + }, + }, + CreationEntryRouteRule { + play_id: "rpg", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/custom-world/agent/sessions"), + }, + CreationEntryRouteRule { + play_id: "rpg", + matcher: CreationEntryRouteMatcher::Exact("/api/runtime/custom-world/profile"), + }, + CreationEntryRouteRule { + play_id: "rpg", + matcher: CreationEntryRouteMatcher::PrefixAndSuffix { + prefix: "/api/runtime/custom-world-gallery/", + suffix: "/remix", + }, + }, + CreationEntryRouteRule { + play_id: "match3d", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/match3d/sessions"), + }, + CreationEntryRouteRule { + play_id: "square-hole", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/square-hole/sessions"), + }, + CreationEntryRouteRule { + play_id: "bark-battle", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/bark-battle/drafts"), + }, + CreationEntryRouteRule { + play_id: "wooden-fish", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/wooden-fish/sessions"), + }, + CreationEntryRouteRule { + play_id: "jump-hop", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/jump-hop/sessions"), + }, + CreationEntryRouteRule { + play_id: "puzzle-clear", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/puzzle-clear/sessions"), + }, + CreationEntryRouteRule { + play_id: "visual-novel", + matcher: CreationEntryRouteMatcher::Exact("/api/creation/visual-novel/sessions"), + }, + CreationEntryRouteRule { + play_id: "baby-object-match", + matcher: CreationEntryRouteMatcher::Exact( + "/api/creation/edutainment/baby-object-match/assets", + ), + }, + CreationEntryRouteRule { + play_id: "baby-love-drawing", + matcher: CreationEntryRouteMatcher::Exact( + "/api/creation/edutainment/baby-love-drawing/magic", + ), + }, +]; + +pub fn router(state: AppState) -> Router { + assert_play_flow_domain_registry(); + + Router::new() + .merge(play_flow_support_router(state.clone())) + .merge(super::public_work::router(state.clone())) + .merge(super::story::router(state.clone())) + .merge(super::custom_world::router(state.clone())) + .merge(super::big_fish::router(state.clone())) + .merge(super::bark_battle::router(state.clone())) + .merge(super::match3d::router(state.clone())) + .merge(super::square_hole::router(state.clone())) + .merge(super::jump_hop::router(state.clone())) + .merge(super::wooden_fish::router(state.clone())) + .merge(super::puzzle_clear::router(state.clone())) + .merge(super::puzzle::router(state.clone())) + .merge(super::visual_novel::router(state.clone())) + .merge(super::edutainment::router(state.clone())) + .route( + "/api/runtime/sessions/{runtime_session_id}/inventory", + get(get_runtime_inventory_state) + .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)), + ) + .layer(middleware::from_fn(attach_play_flow_request_context)) +} + +fn play_flow_support_router(state: AppState) -> Router { + Router::new() + .route( + "/api/creation-entry/config", + get(get_creation_entry_config_handler), + ) + .route( + "/api/runtime/chat/character/suggestions", + post(generate_runtime_character_chat_suggestions).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/runtime/chat/character/summary", + post(generate_runtime_character_chat_summary).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/runtime/chat/character/reply/stream", + post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/chat/npc/dialogue/stream", + post(stream_runtime_npc_chat_dialogue).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/chat/npc/turn/stream", + post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/chat/npc/recruit/stream", + post(stream_runtime_npc_recruit_dialogue).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/creation-agent/document-inputs/parse", + post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks", + post(create_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/start", + post(start_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/stages/{stage_kind}/start", + post(start_ai_task_stage).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/chunks", + post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/stages/{stage_kind}/complete", + post(complete_ai_stage).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/references", + post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/complete", + post(complete_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/fail", + post(fail_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/cancel", + post(cancel_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/assets/character-visual/generate", + post(generate_character_visual), + ) + .route( + "/api/assets/character-visual/jobs/{task_id}", + get(get_character_visual_job), + ) + .route( + "/api/assets/character-visual/publish", + post(publish_character_visual), + ) + .route( + "/api/assets/character-animation/generate", + post(generate_character_animation), + ) + .route( + "/api/assets/character-animation/jobs/{task_id}", + get(get_character_animation_job), + ) + .route( + "/api/assets/character-animation/publish", + post(publish_character_animation), + ) + .route( + "/api/assets/character-animation/import-video", + post(import_character_animation_video), + ) + .route( + "/api/assets/character-animation/templates", + get(list_character_animation_templates), + ) + .route( + "/api/assets/character-workflow-cache", + post(save_character_workflow_cache), + ) + .route( + "/api/assets/character-workflow-cache/{character_id}", + get(get_character_workflow_cache), + ) + .route( + "/api/assets/history", + get(get_asset_history).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world/asset-studio/role/{character_id}/workflow", + post(resolve_role_asset_workflow).put(put_role_asset_workflow), + ) + .route( + "/api/assets/hyper3d/text-to-model", + post(submit_hyper3d_text_to_model).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/assets/hyper3d/image-to-model", + post(submit_hyper3d_image_to_model) + .layer(DefaultBodyLimit::max( + HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/assets/hyper3d/status", + post(get_hyper3d_task_status).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/assets/hyper3d/download", + post(get_hyper3d_downloads).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/settings", + get(get_runtime_settings) + .put(put_runtime_settings) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/save/snapshot", + get(get_runtime_snapshot) + .put(put_runtime_snapshot) + .delete(delete_runtime_snapshot) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/browse-history", + get(get_runtime_browse_history) + .post(post_runtime_browse_history) + .delete(delete_runtime_browse_history) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/save-archives", + get(list_profile_save_archives).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/save-archives/{world_key}", + post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/play-stats", + get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) +} + +pub(crate) fn play_flow_domain_adapters() -> &'static [PlayFlowDomainAdapter] { + PLAY_FLOW_DOMAIN_ADAPTERS +} + +fn assert_play_flow_domain_registry() { + debug_assert!( + play_flow_domain_adapters().iter().all(|adapter| { + !adapter.play_id.is_empty() + && !adapter.module_key.is_empty() + && (!adapter.creation_route_prefixes.is_empty() + || !adapter.runtime_route_prefixes.is_empty()) + }), + "play flow domain adapters must declare identity and at least one route prefix" + ); +} + +pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { + let normalized = path.trim_end_matches('/'); + NEW_CREATION_ROUTE_RULES + .iter() + .find(|rule| rule.matcher.matches(normalized)) + .map(|rule| rule.play_id) +} + +pub(crate) fn resolve_play_flow_request_context(path: &str) -> Option { + let normalized = path.trim_end_matches('/'); + if let Some(context) = resolve_play_flow_support_context(normalized) { + return Some(context); + } + if normalized == "/api/public-works" || normalized.starts_with("/api/public-works/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::PublicReadModel, + play_id: None, + module_key: "public_work", + }); + } + if normalized.starts_with("/api/runtime/sessions/") && normalized.ends_with("/inventory") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeInventory, + play_id: None, + module_key: "runtime_inventory", + }); + } + + play_flow_domain_adapters() + .iter() + .find(|adapter| adapter.creation_matches(normalized)) + .map(|adapter| PlayFlowRequestContext { + stage: PlayFlowStage::Creation, + play_id: Some(adapter.play_id), + module_key: adapter.module_key, + }) + .or_else(|| { + play_flow_domain_adapters() + .iter() + .find(|adapter| adapter.runtime_matches(normalized)) + .map(|adapter| PlayFlowRequestContext { + stage: PlayFlowStage::Runtime, + play_id: Some(adapter.play_id), + module_key: adapter.module_key, + }) + }) +} + +fn resolve_play_flow_support_context(normalized_path: &str) -> Option { + if normalized_path == "/api/creation-entry/config" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationEntryConfig, + play_id: None, + module_key: "creation_entry_config", + }); + } + if normalized_path.starts_with("/api/runtime/chat/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: Some("rpg"), + module_key: "runtime_chat", + }); + } + if normalized_path == "/api/runtime/creation-agent/document-inputs/parse" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("creative-agent"), + module_key: "creation_agent_document_input", + }); + } + if normalized_path == "/api/ai/tasks" || normalized_path.starts_with("/api/ai/tasks/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::AiTask, + play_id: None, + module_key: "ai_tasks", + }); + } + if normalized_path.starts_with("/api/assets/character-visual/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "character_visual_assets", + }); + } + if normalized_path.starts_with("/api/assets/character-animation/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "character_animation_assets", + }); + } + if normalized_path == "/api/assets/character-workflow-cache" + || normalized_path.starts_with("/api/assets/character-workflow-cache/") + || normalized_path.starts_with("/api/runtime/custom-world/asset-studio/") + { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("rpg"), + module_key: "custom_world_asset_studio", + }); + } + if normalized_path == "/api/assets/history" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "asset_history", + }); + } + if normalized_path.starts_with("/api/assets/hyper3d/") { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "hyper3d_generation", + }); + } + if normalized_path == "/api/runtime/settings" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_settings", + }); + } + if normalized_path == "/api/runtime/save/snapshot" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_save", + }); + } + if normalized_path == "/api/profile/browse-history" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_browse_history", + }); + } + if normalized_path == "/api/profile/save-archives" + || normalized_path.starts_with("/api/profile/save-archives/") + { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_save_archives", + }); + } + if normalized_path == "/api/profile/play-stats" { + return Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "profile_play_stats", + }); + } + + None +} + +async fn attach_play_flow_request_context(mut request: Request, next: Next) -> Response { + if let Some(context) = resolve_play_flow_request_context(request.uri().path()) { + request.extensions_mut().insert(context); + } + + next.run(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn play_flow_registry_keeps_current_domain_adapters_together() { + let adapters = play_flow_domain_adapters(); + for play_id in [ + "rpg", + "creative-agent", + "big-fish", + "puzzle", + "match3d", + "square-hole", + "jump-hop", + "wooden-fish", + "puzzle-clear", + "bark-battle", + "visual-novel", + "baby-object-match", + "baby-love-drawing", + ] { + assert!( + adapters.iter().any(|adapter| adapter.play_id == play_id), + "{play_id} should be registered in the unified play flow" + ); + } + } + + #[test] + fn resolves_new_creation_paths_before_domain_fanout() { + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"), + Some("puzzle"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/puzzle-clear/sessions"), + Some("puzzle-clear"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"), + Some("puzzle"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/match3d/sessions"), + Some("match3d"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/square-hole/sessions"), + Some("square-hole"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"), + Some("visual-novel"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/big-fish/agent/sessions"), + Some("big-fish"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id( + "/api/runtime/custom-world-gallery/user-1/profile-1/remix" + ), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/story/sessions/runtime"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"), + Some("bark-battle"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"), + None, + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"), + Some("wooden-fish"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"), + Some("baby-object-match"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/edutainment/baby-love-drawing/magic"), + Some("baby-love-drawing"), + ); + assert_eq!(resolve_creation_entry_route_id("/healthz"), None); + } + + #[test] + fn resolves_unified_play_flow_context_before_domain_handlers() { + assert_eq!( + resolve_play_flow_request_context("/api/creation/match3d/sessions"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::Creation, + play_id: Some("match3d"), + module_key: "match3d", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/jump-hop/runs"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::Runtime, + play_id: Some("jump-hop"), + module_key: "jump_hop", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/public-works/JH-12345678"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::PublicReadModel, + play_id: None, + module_key: "public_work", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/sessions/runtime-1/inventory"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeInventory, + play_id: None, + module_key: "runtime_inventory", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/creation-entry/config"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationEntryConfig, + play_id: None, + module_key: "creation_entry_config", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/chat/npc/turn/stream"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: Some("rpg"), + module_key: "runtime_chat", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/creation-agent/document-inputs/parse"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("creative-agent"), + module_key: "creation_agent_document_input", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/ai/tasks/aitask_001/complete"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::AiTask, + play_id: None, + module_key: "ai_tasks", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/character-workflow-cache/role-1"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("rpg"), + module_key: "custom_world_asset_studio", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/character-visual/generate"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "character_visual_assets", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/history"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "asset_history", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/character-animation/jobs/task-1"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "character_animation_assets", + }), + ); + assert_eq!( + resolve_play_flow_request_context( + "/api/runtime/custom-world/asset-studio/role/role-1/workflow" + ), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: Some("rpg"), + module_key: "custom_world_asset_studio", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/assets/hyper3d/text-to-model"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::CreationSupport, + play_id: None, + module_key: "hyper3d_generation", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/settings"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_settings", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/runtime/save/snapshot"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_save", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/profile/browse-history"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_browse_history", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/profile/save-archives/world-1"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "runtime_save_archives", + }), + ); + assert_eq!( + resolve_play_flow_request_context("/api/profile/play-stats"), + Some(PlayFlowRequestContext { + stage: PlayFlowStage::RuntimeSupport, + play_id: None, + module_key: "profile_play_stats", + }), + ); + assert_eq!(resolve_play_flow_request_context("/api/profile/me"), None); + } +} diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs index 8875caf2..54b17f8b 100644 --- a/server-rs/crates/api-server/src/modules/profile.rs +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -6,18 +6,13 @@ use axum::{ use crate::{ auth::require_bearer_auth, profile_identity::update_profile_identity, - runtime_browse_history::{ - delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history, - }, runtime_profile::{ claim_profile_task_reward, confirm_wechat_profile_recharge_order, create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard, - get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, - get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code, - redeem_profile_reward_code, stream_wechat_profile_recharge_order_events, - submit_profile_feedback, + get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center, + get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code, + stream_wechat_profile_recharge_order_events, submit_profile_feedback, }, - runtime_save::{list_profile_save_archives, resume_profile_save_archive}, state::AppState, }; @@ -30,16 +25,6 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) - .route( - "/api/profile/browse-history", - get(get_runtime_browse_history) - .post(post_runtime_browse_history) - .delete(delete_runtime_browse_history) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) .route( "/api/profile/dashboard", get(get_profile_dashboard).route_layer(middleware::from_fn_with_state( @@ -131,25 +116,4 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) - .route( - "/api/profile/save-archives", - get(list_profile_save_archives).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/profile/save-archives/{world_key}", - post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/profile/play-stats", - get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) } diff --git a/server-rs/crates/api-server/src/modules/visual_novel.rs b/server-rs/crates/api-server/src/modules/visual_novel.rs new file mode 100644 index 00000000..521122ca --- /dev/null +++ b/server-rs/crates/api-server/src/modules/visual_novel.rs @@ -0,0 +1,183 @@ +use axum::{ + Router, middleware, + routing::{get, post}, +}; + +use crate::{ + auth::require_bearer_auth, + state::AppState, + vector_engine_audio_generation::{ + create_background_music_task, create_sound_effect_task, + create_visual_novel_background_music_task, create_visual_novel_sound_effect_task, + publish_background_music_asset, publish_sound_effect_asset, + publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset, + }, + visual_novel::{ + compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work, + execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session, + get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history, + list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run, + start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message, + submit_visual_novel_message, update_visual_novel_work, + }, +}; + +pub fn router(state: AppState) -> Router { + Router::new() + .route( + "/api/creation/visual-novel/sessions", + post(create_visual_novel_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/visual-novel/sessions/{session_id}", + get(get_visual_novel_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/visual-novel/sessions/{session_id}/messages", + post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/visual-novel/sessions/{session_id}/messages/stream", + post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/visual-novel/sessions/{session_id}/actions", + post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/visual-novel/sessions/{session_id}/compile", + post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/visual-novel/works", + get(list_visual_novel_works).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/visual-novel/works/{profile_id}", + get(get_visual_novel_work) + .put(update_visual_novel_work) + .patch(update_visual_novel_work) + .delete(delete_visual_novel_work) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/visual-novel/works/{profile_id}/publish", + post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/visual-novel/audio/background-music", + post(create_visual_novel_background_music_task).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/creation/visual-novel/audio/background-music/{task_id}/asset", + post(publish_visual_novel_background_music_asset).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/creation/visual-novel/audio/sound-effect", + post(create_visual_novel_sound_effect_task).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/creation/visual-novel/audio/sound-effect/{task_id}/asset", + post(publish_visual_novel_sound_effect_asset).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/creation/audio/background-music", + post(create_background_music_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/audio/background-music/{task_id}/asset", + post(publish_background_music_asset).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/audio/sound-effect", + post(create_sound_effect_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/audio/sound-effect/{task_id}/asset", + post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/visual-novel/gallery", + get(list_visual_novel_gallery), + ) + .route( + "/api/runtime/visual-novel/works/{profile_id}/runs", + post(start_visual_novel_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/visual-novel/runs/{run_id}", + get(get_visual_novel_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/visual-novel/runs/{run_id}/actions/stream", + post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/visual-novel/runs/{run_id}/history", + get(list_visual_novel_history).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/visual-novel/runs/{run_id}/regenerate", + post(regenerate_visual_novel_run) + .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)), + ) +} diff --git a/server-rs/crates/api-server/src/modules/wooden_fish.rs b/server-rs/crates/api-server/src/modules/wooden_fish.rs index e377e235..de9d5df7 100644 --- a/server-rs/crates/api-server/src/modules/wooden_fish.rs +++ b/server-rs/crates/api-server/src/modules/wooden_fish.rs @@ -24,9 +24,7 @@ pub fn router(state: AppState) -> Router { "/api/creation/wooden-fish/sessions", post(create_wooden_fish_session) // 中文注释:兼容旧小程序把参考图或录音 Data URL 放进创作 JSON 的请求;新前端音频会先直传 OSS。 - .layer(DefaultBodyLimit::max( - WOODEN_FISH_CREATION_BODY_LIMIT_BYTES, - )) + .layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES)) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, @@ -43,9 +41,7 @@ pub fn router(state: AppState) -> Router { "/api/creation/wooden-fish/sessions/{session_id}/actions", post(execute_wooden_fish_action) // 中文注释:compile/regenerate 会携带参考图旧兼容输入,避免 Axum 默认 2MB 先于 handler 拦截。 - .layer(DefaultBodyLimit::max( - WOODEN_FISH_CREATION_BODY_LIMIT_BYTES, - )) + .layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES)) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, @@ -98,4 +94,4 @@ pub fn router(state: AppState) -> Router { "/api/runtime/wooden-fish/gallery/{public_work_code}", get(get_wooden_fish_gallery_detail), ) -} \ No newline at end of file +} diff --git a/server-rs/crates/api-server/src/platform_errors.rs b/server-rs/crates/api-server/src/platform_errors.rs index 6da6f87c..792fb881 100644 --- a/server-rs/crates/api-server/src/platform_errors.rs +++ b/server-rs/crates/api-server/src/platform_errors.rs @@ -1,4 +1,4 @@ -use axum::http::{HeaderValue, StatusCode}; +use axum::http::{HeaderValue, StatusCode}; use platform_auth::{AuthPlatformErrorKind, WechatProviderError}; use platform_llm::{LlmError, LlmErrorKind}; use platform_oss::{OssError, OssErrorKind}; diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 6aef4f88..b428f2f2 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::{BTreeMap, HashSet}, sync::{Mutex, OnceLock}, time::{Instant, SystemTime, UNIX_EPOCH}, @@ -105,7 +105,7 @@ use crate::{ puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json}, request_context::RequestContext, state::{AppState, PuzzleApiState}, - wechat_subscribe_message::{ + wechat::subscribe_message::{ GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, send_generation_result_subscribe_message_after_completion, }, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index ec10fb5c..2fa1a265 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -641,6 +641,7 @@ pub async fn execute_puzzle_agent_action( state.root_state(), GenerationResultSubscribeMessage { owner_user_id, + task_name: Some("拼图".to_string()), work_name: None, status: GenerationResultSubscribeMessageStatus::Failed, consumed_points: 0, @@ -768,6 +769,7 @@ pub async fn execute_puzzle_agent_action( &background_root_state, GenerationResultSubscribeMessage { owner_user_id: background_owner_user_id.clone(), + task_name: Some("拼图".to_string()), work_name: session .draft .as_ref() @@ -814,6 +816,7 @@ pub async fn execute_puzzle_agent_action( &background_root_state, GenerationResultSubscribeMessage { owner_user_id: background_owner_user_id.clone(), + task_name: Some("拼图".to_string()), work_name: background_work_name.clone(), status: GenerationResultSubscribeMessageStatus::Failed, @@ -1491,6 +1494,7 @@ pub async fn execute_puzzle_agent_action( state.root_state(), GenerationResultSubscribeMessage { owner_user_id: owner_user_id.clone(), + task_name: Some("拼图".to_string()), work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()), status: GenerationResultSubscribeMessageStatus::Succeeded, consumed_points: operation_consumed_points, diff --git a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs index 4c66badb..77a6bbfc 100644 --- a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs +++ b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs @@ -9,12 +9,12 @@ use shared_contracts::{ puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse}, puzzle_works::PuzzleWorkSummaryResponse, }; +#[cfg(test)] +use tokio::sync::OwnedMutexGuard; use tokio::{ sync::{Mutex, MutexGuard, RwLock}, time, }; -#[cfg(test)] -use tokio::sync::OwnedMutexGuard; use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext}; diff --git a/server-rs/crates/api-server/src/runtime_browse_history.rs b/server-rs/crates/api-server/src/runtime_browse_history.rs index 7981ad82..17a499af 100644 --- a/server-rs/crates/api-server/src/runtime_browse_history.rs +++ b/server-rs/crates/api-server/src/runtime_browse_history.rs @@ -270,8 +270,8 @@ mod tests { #[tokio::test] async fn runtime_browse_history_rejects_blank_required_fields() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -316,8 +316,8 @@ mod tests { #[tokio::test] async fn runtime_browse_history_accepts_batch_shape_and_surfaces_backend_failure_as_bad_gateway() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -361,23 +361,21 @@ mod tests { ); } - async fn seed_authenticated_state() -> AppState { + async fn seed_authenticated_state() -> (AppState, String) { let state = AppState::new(AppConfig::default()).expect("state should build"); - state + let user_id = state .seed_test_phone_user_with_password("13800138102", "secret123") .await .id; - state + (state, user_id) } - fn issue_access_token(state: &AppState) -> String { + fn issue_access_token(state: &AppState, user_id: &str) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), - session_id: state.seed_test_refresh_session_for_user_id( - "user_00000001", - "sess_runtime_browse_history", - ), + user_id: user_id.to_string(), + session_id: state + .seed_test_refresh_session_for_user_id(user_id, "sess_runtime_browse_history"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index c02efa28..65d67d45 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -26,6 +26,7 @@ use module_runtime::{ RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; +use platform_wechat::pay::WechatPayNotifyOrder; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::Sha256; @@ -81,9 +82,9 @@ use crate::{ http_error::AppError, request_context::RequestContext, state::AppState, - wechat_pay::{ - WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request, - current_unix_micros, map_wechat_pay_error, + wechat::pay::{ + build_wechat_payment_request, build_wechat_web_payment_request, current_unix_micros, + map_wechat_pay_error, }, }; @@ -3056,11 +3057,12 @@ mod tests { } fn issue_access_token(state: &AppState) -> String { + let user_id = test_authenticated_user_id(state); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), + user_id: user_id.clone(), session_id: state - .seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"), + .seed_test_refresh_session_for_user_id(&user_id, "sess_runtime_profile"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, @@ -3081,11 +3083,11 @@ mod tests { client_platform: &str, session_id: &str, ) -> String { + let user_id = test_authenticated_user_id(state); let claims = AccessTokenClaims::from_input_with_device( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), - session_id: state - .seed_test_refresh_session_for_user_id("user_00000001", session_id), + user_id: user_id.clone(), + session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id), provider: AuthProvider::Wechat, roles: vec!["user".to_string()], token_version: 2, @@ -3105,4 +3107,13 @@ mod tests { sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } + + fn test_authenticated_user_id(state: &AppState) -> String { + state + .auth_user_service() + .get_user_by_public_user_code("SY-00000001") + .expect("test user lookup should succeed") + .expect("seeded test user should exist") + .id + } } diff --git a/server-rs/crates/api-server/src/runtime_save.rs b/server-rs/crates/api-server/src/runtime_save.rs index 3b02de8f..9a05ca8a 100644 --- a/server-rs/crates/api-server/src/runtime_save.rs +++ b/server-rs/crates/api-server/src/runtime_save.rs @@ -347,8 +347,8 @@ mod tests { #[tokio::test] async fn runtime_snapshot_checkpoint_rejects_legacy_full_snapshot_upload() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -379,8 +379,8 @@ mod tests { #[tokio::test] async fn runtime_snapshot_checkpoint_requires_existing_server_snapshot() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -407,9 +407,9 @@ mod tests { #[tokio::test] async fn runtime_snapshot_checkpoint_rejects_session_mismatch() { - let state = seed_authenticated_state().await; - seed_runtime_snapshot(&state, "runtime-server", "adventure").await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + seed_runtime_snapshot(&state, user_id.as_str(), "runtime-server", "adventure").await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -436,9 +436,9 @@ mod tests { #[tokio::test] async fn runtime_snapshot_checkpoint_uses_persisted_server_snapshot() { - let state = seed_authenticated_state().await; - seed_runtime_snapshot(&state, "runtime-main", "adventure").await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + seed_runtime_snapshot(&state, user_id.as_str(), "runtime-main", "adventure").await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -509,8 +509,8 @@ mod tests { #[tokio::test] async fn resume_profile_save_archive_rejects_blank_world_key() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -529,21 +529,26 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } - async fn seed_authenticated_state() -> AppState { + async fn seed_authenticated_state() -> (AppState, String) { let state = AppState::new(AppConfig::default()).expect("state should build"); - state + let user_id = state .seed_test_phone_user_with_password("13800138105", "secret123") .await .id; - state + (state, user_id) } - async fn seed_runtime_snapshot(state: &AppState, session_id: &str, bottom_tab: &str) { + async fn seed_runtime_snapshot( + state: &AppState, + user_id: &str, + session_id: &str, + bottom_tab: &str, + ) { let now = OffsetDateTime::now_utc(); let now_micros = shared_kernel::offset_datetime_to_unix_micros(now); state .put_runtime_snapshot_record( - "user_00000001".to_string(), + user_id.to_string(), now_micros - 2_000_000, bottom_tab.to_string(), json!({ @@ -571,12 +576,12 @@ mod tests { .expect("runtime snapshot should seed"); } - fn issue_access_token(state: &AppState) -> String { + fn issue_access_token(state: &AppState, user_id: &str) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), + user_id: user_id.to_string(), session_id: state - .seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_save"), + .seed_test_refresh_session_for_user_id(user_id, "sess_runtime_save"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_settings.rs b/server-rs/crates/api-server/src/runtime_settings.rs index 8535f692..26df7b03 100644 --- a/server-rs/crates/api-server/src/runtime_settings.rs +++ b/server-rs/crates/api-server/src/runtime_settings.rs @@ -184,8 +184,8 @@ mod tests { #[tokio::test] async fn runtime_settings_returns_bad_gateway_when_spacetime_not_published() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -221,8 +221,8 @@ mod tests { #[tokio::test] async fn runtime_settings_rejects_invalid_theme_with_envelope() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let response = app @@ -266,8 +266,8 @@ mod tests { #[tokio::test] #[ignore = "需要本地 SpacetimeDB xushi-p4wfr 已启动并发布当前 module;验证 PUT/GET settings 主链"] async fn runtime_settings_round_trip_against_local_spacetimedb() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, user_id) = seed_authenticated_state().await; + let token = issue_access_token(&state, user_id.as_str()); let app = build_router(state); let put_response = app @@ -337,23 +337,21 @@ mod tests { assert_eq!(get_payload["data"]["musicVolume"], json!(1.0)); } - async fn seed_authenticated_state() -> AppState { + async fn seed_authenticated_state() -> (AppState, String) { let state = AppState::new(AppConfig::default()).expect("state should build"); - state + let user_id = state .seed_test_phone_user_with_password("13800138106", "secret123") .await .id; - state + (state, user_id) } - fn issue_access_token(state: &AppState) -> String { + fn issue_access_token(state: &AppState, user_id: &str) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), - session_id: state.seed_test_refresh_session_for_user_id( - "user_00000001", - "sess_runtime_settings", - ), + user_id: user_id.to_string(), + session_id: state + .seed_test_refresh_session_for_user_id(user_id, "sess_runtime_settings"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index 45f0e05a..dcfd3ddd 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -81,12 +81,18 @@ use crate::{ SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn, }, state::AppState, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent"; const SQUARE_HOLE_WORKS_PROVIDER: &str = "square-hole-works"; const SQUARE_HOLE_RUNTIME_PROVIDER: &str = "square-hole-runtime"; +const SQUARE_HOLE_TEMPLATE_ID: &str = "square-hole"; +const SQUARE_HOLE_TEMPLATE_NAME: &str = "方洞"; const SQUARE_HOLE_DEFAULT_THEME: &str = "纸箱"; const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能"; const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12; @@ -1112,14 +1118,24 @@ async fn compile_square_hole_draft_for_session( .as_ref() .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); - state + let resolved_game_name = game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))); + let generation_points_cost = + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + SQUARE_HOLE_TEMPLATE_ID, + u64::from( + shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST, + ), + ) + .await; + let result = state .spacetime_client() .compile_square_hole_draft(SquareHoleCompileDraftRecordInput { session_id, - owner_user_id, + owner_user_id: owner_user_id.clone(), profile_id: build_prefixed_uuid_id(SQUARE_HOLE_PROFILE_ID_PREFIX), author_display_name: resolve_author_display_name(state, authenticated), - game_name: game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))), + game_name: resolved_game_name.clone(), summary_text: summary, tags_json, cover_image_src, @@ -1132,7 +1148,43 @@ async fn compile_square_hole_draft_for_session( SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) - }) + }); + match result { + Ok(session) => { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()), + work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + Ok(session) + } + Err(response) => { + if response.status().is_server_error() { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()), + work_name: resolved_game_name, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + Err(response) + } + } } mod visual_assets; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index e03ca441..49d0e381 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -27,7 +27,7 @@ use platform_auth::{ }; use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider}; use platform_oss::{OssClient, OssConfig, OssError}; -use platform_wechat::{WechatClient, WechatConfig}; +use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient}; use serde_json::Value; use shared_contracts::creation_entry_config::CreationEntryConfigResponse; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; @@ -39,8 +39,8 @@ use tracing::{info, warn}; use crate::config::AppConfig; use crate::puzzle_gallery_cache::PuzzleGalleryCache; use crate::tracking_outbox::TrackingOutbox; -use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error}; -use crate::wechat_provider::build_wechat_provider; +use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; +use crate::wechat::provider::build_wechat_provider; use crate::work_author::{ ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID, }; @@ -388,8 +388,8 @@ impl AppState { let wechat_auth_service = WechatAuthService::new(auth_store.clone()); let wechat_provider = build_wechat_provider(&config); let wechat_client = build_wechat_client(&config); - let wechat_pay_client = - WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?; + let wechat_pay_client = WechatPayClient::from_config(&build_wechat_pay_config(&config)) + .map_err(map_wechat_pay_init_error)?; let refresh_session_service = RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days); // AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。 @@ -1378,8 +1378,9 @@ fn build_llm_client(config: &AppConfig) -> Result, AppStateIni fn build_creative_agent_gpt5_client( config: &AppConfig, ) -> Result, AppStateInitError> { + // 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine。 let Some(api_key) = config - .apimart_api_key + .vector_engine_api_key .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) @@ -1387,9 +1388,15 @@ fn build_creative_agent_gpt5_client( return Ok(None); }; + let base_url = if config.vector_engine_base_url.ends_with("/v1") { + config.vector_engine_base_url.clone() + } else { + format!("{}/v1", config.vector_engine_base_url.trim_end_matches('/')) + }; + let llm_config = LlmConfig::new( LlmProvider::OpenAiCompatible, - config.apimart_base_url.clone(), + base_url, api_key.to_string(), platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(), config.llm_request_timeout_ms, @@ -1512,11 +1519,11 @@ mod tests { } #[test] - fn app_state_builds_creative_agent_gpt5_client_from_apimart_settings() { + fn app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings() { let mut config = AppConfig::default(); config.llm_api_key = None; - config.apimart_base_url = "https://api.apimart.test/v1".to_string(); - config.apimart_api_key = Some("apimart-key".to_string()); + config.vector_engine_base_url = "https://api.vectorengine.test".to_string(); + config.vector_engine_api_key = Some("ve-key".to_string()); let state = AppState::new(config).expect("state should build"); let client = state @@ -1529,7 +1536,7 @@ mod tests { ); assert_eq!( client.config().responses_url(), - "https://api.apimart.test/v1/responses" + "https://api.vectorengine.test/v1/responses" ); assert!(client.config().official_fallback()); } diff --git a/server-rs/crates/api-server/src/visual_novel.rs b/server-rs/crates/api-server/src/visual_novel.rs index 08c1749b..228c147b 100644 --- a/server-rs/crates/api-server/src/visual_novel.rs +++ b/server-rs/crates/api-server/src/visual_novel.rs @@ -35,6 +35,10 @@ use crate::{ prompt::visual_novel as vn_prompt, request_context::RequestContext, state::AppState, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_author::resolve_work_author_by_user_id, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; @@ -1743,8 +1747,18 @@ async fn compile_visual_novel_session_inner( current_utc_iso().as_str(), ); let projection = project_draft_for_work(&draft, &profile_id)?; + let notification_work_name = projection.work_title.clone(); + let generation_points_cost = + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + VISUAL_NOVEL_RUNTIME_KIND, + u64::from( + shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST, + ), + ) + .await; let author = resolve_work_author_by_user_id(state, &owner_user_id, None, None); - let compiled_session = state + let compile_result = state .spacetime_client() .compile_visual_novel_work_profile(VisualNovelWorkCompileRecordInput { session_id: session_id.clone(), @@ -1759,9 +1773,43 @@ async fn compile_visual_novel_session_inner( compiled_at_micros: current_utc_micros(), }) .await - .map_err(|error| { - visual_novel_error_response(request_context, map_spacetime_error(error)) - })?; + .map_err(|error| visual_novel_error_response(request_context, map_spacetime_error(error))); + let compiled_session = match compile_result { + Ok(session) => { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id: owner_user_id.clone(), + task_name: Some("视觉小说".to_string()), + work_name: Some(notification_work_name.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + session + } + Err(response) => { + if response.status().is_server_error() { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some("视觉小说".to_string()), + work_name: Some(notification_work_name), + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + return Err(response); + } + }; let work = state .spacetime_client() .get_visual_novel_work_detail(profile_id, owner_user_id) diff --git a/server-rs/crates/api-server/src/wechat.rs b/server-rs/crates/api-server/src/wechat.rs new file mode 100644 index 00000000..5d17d3bd --- /dev/null +++ b/server-rs/crates/api-server/src/wechat.rs @@ -0,0 +1,4 @@ +pub(crate) mod auth; +pub(crate) mod pay; +pub(crate) mod provider; +pub(crate) mod subscribe_message; diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat/auth.rs similarity index 99% rename from server-rs/crates/api-server/src/wechat_auth.rs rename to server-rs/crates/api-server/src/wechat/auth.rs index 165dc5e7..28446f33 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Json, extract::{Extension, Query, State}, http::{HeaderMap, StatusCode}, diff --git a/server-rs/crates/api-server/src/wechat/pay.rs b/server-rs/crates/api-server/src/wechat/pay.rs new file mode 100644 index 00000000..438a439d --- /dev/null +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -0,0 +1,423 @@ +use axum::{ + Json, + extract::{Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use platform_wechat::pay::{ + WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayConfig, + WechatPayError, WechatWebOrderRequest, decrypt_wechat_message_push_ciphertext, + parse_virtual_payment_notify, parse_wechat_mini_program_message_push_payload, + resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature, +}; +use serde::Serialize; +use serde_json::json; +use shared_kernel::offset_datetime_to_unix_micros; +use time::OffsetDateTime; +use tracing::{info, warn}; + +use crate::{config::AppConfig, http_error::AppError, state::AppState}; + +#[derive(Clone, Copy)] +enum VirtualPaymentNotifyResponseFormat { + Json, + Xml, +} + +#[derive(Serialize)] +struct ApiWechatVirtualPaymentNotifyResponse { + #[serde(rename = "ErrCode")] + err_code: i32, + #[serde(rename = "ErrMsg")] + err_msg: String, +} + +pub async fn handle_wechat_pay_notify( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + let notify = state + .wechat_pay_client() + .parse_notify(&headers, &body) + .map_err(map_wechat_pay_notify_error)?; + if notify.trade_state != "SUCCESS" { + info!( + order_id = notify.out_trade_no.as_str(), + trade_state = notify.trade_state.as_str(), + "收到非成功微信支付通知" + ); + return Ok(StatusCode::NO_CONTENT); + } + + let paid_at_micros = notify + .success_time + .as_deref() + .and_then(|value| shared_kernel::parse_rfc3339(value).ok()) + .map(offset_datetime_to_unix_micros) + .unwrap_or_else(current_unix_micros); + + state + .spacetime_client() + .mark_profile_recharge_order_paid( + notify.out_trade_no.clone(), + paid_at_micros, + notify.transaction_id.clone(), + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("确认微信支付订单失败:{error}")) + })?; + info!( + order_id = notify.out_trade_no.as_str(), + "微信支付通知已确认订单入账" + ); + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn handle_wechat_virtual_payment_message_push_verify( + State(state): State, + Query(query): Query, +) -> Response { + let token = match read_wechat_message_push_config( + state.config.wechat_mini_program_message_token.as_deref(), + "WECHAT_MINIPROGRAM_MESSAGE_TOKEN", + ) { + Ok(token) => token, + Err(error) => return build_wechat_message_push_verify_error_response(error), + }; + let aes_key = match read_wechat_message_push_config( + state + .config + .wechat_mini_program_message_encoding_aes_key + .as_deref(), + "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", + ) { + Ok(value) => value, + Err(error) => return build_wechat_message_push_verify_error_response(error), + }; + match resolve_wechat_message_push_verify_response( + token, + aes_key, + state + .config + .wechat_mini_program_app_id + .as_deref() + .or(state.config.wechat_app_id.as_deref()), + &query, + ) { + Ok(plaintext) => (StatusCode::OK, plaintext).into_response(), + Err(error) => build_wechat_message_push_verify_error_response(error), + } +} + +pub async fn handle_wechat_virtual_payment_notify( + State(state): State, + headers: HeaderMap, + Query(query): Query, + body: Bytes, +) -> Response { + let response_format = detect_virtual_payment_notify_response_format(&headers, &body); + let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) { + Ok(payload) => payload, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + let token = match read_wechat_message_push_config( + state.config.wechat_mini_program_message_token.as_deref(), + "WECHAT_MINIPROGRAM_MESSAGE_TOKEN", + ) { + Ok(token) => token, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + let aes_key = match read_wechat_message_push_config( + state + .config + .wechat_mini_program_message_encoding_aes_key + .as_deref(), + "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", + ) { + Ok(value) => value, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + let signature = query + .msg_signature + .as_deref() + .or(query.signature.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or(""); + let nonce = query.nonce.as_deref().map(str::trim).unwrap_or(""); + if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() { + return build_virtual_payment_notify_error_response( + WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()), + response_format, + ); + } + if !verify_wechat_message_push_signature( + token, + timestamp, + nonce, + encrypted_payload.encrypt.as_str(), + signature, + ) { + return build_virtual_payment_notify_error_response( + WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()), + response_format, + ); + } + let notify_body = match decrypt_wechat_message_push_ciphertext( + aes_key, + encrypted_payload.encrypt.as_str(), + state + .config + .wechat_mini_program_app_id + .as_deref() + .or(state.config.wechat_app_id.as_deref()), + ) { + Ok(body) => body, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) { + Ok(notify) => notify, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" { + info!( + event = notify.event.as_str(), + order_id = notify.out_trade_no.as_str(), + "收到非订单入账虚拟支付推送" + ); + return build_virtual_payment_notify_success_response(response_format); + } + + let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros); + if state + .spacetime_client() + .mark_profile_recharge_order_paid( + notify.out_trade_no.clone(), + paid_at_micros, + notify.transaction_id.clone(), + ) + .await + .is_err() + { + warn!( + order_id = notify.out_trade_no.as_str(), + "确认微信虚拟支付订单失败" + ); + return build_virtual_payment_notify_error_response( + WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()), + response_format, + ); + } + + state.publish_profile_recharge_order_update(notify.out_trade_no.clone()); + + info!( + event = notify.event.as_str(), + order_id = notify.out_trade_no.as_str(), + "微信虚拟支付推送已确认订单入账" + ); + + build_virtual_payment_notify_success_response(response_format) +} + +pub fn build_wechat_pay_config(config: &AppConfig) -> WechatPayConfig { + WechatPayConfig { + enabled: config.wechat_pay_enabled, + provider: config.wechat_pay_provider.clone(), + app_id: config + .wechat_mini_program_app_id + .clone() + .or_else(|| config.wechat_app_id.clone()), + mch_id: config.wechat_pay_mch_id.clone(), + merchant_serial_no: config.wechat_pay_merchant_serial_no.clone(), + private_key_pem: config.wechat_pay_private_key_pem.clone(), + private_key_path: config.wechat_pay_private_key_path.clone(), + platform_public_key_pem: config.wechat_pay_platform_public_key_pem.clone(), + platform_public_key_path: config.wechat_pay_platform_public_key_path.clone(), + platform_serial_no: config.wechat_pay_platform_serial_no.clone(), + api_v3_key: config.wechat_pay_api_v3_key.clone(), + notify_url: config.wechat_pay_notify_url.clone(), + jsapi_endpoint: config.wechat_pay_jsapi_endpoint.clone(), + } +} + +pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { + match error { + WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付暂未启用") + .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::InvalidConfig(message) => { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message(message) + .with_details(json!({ "provider": "wechat_pay" })) + } + WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(message) + .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::RequestFailed(message) + | WechatPayError::Upstream(message) + | WechatPayError::Deserialize(message) + | WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(message) + .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::InvalidSignature(message) => { + AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("微信支付通知签名无效") + .with_details(json!({ "provider": "wechat_pay", "reason": message })) + } + } +} + +pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError { + crate::state::AppStateInitError::WechatPay(error.to_string()) +} + +pub fn build_wechat_payment_request( + order_id: String, + product_title: String, + amount_cents: u64, + payer_openid: String, +) -> WechatMiniProgramOrderRequest { + WechatMiniProgramOrderRequest { + order_id, + description: format!("陶泥儿 - {product_title}"), + amount_cents, + payer_openid, + } +} + +pub fn build_wechat_web_payment_request( + order_id: String, + product_title: String, + amount_cents: u64, + payer_client_ip: String, +) -> WechatWebOrderRequest { + WechatWebOrderRequest { + order_id, + description: format!("陶泥儿 - {product_title}"), + amount_cents, + payer_client_ip, + } +} + +pub fn current_unix_micros() -> i64 { + let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + i64::try_from(value).unwrap_or(i64::MAX) +} + +fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { + warn!(error = %error, "微信支付通知处理失败"); + map_wechat_pay_error(error) +} + +fn read_wechat_message_push_config<'a>( + value: Option<&'a str>, + key: &str, +) -> Result<&'a str, WechatPayError> { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置"))) +} + +fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response { + let message = match error { + WechatPayError::Disabled => "微信消息推送暂未启用".to_string(), + WechatPayError::InvalidConfig(message) + | WechatPayError::InvalidRequest(message) + | WechatPayError::RequestFailed(message) + | WechatPayError::Upstream(message) + | WechatPayError::Deserialize(message) + | WechatPayError::Crypto(message) + | WechatPayError::InvalidSignature(message) => message, + }; + (StatusCode::BAD_REQUEST, message).into_response() +} + +fn build_virtual_payment_notify_error_response( + error: WechatPayError, + response_format: VirtualPaymentNotifyResponseFormat, +) -> Response { + warn!(error = %error, "微信虚拟支付通知处理失败"); + let message = match error { + WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(), + WechatPayError::InvalidConfig(message) + | WechatPayError::InvalidRequest(message) + | WechatPayError::RequestFailed(message) + | WechatPayError::Upstream(message) + | WechatPayError::Deserialize(message) + | WechatPayError::Crypto(message) + | WechatPayError::InvalidSignature(message) => message, + }; + build_virtual_payment_notify_response(response_format, 1, message) +} + +fn build_virtual_payment_notify_success_response( + response_format: VirtualPaymentNotifyResponseFormat, +) -> Response { + build_virtual_payment_notify_response(response_format, 0, "success") +} + +fn build_virtual_payment_notify_response( + response_format: VirtualPaymentNotifyResponseFormat, + err_code: i32, + err_msg: impl Into, +) -> Response { + let err_msg = err_msg.into(); + match response_format { + VirtualPaymentNotifyResponseFormat::Json => Json( + build_wechat_virtual_payment_notify_response(err_code, err_msg), + ) + .into_response(), + VirtualPaymentNotifyResponseFormat::Xml => { + let body = format!( + "{err_code}" + ); + let mut response = (StatusCode::OK, body).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/xml; charset=utf-8"), + ); + response + } + } +} + +fn build_wechat_virtual_payment_notify_response( + err_code: i32, + err_msg: impl Into, +) -> ApiWechatVirtualPaymentNotifyResponse { + ApiWechatVirtualPaymentNotifyResponse { + err_code, + err_msg: err_msg.into(), + } +} + +fn detect_virtual_payment_notify_response_format( + headers: &HeaderMap, + body: &[u8], +) -> VirtualPaymentNotifyResponseFormat { + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + if content_type.contains("xml") { + return VirtualPaymentNotifyResponseFormat::Xml; + } + let body_trimmed = body + .iter() + .copied() + .skip_while(|byte| byte.is_ascii_whitespace()) + .next(); + match body_trimmed { + Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml, + _ => VirtualPaymentNotifyResponseFormat::Json, + } +} diff --git a/server-rs/crates/api-server/src/wechat_provider.rs b/server-rs/crates/api-server/src/wechat/provider.rs similarity index 98% rename from server-rs/crates/api-server/src/wechat_provider.rs rename to server-rs/crates/api-server/src/wechat/provider.rs index 60722cb8..94c3a117 100644 --- a/server-rs/crates/api-server/src/wechat_provider.rs +++ b/server-rs/crates/api-server/src/wechat/provider.rs @@ -1,4 +1,4 @@ -use platform_auth::{ +use platform_auth::{ DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT, DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT, DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT, diff --git a/server-rs/crates/api-server/src/wechat_subscribe_message.rs b/server-rs/crates/api-server/src/wechat/subscribe_message.rs similarity index 88% rename from server-rs/crates/api-server/src/wechat_subscribe_message.rs rename to server-rs/crates/api-server/src/wechat/subscribe_message.rs index 5ff638fe..8946afaf 100644 --- a/server-rs/crates/api-server/src/wechat_subscribe_message.rs +++ b/server-rs/crates/api-server/src/wechat/subscribe_message.rs @@ -19,6 +19,7 @@ pub enum GenerationResultSubscribeMessageStatus { #[derive(Clone, Debug)] pub struct GenerationResultSubscribeMessage { pub owner_user_id: String, + pub task_name: Option, pub work_name: Option, pub status: GenerationResultSubscribeMessageStatus, pub consumed_points: u64, @@ -110,7 +111,13 @@ fn build_generation_result_template_data( BTreeMap::from([ ( "thing1".to_string(), - truncate_template_value(GENERATION_RESULT_TASK_NAME, 20), + truncate_template_value( + message + .task_name + .as_deref() + .unwrap_or(GENERATION_RESULT_TASK_NAME), + 20, + ), ), ( "phrase2".to_string(), @@ -192,6 +199,7 @@ mod tests { fn failed_generation_result_template_uses_failed_status_and_zero_points() { let data = build_generation_result_template_data(&GenerationResultSubscribeMessage { owner_user_id: "user-1".to_string(), + task_name: Some("拼图".to_string()), work_name: Some("首关拼图".to_string()), status: GenerationResultSubscribeMessageStatus::Failed, consumed_points: 0, @@ -207,6 +215,7 @@ mod tests { fn generation_result_template_time_uses_wechat_time_format() { let data = build_generation_result_template_data(&GenerationResultSubscribeMessage { owner_user_id: "user-1".to_string(), + task_name: Some("拼图".to_string()), work_name: Some("首关拼图".to_string()), status: GenerationResultSubscribeMessageStatus::Succeeded, consumed_points: 15, @@ -219,4 +228,19 @@ mod tests { Some("1970-01-01 08:00") ); } + + #[test] + fn generation_result_template_uses_task_template_name() { + let data = build_generation_result_template_data(&GenerationResultSubscribeMessage { + owner_user_id: "user-1".to_string(), + task_name: Some("敲木鱼".to_string()), + work_name: Some("功德木鱼".to_string()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: 10, + completed_at_micros: 0, + page: None, + }); + + assert_eq!(data.get("thing1").map(String::as_str), Some("敲木鱼")); + } } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index a0e60220..a8c46668 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, }; @@ -43,6 +43,10 @@ use crate::{ platform_errors::map_oss_error, request_context::RequestContext, state::AppState, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, }; const WOODEN_FISH_PROVIDER: &str = "wooden-fish"; @@ -147,6 +151,15 @@ pub async fn execute_wooden_fish_action( wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let author_display_name = resolve_author_display_name(&state, &authenticated); + let is_compile_draft = matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + ); + let generation_points_cost = if is_compile_draft { + resolve_wooden_fish_generation_points_cost(&state).await + } else { + 0 + }; let result = execute_wooden_fish_action_with_generated_assets( &state, &request_context, @@ -160,21 +173,55 @@ pub async fn execute_wooden_fish_action( .as_ref() .err() .is_some_and(|response| response.status().is_server_error()) - && matches!( - payload.action_type, - shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft - ) + && is_compile_draft { - mark_wooden_fish_generation_failed( + let failed_at_micros = current_utc_micros(); + let work_name = + resolve_wooden_fish_notification_work_name(&state, &session_id, &owner_user_id).await; + if mark_wooden_fish_generation_failed( &state, &request_context, &session_id, owner_user_id.as_str(), author_display_name.as_str(), ) - .await; + .await + { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id: owner_user_id.clone(), + task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()), + work_name, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: failed_at_micros, + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } } let response = result?; + if is_compile_draft && response.session.status == WoodenFishGenerationStatus::Ready { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()), + work_name: response + .session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } Ok(json_success_body(Some(&request_context), response)) } @@ -588,13 +635,37 @@ async fn execute_wooden_fish_action_with_generated_assets( }) } +async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 { + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + WOODEN_FISH_TEMPLATE_ID, + u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ) + .await +} + +async fn resolve_wooden_fish_notification_work_name( + state: &AppState, + session_id: &str, + owner_user_id: &str, +) -> Option { + state + .spacetime_client() + .get_wooden_fish_session(session_id.to_string(), owner_user_id.to_string()) + .await + .ok() + .and_then(|session| session.draft) + .map(|draft| draft.work_title) + .filter(|value| !value.trim().is_empty()) +} + async fn mark_wooden_fish_generation_failed( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, author_display_name: &str, -) { +) -> bool { if let Err(error) = state .spacetime_client() .mark_wooden_fish_generation_failed( @@ -612,7 +683,9 @@ async fn mark_wooden_fish_generation_failed( error = %error, "敲木鱼草稿生成失败后的状态回写失败" ); + return false; } + true } fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset { diff --git a/server-rs/crates/module-jump-hop/src/application.rs b/server-rs/crates/module-jump-hop/src/application.rs index 71a990d5..bce39499 100644 --- a/server-rs/crates/module-jump-hop/src/application.rs +++ b/server-rs/crates/module-jump-hop/src/application.rs @@ -6,7 +6,9 @@ use crate::{ }; const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0; -const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.008; +const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.004; +const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO: f32 = 0.72; +const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO: f32 = 0.52; pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath { let config = difficulty_config(difficulty); @@ -94,12 +96,11 @@ pub fn apply_jump( ); let landed_x = current.x + unit_x * jump_distance; let landed_y = current.y + unit_y * jump_distance; - let landing_error = (landed_x - target.x).hypot(landed_y - target.y); - let target_landing_radius = target.landing_radius; + let landed_on_target = is_landing_inside_platform_footprint(target, landed_x, landed_y); let mut next = run.clone(); next.path = path; - let result = if landing_error <= target_landing_radius { + let result = if landed_on_target { JumpHopJumpResultKind::Hit } else { JumpHopJumpResultKind::Miss @@ -128,6 +129,42 @@ pub fn apply_jump( Ok(next) } +fn is_landing_inside_platform_footprint( + platform: &JumpHopPlatform, + landed_x: f32, + landed_y: f32, +) -> bool { + let half_width = (platform.width * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO).max(0.0); + let half_height = (platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO).max(0.0); + let error_x = landed_x - platform.x; + let error_y = landed_y - platform.y; + + error_x.abs() <= half_width && error_y.abs() <= half_height +} + +fn normalize_jump_direction( + drag_vector_x: Option, + drag_vector_y: Option, + fallback_x: f32, + fallback_y: f32, +) -> (f32, f32) { + let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else { + return (fallback_x, fallback_y); + }; + let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else { + return (fallback_x, fallback_y); + }; + // 前端提交屏幕拖拽向量:x 轴同向,y 轴向下为正;真实起跳反向弹出,世界 y 向上为正。 + let jump_x = -drag_x; + let jump_y = drag_y; + let length = jump_x.hypot(jump_y); + if length < 0.0001 { + (fallback_x, fallback_y) + } else { + (jump_x / length, jump_y / length) + } +} + pub fn restart_run( run: &JumpHopRunSnapshot, next_run_id: String, @@ -250,30 +287,6 @@ fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHop path } -fn normalize_jump_direction( - drag_vector_x: Option, - drag_vector_y: Option, - fallback_x: f32, - fallback_y: f32, -) -> (f32, f32) { - let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else { - return (fallback_x, fallback_y); - }; - let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else { - return (fallback_x, fallback_y); - }; - // 前端提交的是屏幕拖拽向量:x 轴同向,y 轴向下为正。 - // 真实起跳需要“反向弹出”,同时把屏幕 y 翻回世界坐标的向上为正。 - let jump_x = -drag_x; - let jump_y = drag_y; - let length = jump_x.hypot(jump_y); - if length < 0.0001 { - (fallback_x, fallback_y) - } else { - (jump_x / length, jump_y / length) - } -} - fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { match difficulty { JumpHopDifficulty::Easy => DifficultyConfig { @@ -353,8 +366,8 @@ impl DeterministicRng { #[cfg(test)] mod tests { use crate::{ - JumpHopDifficulty, JumpHopJumpResultKind, JumpHopRunStatus, apply_jump, - generate_jump_hop_path, restart_run, start_run, + JumpHopDifficulty, JumpHopJumpResultKind, JumpHopPlatform, JumpHopRunStatus, + JumpHopTileType, apply_jump, generate_jump_hop_path, restart_run, start_run, }; #[test] @@ -371,16 +384,17 @@ mod tests { } #[test] - fn difficulty_charge_to_distance_ratio_is_doubled() { + fn difficulty_charge_to_distance_ratio_is_reduced_for_long_press() { let easy = generate_jump_hop_path("seed-ratio-easy", JumpHopDifficulty::Easy); let standard = generate_jump_hop_path("seed-ratio-standard", JumpHopDifficulty::Standard); let advanced = generate_jump_hop_path("seed-ratio-advanced", JumpHopDifficulty::Advanced); - let challenge = generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge); + let challenge = + generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge); - assert_eq!(easy.scoring.charge_to_distance_ratio, 0.008); - assert_eq!(standard.scoring.charge_to_distance_ratio, 0.008); - assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.008); - assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.008); + assert_eq!(easy.scoring.charge_to_distance_ratio, 0.004); + assert_eq!(standard.scoring.charge_to_distance_ratio, 0.004); + assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.004); + assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.004); } #[test] @@ -454,7 +468,7 @@ mod tests { None, 200, ) - .expect("jump should resolve"); + .expect("jump should resolve"); assert_eq!(miss.status, JumpHopRunStatus::Failed); assert_eq!( miss.last_jump.as_ref().unwrap().result, @@ -463,7 +477,7 @@ mod tests { } #[test] - fn jump_resolution_uses_screen_drag_y_axis_for_forward_jump_direction() { + fn jump_resolution_uses_client_drag_direction_for_landing() { let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy); let run = start_run( "run-screen-axis".to_string(), @@ -478,21 +492,74 @@ mod tests { let target_distance = (target.x - current.x).hypot(target.y - current.y); let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32; - let result = apply_jump( - &run, - charge as f32, - Some(-(target.x - current.x)), - Some(target.y - current.y), - 200, - ) - .expect("jump should resolve"); + let result = apply_jump(&run, charge as f32, Some(999.0), Some(-999.0), 200) + .expect("jump should resolve"); - assert_eq!(result.status, JumpHopRunStatus::Playing); + assert_eq!(result.status, JumpHopRunStatus::Failed); assert_eq!( result.last_jump.as_ref().unwrap().result, - JumpHopJumpResultKind::Hit + JumpHopJumpResultKind::Miss ); + } + + #[test] + fn jump_resolution_falls_back_to_next_center_when_drag_direction_missing() { + let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy); + let run = start_run( + "run-screen-axis".to_string(), + "user-screen-axis".to_string(), + "profile-screen-axis".to_string(), + path, + 100, + ) + .expect("run should start"); + let current = &run.path.platforms[0]; + let target = &run.path.platforms[1]; + let target_distance = (target.x - current.x).hypot(target.y - current.y); + let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32; + + let result = apply_jump(&run, charge as f32, None, None, 200).expect("jump should resolve"); + + let last_jump = result.last_jump.as_ref().expect("last jump should exist"); + assert_eq!(result.status, JumpHopRunStatus::Playing); + assert_eq!(last_jump.result, JumpHopJumpResultKind::Hit); assert_eq!(result.current_platform_index, 1); + assert!((last_jump.landed_x - target.x).abs() < target.landing_radius); + assert!((last_jump.landed_y - target.y).abs() < target.landing_radius); + } + + #[test] + fn jump_resolution_uses_visual_top_face_footprint_instead_of_landing_radius() { + let mut path = generate_jump_hop_path("seed-footprint", JumpHopDifficulty::Easy); + path.platforms[0] = test_platform("p0", 0.0, 0.0, 1.2, 1.0); + path.platforms[1] = test_platform("p1", 1.0, 0.0, 2.0, 0.6); + path.scoring.max_charge_ms = 600; + let run = start_run( + "run-footprint".to_string(), + "user-footprint".to_string(), + "profile-footprint".to_string(), + path, + 100, + ) + .expect("run should start"); + + let edge_hit_charge = 1.6 / run.path.scoring.charge_to_distance_ratio; + let edge_hit = + apply_jump(&run, edge_hit_charge, None, None, 200).expect("jump should resolve"); + let last_hit = edge_hit.last_jump.as_ref().expect("last jump should exist"); + assert_eq!(edge_hit.status, JumpHopRunStatus::Playing); + assert_eq!(last_hit.result, JumpHopJumpResultKind::Hit); + assert!(last_hit.landed_x > 1.5); + assert!(last_hit.landed_x <= 1.72); + + let outside_charge = 1.8 / run.path.scoring.charge_to_distance_ratio; + let outside = + apply_jump(&run, outside_charge, None, None, 200).expect("jump should resolve"); + assert_eq!(outside.status, JumpHopRunStatus::Failed); + assert_eq!( + outside.last_jump.as_ref().unwrap().result, + JumpHopJumpResultKind::Miss + ); } #[test] @@ -551,4 +618,18 @@ mod tests { assert!(run.path.platforms.len() >= 12); assert!(run.finished_at_ms.is_none()); } + + fn test_platform(id: &str, x: f32, y: f32, width: f32, height: f32) -> JumpHopPlatform { + JumpHopPlatform { + platform_id: id.to_string(), + tile_type: JumpHopTileType::Normal, + x, + y, + width, + height, + landing_radius: 0.2, + perfect_radius: 0.1, + score_value: 1, + } + } } diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs index d95b4675..ac23daf0 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs @@ -38,6 +38,10 @@ pub struct GeneratedAssetSheetAlphaOptions { pub key_color: GeneratedAssetSheetKeyColor, pub remove_near_white_background: bool, pub remove_disconnected_hard_key_background: bool, + // 中文注释:检测并清除被主体包围、不与画布四边连通的品红镂空区域。 + // 仅对独立连通域整体判定,通过 min_pixels 过滤微小噪点。 + pub detect_internal_holes: bool, + pub internal_hole_min_pixels: usize, } impl GeneratedAssetSheetAlphaOptions { @@ -46,6 +50,8 @@ impl GeneratedAssetSheetAlphaOptions { key_color: GeneratedAssetSheetKeyColor::GREEN_SCREEN, remove_near_white_background: true, remove_disconnected_hard_key_background: true, + detect_internal_holes: false, + internal_hole_min_pixels: 0, } } @@ -54,6 +60,8 @@ impl GeneratedAssetSheetAlphaOptions { key_color: GeneratedAssetSheetKeyColor::MAGENTA_SCREEN, remove_near_white_background: false, remove_disconnected_hard_key_background: false, + detect_internal_holes: true, + internal_hole_min_pixels: 16, } } } @@ -216,6 +224,66 @@ fn remove_generated_asset_sheet_green_screen_background( } } + // 中文注释:内部镂空洞检测——寻找与四边不连通、被主体包围的品红区域。 + // 必须在软 matte 扩展之前执行,避免软扩展跨越窄前景通道误判。 + if options.detect_internal_holes && options.internal_hole_min_pixels > 0 { + let mut hole_visited = vec![false; pixel_count]; + let mut hole_queue = Vec::::new(); + + for start_index in 0..pixel_count { + if background_mask[start_index] != 0 + || key_scores[start_index] < GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE + || hole_visited[start_index] + { + continue; + } + + // 中文注释:BFS 收集当前候选背景连通域 + hole_queue.clear(); + hole_queue.push(start_index); + hole_visited[start_index] = true; + let mut component = Vec::::new(); + let mut touches_border = false; + let mut queue_cursor = 0usize; + + while queue_cursor < hole_queue.len() { + let pixel_index = hole_queue[queue_cursor]; + queue_cursor += 1; + component.push(pixel_index); + + let x = pixel_index % width; + let y = pixel_index / width; + if x == 0 || x == width.saturating_sub(1) || y == 0 || y == height.saturating_sub(1) { + touches_border = true; + } + + let neighbors = [ + if x > 0 { Some(pixel_index - 1) } else { None }, + if x + 1 < width { Some(pixel_index + 1) } else { None }, + if y > 0 { Some(pixel_index - width) } else { None }, + if y + 1 < height { Some(pixel_index + width) } else { None }, + ]; + + for next in neighbors.into_iter().flatten() { + if background_mask[next] != 0 || hole_visited[next] { + continue; + } + if key_scores[next] < GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE { + continue; + } + hole_visited[next] = true; + hole_queue.push(next); + } + } + + if !touches_border && component.len() >= options.internal_hole_min_pixels { + for pixel_index in component { + background_mask[pixel_index] = 1; + } + } + } + } + let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14); for _ in 0..soft_green_cleanup_rounds { let mut expanded_mask = background_mask.clone(); diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index 6fcd23dd..70754cae 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -18,6 +18,7 @@ use super::{ }, response::handle_vector_engine_response, types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, + util::truncate_raw, }; pub async fn create_vector_engine_image_generation( @@ -66,7 +67,25 @@ pub async fn create_vector_engine_image_generation( ) .await { - Ok(response) => break response, + Ok(response) => { + if should_retry_vector_engine_upstream_status(response.status, attempt) { + retry_vector_engine_upstream_status_after_delay( + "generation", + request_url.as_str(), + attempt, + response.status, + response.body.as_str(), + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + Some(&request_body), + ) + .await; + attempt += 1; + continue; + } + break response; + } Err(error) => { if should_retry_vector_engine_curl_send_error(&error, attempt) { retry_vector_engine_send_after_delay( @@ -75,7 +94,7 @@ pub async fn create_vector_engine_image_generation( "request_send", attempt, error.is_timeout(), - error.is_connect(), + error.is_connect() || error.is_transient_transport(), true, false, error.to_string().as_str(), @@ -220,7 +239,25 @@ pub async fn create_vector_engine_image_edit_with_references( ) .await { - Ok(response) => break response, + Ok(response) => { + if should_retry_vector_engine_upstream_status(response.status, attempt) { + retry_vector_engine_upstream_status_after_delay( + "edit", + request_url.as_str(), + attempt, + response.status, + response.body.as_str(), + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + Some(&request_params), + ) + .await; + attempt += 1; + continue; + } + break response; + } Err(error) => { if should_retry_vector_engine_curl_send_error(&error, attempt) { retry_vector_engine_send_after_delay( @@ -229,7 +266,7 @@ pub async fn create_vector_engine_image_edit_with_references( "request_send", attempt, error.is_timeout(), - error.is_connect(), + error.is_connect() || error.is_transient_transport(), true, false, error.to_string().as_str(), @@ -290,7 +327,12 @@ fn should_retry_vector_engine_curl_send_error( error: &super::curl_transport::VectorEngineCurlError, attempt: u32, ) -> bool { - attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (error.is_timeout() || error.is_connect()) + attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS + && (error.is_timeout() || error.is_connect() || error.is_transient_transport()) +} + +fn should_retry_vector_engine_upstream_status(status: u16, attempt: u32) -> bool { + attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (status == 408 || status == 429 || status >= 500) } async fn retry_vector_engine_send_after_delay( @@ -334,6 +376,40 @@ async fn retry_vector_engine_send_after_delay( tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; } +async fn retry_vector_engine_upstream_status_after_delay( + request_kind: &'static str, + request_url: &str, + attempt: u32, + status: u16, + raw_body: &str, + elapsed_ms: u64, + prompt_chars: Option, + reference_image_count: Option, + request_params: Option<&serde_json::Value>, +) { + let delay_ms = vector_engine_send_retry_delay_ms(attempt, vector_engine_send_retry_jitter_ms()); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + request_kind, + failure_stage = "upstream_status", + attempt, + max_attempts = VECTOR_ENGINE_SEND_MAX_ATTEMPTS, + retry_delay_ms = delay_ms, + status, + retryable = true, + elapsed_ms, + prompt_chars, + reference_image_count, + raw_excerpt = %truncate_raw(raw_body), + request_params = %request_params + .map(|value| value.to_string()) + .unwrap_or_default(), + "VectorEngine 图片上游状态可重试,准备重试" + ); + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; +} + fn vector_engine_send_retry_delay_ms(attempt: u32, jitter_ms: u64) -> u64 { let exponential_factor = 1_u64 << attempt.saturating_sub(1).min(10); let bounded_jitter_ms = jitter_ms.min(VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS); @@ -357,6 +433,33 @@ mod tests { assert_eq!(VECTOR_ENGINE_SEND_MAX_ATTEMPTS, 5); } + #[test] + fn vector_engine_send_retry_policy_treats_ssl_reset_as_transient_transport() { + let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(35)); + + assert!(error.is_transient_transport()); + assert!(should_retry_vector_engine_curl_send_error(&error, 1)); + assert!(!should_retry_vector_engine_curl_send_error(&error, 5)); + } + + #[test] + fn vector_engine_send_retry_policy_treats_recv_eof_as_transient_transport() { + let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(56)); + + assert!(error.is_transient_transport()); + assert!(should_retry_vector_engine_curl_send_error(&error, 1)); + assert!(!should_retry_vector_engine_curl_send_error(&error, 5)); + } + + #[test] + fn vector_engine_send_retry_policy_treats_upstream_502_as_retryable() { + assert!(should_retry_vector_engine_upstream_status(502, 1)); + assert!(should_retry_vector_engine_upstream_status(429, 1)); + assert!(should_retry_vector_engine_upstream_status(408, 1)); + assert!(!should_retry_vector_engine_upstream_status(400, 1)); + assert!(!should_retry_vector_engine_upstream_status(502, 5)); + } + #[test] fn vector_engine_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() { assert_eq!(vector_engine_send_retry_delay_ms(1, 0), 500); diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs index 1991bdda..a5c6af67 100644 --- a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs +++ b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs @@ -45,6 +45,25 @@ impl VectorEngineCurlError { Self::Form(_) | Self::WorkerJoin(_) => false, } } + + pub(crate) fn is_transient_transport(&self) -> bool { + match self { + Self::Curl(error) => { + let message = error.to_string().to_ascii_lowercase(); + error.is_ssl_connect_error() + || error.is_recv_error() + || error.is_send_error() + || message.contains("connection reset") + || message.contains("recv failure") + || message.contains("receive failure") + || message.contains("receiving data") + || message.contains("unexpected eof") + || message.contains("send failure") + || message.contains("broken pipe") + } + Self::Form(_) | Self::WorkerJoin(_) => false, + } + } } impl fmt::Display for VectorEngineCurlError { @@ -136,7 +155,7 @@ pub(crate) fn map_curl_error( request_params: Option<&Value>, ) -> PlatformImageError { let is_timeout = error.is_timeout(); - let is_connect = error.is_connect(); + let is_connect = error.is_connect() || error.is_transient_transport(); let source = error.to_string(); let message = format!("{context}:{source}"); let audit = build_failure_audit( diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index c53d63c2..8dadd9eb 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -1,8 +1,8 @@ use platform_image::vector_engine::{ GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, build_vector_engine_image_request_body, - create_vector_engine_image_edit, vector_engine_images_edit_url, - vector_engine_images_generation_url, + create_vector_engine_image_edit, create_vector_engine_image_generation, + vector_engine_images_edit_url, vector_engine_images_generation_url, }; use std::{ sync::{ @@ -109,3 +109,72 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { assert_eq!(request_count.load(Ordering::SeqCst), 2); server.abort(); } + +#[tokio::test] +async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server should bind"); + let server_addr = listener + .local_addr() + .expect("mock server address should be readable"); + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_for_server = Arc::clone(&request_count); + + let server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let request_index = request_count_for_server.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let mut buffer = [0_u8; 4096]; + let _ = stream.read(&mut buffer).await; + if request_index == 0 { + let body = "502 Bad Gateway

502 Bad Gateway


nginx
"; + let response = format!( + "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + return; + } + + let body = r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + + let settings = VectorEngineImageSettings { + base_url: format!("http://{server_addr}/v1"), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + let http_client = + build_vector_engine_image_http_client(&settings).expect("client should build"); + + let generated = create_vector_engine_image_generation( + &http_client, + &settings, + "测试提示词", + None, + "1024x1024", + 1, + &[], + "测试 VectorEngine 图片生成失败", + ) + .await + .expect("second attempt should return generated image"); + + assert_eq!(generated.images.len(), 1); + assert_eq!(generated.images[0].mime_type, "image/png"); + assert_eq!(request_count.load(Ordering::SeqCst), 2); + server.abort(); +} diff --git a/server-rs/crates/platform-wechat/Cargo.toml b/server-rs/crates/platform-wechat/Cargo.toml index 0b0f5db4..5d1dd7b8 100644 --- a/server-rs/crates/platform-wechat/Cargo.toml +++ b/server-rs/crates/platform-wechat/Cargo.toml @@ -5,8 +5,18 @@ version.workspace = true license.workspace = true [dependencies] +aes = { workspace = true } +base64 = { workspace = true } +cbc = { workspace = true } +hex = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls"] } +ring = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha1 = { workspace = true } +sha2 = { workspace = true } +shared-contracts = { workspace = true } +time = { workspace = true } tracing = { workspace = true } url = { workspace = true } +urlencoding = { workspace = true } diff --git a/server-rs/crates/platform-wechat/src/lib.rs b/server-rs/crates/platform-wechat/src/lib.rs index 0935554e..e1c4d5a5 100644 --- a/server-rs/crates/platform-wechat/src/lib.rs +++ b/server-rs/crates/platform-wechat/src/lib.rs @@ -1,234 +1,11 @@ -use std::{collections::BTreeMap, error::Error, fmt}; +pub mod pay; +pub mod subscribe_message; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tracing::warn; -use url::Url; - -pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str = - "https://api.weixin.qq.com/cgi-bin/stable_token"; -pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str = - "https://api.weixin.qq.com/cgi-bin/message/subscribe/send"; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct WechatConfig { - pub app_id: Option, - pub app_secret: Option, - pub stable_access_token_endpoint: String, - pub subscribe_message_endpoint: String, -} - -#[derive(Clone, Debug)] -pub struct WechatClient { - client: Client, - config: WechatConfig, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct WechatSubscribeMessageRequest { - pub touser: String, - pub template_id: String, - pub page: Option, - pub miniprogram_state: Option, - pub lang: Option, - pub data: BTreeMap, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum WechatError { - InvalidConfig(String), - RequestFailed(String), - DeserializeFailed(String), - Upstream(String), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum WechatErrorKind { - InvalidConfig, - RequestFailed, - DeserializeFailed, - Upstream, -} - -#[derive(Debug, Deserialize)] -struct WechatStableAccessTokenResponse { - access_token: Option, - errcode: Option, - errmsg: Option, -} - -#[derive(Debug, Deserialize)] -struct WechatSubscribeMessageResponse { - errcode: i64, - errmsg: Option, -} - -#[derive(Debug, Serialize)] -struct WechatTemplateDataValue { - value: String, -} - -impl WechatClient { - pub fn new(config: WechatConfig) -> Self { - Self { - client: Client::new(), - config, - } - } - - pub async fn send_subscribe_message( - &self, - request: WechatSubscribeMessageRequest, - ) -> Result<(), WechatError> { - let app_id = self - .config - .app_id - .as_deref() - .and_then(non_empty) - .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?; - let app_secret = self - .config - .app_secret - .as_deref() - .and_then(non_empty) - .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?; - - let access_token = self.request_access_token(app_id, app_secret).await?; - let mut send_url = - Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| { - WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}")) - })?; - send_url - .query_pairs_mut() - .append_pair("access_token", &access_token); - - let data = request - .data - .into_iter() - .map(|(key, value)| (key, WechatTemplateDataValue { value })) - .collect::>(); - let payload = json!({ - "touser": request.touser, - "template_id": request.template_id, - "page": request.page, - "miniprogram_state": request.miniprogram_state, - "lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()), - "data": data, - }); - let response = self - .client - .post(send_url.as_str()) - .json(&payload) - .send() - .await - .map_err(|error| { - warn!(error = %error, "微信订阅消息请求失败"); - WechatError::RequestFailed("微信订阅消息请求失败".to_string()) - })? - .json::() - .await - .map_err(|error| { - warn!(error = %error, "微信订阅消息响应解析失败"); - WechatError::DeserializeFailed("微信订阅消息响应非法".to_string()) - })?; - - if response.errcode != 0 { - return Err(WechatError::Upstream(format!( - "微信订阅消息发送失败:{}", - response.errmsg.unwrap_or_else(|| format!( - "subscribeMessage.send 返回错误 {}", - response.errcode - )) - ))); - } - - Ok(()) - } - - async fn request_access_token( - &self, - app_id: &str, - app_secret: &str, - ) -> Result { - let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| { - WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}")) - })?; - let payload = self - .client - .post(url.as_str()) - .json(&json!({ - "grant_type": "client_credential", - "appid": app_id, - "secret": app_secret, - "force_refresh": false, - })) - .send() - .await - .map_err(|error| { - warn!(error = %error, "微信 stable_token 请求失败"); - WechatError::RequestFailed("微信 stable_token 请求失败".to_string()) - })? - .json::() - .await - .map_err(|error| { - warn!(error = %error, "微信 stable_token 响应解析失败"); - WechatError::DeserializeFailed("微信 stable_token 响应非法".to_string()) - })?; - - if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { - return Err(WechatError::Upstream(format!( - "微信 stable_token 返回错误:{}", - payload - .errmsg - .unwrap_or_else(|| format!("errcode={errcode}")) - ))); - } - - payload - .access_token - .and_then(|value| non_empty_owned(value)) - .ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string())) - } -} - -impl WechatError { - pub fn kind(&self) -> WechatErrorKind { - match self { - Self::InvalidConfig(_) => WechatErrorKind::InvalidConfig, - Self::RequestFailed(_) => WechatErrorKind::RequestFailed, - Self::DeserializeFailed(_) => WechatErrorKind::DeserializeFailed, - Self::Upstream(_) => WechatErrorKind::Upstream, - } - } -} - -impl fmt::Display for WechatError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidConfig(message) - | Self::RequestFailed(message) - | Self::DeserializeFailed(message) - | Self::Upstream(message) => f.write_str(message), - } - } -} - -impl Error for WechatError {} - -fn non_empty(value: &str) -> Option<&str> { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } -} - -fn non_empty_owned(value: String) -> Option { - if value.trim().is_empty() { - None - } else { - Some(value) - } -} +pub use pay::{ + WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayClient, + WechatPayConfig, WechatPayError, WechatPayNotifyOrder, WechatWebOrderRequest, +}; +pub use subscribe_message::{ + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT, + WechatClient, WechatConfig, WechatError, WechatErrorKind, WechatSubscribeMessageRequest, +}; diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/platform-wechat/src/pay.rs similarity index 81% rename from server-rs/crates/api-server/src/wechat_pay.rs rename to server-rs/crates/platform-wechat/src/pay.rs index 2b6cffd3..44c5aa51 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/platform-wechat/src/pay.rs @@ -1,38 +1,33 @@ -use std::{fs, path::Path, sync::Arc}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; use aes::Aes256; -use axum::{ - Json, - extract::{Query, State}, - http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE}, - response::{IntoResponse, Response}, -}; use base64::{ Engine as _, alphabet, engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD}, }; -use bytes::Bytes; use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding}; +use reqwest::header::HeaderMap; use ring::{ aead, rand::{SecureRandom, SystemRandom}, signature, }; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::Value; use sha1::Sha1; use sha2::{Digest, Sha256}; use shared_contracts::runtime::{ WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse, }; -use shared_kernel::offset_datetime_to_unix_micros; use std::convert::TryInto; use time::OffsetDateTime; -use tracing::{info, warn}; +use tracing::warn; use url::Url; -use crate::{http_error::AppError, state::AppState}; - const WECHAT_PAY_PROVIDER_MOCK: &str = "mock"; const WECHAT_PAY_PROVIDER_REAL: &str = "real"; const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048"; @@ -61,6 +56,23 @@ const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64: GeneralPurpose = GeneralPurpose GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true), ); +#[derive(Clone, Debug)] +pub struct WechatPayConfig { + pub enabled: bool, + pub provider: String, + pub app_id: Option, + pub mch_id: Option, + pub merchant_serial_no: Option, + pub private_key_pem: Option, + pub private_key_path: Option, + pub platform_public_key_pem: Option, + pub platform_public_key_path: Option, + pub platform_serial_no: Option, + pub api_v3_key: Option, + pub notify_url: Option, + pub jsapi_endpoint: String, +} + #[derive(Clone, Debug)] pub enum WechatPayClient { Disabled, @@ -110,19 +122,11 @@ pub struct WechatPayNotifyOrder { } #[derive(Clone, Debug, PartialEq, Eq)] -struct WechatVirtualPaymentNotifyOrder { - out_trade_no: String, - transaction_id: Option, - paid_at_micros: Option, - event: String, -} - -#[derive(Serialize)] -pub struct WechatVirtualPaymentNotifyResponse { - #[serde(rename = "ErrCode")] - err_code: i32, - #[serde(rename = "ErrMsg")] - err_msg: String, +pub struct WechatVirtualPaymentNotifyOrder { + pub out_trade_no: String, + pub transaction_id: Option, + pub paid_at_micros: Option, + pub event: String, } #[derive(Debug)] @@ -276,30 +280,30 @@ struct WechatVirtualPaymentNotifyPayInfo { } #[derive(Debug, Deserialize)] -pub(crate) struct WechatMiniProgramMessagePushQuery { - signature: Option, - timestamp: Option, - nonce: Option, - echostr: Option, - msg_signature: Option, +pub struct WechatMiniProgramMessagePushQuery { + pub signature: Option, + pub timestamp: Option, + pub nonce: Option, + pub echostr: Option, + pub msg_signature: Option, } #[derive(Debug, Deserialize)] -struct WechatMiniProgramEncryptedMessage { +pub struct WechatMiniProgramEncryptedMessage { #[serde(rename = "ToUserName", alias = "to_user_name", default)] _to_user_name: Option, #[serde(rename = "Encrypt", alias = "encrypt")] - encrypt: String, + pub encrypt: String, } impl WechatPayClient { - pub fn from_config(config: &crate::config::AppConfig) -> Result { - if !config.wechat_pay_enabled { + pub fn from_config(config: &WechatPayConfig) -> Result { + if !config.enabled { return Ok(Self::Disabled); } if config - .wechat_pay_provider + .provider .trim() .eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_MOCK) { @@ -307,7 +311,7 @@ impl WechatPayClient { } if !config - .wechat_pay_provider + .provider .trim() .eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_REAL) { @@ -317,52 +321,43 @@ impl WechatPayClient { } let app_id = config - .wechat_mini_program_app_id + .app_id .as_ref() - .or(config.wechat_app_id.as_ref()) .map(|value| value.trim()) .filter(|value| !value.is_empty()) .ok_or_else(|| WechatPayError::InvalidConfig("微信支付缺少小程序 AppID".to_string()))? .to_string(); - let mch_id = required_config(config.wechat_pay_mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?; + let mch_id = required_config(config.mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?; let merchant_serial_no = required_config( - config.wechat_pay_merchant_serial_no.as_deref(), + config.merchant_serial_no.as_deref(), "WECHAT_PAY_MERCHANT_SERIAL_NO", )?; let private_key_pem = read_private_key_pem( - config.wechat_pay_private_key_pem.as_deref(), - config.wechat_pay_private_key_path.as_deref(), + config.private_key_pem.as_deref(), + config.private_key_path.as_deref(), )?; let private_key = Arc::new(parse_rsa_private_key(&private_key_pem)?); let platform_public_key_pem = read_pem( - config.wechat_pay_platform_public_key_pem.as_deref(), - config.wechat_pay_platform_public_key_path.as_deref(), + config.platform_public_key_pem.as_deref(), + config.platform_public_key_path.as_deref(), "WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH 未配置", "读取微信支付平台公钥失败", )?; let platform_public_key_der = parse_public_key_pem(&platform_public_key_pem)?; let platform_serial_no = required_config( - config.wechat_pay_platform_serial_no.as_deref(), + config.platform_serial_no.as_deref(), "WECHAT_PAY_PLATFORM_SERIAL_NO", )?; - let api_v3_key = required_config( - config.wechat_pay_api_v3_key.as_deref(), - "WECHAT_PAY_API_V3_KEY", - )?; + let api_v3_key = required_config(config.api_v3_key.as_deref(), "WECHAT_PAY_API_V3_KEY")?; if api_v3_key.as_bytes().len() != 32 { return Err(WechatPayError::InvalidConfig( "WECHAT_PAY_API_V3_KEY 必须是 32 字节字符串".to_string(), )); } - let notify_url = required_config( - config.wechat_pay_notify_url.as_deref(), - "WECHAT_PAY_NOTIFY_URL", - )?; + let notify_url = required_config(config.notify_url.as_deref(), "WECHAT_PAY_NOTIFY_URL")?; validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?; - let jsapi_endpoint = normalize_required_url( - &config.wechat_pay_jsapi_endpoint, - "WECHAT_PAY_JSAPI_ENDPOINT", - )?; + let jsapi_endpoint = + normalize_required_url(&config.jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT")?; let h5_endpoint = resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_H5_PATH)?; let native_endpoint = @@ -833,293 +828,97 @@ impl RealWechatPayClient { } } -pub async fn handle_wechat_pay_notify( - State(state): State, - headers: HeaderMap, - body: Bytes, -) -> Result { - let notify = state - .wechat_pay_client() - .parse_notify(&headers, &body) - .map_err(map_wechat_pay_notify_error)?; - if notify.trade_state != "SUCCESS" { - info!( - order_id = notify.out_trade_no.as_str(), - trade_state = notify.trade_state.as_str(), - "收到非成功微信支付通知" - ); - return Ok(StatusCode::NO_CONTENT); - } - - let paid_at_micros = notify - .success_time - .as_deref() - .and_then(|value| shared_kernel::parse_rfc3339(value).ok()) - .map(offset_datetime_to_unix_micros) - .unwrap_or_else(current_unix_micros); - - state - .spacetime_client() - .mark_profile_recharge_order_paid( - notify.out_trade_no.clone(), - paid_at_micros, - notify.transaction_id.clone(), +fn with_wechat_pay_json_headers( + builder: reqwest::RequestBuilder, + platform_serial_no: &str, +) -> reqwest::RequestBuilder { + builder + .header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER) + .header( + reqwest::header::CONTENT_TYPE, + WECHAT_PAY_CONTENT_TYPE_HEADER, ) - .await - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY) - .with_message(format!("确认微信支付订单失败:{error}")) - })?; - info!( - order_id = notify.out_trade_no.as_str(), - "微信支付通知已确认订单入账" - ); - - Ok(StatusCode::NO_CONTENT) + .header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT) + .header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no) } -pub async fn handle_wechat_virtual_payment_message_push_verify( - State(state): State, - Query(query): Query, -) -> Response { - let token = match read_wechat_message_push_config( - state.config.wechat_mini_program_message_token.as_deref(), - "WECHAT_MINIPROGRAM_MESSAGE_TOKEN", - ) { - Ok(token) => token, - Err(error) => return build_wechat_message_push_verify_error_response(error), - }; - let aes_key = match read_wechat_message_push_config( - state - .config - .wechat_mini_program_message_encoding_aes_key - .as_deref(), - "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", - ) { - Ok(value) => value, - Err(error) => return build_wechat_message_push_verify_error_response(error), - }; - match resolve_wechat_message_push_verify_response( - token, - aes_key, - state - .config - .wechat_mini_program_app_id - .as_deref() - .or(state.config.wechat_app_id.as_deref()), - &query, - ) { - Ok(plaintext) => (StatusCode::OK, plaintext).into_response(), - Err(error) => build_wechat_message_push_verify_error_response(error), +fn with_wechat_pay_jsapi_headers( + builder: reqwest::RequestBuilder, + platform_serial_no: &str, +) -> reqwest::RequestBuilder { + with_wechat_pay_json_headers(builder, platform_serial_no) +} + +fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse { + let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce_str = "mock-nonce".to_string(); + let package = format!("prepay_id=mock-{order_id}"); + let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes()); + + WechatMiniProgramPayParamsResponse { + time_stamp, + nonce_str, + package, + sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(), + pay_sign, } } -pub async fn handle_wechat_virtual_payment_notify( - State(state): State, - headers: HeaderMap, - Query(query): Query, - body: Bytes, -) -> Response { - let response_format = detect_virtual_payment_notify_response_format(&headers, &body); - let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) { - Ok(payload) => payload, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - let token = match read_wechat_message_push_config( - state.config.wechat_mini_program_message_token.as_deref(), - "WECHAT_MINIPROGRAM_MESSAGE_TOKEN", - ) { - Ok(token) => token, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - let aes_key = match read_wechat_message_push_config( - state - .config - .wechat_mini_program_message_encoding_aes_key - .as_deref(), - "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", - ) { - Ok(value) => value, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - let signature = query - .msg_signature - .as_deref() - .or(query.signature.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(""); - let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or(""); - let nonce = query.nonce.as_deref().map(str::trim).unwrap_or(""); - if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() { - return build_virtual_payment_notify_error_response( - WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()), - response_format, - ); - } - if !verify_wechat_message_push_signature( - token, - timestamp, - nonce, - encrypted_payload.encrypt.as_str(), - signature, - ) { - return build_virtual_payment_notify_error_response( - WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()), - response_format, - ); - } - let notify_body = match decrypt_wechat_message_push_ciphertext( - aes_key, - encrypted_payload.encrypt.as_str(), - state - .config - .wechat_mini_program_app_id - .as_deref() - .or(state.config.wechat_app_id.as_deref()), - ) { - Ok(body) => body, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) { - Ok(notify) => notify, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" { - info!( - event = notify.event.as_str(), - order_id = notify.out_trade_no.as_str(), - "收到非订单入账虚拟支付推送" - ); - return build_virtual_payment_notify_success_response(response_format); - } - - let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros); - if state - .spacetime_client() - .mark_profile_recharge_order_paid( - notify.out_trade_no.clone(), - paid_at_micros, - notify.transaction_id.clone(), - ) - .await - .is_err() - { - warn!( - order_id = notify.out_trade_no.as_str(), - "确认微信虚拟支付订单失败" - ); - return build_virtual_payment_notify_error_response( - WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()), - response_format, - ); - } - - state.publish_profile_recharge_order_update(notify.out_trade_no.clone()); - - info!( - event = notify.event.as_str(), - order_id = notify.out_trade_no.as_str(), - "微信虚拟支付推送已确认订单入账" - ); - - build_virtual_payment_notify_success_response(response_format) -} - -pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { - match error { - WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("微信支付暂未启用") - .with_details(json!({ "provider": "wechat_pay" })), - WechatPayError::InvalidConfig(message) => { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_message(message) - .with_details(json!({ "provider": "wechat_pay" })) - } - WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST) - .with_message(message) - .with_details(json!({ "provider": "wechat_pay" })), - WechatPayError::RequestFailed(message) - | WechatPayError::Upstream(message) - | WechatPayError::Deserialize(message) - | WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY) - .with_message(message) - .with_details(json!({ "provider": "wechat_pay" })), - WechatPayError::InvalidSignature(message) => { - AppError::from_status(StatusCode::UNAUTHORIZED) - .with_message("微信支付通知签名无效") - .with_details(json!({ "provider": "wechat_pay", "reason": message })) - } +fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse { + WechatH5PaymentResponse { + h5_url: format!( + "https://mock.wechat-pay.local/h5?out_trade_no={}", + urlencoding::encode(order_id) + ), } } -pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError { - crate::state::AppStateInitError::WechatPay(error.to_string()) -} - -pub fn build_wechat_payment_request( - order_id: String, - product_title: String, - amount_cents: u64, - payer_openid: String, -) -> WechatMiniProgramOrderRequest { - WechatMiniProgramOrderRequest { - order_id, - description: format!("陶泥儿 - {product_title}"), - amount_cents, - payer_openid, +fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse { + WechatNativePaymentResponse { + code_url: format!( + "weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}", + hex_sha256(order_id.as_bytes()) + ), } } -pub fn build_wechat_web_payment_request( - order_id: String, - product_title: String, - amount_cents: u64, - payer_client_ip: String, -) -> WechatWebOrderRequest { - WechatWebOrderRequest { - order_id, - description: format!("陶泥儿 - {product_title}"), - amount_cents, - payer_client_ip, - } +fn parse_mock_notify(body: &[u8]) -> Result { + let value = serde_json::from_slice::(body).map_err(|error| { + WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}")) + })?; + Ok(WechatPayNotifyOrder { + out_trade_no: value + .get("outTradeNo") + .or_else(|| value.get("out_trade_no")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string()) + })? + .to_string(), + transaction_id: value + .get("transactionId") + .or_else(|| value.get("transaction_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + trade_state: value + .get("tradeState") + .or_else(|| value.get("trade_state")) + .and_then(Value::as_str) + .unwrap_or("SUCCESS") + .to_string(), + success_time: value + .get("successTime") + .or_else(|| value.get("success_time")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + }) } -pub fn current_unix_micros() -> i64 { - let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; - i64::try_from(value).unwrap_or(i64::MAX) -} - -fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { - warn!(error = %error, "微信支付通知处理失败"); - map_wechat_pay_error(error) -} - -fn read_wechat_message_push_config<'a>( - value: Option<&'a str>, - key: &str, -) -> Result<&'a str, WechatPayError> { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置"))) -} - -fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response { - let message = match error { - WechatPayError::Disabled => "微信消息推送暂未启用".to_string(), - WechatPayError::InvalidConfig(message) - | WechatPayError::InvalidRequest(message) - | WechatPayError::RequestFailed(message) - | WechatPayError::Upstream(message) - | WechatPayError::Deserialize(message) - | WechatPayError::Crypto(message) - | WechatPayError::InvalidSignature(message) => message, - }; - (StatusCode::BAD_REQUEST, message).into_response() -} - -fn resolve_wechat_message_push_verify_response( +pub fn resolve_wechat_message_push_verify_response( token: &str, aes_key: &str, expected_app_id: Option<&str>, @@ -1161,7 +960,7 @@ fn resolve_wechat_message_push_verify_response( Ok(echostr.to_string()) } -fn parse_wechat_mini_program_message_push_payload( +pub fn parse_wechat_mini_program_message_push_payload( body: &[u8], ) -> Result { serde_json::from_slice(body).map_err(|error| { @@ -1169,7 +968,7 @@ fn parse_wechat_mini_program_message_push_payload( }) } -fn verify_wechat_message_push_signature( +pub fn verify_wechat_message_push_signature( token: &str, timestamp: &str, nonce: &str, @@ -1184,7 +983,7 @@ fn verify_wechat_message_push_signature( expected.eq_ignore_ascii_case(signature) } -fn decrypt_wechat_message_push_ciphertext( +pub fn decrypt_wechat_message_push_ciphertext( encoding_aes_key: &str, ciphertext: &str, expected_app_id: Option<&str>, @@ -1302,7 +1101,7 @@ fn parse_wechat_message_push_plaintext( Ok(WechatMessagePushPlaintext { message, app_id }) } -fn parse_virtual_payment_notify( +pub fn parse_virtual_payment_notify( body: &[u8], ) -> Result { if let Ok(notify) = serde_json::from_slice::(body) { @@ -1402,184 +1201,6 @@ fn trim_virtual_payment_text_value(value: &str) -> String { trimmed.to_string() } -fn build_virtual_payment_notify_error_response( - error: WechatPayError, - response_format: VirtualPaymentNotifyResponseFormat, -) -> Response { - warn!(error = %error, "微信虚拟支付通知处理失败"); - let message = match error { - WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(), - WechatPayError::InvalidConfig(message) - | WechatPayError::InvalidRequest(message) - | WechatPayError::RequestFailed(message) - | WechatPayError::Upstream(message) - | WechatPayError::Deserialize(message) - | WechatPayError::Crypto(message) - | WechatPayError::InvalidSignature(message) => message, - }; - build_virtual_payment_notify_response(response_format, 1, message) -} - -fn build_virtual_payment_notify_success_response( - response_format: VirtualPaymentNotifyResponseFormat, -) -> Response { - build_virtual_payment_notify_response(response_format, 0, "success") -} - -fn build_virtual_payment_notify_response( - response_format: VirtualPaymentNotifyResponseFormat, - err_code: i32, - err_msg: impl Into, -) -> Response { - let err_msg = err_msg.into(); - match response_format { - VirtualPaymentNotifyResponseFormat::Json => Json( - build_wechat_virtual_payment_notify_response(err_code, err_msg), - ) - .into_response(), - VirtualPaymentNotifyResponseFormat::Xml => { - let body = format!( - "{err_code}" - ); - let mut response = (StatusCode::OK, body).into_response(); - response.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("application/xml; charset=utf-8"), - ); - response - } - } -} - -fn with_wechat_pay_json_headers( - builder: reqwest::RequestBuilder, - platform_serial_no: &str, -) -> reqwest::RequestBuilder { - builder - .header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER) - .header( - reqwest::header::CONTENT_TYPE, - WECHAT_PAY_CONTENT_TYPE_HEADER, - ) - .header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT) - .header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no) -} - -fn with_wechat_pay_jsapi_headers( - builder: reqwest::RequestBuilder, - platform_serial_no: &str, -) -> reqwest::RequestBuilder { - with_wechat_pay_json_headers(builder, platform_serial_no) -} - -fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse { - let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); - let nonce_str = "mock-nonce".to_string(); - let package = format!("prepay_id=mock-{order_id}"); - let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes()); - - WechatMiniProgramPayParamsResponse { - time_stamp, - nonce_str, - package, - sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(), - pay_sign, - } -} - -fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse { - WechatH5PaymentResponse { - h5_url: format!( - "https://mock.wechat-pay.local/h5?out_trade_no={}", - urlencoding::encode(order_id) - ), - } -} - -fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse { - WechatNativePaymentResponse { - code_url: format!( - "weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}", - hex_sha256(order_id.as_bytes()) - ), - } -} - -fn parse_mock_notify(body: &[u8]) -> Result { - let value = serde_json::from_slice::(body).map_err(|error| { - WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}")) - })?; - Ok(WechatPayNotifyOrder { - out_trade_no: value - .get("outTradeNo") - .or_else(|| value.get("out_trade_no")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string()) - })? - .to_string(), - transaction_id: value - .get("transactionId") - .or_else(|| value.get("transaction_id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned), - trade_state: value - .get("tradeState") - .or_else(|| value.get("trade_state")) - .and_then(Value::as_str) - .unwrap_or("SUCCESS") - .to_string(), - success_time: value - .get("successTime") - .or_else(|| value.get("success_time")) - .and_then(Value::as_str) - .map(ToOwned::to_owned), - }) -} - -fn build_wechat_virtual_payment_notify_response( - err_code: i32, - err_msg: impl Into, -) -> WechatVirtualPaymentNotifyResponse { - WechatVirtualPaymentNotifyResponse { - err_code, - err_msg: err_msg.into(), - } -} - -#[derive(Clone, Copy)] -enum VirtualPaymentNotifyResponseFormat { - Json, - Xml, -} - -fn detect_virtual_payment_notify_response_format( - headers: &HeaderMap, - body: &[u8], -) -> VirtualPaymentNotifyResponseFormat { - let content_type = headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_ascii_lowercase(); - if content_type.contains("xml") { - return VirtualPaymentNotifyResponseFormat::Xml; - } - let body_trimmed = body - .iter() - .copied() - .skip_while(|byte| byte.is_ascii_whitespace()) - .next(); - match body_trimmed { - Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml, - _ => VirtualPaymentNotifyResponseFormat::Json, - } -} - fn required_config(value: Option<&str>, key: &str) -> Result { value .map(str::trim) @@ -1946,6 +1567,7 @@ impl std::error::Error for WechatPayError {} mod tests { use super::*; use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding}; + use serde_json::json; #[test] fn mock_pay_params_use_request_payment_shape() { diff --git a/server-rs/crates/platform-wechat/src/subscribe_message.rs b/server-rs/crates/platform-wechat/src/subscribe_message.rs new file mode 100644 index 00000000..0935554e --- /dev/null +++ b/server-rs/crates/platform-wechat/src/subscribe_message.rs @@ -0,0 +1,234 @@ +use std::{collections::BTreeMap, error::Error, fmt}; + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::warn; +use url::Url; + +pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str = + "https://api.weixin.qq.com/cgi-bin/stable_token"; +pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str = + "https://api.weixin.qq.com/cgi-bin/message/subscribe/send"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatConfig { + pub app_id: Option, + pub app_secret: Option, + pub stable_access_token_endpoint: String, + pub subscribe_message_endpoint: String, +} + +#[derive(Clone, Debug)] +pub struct WechatClient { + client: Client, + config: WechatConfig, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatSubscribeMessageRequest { + pub touser: String, + pub template_id: String, + pub page: Option, + pub miniprogram_state: Option, + pub lang: Option, + pub data: BTreeMap, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum WechatError { + InvalidConfig(String), + RequestFailed(String), + DeserializeFailed(String), + Upstream(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WechatErrorKind { + InvalidConfig, + RequestFailed, + DeserializeFailed, + Upstream, +} + +#[derive(Debug, Deserialize)] +struct WechatStableAccessTokenResponse { + access_token: Option, + errcode: Option, + errmsg: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatSubscribeMessageResponse { + errcode: i64, + errmsg: Option, +} + +#[derive(Debug, Serialize)] +struct WechatTemplateDataValue { + value: String, +} + +impl WechatClient { + pub fn new(config: WechatConfig) -> Self { + Self { + client: Client::new(), + config, + } + } + + pub async fn send_subscribe_message( + &self, + request: WechatSubscribeMessageRequest, + ) -> Result<(), WechatError> { + let app_id = self + .config + .app_id + .as_deref() + .and_then(non_empty) + .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?; + let app_secret = self + .config + .app_secret + .as_deref() + .and_then(non_empty) + .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?; + + let access_token = self.request_access_token(app_id, app_secret).await?; + let mut send_url = + Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| { + WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}")) + })?; + send_url + .query_pairs_mut() + .append_pair("access_token", &access_token); + + let data = request + .data + .into_iter() + .map(|(key, value)| (key, WechatTemplateDataValue { value })) + .collect::>(); + let payload = json!({ + "touser": request.touser, + "template_id": request.template_id, + "page": request.page, + "miniprogram_state": request.miniprogram_state, + "lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()), + "data": data, + }); + let response = self + .client + .post(send_url.as_str()) + .json(&payload) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信订阅消息请求失败"); + WechatError::RequestFailed("微信订阅消息请求失败".to_string()) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信订阅消息响应解析失败"); + WechatError::DeserializeFailed("微信订阅消息响应非法".to_string()) + })?; + + if response.errcode != 0 { + return Err(WechatError::Upstream(format!( + "微信订阅消息发送失败:{}", + response.errmsg.unwrap_or_else(|| format!( + "subscribeMessage.send 返回错误 {}", + response.errcode + )) + ))); + } + + Ok(()) + } + + async fn request_access_token( + &self, + app_id: &str, + app_secret: &str, + ) -> Result { + let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| { + WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}")) + })?; + let payload = self + .client + .post(url.as_str()) + .json(&json!({ + "grant_type": "client_credential", + "appid": app_id, + "secret": app_secret, + "force_refresh": false, + })) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信 stable_token 请求失败"); + WechatError::RequestFailed("微信 stable_token 请求失败".to_string()) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信 stable_token 响应解析失败"); + WechatError::DeserializeFailed("微信 stable_token 响应非法".to_string()) + })?; + + if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { + return Err(WechatError::Upstream(format!( + "微信 stable_token 返回错误:{}", + payload + .errmsg + .unwrap_or_else(|| format!("errcode={errcode}")) + ))); + } + + payload + .access_token + .and_then(|value| non_empty_owned(value)) + .ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string())) + } +} + +impl WechatError { + pub fn kind(&self) -> WechatErrorKind { + match self { + Self::InvalidConfig(_) => WechatErrorKind::InvalidConfig, + Self::RequestFailed(_) => WechatErrorKind::RequestFailed, + Self::DeserializeFailed(_) => WechatErrorKind::DeserializeFailed, + Self::Upstream(_) => WechatErrorKind::Upstream, + } + } +} + +impl fmt::Display for WechatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConfig(message) + | Self::RequestFailed(message) + | Self::DeserializeFailed(message) + | Self::Upstream(message) => f.write_str(message), + } + } +} + +impl Error for WechatError {} + +fn non_empty(value: &str) -> Option<&str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn non_empty_owned(value: String) -> Option { + if value.trim().is_empty() { + None + } else { + Some(value) + } +} diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index 826130f4..3bc62911 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -166,6 +166,45 @@ pub struct JumpHopTileAsset { pub visual_height: u32, pub top_surface_radius: f32, pub landing_radius: f32, + #[serde(default)] + pub face_assets: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum JumpHopTileFaceKey { + Top, + Front, + Right, + Back, + Left, + Bottom, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileFaceAsset { + pub face: JumpHopTileFaceKey, + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, + pub source_atlas_cell: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileFaceAssets { + pub top: JumpHopTileFaceAsset, + pub front: JumpHopTileFaceAsset, + pub right: JumpHopTileFaceAsset, + pub back: JumpHopTileFaceAsset, + pub left: JumpHopTileFaceAsset, + pub bottom: JumpHopTileFaceAsset, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 9f1aeef1..6274315c 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -473,9 +473,9 @@ fn validate_jump_hop_runtime_ready( } validate_jump_hop_default_character_ready(work)?; validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?; - if work.tile_assets.len() < 25 { + if work.tile_assets.len() < 18 { return Err(SpacetimeClientError::validation_failed( - "jump-hop runtime 需要 25 个地块资产", + "jump-hop runtime 需要 18 个地块资产", )); } for (index, asset) in work.tile_assets.iter().enumerate() { @@ -761,12 +761,12 @@ fn build_compile_input( draft.default_character = Some(default_jump_hop_default_character()); let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed( - "jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object", + "jump-hop compile-draft 缺少真实地板贴图图集资产,请先由 api-server 生成并持久化 asset_object", ) })?; - let tile_assets = if draft.tile_assets.len() < 25 { + let tile_assets = if draft.tile_assets.len() < 18 { return Err(SpacetimeClientError::validation_failed( - "jump-hop compile-draft 需要 25 个真实地块资产,请先由 api-server 生成并持久化 asset_object", + "jump-hop compile-draft 需要 18 个真实地块资产,请先由 api-server 生成并持久化 asset_object", )); } else { draft.tile_assets.clone() @@ -878,7 +878,7 @@ fn default_draft() -> JumpHopDraftResponse { style_preset: JumpHopStylePreset::MinimalBlocks, default_character: Some(default_jump_hop_default_character()), character_prompt: "内置默认 3D 角色".to_string(), - tile_prompt: "跳一跳主题的正面30度视角主题物体图集,物体本身作为跳跃落点".to_string(), + tile_prompt: "跳一跳主题的3D立方体主题身份方块包装图集".to_string(), end_mood_prompt: None, character_asset: None, tile_atlas_asset: None, @@ -994,7 +994,7 @@ mod tests { const NOW_MICROS: i64 = 1_763_456_789_000_000; #[test] - fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character() + fn jump_hop_action_compile_draft_builds_compile_input_with_18_tile_assets_and_builtin_character() { let session = session_with_draft(draft_without_character_asset()); let payload = action(JumpHopActionType::CompileDraft); @@ -1028,9 +1028,9 @@ mod tests { .tile_assets_json .as_deref() .unwrap_or("") - .contains("old-tile-25-object") + .contains("old-tile-18-object") ); - assert_eq!(draft.tile_assets.len(), 25); + assert_eq!(draft.tile_assets.len(), 18); assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready); } @@ -1040,7 +1040,7 @@ mod tests { let mut payload = action(JumpHopActionType::RegenerateTiles); payload.tile_prompt = Some("新的地块提示词".to_string()); payload.tile_atlas_asset = Some(tile_atlas_asset("new-tile-atlas", NOW_MICROS)); - payload.tile_assets = Some(tile_assets("new", 25)); + payload.tile_assets = Some(tile_assets("new", 18)); let (plan, _draft) = build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) @@ -1082,7 +1082,7 @@ mod tests { .tile_assets_json .as_deref() .unwrap_or("") - .contains("new-tile-25-object") + .contains("new-tile-18-object") ); } @@ -1196,7 +1196,7 @@ mod tests { JumpHopDraftResponse { profile_id: None, tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)), - tile_assets: tile_assets("old", 25), + tile_assets: tile_assets("old", 18), ..base_draft() } } @@ -1206,7 +1206,7 @@ mod tests { profile_id: Some(PROFILE_ID.to_string()), character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")), tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)), - tile_assets: tile_assets("old", 25), + tile_assets: tile_assets("old", 18), path: Some(sample_jump_hop_path()), cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()), generation_status: JumpHopGenerationStatus::Ready, @@ -1243,13 +1243,14 @@ mod tests { index + 1 ), asset_object_id: format!("{prefix}-tile-{:02}-object", index + 1), - source_atlas_cell: format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1), - atlas_row: Some(index as u32 / 5 + 1), - atlas_col: Some(index as u32 % 5 + 1), + source_atlas_cell: format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1), + atlas_row: Some(index as u32 / 3 + 1), + atlas_col: Some(index as u32 % 3 + 1), visual_width: 256, visual_height: 192, top_surface_radius: 42.0, landing_radius: 34.0, + face_assets: None, }) .collect() } diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs index 5a5a8a5e..b37dc8f3 100644 --- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -8,9 +8,9 @@ pub use shared_contracts::jump_hop::{ JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, - JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, - JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, - JumpHopWorkspaceCreateRequest, + JumpHopTileFaceAsset, JumpHopTileFaceAssets, JumpHopTileFaceKey, JumpHopTileType, + JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, + JumpHopWorkSummaryResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; pub(crate) fn map_jump_hop_agent_session_procedure_result( @@ -267,6 +267,33 @@ fn map_tile_asset(snapshot: JumpHopTileAssetSnapshot) -> JumpHopTileAsset { visual_height: snapshot.visual_height, top_surface_radius: snapshot.top_surface_radius, landing_radius: snapshot.landing_radius, + face_assets: snapshot.face_assets.map(map_tile_face_assets), + } +} + +fn map_tile_face_assets(snapshot: JumpHopTileFaceAssetsSnapshot) -> JumpHopTileFaceAssets { + JumpHopTileFaceAssets { + top: map_tile_face_asset(snapshot.top), + front: map_tile_face_asset(snapshot.front), + right: map_tile_face_asset(snapshot.right), + back: map_tile_face_asset(snapshot.back), + left: map_tile_face_asset(snapshot.left), + bottom: map_tile_face_asset(snapshot.bottom), + } +} + +fn map_tile_face_asset(snapshot: JumpHopTileFaceAssetSnapshot) -> JumpHopTileFaceAsset { + JumpHopTileFaceAsset { + face: parse_tile_face_key(&snapshot.face), + asset_id: snapshot.asset_id, + image_src: snapshot.image_src, + image_object_key: snapshot.image_object_key, + asset_object_id: snapshot.asset_object_id, + generation_provider: snapshot.generation_provider, + prompt: snapshot.prompt, + width: snapshot.width, + height: snapshot.height, + source_atlas_cell: snapshot.source_atlas_cell, } } @@ -405,6 +432,17 @@ fn parse_tile_type(value: &str) -> JumpHopTileType { } } +fn parse_tile_face_key(value: &str) -> JumpHopTileFaceKey { + match value { + "front" => JumpHopTileFaceKey::Front, + "right" => JumpHopTileFaceKey::Right, + "back" => JumpHopTileFaceKey::Back, + "left" => JumpHopTileFaceKey::Left, + "bottom" => JumpHopTileFaceKey::Bottom, + _ => JumpHopTileFaceKey::Top, + } +} + fn parse_domain_tile_type(value: crate::module_bindings::JumpHopTileType) -> JumpHopTileType { match value { crate::module_bindings::JumpHopTileType::Start => JumpHopTileType::Start, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index acdf3fc5..d2ac5477 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -463,6 +463,8 @@ pub mod jump_hop_runtime_run_row_type; pub mod jump_hop_runtime_run_table; pub mod jump_hop_scoring_type; pub mod jump_hop_tile_asset_snapshot_type; +pub mod jump_hop_tile_face_asset_snapshot_type; +pub mod jump_hop_tile_face_assets_snapshot_type; pub mod jump_hop_tile_type_type; pub mod jump_hop_work_delete_input_type; pub mod jump_hop_work_get_input_type; @@ -1567,6 +1569,8 @@ pub use jump_hop_runtime_run_row_type::JumpHopRuntimeRunRow; pub use jump_hop_runtime_run_table::*; pub use jump_hop_scoring_type::JumpHopScoring; pub use jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot; +pub use jump_hop_tile_face_asset_snapshot_type::JumpHopTileFaceAssetSnapshot; +pub use jump_hop_tile_face_assets_snapshot_type::JumpHopTileFaceAssetsSnapshot; pub use jump_hop_tile_type_type::JumpHopTileType; pub use jump_hop_work_delete_input_type::JumpHopWorkDeleteInput; pub use jump_hop_work_get_input_type::JumpHopWorkGetInput; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs index 9ca1fe02..5223f15a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs @@ -4,6 +4,8 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::jump_hop_tile_face_assets_snapshot_type::JumpHopTileFaceAssetsSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct JumpHopTileAssetSnapshot { @@ -19,6 +21,7 @@ pub struct JumpHopTileAssetSnapshot { pub visual_height: u32, pub top_surface_radius: f32, pub landing_radius: f32, + pub face_assets: Option, } impl __sdk::InModule for JumpHopTileAssetSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_asset_snapshot_type.rs new file mode 100644 index 00000000..b2b27196 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_asset_snapshot_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopTileFaceAssetSnapshot { + pub face: String, + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, + pub source_atlas_cell: String, +} + +impl __sdk::InModule for JumpHopTileFaceAssetSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_assets_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_assets_snapshot_type.rs new file mode 100644 index 00000000..7625d48f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_assets_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::jump_hop_tile_face_asset_snapshot_type::JumpHopTileFaceAssetSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopTileFaceAssetsSnapshot { + pub top: JumpHopTileFaceAssetSnapshot, + pub front: JumpHopTileFaceAssetSnapshot, + pub right: JumpHopTileFaceAssetSnapshot, + pub back: JumpHopTileFaceAssetSnapshot, + pub left: JumpHopTileFaceAssetSnapshot, + pub bottom: JumpHopTileFaceAssetSnapshot, +} + +impl __sdk::InModule for JumpHopTileFaceAssetsSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index 743d62f9..ee865db2 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -1311,7 +1311,7 @@ fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot { difficulty: JumpHopDifficulty::Standard.as_str().to_string(), style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(), character_prompt: "内置默认 3D 角色".to_string(), - tile_prompt: format!("{seed}主题的正面30度视角主题物体图集,物体本身作为跳跃落点"), + tile_prompt: format!("{seed}主题的3D立方体主题身份方块包装图集"), end_mood_prompt: String::new(), } } diff --git a/server-rs/crates/spacetime-module/src/jump_hop/types.rs b/server-rs/crates/spacetime-module/src/jump_hop/types.rs index 42c1d12b..218a4b46 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/types.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/types.rs @@ -232,6 +232,34 @@ pub struct JumpHopTileAssetSnapshot { pub visual_height: u32, pub top_surface_radius: f32, pub landing_radius: f32, + #[serde(default)] + pub face_assets: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileFaceAssetSnapshot { + pub face: String, + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, + pub source_atlas_cell: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileFaceAssetsSnapshot { + pub top: JumpHopTileFaceAssetSnapshot, + pub front: JumpHopTileFaceAssetSnapshot, + pub right: JumpHopTileFaceAssetSnapshot, + pub back: JumpHopTileFaceAssetSnapshot, + pub left: JumpHopTileFaceAssetSnapshot, + pub bottom: JumpHopTileFaceAssetSnapshot, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index e5497b6a..3d5e3c68 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -8,9 +8,13 @@ import type { JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; -import { buildJumpHopVisiblePlatforms } from '../../services/jump-hop/jumpHopRuntimeModel'; import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; -import { JumpHopRuntimeShell } from './JumpHopRuntimeShell'; +import { + JUMP_HOP_THREE_CAMERA_UP_Y, + JumpHopRuntimeShell, + getJumpHopThreeProjectedY, + getJumpHopTileTextureSignature, +} from './JumpHopRuntimeShell'; vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({ @@ -44,22 +48,10 @@ function dispatchPointerEvent( target.dispatchEvent(event); } -test('跳一跳运行态松手时提交向后拖动向量', async () => { +test('跳一跳运行态松手时提交长按蓄力值和后端方向向量', async () => { vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); const run = buildRun(); - const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []); - const current = visiblePlatforms[0]!; - const target = visiblePlatforms[1]!; - const stageSize = { width: 320, height: 568 }; - const xPixelsPerWorldUnit = - Math.abs( - ((target.screenX - current.screenX) / 100) * stageSize.width, - ) / Math.abs(target.platform.x - current.platform.x); - const yPixelsPerWorldUnit = - Math.abs( - ((target.screenY - current.screenY) / 100) * stageSize.height, - ) / Math.abs(target.platform.y - current.platform.y); render( { clientY: 478, }); }); + await act(async () => { + await vi.advanceTimersByTimeAsync(360); + }); await act(async () => { dispatchPointerEvent(stage, 'pointerup', { @@ -96,28 +91,19 @@ test('跳一跳运行态松手时提交向后拖动向量', async () => { expect(onJump).toHaveBeenCalledTimes(1); const jumpPayload = onJump.mock.calls[0]?.[0]; - expect(jumpPayload?.dragVectorX).toBeCloseTo(-48 / xPixelsPerWorldUnit, 2); - expect(jumpPayload?.dragVectorY).toBeCloseTo(58 / yPixelsPerWorldUnit, 2); - expect(jumpPayload?.dragDistance).toBeGreaterThan(74); - expect(jumpPayload?.dragDistance).toBeLessThan(76); + expect(typeof jumpPayload?.dragVectorX).toBe('number'); + expect(typeof jumpPayload?.dragVectorY).toBe('number'); + expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true); + expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true); + expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(360); + expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(380); vi.useRealTimers(); }); -test('跳一跳运行态拖拽方向按手指起点到松手点计算', async () => { +test('跳一跳运行态手指移动不改变蓄力时长但仍提交方向向量', async () => { + vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); const run = buildRun(); - const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []); - const current = visiblePlatforms[0]!; - const target = visiblePlatforms[1]!; - const stageSize = { width: 320, height: 568 }; - const xPixelsPerWorldUnit = - Math.abs( - ((target.screenX - current.screenX) / 100) * stageSize.width, - ) / Math.abs(target.platform.x - current.platform.x); - const yPixelsPerWorldUnit = - Math.abs( - ((target.screenY - current.screenY) / 100) * stageSize.height, - ) / Math.abs(target.platform.y - current.platform.y); render( { + await vi.advanceTimersByTimeAsync(240); + }); await act(async () => { dispatchPointerEvent(stage, 'pointerup', { pointerId: 1, @@ -152,15 +141,63 @@ test('跳一跳运行态拖拽方向按手指起点到松手点计算', async () }); const jumpPayload = onJump.mock.calls[0]?.[0]; - expect(jumpPayload?.dragVectorX).toBeLessThan(0); - expect(jumpPayload?.dragVectorY).toBeLessThan(0); - expect(Math.abs(jumpPayload?.dragVectorX ?? 0)).toBeLessThan(30); - expect(Math.abs(jumpPayload?.dragVectorY ?? 0)).toBeLessThan(20); - expect(jumpPayload?.dragVectorX).toBeCloseTo(-30 / xPixelsPerWorldUnit, 2); - expect(jumpPayload?.dragVectorY).toBeCloseTo(-20 / yPixelsPerWorldUnit, 2); + expect(typeof jumpPayload?.dragVectorX).toBe('number'); + expect(typeof jumpPayload?.dragVectorY).toBe('number'); + expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true); + expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true); + expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240); + expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260); + vi.useRealTimers(); }); -test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线', async () => { +test('跳一跳运行态长按蓄力不会超过后端上限', async () => { + vi.useFakeTimers(); + const onJump = vi.fn().mockResolvedValue(undefined); + const baseRun = buildRun(); + const run: JumpHopRuntimeRunSnapshotResponse = { + ...baseRun, + path: { + ...baseRun.path, + scoring: { + ...baseRun.path.scoring, + maxChargeMs: 300, + }, + }, + }; + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 40, + clientY: 40, + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(780); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 40, + clientY: 40, + }); + }); + + expect(onJump.mock.calls[0]?.[0]?.dragDistance).toBe(300); + vi.useRealTimers(); +}); + +test('跳一跳运行态不再显示旧圆弧蓄力条而是显示长按蓄力引导', async () => { const onJump = vi.fn().mockResolvedValue(undefined); render( @@ -183,10 +220,12 @@ test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线', expect(screen.queryByText('起跳')).toBeNull(); expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull(); - expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull(); }); -test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => { +test('跳一跳蓄力时角色只做垂直压缩', async () => { + vi.useFakeTimers(); render( { }); }); await act(async () => { - dispatchPointerEvent(stage, 'pointermove', { - pointerId: 1, - clientX: 132, - clientY: 478, - }); + await vi.advanceTimersByTimeAsync(180); }); const character = screen.getByTestId('jump-hop-character-logo') @@ -221,12 +256,20 @@ test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => { .map((style) => style.textContent ?? '') .join('\n'); - expect(stretchTransform).toContain('matrix('); - expect(stretchTransform).not.toBe('matrix(1, 0, 0, 1, 0, 0)'); + expect(stretchTransform).toMatch(/^scale\((?[\d.]+), (?[\d.]+)\)$/); + const scaleMatch = stretchTransform.match( + /^scale\((?[\d.]+), (?[\d.]+)\)$/, + ); + const scaleX = Number(scaleMatch?.groups?.x ?? 1); + const scaleY = Number(scaleMatch?.groups?.y ?? 1); + expect(scaleX).toBeGreaterThan(1); + expect(scaleY).toBeLessThan(1); + expect(scaleY).toBeLessThan(scaleX); expect(styleText).toContain('var(--jump-hop-character-stretch-transform)'); expect(styleText).not.toContain( 'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))', ); + vi.useRealTimers(); }); test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () => { @@ -379,7 +422,7 @@ test('跳一跳草稿运行失败后不请求公开排行榜', () => { expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull(); }); -test('跳一跳角色层永远压在地块层之上', () => { +test('跳一跳 Three.js 地板层位于 DOM 角色层下方', () => { render( { const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0] ?.parentElement?.parentElement as HTMLElement | undefined; - expect(threeScene.style.zIndex).toBe('100'); + expect(threeScene.style.zIndex).toBe('42'); expect(Number(threeScene.style.zIndex)).toBeGreaterThan( Number(firstPlatform?.style.zIndex ?? 0), ); }); -test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async () => { +test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', () => { + expect(JUMP_HOP_THREE_CAMERA_UP_Y).toBe(1); + expect(getJumpHopThreeProjectedY(360, 568)).toBeLessThan(284); + expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284); +}); + +test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => { const onJump = vi.fn().mockResolvedValue(undefined); render( @@ -429,7 +478,7 @@ test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async () }); expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); - expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); await act(async () => { dispatchPointerEvent(stage, 'pointermove', { @@ -440,10 +489,11 @@ test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async () }); expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); - expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull(); }); -test('跳一跳运行态直接渲染生成的地块切片图片', () => { +test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => { render( { + render( + {}} + />, + ); + + const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image'); + const faceImageSources = preloadImages + .map((image) => image.getAttribute('src') ?? '') + .filter((source) => + source.includes('/generated-jump-hop-assets/jump-hop-profile-test/tile-'), + ); + + const firstTileMatch = faceImageSources[0]?.match(/tile-(\d{2})-/); + const firstTileNumber = firstTileMatch?.[1]; + expect(firstTileNumber).toBeTruthy(); + expect(faceImageSources).toEqual( + expect.arrayContaining([ + expect.stringContaining(`/tile-${firstTileNumber}-top/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-front/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-right/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-back/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-left/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-bottom/image.png`), + ]), + ); + const frontSource = `/tile-${firstTileNumber}-front/image.png`; + const frontRefreshKey = `asset-object-${firstTileNumber}-front`; + expect( + vi + .mocked(useResolvedAssetReadUrl) + .mock.calls.some( + ([source, options]) => + source?.includes(frontSource) && options?.refreshKey === frontRefreshKey, + ), + ).toBe(true); +}); + +test('跳一跳 Three.js 地板贴图签名包含六面贴图 URL', () => { + const asset = buildTileAssets({ withFaceAssets: true })[0]; + const signature = getJumpHopTileTextureSignature( + { + 'p1::top': 'top-url', + 'p1::front': 'front-url', + 'p1::right': 'right-url', + 'p1::back': 'back-url', + 'p1::left': 'left-url', + 'p1::bottom': 'bottom-url', + }, + 'p1', + asset, + ); + + expect(signature).toContain('top-url'); + expect(signature).toContain('front-url'); + expect(signature).toContain('right-url'); + expect(signature).toContain('back-url'); + expect(signature).toContain('left-url'); + expect(signature).toContain('bottom-url'); +}); + test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => { render( { @@ -604,11 +719,11 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async () successfulJumpCount: 1, score: 1, lastJump: { - chargeMs: 150, - jumpDistance: 1.44, + chargeMs: 420, + jumpDistance: 1.68, targetPlatformIndex: 1, - landedX: 0.8, - landedY: 1.2, + landedX: 0.93, + landedY: 1.4, result: 'hit', }, }; @@ -636,6 +751,9 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async () clientY: 478, }); }); + await act(async () => { + await vi.advanceTimersByTimeAsync(420); + }); await act(async () => { dispatchPointerEvent(stage, 'pointerup', { pointerId: 1, @@ -678,6 +796,63 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async () vi.useRealTimers(); }); +test('跳一跳成功落点偏移后下一跳视觉仍朝下一块地块方向', async () => { + vi.useFakeTimers(); + const onJump = vi.fn().mockResolvedValue(undefined); + const run: JumpHopRuntimeRunSnapshotResponse = { + ...buildRun(), + currentPlatformIndex: 1, + successfulJumpCount: 1, + score: 1, + lastJump: { + chargeMs: 300, + jumpDistance: 1.0, + targetPlatformIndex: 1, + landedX: 0, + landedY: 1.2, + result: 'hit', + }, + }; + + render( + {}} + />, + ); + + const character = screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement; + const initialLeft = Number.parseFloat(character.style.left); + const initialTop = Number.parseFloat(character.style.top); + const stage = screen.getByTestId('jump-hop-stage'); + + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + + expect(onJump).toHaveBeenCalledTimes(1); + expect(Number.parseFloat(character.style.left)).toBeLessThan(initialLeft); + expect(Number.parseFloat(character.style.top)).toBeLessThan(initialTop); + vi.useRealTimers(); +}); + test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => { vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); @@ -688,11 +863,25 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async successfulJumpCount: 1, score: 1, lastJump: { - chargeMs: 150, - jumpDistance: 1.44, + chargeMs: 420, + jumpDistance: 1.68, targetPlatformIndex: 1, - landedX: 0.8, - landedY: 1.2, + landedX: 0.93, + landedY: 1.4, + result: 'hit', + }, + }; + const runAfterSecondJump: JumpHopRuntimeRunSnapshotResponse = { + ...buildRunWithExtraPreviewPlatform(), + currentPlatformIndex: 2, + successfulJumpCount: 2, + score: 2, + lastJump: { + chargeMs: 360, + jumpDistance: 1.44, + targetPlatformIndex: 2, + landedX: -0.2, + landedY: 2.4, result: 'hit', }, }; @@ -720,6 +909,9 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async clientY: 478, }); }); + await act(async () => { + await vi.advanceTimersByTimeAsync(420); + }); await act(async () => { dispatchPointerEvent(stage, 'pointerup', { pointerId: 1, @@ -731,7 +923,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async expect(onJump).toHaveBeenCalledTimes(1); expect(stage.getAttribute('data-jump-animating')).toBe('true'); expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( - '78%', + '64%', ); expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( 'true', @@ -753,7 +945,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async 'true', ); expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( - '78%', + '64%', ); expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( 'true', @@ -775,6 +967,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async const landedCharacter = screen.getByTestId('jump-hop-character-logo') .parentElement as HTMLElement; expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true'); + expect(Number.parseFloat(landedCharacter.style.left)).not.toBeCloseTo(50, 1); + expect(Number.parseFloat(landedCharacter.style.top)).not.toBeCloseTo(75, 1); expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe( '0px', ); @@ -783,18 +977,23 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async ); const cameraLayer = screen.getByTestId('jump-hop-camera-layer'); expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true'); + expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-zoom')).toBe( + '1.3', + ); expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe( - '-28%', + '-17%', ); expect( Number.parseFloat( cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'), ), - ).toBeCloseTo(12.29, 2); + ).toBeCloseTo(8.96, 2); const styleText = Array.from(document.querySelectorAll('style')) .map((style) => style.textContent ?? '') .join('\n'); expect(styleText).toContain('@keyframes jump-hop-character-recoil'); + expect(styleText).not.toContain('@keyframes jump-hop-platform-exit-drift'); + expect(styleText).toContain('scale(var(--jump-hop-camera-zoom, 1))'); expect(styleText).toMatch( /data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/, ); @@ -826,7 +1025,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async 'p1', ); expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe( - '78%', + '64%', ); expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe( '1.08', @@ -835,7 +1034,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async 'p2', ); expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe( - '50%', + '47%', ); await act(async () => { @@ -870,19 +1069,78 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe( 'false', ); - expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( - '78%', + const retainedOldPlatform = screen + .getByTestId('jump-hop-stage') + .querySelector("[data-platform-id='p0']") as HTMLElement | null; + expect(retainedOldPlatform?.getAttribute('data-advance-state')).toBe( + 'exiting', ); - expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( + expect(retainedOldPlatform?.style.top).toBe('81%'); + const currentPlatform = screen + .getByTestId('jump-hop-stage') + .querySelector("[data-platform-id='p1']") as HTMLElement | null; + expect(currentPlatform?.style.top).toBe('64%'); + expect(currentPlatform?.getAttribute('data-current')).toBe( 'true', ); - expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + expect(currentPlatform?.getAttribute('data-platform-id')).toBe( 'p1', ); - expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe( - '50%', + expect( + ( + screen + .getByTestId('jump-hop-stage') + .querySelector("[data-platform-id='p2']") as HTMLElement | null + )?.style.top, + ).toBe('47%'); + + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 2, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(160); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 2, + clientX: 180, + clientY: 420, + }); + }); + + expect(onJump).toHaveBeenCalledTimes(2); + + rerender( + {}} + />, ); + await act(async () => { + await vi.advanceTimersByTimeAsync(580); + }); + + const movedOldPlatform = screen + .getByTestId('jump-hop-stage') + .querySelector("[data-platform-id='p0']") as HTMLElement | null; + if (movedOldPlatform) { + expect(Number.parseFloat(movedOldPlatform.style.top)).toBeGreaterThan(81); + } + expect( + ( + screen + .getByTestId('jump-hop-stage') + .querySelector("[data-current='true']") as HTMLElement | null + )?.getAttribute('data-platform-id'), + ).toBe('p2'); + vi.useRealTimers(); }); @@ -994,22 +1252,51 @@ function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse { }; } -function buildTileAssets() { - return Array.from({ length: 25 }, (_, index) => { +function buildTileAssets(options: { withFaceAssets?: boolean } = {}) { + return Array.from({ length: 18 }, (_, index) => { const tileNumber = String(index + 1).padStart(2, '0'); + const atlasRow = Math.floor(index / 3) + 1; + const atlasCol = (index % 3) + 1; + const buildFaceAsset = ( + face: keyof NonNullable< + JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets'] + >, + ) => ({ + face, + assetId: `asset-${tileNumber}-${face}`, + imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`, + imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`, + assetObjectId: `asset-object-${tileNumber}-${face}`, + generationProvider: 'vector-engine', + prompt: `tile ${tileNumber} ${face}`, + width: 256, + height: 256, + sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}/${face}`, + }); + const faceAssets: NonNullable< + JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets'] + > = { + top: buildFaceAsset('top'), + front: buildFaceAsset('front'), + right: buildFaceAsset('right'), + back: buildFaceAsset('back'), + left: buildFaceAsset('left'), + bottom: buildFaceAsset('bottom'), + }; return { tileType: index === 0 ? 'start' : 'normal', tileId: `tile-${tileNumber}`, imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`, imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`, assetObjectId: `asset-object-${tileNumber}`, - sourceAtlasCell: `row-${Math.floor(index / 5) + 1}-col-${(index % 5) + 1}`, - atlasRow: Math.floor(index / 5) + 1, - atlasCol: (index % 5) + 1, + sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}`, + atlasRow, + atlasCol, visualWidth: 256, - visualHeight: 192, + visualHeight: 256, topSurfaceRadius: 42, landingRadius: 34, + faceAssets: options.withFaceAssets ? faceAssets : undefined, } satisfies JumpHopWorkProfileResponse['tileAssets'][number]; }); } diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 2b434eb0..2c2eb1a2 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -14,17 +14,22 @@ import { import jumpHopRuntimeLevelLogo from '../../../media/logo.png'; import type { JumpHopRuntimeRunSnapshotResponse, + JumpHopTileFaceAsset, JumpHopTileAsset, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { + isGeneratedLegacyPath, + readAssetBytes, +} from '../../services/assetReadUrlService'; import type { JumpHopRuntimeRequestOptions } from '../../services/jump-hop/jumpHopClient'; import { buildJumpHopVisiblePlatforms, formatJumpHopDurationLabel, - getJumpHopBackendDragVector, getJumpHopCharacterVisualPosition, getJumpHopJumpFeedbackLabel, + getJumpHopBackendDragVector, getJumpHopLandingAssistVisualPosition, getJumpHopPlatformVisualSize, getJumpHopRunDurationMs, @@ -50,6 +55,25 @@ type JumpHopVisualJump = { to: JumpHopCharacterVisualPosition; }; +type JumpHopPlatformRenderItem = JumpHopVisiblePlatform & { + renderKey: string; + advanceState: 'exiting' | 'camera' | 'idle'; +}; + +type JumpHopTilePreloadItem = { + textureKey: string; + asset: JumpHopTileAsset; +}; + +type JumpHopTileFaceKey = 'top' | 'front' | 'right' | 'back' | 'left' | 'bottom'; + +type JumpHopTileTextureSource = { + imageSrc: string; + imageObjectKey?: string; + assetObjectId?: string; + tileId?: string; +}; + type JumpHopRuntimeShellProps = { profile?: JumpHopWorkProfileResponse | null; run?: JumpHopRuntimeRunSnapshotResponse | null; @@ -68,9 +92,25 @@ const DEFAULT_MAX_DRAG_DISTANCE_PX = 180; const JUMP_HOP_ANIMATION_DURATION_MS = 560; const JUMP_HOP_LANDING_RECOIL_DURATION_MS = 560; const JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS = 1440; +const JUMP_HOP_PLATFORM_RETAIN_OFFSCREEN_SCREEN_Y = 122; +const JUMP_HOP_CAMERA_ZOOM = 1.3; const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC = '/branding/jump-hop-taonier-character.png'; const JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT = 3; +const JUMP_HOP_TILE_FACE_KEYS: JumpHopTileFaceKey[] = [ + 'top', + 'front', + 'right', + 'back', + 'left', + 'bottom', +]; +const JUMP_HOP_THREE_CAMERA_PITCH_RAD = Math.PI / 4; +const JUMP_HOP_THREE_CAMERA_PITCH_COS = Math.cos( + JUMP_HOP_THREE_CAMERA_PITCH_RAD, +); +export const JUMP_HOP_THREE_CAMERA_UP_Y = 1; +const JUMP_HOP_THREE_CAMERA_DISTANCE_MULTIPLIER = 1.34; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -83,34 +123,6 @@ function formatJumpHopCssNumber(value: number) { return value.toFixed(4).replace(/\.?0+$/, ''); } -function buildJumpHopDirectionalScaleMatrix({ - directionX, - directionY, - stretchScale, - crossScale, -}: { - directionX: number; - directionY: number; - stretchScale: number; - crossScale: number; -}) { - const distance = Math.hypot(directionX, directionY); - if (distance < 0.1) { - return 'matrix(1, 0, 0, 1, 0, 0)'; - } - - const unitX = directionX / distance; - const unitY = directionY / distance; - const stretchDelta = stretchScale - crossScale; - const a = crossScale + stretchDelta * unitX * unitX; - const b = stretchDelta * unitX * unitY; - const c = stretchDelta * unitX * unitY; - const d = crossScale + stretchDelta * unitY * unitY; - return `matrix(${formatJumpHopCssNumber(a)}, ${formatJumpHopCssNumber( - b, - )}, ${formatJumpHopCssNumber(c)}, ${formatJumpHopCssNumber(d)}, 0, 0)`; -} - function getRun( run: JumpHopRuntimeRunSnapshotResponse | null | undefined, snapshot: JumpHopRuntimeRunSnapshotResponse | null | undefined, @@ -130,8 +142,7 @@ function hasJumpHopRunDisplayChange( current.score !== next.score || current.combo !== next.combo || current.finishedAtMs !== next.finishedAtMs || - current.lastJump?.targetPlatformIndex !== - next.lastJump?.targetPlatformIndex || + current.lastJump?.targetPlatformIndex !== next.lastJump?.targetPlatformIndex || current.lastJump?.result !== next.lastJump?.result || current.lastJump?.chargeMs !== next.lastJump?.chargeMs ); @@ -173,6 +184,41 @@ function buildJumpHopCharacterVisualPositionFromPlatform( }; } +function getJumpHopRunLandingVisualPosition({ + run, + platforms, + stageSize, +}: { + run: JumpHopRuntimeRunSnapshotResponse; + platforms: JumpHopVisiblePlatform[]; + stageSize: { width: number; height: number }; +}) { + const lastJump = run.lastJump; + if (!lastJump || stageSize.width <= 0 || stageSize.height <= 0) { + return null; + } + + return getJumpHopCharacterVisualPosition(run, platforms, stageSize); +} + +function getJumpHopThreeCubeSide( + platform: JumpHopVisiblePlatform['platform'], + scale: number, +) { + const platformSize = getJumpHopPlatformVisualSize(platform, scale); + return Math.max(56, Math.min(platformSize.width, platformSize.height) * 0.86); +} + +export function getJumpHopThreeProjectedY( + screenY: number, + viewportHeight: number, +) { + return ( + viewportHeight / 2 + + (viewportHeight / 2 - screenY) / JUMP_HOP_THREE_CAMERA_PITCH_COS + ); +} + function IsometricFallbackTile({ platform, }: { @@ -196,10 +242,118 @@ function IsometricFallbackTile({ ); } -function getJumpHopTileAssetRefreshKey(asset: JumpHopTileAsset | null) { +function getJumpHopTileAssetRefreshKey( + asset: JumpHopTileTextureSource | null | undefined, +) { return asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null; } +function getJumpHopTileFaceAsset( + asset: JumpHopTileAsset | null | undefined, + face: JumpHopTileFaceKey, +): JumpHopTileFaceAsset | null { + return asset?.faceAssets?.[face] ?? null; +} + +function getJumpHopTileTextureSource( + asset: JumpHopTileAsset | null | undefined, + face?: JumpHopTileFaceKey, +): JumpHopTileTextureSource | null { + const faceAsset = face ? getJumpHopTileFaceAsset(asset, face) : null; + if (faceAsset?.imageSrc) { + return { + imageSrc: faceAsset.imageSrc, + imageObjectKey: faceAsset.imageObjectKey, + assetObjectId: faceAsset.assetObjectId, + tileId: `${asset?.tileId ?? asset?.sourceAtlasCell ?? 'tile'}-${face}`, + }; + } + if (!asset?.imageSrc) { + return null; + } + return { + imageSrc: asset.imageSrc, + imageObjectKey: asset.imageObjectKey, + assetObjectId: asset.assetObjectId, + tileId: asset.tileId ?? asset.sourceAtlasCell, + }; +} + +function getJumpHopTileTextureKey(renderKey: string, face: JumpHopTileFaceKey) { + return `${renderKey}::${face}`; +} + +function getJumpHopTileTextureUrl( + textureUrls: Record, + renderKey: string, + face: JumpHopTileFaceKey, +) { + return textureUrls[getJumpHopTileTextureKey(renderKey, face)] ?? textureUrls[renderKey] ?? ''; +} + +export function getJumpHopTileTextureSignature( + textureUrls: Record, + renderKey: string, + asset: JumpHopTileAsset | null | undefined, +) { + if (!asset?.faceAssets) { + return textureUrls[renderKey] ?? ''; + } + + return JUMP_HOP_TILE_FACE_KEYS.map((face) => + getJumpHopTileTextureUrl(textureUrls, renderKey, face), + ).join('|'); +} + +function hasJumpHopTileTexturesReady( + textureUrls: Record, + renderKey: string, + asset: JumpHopTileAsset | null | undefined, +) { + if (!asset?.imageSrc) { + return true; + } + if (!asset.faceAssets) { + return Boolean(textureUrls[renderKey]); + } + return JUMP_HOP_TILE_FACE_KEYS.every((face) => + Boolean(textureUrls[getJumpHopTileTextureKey(renderKey, face)]), + ); +} + +function getJumpHopActiveTextureKeys( + renderKey: string, + asset: JumpHopTileAsset | null | undefined, +) { + if (!asset?.faceAssets) { + return [renderKey]; + } + return [ + renderKey, + ...JUMP_HOP_TILE_FACE_KEYS.map((face) => + getJumpHopTileTextureKey(renderKey, face), + ), + ]; +} + +function buildJumpHopTileTextureEntries( + asset: JumpHopTileAsset, + textureKey: string, +) { + if (!asset.faceAssets) { + const source = getJumpHopTileTextureSource(asset); + return source ? [{ textureKey, source }] : []; + } + + return JUMP_HOP_TILE_FACE_KEYS.map((face) => ({ + textureKey: getJumpHopTileTextureKey(textureKey, face), + source: getJumpHopTileTextureSource(asset, face), + })).filter( + (item): item is { textureKey: string; source: JumpHopTileTextureSource } => + Boolean(item.source?.imageSrc), + ); +} + function isJumpHopGeneratedBackgroundSource(source: string | null | undefined) { const value = source?.trim() ?? ''; if (!value) { @@ -207,21 +361,29 @@ function isJumpHopGeneratedBackgroundSource(source: string | null | undefined) { } return !( value.startsWith('/generated-jump-hop-assets/') && - (value.endsWith('/cover-composite.png') || - value.includes('/cover-composite-')) + (value.endsWith('/cover-composite.png') || value.includes('/cover-composite-')) ); } function JumpHopTileImage({ asset, platform, + textureKey, + onResolvedTextureUrl, }: { asset: JumpHopTileAsset | null; platform: JumpHopVisiblePlatform['platform']; + textureKey?: string; + onResolvedTextureUrl?: ( + textureKey: string, + resolvedUrl: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => void; }) { - const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset); + const textureSource = getJumpHopTileTextureSource(asset, 'top'); + const assetRefreshKey = getJumpHopTileAssetRefreshKey(textureSource); const { resolvedUrl, isResolving } = useResolvedAssetReadUrl( - asset?.imageSrc, + textureSource?.imageSrc, { refreshKey: assetRefreshKey, }, @@ -237,16 +399,82 @@ function JumpHopTileImage({ const shouldShowImage = Boolean(resolvedUrl && !hasError); const shouldShowFallback = !shouldShowImage; + useEffect(() => { + if (!textureKey || !onResolvedTextureUrl) { + return; + } + + let disposed = false; + const assetSource = textureSource?.imageSrc?.trim() ?? ''; + const publishTextureUrl = ( + url: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => { + if (!disposed) { + onResolvedTextureUrl(textureKey, url, options); + } + }; + + publishTextureUrl(''); + if (!assetSource || !shouldShowImage || !isLoaded) { + return () => { + disposed = true; + }; + } + + if (!isGeneratedLegacyPath(assetSource)) { + publishTextureUrl(resolvedUrl ?? ''); + return () => { + disposed = true; + }; + } + + // 中文注释:Three.js 纹理不能直接依赖跨域 OSS 签名 URL;转同源字节为 blob,避免 bucket CORS 导致 WebGL 贴图失败。 + void readAssetBytes(assetSource) + .then((response) => response.blob()) + .then((blob) => { + if (disposed) { + return; + } + publishTextureUrl(URL.createObjectURL(blob), { + parentOwnedObjectUrl: true, + }); + }) + .catch(() => { + publishTextureUrl(''); + }); + + return () => { + disposed = true; + onResolvedTextureUrl(textureKey, ''); + }; + }, [ + isLoaded, + onResolvedTextureUrl, + resolvedUrl, + shouldShowImage, + textureSource?.imageSrc, + textureKey, + ]); + return (
- {shouldShowFallback ? ( - - ) : null} + {shouldShowFallback ? : null} + {asset?.faceAssets && textureKey + ? buildJumpHopTileTextureEntries(asset, textureKey).map((item) => ( + + )) + : null} {shouldShowImage ? ( void; +}) { + const assetRefreshKey = getJumpHopTileAssetRefreshKey(source); + const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(source.imageSrc, { refreshKey: assetRefreshKey, }); + useEffect(() => { + if (!textureKey || !onResolvedTextureUrl) { + return undefined; + } + + let disposed = false; + const assetSource = source.imageSrc?.trim() ?? ''; + const publishTextureUrl = ( + url: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => { + if (!disposed) { + onResolvedTextureUrl(textureKey, url, options); + } + }; + + if (!assetSource || !resolvedUrl) { + return () => { + disposed = true; + }; + } + + if (!isGeneratedLegacyPath(assetSource)) { + publishTextureUrl(resolvedUrl); + return () => { + disposed = true; + }; + } + + void readAssetBytes(assetSource) + .then((response) => response.blob()) + .then((blob) => { + if (disposed) { + return; + } + publishTextureUrl(URL.createObjectURL(blob), { + parentOwnedObjectUrl: true, + }); + }) + .catch(() => {}); + + return () => { + disposed = true; + }; + }, [ + onResolvedTextureUrl, + resolvedUrl, + source.imageSrc, + textureKey, + ]); + if (!resolvedUrl) { return ( @@ -287,7 +578,7 @@ function JumpHopTilePreloadImage({ asset }: { asset: JumpHopTileAsset }) { return ( <> @@ -303,6 +594,35 @@ function JumpHopTilePreloadImage({ asset }: { asset: JumpHopTileAsset }) { ); } +function JumpHopTilePreloadImage({ + asset, + textureKey, + onResolvedTextureUrl, +}: { + asset: JumpHopTileAsset; + textureKey: string; + onResolvedTextureUrl?: ( + textureKey: string, + resolvedUrl: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => void; +}) { + const sources = buildJumpHopTileTextureEntries(asset, textureKey); + + return ( + <> + {sources.map((item) => ( + + ))} + + ); +} + function hasJumpHopWebGLSupport() { if (import.meta.env.MODE === 'test') { return false; @@ -341,21 +661,29 @@ function JumpHopThreeScene({ characterPosition, chargeRatio, isJumpAnimating, + platforms, platformCount, renderCharacter, + textureUrlsByRenderKey, onCharacterLayerReadyChange, + onPlatformLayerReadyChange, }: { characterPosition: JumpHopCharacterVisualPosition | null; chargeRatio: number; isJumpAnimating: boolean; + platforms: JumpHopPlatformRenderItem[]; platformCount: number; renderCharacter: boolean; + textureUrlsByRenderKey: Record; onCharacterLayerReadyChange: Dispatch>; + onPlatformLayerReadyChange: Dispatch>; }) { const hostRef = useRef(null); const characterPositionRef = useRef(characterPosition); const chargeRatioRef = useRef(chargeRatio); const isJumpAnimatingRef = useRef(isJumpAnimating); + const platformsRef = useRef(platforms); + const textureUrlsByRenderKeyRef = useRef(textureUrlsByRenderKey); useEffect(() => { characterPositionRef.current = characterPosition; @@ -369,6 +697,14 @@ function JumpHopThreeScene({ isJumpAnimatingRef.current = isJumpAnimating; }, [isJumpAnimating]); + useEffect(() => { + platformsRef.current = platforms; + }, [platforms]); + + useEffect(() => { + textureUrlsByRenderKeyRef.current = textureUrlsByRenderKey; + }, [textureUrlsByRenderKey]); + useEffect(() => { const host = hostRef.current; if (!host) { @@ -376,15 +712,17 @@ function JumpHopThreeScene({ } onCharacterLayerReadyChange(false); + onPlatformLayerReadyChange(false); host.replaceChildren(); const fallbackCanvas = document.createElement('canvas'); applyJumpHopCanvasLayout(fallbackCanvas); fallbackCanvas.setAttribute('data-testid', 'jump-hop-three-canvas'); host.appendChild(fallbackCanvas); - if (!renderCharacter || !hasJumpHopWebGLSupport()) { + if (!hasJumpHopWebGLSupport()) { return () => { onCharacterLayerReadyChange(false); + onPlatformLayerReadyChange(false); fallbackCanvas.remove(); }; } @@ -394,7 +732,10 @@ function JumpHopThreeScene({ let cleanup: (() => void) | null = null; const setup = async () => { - const three = await import('three'); + const [three, roundedBoxModule] = await Promise.all([ + import('three'), + import('three/examples/jsm/geometries/RoundedBoxGeometry.js'), + ]); if (disposed || !hostRef.current) { return; } @@ -406,66 +747,294 @@ function JumpHopThreeScene({ }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8)); renderer.outputColorSpace = three.SRGBColorSpace; + renderer.sortObjects = true; const scene = new three.Scene(); scene.background = null; - const camera = new three.OrthographicCamera(0, 320, 0, 568, -100, 100); - camera.position.set(0, 0, 50); - camera.lookAt(0, 0, 0); + const camera = new three.OrthographicCamera( + -160, + 160, + 284, + -284, + 1, + 2400, + ); + // 中文注释:保持 Three 平台层和 DOM 角色层的屏幕 X 轴同向,避免 WebGL 地块左右镜像后让跳跃看起来反向。 + camera.up.set(0, JUMP_HOP_THREE_CAMERA_UP_Y, 0); - scene.add(new three.AmbientLight(0xffffff, 1.45)); - const keyLight = new three.DirectionalLight(0xffffff, 2.2); - keyLight.position.set(-80, 120, 80); + scene.add(new three.AmbientLight(0xffffff, 1.22)); + const keyLight = new three.DirectionalLight(0xffffff, 2.45); + keyLight.position.set(-90, 105, 110); scene.add(keyLight); - const rimLight = new three.DirectionalLight(0xffedd5, 0.8); - rimLight.position.set(120, 80, 60); + const fillLight = new three.DirectionalLight(0xfef3c7, 0.82); + fillLight.position.set(110, 96, 70); + scene.add(fillLight); + const rimLight = new three.DirectionalLight(0xffedd5, 0.64); + rimLight.position.set(120, 44, 120); scene.add(rimLight); - const character = new three.Group(); - const body = new three.Mesh( - new three.CapsuleGeometry(10, 22, 8, 18), - new three.MeshStandardMaterial({ - color: 0xdf7f40, - roughness: 0.74, - }), - ); - body.position.y = -28; - const head = new three.Mesh( - new three.SphereGeometry(11, 28, 20), - new three.MeshStandardMaterial({ - color: 0xf59e0b, - roughness: 0.7, - }), - ); - head.position.y = -62; - const accent = new three.Mesh( - new three.BoxGeometry(15, 7, 7), - new three.MeshStandardMaterial({ - color: 0x2563eb, - roughness: 0.64, - }), - ); - accent.position.set(0, -36, 10); - character.add(body, head, accent); - scene.add(character); + const character = renderCharacter ? new three.Group() : null; + if (character) { + const body = new three.Mesh( + new three.CapsuleGeometry(10, 22, 8, 18), + new three.MeshStandardMaterial({ + color: 0xdf7f40, + roughness: 0.74, + }), + ); + body.position.y = -28; + const head = new three.Mesh( + new three.SphereGeometry(11, 28, 20), + new three.MeshStandardMaterial({ + color: 0xf59e0b, + roughness: 0.7, + }), + ); + head.position.y = -62; + const accent = new three.Mesh( + new three.BoxGeometry(15, 7, 7), + new three.MeshStandardMaterial({ + color: 0x2563eb, + roughness: 0.64, + }), + ); + accent.position.set(0, -36, 10); + character.add(body, head, accent); + scene.add(character); + } - const size = { + const platformGroup = new three.Group(); + platformGroup.renderOrder = 20; + scene.add(platformGroup); + + // 中文注释:平台几何只创建一份,运行态只做等比缩放,保持标准 1x1x1 立方体规格。 + const platformGeometry = new roundedBoxModule.RoundedBoxGeometry( + 1, + 1, + 1, + 2, + 0.035, + ); + const shadowGeometry = new three.CircleGeometry(1, 48); + const shadowMaterial = new three.MeshBasicMaterial({ + color: 0x0f172a, + depthWrite: false, + opacity: 0.16, + transparent: true, + }); + const textureLoader = new three.TextureLoader(); + textureLoader.setCrossOrigin('anonymous'); + const textureCache = new Map(); + const materialCache = new Map< + string, + import('three').Material | import('three').Material[] + >(); + const fallbackMaterialCache = new Map(); + let platformSignature = ''; + + const getTexture = (url: string) => { + const cached = textureCache.get(url); + if (cached) { + return cached; + } + + const texture = textureLoader.load(url, () => { + renderer.render(scene, camera); + }); + texture.colorSpace = three.SRGBColorSpace; + texture.wrapS = three.ClampToEdgeWrapping; + texture.wrapT = three.ClampToEdgeWrapping; + texture.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 6); + textureCache.set(url, texture); + return texture; + }; + + const getPlatformMaterial = ( + item: JumpHopPlatformRenderItem, + textureUrls: Record, + ) => { + const textureUrl = getJumpHopTileTextureUrl( + textureUrls, + item.renderKey, + 'top', + ); + if (item.asset?.faceAssets && textureUrl) { + const cacheKey = JUMP_HOP_TILE_FACE_KEYS.map((face) => + getJumpHopTileTextureUrl(textureUrls, item.renderKey, face), + ).join('|'); + const cached = materialCache.get(cacheKey); + if (cached) { + return cached; + } + + // 中文注释:Three.js Box/RoundedBox 材质顺序为 right, left, top, bottom, front, back。 + const materials = [ + 'right', + 'left', + 'top', + 'bottom', + 'front', + 'back', + ].map((face) => { + const faceUrl = + getJumpHopTileTextureUrl( + textureUrls, + item.renderKey, + face as JumpHopTileFaceKey, + ) || textureUrl; + return new three.MeshStandardMaterial({ + alphaTest: 0.04, + map: getTexture(faceUrl), + metalness: 0, + roughness: 0.76, + transparent: true, + }); + }); + materialCache.set(cacheKey, materials); + return materials; + } + + if (textureUrl) { + const cached = materialCache.get(textureUrl); + if (cached) { + return cached; + } + + const material = new three.MeshStandardMaterial({ + alphaTest: 0.04, + map: getTexture(textureUrl), + metalness: 0, + roughness: 0.76, + transparent: true, + }); + materialCache.set(textureUrl, material); + return material; + } + + const tone = getJumpHopTileTone(item.platform.tileType); + const cached = fallbackMaterialCache.get(tone); + if (cached) { + return cached; + } + + const material = new three.MeshStandardMaterial({ + color: new three.Color(tone), + metalness: 0, + roughness: 0.82, + }); + fallbackMaterialCache.set(tone, material); + return material; + }; + + const viewportSize = { width: 320, height: 568, }; + + const syncCamera = () => { + const distance = + Math.max(viewportSize.width, viewportSize.height) * + JUMP_HOP_THREE_CAMERA_DISTANCE_MULTIPLIER; + const targetX = viewportSize.width / 2; + const targetY = viewportSize.height / 2; + camera.left = -viewportSize.width / 2; + camera.right = viewportSize.width / 2; + camera.top = viewportSize.height / 2; + camera.bottom = -viewportSize.height / 2; + camera.position.set( + targetX, + targetY - + Math.cos(JUMP_HOP_THREE_CAMERA_PITCH_RAD) * distance, + Math.sin(JUMP_HOP_THREE_CAMERA_PITCH_RAD) * distance, + ); + camera.lookAt(targetX, targetY, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(); + }; + + const syncPlatformMeshes = () => { + const nextPlatforms = platformsRef.current; + const textureUrls = textureUrlsByRenderKeyRef.current; + const nextSignature = nextPlatforms + .map((item) => { + const cubeSide = getJumpHopThreeCubeSide( + item.platform, + item.scale, + ); + return [ + item.renderKey, + item.platform.platformId, + item.screenX.toFixed(3), + item.screenY.toFixed(3), + item.scale.toFixed(3), + cubeSide.toFixed(2), + getJumpHopTileTextureSignature( + textureUrls, + item.renderKey, + item.asset, + ), + item.advanceState, + ].join(':'); + }) + .join('|'); + + if (nextSignature === platformSignature) { + return; + } + + platformSignature = nextSignature; + platformGroup.clear(); + + nextPlatforms.forEach((item) => { + const cubeSide = getJumpHopThreeCubeSide(item.platform, item.scale); + const root = new three.Group(); + const rootBaseX = (item.screenX / 100) * viewportSize.width; + const rootBaseY = getJumpHopThreeProjectedY( + (item.screenY / 100) * viewportSize.height, + viewportSize.height, + ); + root.position.set(rootBaseX, rootBaseY, 0); + root.renderOrder = 20 + item.index; + root.userData = { + advanceState: item.advanceState, + baseX: rootBaseX, + baseY: rootBaseY, + }; + + const shadow = new three.Mesh(shadowGeometry, shadowMaterial); + shadow.position.set(0, cubeSide * 0.32, -9); + shadow.scale.set( + Math.max(24, cubeSide * 0.48), + Math.max(7, cubeSide * 0.13), + 1, + ); + shadow.renderOrder = 10 + item.index; + + const mesh = new three.Mesh( + platformGeometry, + getPlatformMaterial(item, textureUrls), + ); + mesh.position.set(0, 0, 0); + mesh.rotation.set(0, 0, 0); + mesh.scale.setScalar(cubeSide); + mesh.renderOrder = 30 + item.index; + + root.add(shadow, mesh); + platformGroup.add(root); + }); + }; + const resize = () => { const rect = host.getBoundingClientRect(); const width = Math.max(1, rect.width || host.clientWidth || 320); const height = Math.max(1, rect.height || host.clientHeight || 568); - size.width = width; - size.height = height; + viewportSize.width = width; + viewportSize.height = height; renderer.setSize(width, height, false); - camera.left = 0; - camera.right = width; - camera.top = 0; - camera.bottom = height; - camera.updateProjectionMatrix(); + syncCamera(); + platformSignature = ''; + syncPlatformMeshes(); renderer.render(scene, camera); }; @@ -474,22 +1043,20 @@ function JumpHopThreeScene({ : null; resizeObserver?.observe(host); resize(); - onCharacterLayerReadyChange(true); + onCharacterLayerReadyChange(Boolean(character)); + onPlatformLayerReadyChange(true); const animate = () => { + syncPlatformMeshes(); const nextCharacterPosition = characterPositionRef.current; - if (nextCharacterPosition) { + if (character && nextCharacterPosition) { const nextChargeRatio = chargeRatioRef.current; const canvasPosition = resolveJumpHopCharacterCanvasPosition( nextCharacterPosition, - size, + viewportSize, ); character.visible = true; - character.position.set( - canvasPosition?.x ?? 0, - canvasPosition?.y ?? 0, - 0, - ); + character.position.set(canvasPosition?.x ?? 0, canvasPosition?.y ?? 0, 0); if (isJumpAnimatingRef.current) { const now = window.performance.now(); character.rotation.z = Math.sin(now / 42) * 1.22; @@ -506,7 +1073,7 @@ function JumpHopThreeScene({ 1 - nextChargeRatio * 0.12, 1 + nextChargeRatio * 0.08, ); - } else { + } else if (character) { character.visible = false; } renderer.render(scene, camera); @@ -520,8 +1087,21 @@ function JumpHopThreeScene({ } resizeObserver?.disconnect(); disposeJumpHopThreeObject(scene); + textureCache.forEach((texture) => texture.dispose()); + materialCache.forEach((material) => { + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + } else { + material.dispose(); + } + }); + fallbackMaterialCache.forEach((material) => material.dispose()); + shadowMaterial.dispose(); + platformGeometry.dispose(); + shadowGeometry.dispose(); renderer.dispose(); onCharacterLayerReadyChange(false); + onPlatformLayerReadyChange(false); }; }; @@ -533,14 +1113,18 @@ function JumpHopThreeScene({ fallbackCanvas.remove(); host.replaceChildren(); }; - }, [onCharacterLayerReadyChange, renderCharacter]); + }, [ + onCharacterLayerReadyChange, + onPlatformLayerReadyChange, + renderCharacter, + ]); return (
); @@ -617,10 +1201,11 @@ export function JumpHopRuntimeShell({ const [nowMs, setNowMs] = useState(() => Date.now()); const [isThreeCharacterLayerReady, setIsThreeCharacterLayerReady] = useState(false); - const [dragPointerPosition, setDragPointerPosition] = useState<{ - x: number; - y: number; - } | null>(null); + const [isThreePlatformLayerReady, setIsThreePlatformLayerReady] = + useState(false); + const [platformTextureUrlsByRenderKey, setPlatformTextureUrlsByRenderKey] = + useState>({}); + const platformTextureParentObjectUrlsRef = useRef>(new Set()); const [dragVector, setDragVector] = useState({ x: 0, y: 0 }); const [jumpAnimationProgress, setJumpAnimationProgress] = useState(0); const [isPlatformAdvancing, setIsPlatformAdvancing] = useState(false); @@ -632,8 +1217,8 @@ export function JumpHopRuntimeShell({ useState(0); const [stageSize, setStageSize] = useState({ width: 0, height: 0 }); const stageRef = useRef(null); - const dragStartRef = useRef<{ x: number; y: number } | null>(null); - const dragCurrentRef = useRef<{ x: number; y: number } | null>(null); + const chargeStartedAtRef = useRef(null); + const chargeFrameRef = useRef(null); const animationFrameRef = useRef(null); const animationEndTimerRef = useRef(null); const landingRecoilEndTimerRef = useRef(null); @@ -713,20 +1298,42 @@ export function JumpHopRuntimeShell({ const visibleItems = visiblePlatforms.map((item) => ({ ...item, renderKey: item.platform.platformId, - advanceState: isPlatformAdvancing - ? ('camera' as const) - : ('idle' as const), + advanceState: isPlatformAdvancing ? ('camera' as const) : ('idle' as const), })); return [...exitingItems, ...visibleItems]; - }, [isPlatformAdvancing, platformAdvanceExitingPlatforms, visiblePlatforms]); + }, [ + isPlatformAdvancing, + platformAdvanceExitingPlatforms, + visiblePlatforms, + ]); + const platformRenderKeySignature = useMemo( + () => platformRenderItems.map((item) => item.renderKey).join('|'), + [platformRenderItems], + ); + const shouldUseThreePlatformLayer = useMemo( + () => + isThreePlatformLayerReady && + platformRenderItems.every((item) => + hasJumpHopTileTexturesReady( + platformTextureUrlsByRenderKey, + item.renderKey, + item.asset, + ), + ), + [ + isThreePlatformLayerReady, + platformRenderItems, + platformTextureUrlsByRenderKey, + ], + ); const preloadTileAssets = useMemo(() => { const path = stageRun?.path; const tileAssets = profile?.tileAssets; const platforms = path?.platforms ?? []; const startIndex = (stageRun?.currentPlatformIndex ?? 0) + visiblePlatforms.length; - const assets = new Map(); + const assets = new Map(); for ( let index = startIndex; @@ -750,8 +1357,11 @@ export function JumpHopRuntimeShell({ if (!asset) { continue; } - const key = getJumpHopTileAssetRefreshKey(asset) ?? asset.imageSrc; - assets.set(key, asset); + const key = platform.platformId; + assets.set(key, { + textureKey: platform.platformId, + asset, + }); } return [...assets.values()]; @@ -761,10 +1371,26 @@ export function JumpHopRuntimeShell({ stageRun?.path, visiblePlatforms.length, ]); + const landingAssistStageSize = + stageSize.width > 0 && stageSize.height > 0 + ? stageSize + : { width: 320, height: 568 }; const characterPosition = getJumpHopCharacterVisualPosition( stageRun, visiblePlatforms, + landingAssistStageSize, ); + const currentPlatformOriginPosition = useMemo(() => { + if (!stageRun) { + return null; + } + const currentPlatform = visiblePlatforms.find( + (item) => item.index === stageRun.currentPlatformIndex, + ); + return currentPlatform + ? buildJumpHopCharacterVisualPositionFromPlatform(currentPlatform) + : null; + }, [stageRun, visiblePlatforms]); const jumpTargetPlatform = useMemo(() => { if (!stageRun) { return null; @@ -775,6 +1401,27 @@ export function JumpHopRuntimeShell({ ) ?? null ); }, [stageRun, visiblePlatforms]); + const targetDirection = useMemo(() => { + const directionOrigin = currentPlatformOriginPosition ?? characterPosition; + if (!directionOrigin || !jumpTargetPlatform) { + return null; + } + const targetCharacterPosition = + buildJumpHopCharacterVisualPositionFromPlatform(jumpTargetPlatform); + const directionX = targetCharacterPosition.screenX - directionOrigin.screenX; + const directionY = targetCharacterPosition.screenY - directionOrigin.screenY; + const distance = Math.hypot(directionX, directionY); + if (distance < 0.0001) { + return null; + } + + return { + screenX: directionX, + screenY: directionY, + unitScreenX: directionX / distance, + unitScreenY: directionY / distance, + }; + }, [characterPosition, currentPlatformOriginPosition, jumpTargetPlatform]); const visualCharacterPosition = useMemo(() => { if (!characterPosition) { return null; @@ -782,72 +1429,35 @@ export function JumpHopRuntimeShell({ if (isJumpAnimating && visualJump) { return visualJump.to; } - if (!isJumpAnimating || !jumpTargetPlatform) { - return characterPosition; - } - - const targetCharacterPosition = - buildJumpHopCharacterVisualPositionFromPlatform( - jumpTargetPlatform, - false, - ); - const easedProgress = - 1 - Math.pow(1 - clamp(jumpAnimationProgress, 0, 1), 3); - const arcOffset = Math.sin(Math.PI * easedProgress) * -24; - - return { - screenX: - characterPosition.screenX + - (targetCharacterPosition.screenX - characterPosition.screenX) * - easedProgress, - screenY: - characterPosition.screenY + - (targetCharacterPosition.screenY - characterPosition.screenY) * - easedProgress + - arcOffset, - sceneX: - characterPosition.sceneX + - (targetCharacterPosition.sceneX - characterPosition.sceneX) * - easedProgress, - sceneY: - characterPosition.sceneY + - (targetCharacterPosition.sceneY - characterPosition.sceneY) * - easedProgress, - sceneZ: - characterPosition.sceneZ + - (targetCharacterPosition.sceneZ - characterPosition.sceneZ) * - easedProgress, - isMiss: characterPosition.isMiss, - }; + return characterPosition; }, [ characterPosition, isJumpAnimating, - jumpAnimationProgress, - jumpTargetPlatform, visualJump, ]); - const landingAssistStageSize = - stageSize.width > 0 && stageSize.height > 0 - ? stageSize - : { width: 320, height: 568 }; const characterMotionStyle = useMemo(() => { const idleTransform = 'matrix(1, 0, 0, 1, 0, 0)'; const recoilDistance = Math.hypot(dragVector.x, dragVector.y); - const recoilUnitX = recoilDistance > 0 ? dragVector.x / recoilDistance : 0; - const recoilUnitY = recoilDistance > 0 ? dragVector.y / recoilDistance : 0; + const recoilUnitX = + recoilDistance > 0 + ? dragVector.x / recoilDistance + : targetDirection + ? -targetDirection.unitScreenX + : 0; + const recoilUnitY = + recoilDistance > 0 + ? dragVector.y / recoilDistance + : targetDirection + ? -targetDirection.unitScreenY + : 0; let stretchTransform = idleTransform; - if (isCharging && dragPointerPosition && characterPosition) { - const anchorX = - landingAssistStageSize.width * (characterPosition.screenX / 100); - const anchorY = - landingAssistStageSize.height * (characterPosition.screenY / 100); - stretchTransform = buildJumpHopDirectionalScaleMatrix({ - directionX: dragPointerPosition.x - anchorX, - directionY: dragPointerPosition.y - anchorY, - stretchScale: 1 + chargeRatio * 0.62, - crossScale: 1 - chargeRatio * 0.16, - }); + if (isCharging) { + const squashY = 1 - chargeRatio * 0.32; + const squashX = 1 + chargeRatio * 0.1; + stretchTransform = `scale(${formatJumpHopCssNumber( + squashX, + )}, ${formatJumpHopCssNumber(squashY)})`; } return { @@ -869,13 +1479,12 @@ export function JumpHopRuntimeShell({ }; }, [ chargeRatio, - characterPosition, - dragPointerPosition, dragVector.x, dragVector.y, isCharging, landingAssistStageSize.height, landingAssistStageSize.width, + targetDirection, visualJump, ]); const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun); @@ -893,6 +1502,79 @@ export function JumpHopRuntimeShell({ visiblePlatformsRef.current = visiblePlatforms; }, [visiblePlatforms]); + useEffect(() => { + const activeKeys = new Set([ + ...platformRenderItems.flatMap((item) => + getJumpHopActiveTextureKeys(item.renderKey, item.asset), + ), + ...preloadTileAssets.flatMap((item) => + getJumpHopActiveTextureKeys(item.textureKey, item.asset), + ), + ]); + setPlatformTextureUrlsByRenderKey((current) => { + let changed = false; + const next: Record = {}; + for (const [key, value] of Object.entries(current)) { + if (activeKeys.has(key)) { + next[key] = value; + } else { + changed = true; + if ( + value.startsWith('blob:') && + platformTextureParentObjectUrlsRef.current.has(value) + ) { + URL.revokeObjectURL(value); + platformTextureParentObjectUrlsRef.current.delete(value); + } + } + } + return changed ? next : current; + }); + }, [platformRenderItems, platformRenderKeySignature, preloadTileAssets]); + + const handleResolvedPlatformTextureUrl = useCallback( + ( + textureKey: string, + resolvedUrl: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => { + setPlatformTextureUrlsByRenderKey((current) => { + const previousUrl = current[textureKey]; + if (!resolvedUrl) { + return current; + } + if (previousUrl === resolvedUrl) { + return current; + } + if ( + previousUrl && + previousUrl.startsWith('blob:') && + platformTextureParentObjectUrlsRef.current.has(previousUrl) + ) { + URL.revokeObjectURL(previousUrl); + platformTextureParentObjectUrlsRef.current.delete(previousUrl); + } + if (options?.parentOwnedObjectUrl && resolvedUrl.startsWith('blob:')) { + platformTextureParentObjectUrlsRef.current.add(resolvedUrl); + } + return { + ...current, + [textureKey]: resolvedUrl, + }; + }); + }, + [], + ); + + useEffect(() => { + return () => { + platformTextureParentObjectUrlsRef.current.forEach((url) => { + URL.revokeObjectURL(url); + }); + platformTextureParentObjectUrlsRef.current.clear(); + }; + }, []); + useEffect(() => { tileAssetsRef.current = profile?.tileAssets; }, [profile?.tileAssets]); @@ -916,6 +1598,13 @@ export function JumpHopRuntimeShell({ setIsLandingRecoilAnimating(false); }, []); + const stopChargeFrame = useCallback(() => { + if (chargeFrameRef.current != null) { + window.cancelAnimationFrame(chargeFrameRef.current); + chargeFrameRef.current = null; + } + }, []); + const beginPlatformAdvance = useCallback( ( fromRun: JumpHopRuntimeRunSnapshotResponse, @@ -935,29 +1624,75 @@ export function JumpHopRuntimeShell({ const toPlatformIds = new Set( toVisiblePlatforms.map((item) => item.platform.platformId), ); - const fromLandingPlatform = fromVisiblePlatforms.find( - (item) => item.index === toRun.currentPlatformIndex, - ); + const fromLandingPosition = + getJumpHopRunLandingVisualPosition({ + run: toRun, + platforms: fromVisiblePlatforms, + stageSize: landingAssistStageSize, + }) ?? + (() => { + const fromLandingPlatform = fromVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); + return fromLandingPlatform + ? buildJumpHopCharacterVisualPositionFromPlatform( + fromLandingPlatform, + ) + : null; + })(); + const toLandingPosition = + getJumpHopRunLandingVisualPosition({ + run: toRun, + platforms: toVisiblePlatforms, + stageSize: landingAssistStageSize, + }) ?? + (() => { + const toCurrentPlatform = toVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); + return toCurrentPlatform + ? buildJumpHopCharacterVisualPositionFromPlatform( + toCurrentPlatform, + ) + : null; + })(); const toCurrentPlatform = toVisiblePlatforms.find( (item) => item.index === toRun.currentPlatformIndex, ); + const fromLandingPlatform = fromVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); const cameraOffsetX = - (fromLandingPlatform?.screenX ?? toCurrentPlatform?.screenX ?? 0) - - (toCurrentPlatform?.screenX ?? fromLandingPlatform?.screenX ?? 0); + (fromLandingPosition?.screenX ?? fromLandingPlatform?.screenX ?? 0) - + (toLandingPosition?.screenX ?? toCurrentPlatform?.screenX ?? 0); const cameraOffsetY = Math.max( 0, - (toCurrentPlatform?.screenY ?? 0) - (fromLandingPlatform?.screenY ?? 0), + (toLandingPosition?.screenY ?? toCurrentPlatform?.screenY ?? 0) - + (fromLandingPosition?.screenY ?? fromLandingPlatform?.screenY ?? 0), ); - setPlatformAdvanceExitingPlatforms( - fromVisiblePlatforms + const movePlatformBehindCamera = (item: JumpHopVisiblePlatform) => ({ + ...item, + screenX: item.screenX - cameraOffsetX, + screenY: item.screenY + cameraOffsetY, + }); + setPlatformAdvanceExitingPlatforms((currentRetainedPlatforms) => { + const retainedPlatforms = currentRetainedPlatforms .filter((item) => !toPlatformIds.has(item.platform.platformId)) - .map((item) => ({ - ...item, - screenX: item.screenX - cameraOffsetX, - screenY: item.screenY + cameraOffsetY, - })), - ); + .map(movePlatformBehindCamera); + const newlyRetainedPlatforms = fromVisiblePlatforms + .filter((item) => !toPlatformIds.has(item.platform.platformId)) + .map(movePlatformBehindCamera); + const byPlatformId = new Map(); + + [...retainedPlatforms, ...newlyRetainedPlatforms].forEach((item) => { + if (item.screenY < JUMP_HOP_PLATFORM_RETAIN_OFFSCREEN_SCREEN_Y) { + byPlatformId.set(item.platform.platformId, item); + } + }); + + return [...byPlatformId.values()]; + }); setPlatformAdvanceCameraOffsetX(cameraOffsetX); setPlatformAdvanceCameraOffsetY(cameraOffsetY); setIsPlatformAdvancing(true); @@ -968,12 +1703,11 @@ export function JumpHopRuntimeShell({ platformAdvanceEndTimerRef.current = window.setTimeout(() => { platformAdvanceEndTimerRef.current = null; setIsPlatformAdvancing(false); - setPlatformAdvanceExitingPlatforms([]); setPlatformAdvanceCameraOffsetX(0); setPlatformAdvanceCameraOffsetY(0); }, JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS); }, - [clearPlatformAdvanceState], + [clearPlatformAdvanceState, landingAssistStageSize], ); const finishJumpHopFlightAnimation = useCallback( @@ -1068,11 +1802,10 @@ export function JumpHopRuntimeShell({ setIsJumpAnimating(false); setJumpAnimationProgress(0); setIsCharging(false); - dragStartRef.current = null; - dragCurrentRef.current = null; + chargeStartedAtRef.current = null; + stopChargeFrame(); setDragDistance(0); setDragVector({ x: 0, y: 0 }); - setDragPointerPosition(null); setNowMs(Date.now()); return; } @@ -1094,11 +1827,10 @@ export function JumpHopRuntimeShell({ setIsJumpAnimating(false); setJumpAnimationProgress(0); setIsCharging(false); - dragStartRef.current = null; - dragCurrentRef.current = null; + chargeStartedAtRef.current = null; + stopChargeFrame(); setDragDistance(0); setDragVector({ x: 0, y: 0 }); - setDragPointerPosition(null); setNowMs(Date.now()); return; } @@ -1129,6 +1861,7 @@ export function JumpHopRuntimeShell({ finishJumpHopFlightAnimation, isJumpAnimating, jumpAnimationProgress, + stopChargeFrame, ]); useEffect(() => { @@ -1145,6 +1878,9 @@ export function JumpHopRuntimeShell({ if (landingRecoilEndTimerRef.current != null) { window.clearTimeout(landingRecoilEndTimerRef.current); } + if (chargeFrameRef.current != null) { + window.cancelAnimationFrame(chargeFrameRef.current); + } }; }, []); @@ -1186,7 +1922,11 @@ export function JumpHopRuntimeShell({ return; } const elapsed = now - animationStartAtRef.current; - const progress = clamp(elapsed / JUMP_HOP_ANIMATION_DURATION_MS, 0, 1); + const progress = clamp( + elapsed / JUMP_HOP_ANIMATION_DURATION_MS, + 0, + 1, + ); setJumpAnimationProgress(progress); if (progress < 1) { animationFrameRef.current = window.requestAnimationFrame(tick); @@ -1209,50 +1949,39 @@ export function JumpHopRuntimeShell({ }; }, [finishJumpHopFlightAnimation, isJumpAnimating]); - const getStageLocalPoint = (event: PointerEvent) => { - const rect = event.currentTarget.getBoundingClientRect(); - return { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }; - }; - - const updateDragState = (x: number, y: number) => { - const dragStart = dragStartRef.current; - dragCurrentRef.current = { x, y }; - setDragPointerPosition({ x, y }); - if (!dragStart) { - setDragDistance(0); - setDragVector({ x: 0, y: 0 }); - return; - } - setDragVector({ - x: x - dragStart.x, - y: y - dragStart.y, - }); - setDragDistance(Math.hypot(x - dragStart.x, y - dragStart.y)); - }; - const beginCharge = (event: PointerEvent) => { if (!canJump) { return; } event.currentTarget.setPointerCapture?.(event.pointerId); - const dragPoint = getStageLocalPoint(event); - dragStartRef.current = dragPoint; - dragCurrentRef.current = dragPoint; - setDragPointerPosition(dragPoint); + chargeStartedAtRef.current = Date.now(); + stopChargeFrame(); + clearLandingRecoilState(); setIsCharging(true); setDragDistance(0); setDragVector({ x: 0, y: 0 }); - }; - const updateDragVector = (event: PointerEvent) => { - if (!isCharging) { - return; - } - const dragPoint = getStageLocalPoint(event); - updateDragState(dragPoint.x, dragPoint.y); + const tick = () => { + const chargeStartedAt = chargeStartedAtRef.current; + if (chargeStartedAt == null) { + chargeFrameRef.current = null; + return; + } + + const nextDragDistance = clamp( + Date.now() - chargeStartedAt, + 0, + maxDragDistancePx, + ); + setDragDistance(nextDragDistance); + if (nextDragDistance < maxDragDistancePx) { + chargeFrameRef.current = window.requestAnimationFrame(tick); + return; + } + chargeFrameRef.current = null; + }; + + chargeFrameRef.current = window.requestAnimationFrame(tick); }; const finishCharge = async (event?: PointerEvent) => { @@ -1260,59 +1989,54 @@ export function JumpHopRuntimeShell({ return; } if (event) { - const dragPoint = getStageLocalPoint(event); - updateDragState(dragPoint.x, dragPoint.y); + event.currentTarget.releasePointerCapture?.(event.pointerId); } - const dragStart = dragStartRef.current; - const dragCurrent = dragCurrentRef.current ?? dragStart; - const dragVectorX = - dragStart && dragCurrent ? dragCurrent.x - dragStart.x : 0; - const dragVectorY = - dragStart && dragCurrent ? dragCurrent.y - dragStart.y : 0; - const nextDragDistance = Math.hypot(dragVectorX, dragVectorY); - const backendDragVector = getJumpHopBackendDragVector( - activeRun, - visiblePlatforms, - landingAssistStageSize, - dragVectorX, - dragVectorY, - ); + const chargeStartedAt = chargeStartedAtRef.current; + const nextDragDistance = + chargeStartedAt == null + ? 0 + : clamp( + Date.now() - chargeStartedAt, + 0, + maxDragDistancePx, + ); + const predictionRun = stageRun ?? activeRun; const predictedLandingPosition = - activeRun && characterPosition + predictionRun && characterPosition ? getJumpHopLandingAssistVisualPosition( - activeRun, + predictionRun, visiblePlatforms, characterPosition, landingAssistStageSize, nextDragDistance, - dragVectorX, - dragVectorY, ) : null; - const fallbackLandingPosition = jumpTargetPlatform - ? buildJumpHopCharacterVisualPositionFromPlatform(jumpTargetPlatform) - : characterPosition; - if ( - characterPosition && - (predictedLandingPosition || fallbackLandingPosition) - ) { + if (characterPosition) { + const predictionOrigin = + currentPlatformOriginPosition ?? characterPosition; + const visualDeltaX = predictedLandingPosition + ? predictedLandingPosition.screenX - predictionOrigin.screenX + : 0; + const visualDeltaY = predictedLandingPosition + ? predictedLandingPosition.screenY - predictionOrigin.screenY + : 0; setVisualJump({ from: characterPosition, to: predictedLandingPosition ? { ...characterPosition, - screenX: predictedLandingPosition.screenX, - screenY: predictedLandingPosition.screenY, - isMiss: false, + screenX: clamp(characterPosition.screenX + visualDeltaX, 6, 94), + screenY: clamp(characterPosition.screenY + visualDeltaY, 10, 92), + isMiss: !predictedLandingPosition.isOnTargetPlatform, } - : fallbackLandingPosition!, + : characterPosition, }); } else { setVisualJump(null); } - dragStartRef.current = null; - dragCurrentRef.current = null; + chargeStartedAtRef.current = null; + stopChargeFrame(); clearLandingRecoilState(); setIsCharging(false); setJumpAnimationProgress(0); @@ -1320,10 +2044,16 @@ export function JumpHopRuntimeShell({ setIsJumpAnimating(true); setDragDistance(nextDragDistance); setDragVector({ - x: dragVectorX, - y: dragVectorY, + x: targetDirection ? -targetDirection.unitScreenX : 0, + y: targetDirection ? -targetDirection.unitScreenY : 0, }); - setDragPointerPosition(null); + const backendDragVector = getJumpHopBackendDragVector( + predictionRun ?? activeRun, + visiblePlatforms, + landingAssistStageSize, + targetDirection ? -targetDirection.unitScreenX : 0, + targetDirection ? -targetDirection.unitScreenY : 0, + ); await onJump({ dragDistance: nextDragDistance, dragVectorX: backendDragVector.dragVectorX, @@ -1332,15 +2062,14 @@ export function JumpHopRuntimeShell({ }; const cancelCharge = () => { - dragStartRef.current = null; - dragCurrentRef.current = null; + chargeStartedAtRef.current = null; + stopChargeFrame(); clearLandingRecoilState(); hasJumpAnimationReachedTargetRef.current = false; setVisualJump(null); setIsCharging(false); setDragDistance(0); setDragVector({ x: 0, y: 0 }); - setDragPointerPosition(null); }; return ( @@ -1363,7 +2092,6 @@ export function JumpHopRuntimeShell({ data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'} className="jump-hop-runtime__stage absolute inset-0 h-full w-full touch-none select-none overflow-hidden" onPointerDown={beginCharge} - onPointerMove={updateDragVector} onPointerUp={(event) => void finishCharge(event)} onPointerCancel={cancelCharge} > @@ -1385,11 +2113,13 @@ export function JumpHopRuntimeShell({
@@ -1397,9 +2127,12 @@ export function JumpHopRuntimeShell({ characterPosition={visualCharacterPosition} chargeRatio={chargeRatio} isJumpAnimating={isJumpAnimating} + platforms={platformRenderItems} platformCount={platformRenderItems.length} renderCharacter={false} + textureUrlsByRenderKey={platformTextureUrlsByRenderKey} onCharacterLayerReadyChange={setIsThreeCharacterLayerReady} + onPlatformLayerReadyChange={setIsThreePlatformLayerReady} /> {platformRenderItems.map((item) => { @@ -1414,9 +2147,7 @@ export function JumpHopRuntimeShell({ height, '--jump-hop-platform-scale': item.scale, zIndex: - item.advanceState === 'exiting' - ? 12 + item.index - : 20 + item.index, + item.advanceState === 'exiting' ? 12 + item.index : 20 + item.index, } as CSSProperties; const isCurrent = item.advanceState !== 'exiting' && @@ -1433,17 +2164,24 @@ export function JumpHopRuntimeShell({ data-platform-index={item.index} >
- +
); })} {preloadTileAssets.length > 0 ? ( @@ -1454,7 +2192,9 @@ export function JumpHopRuntimeShell({ className="jump-hop-runtime__character" data-charging={isCharging ? 'true' : 'false'} data-jump-animating={isJumpAnimating ? 'true' : 'false'} - data-landing-recoil={isLandingRecoilAnimating ? 'true' : 'false'} + data-landing-recoil={ + isLandingRecoilAnimating ? 'true' : 'false' + } data-miss={visualCharacterPosition.isMiss ? 'true' : 'false'} style={ { @@ -1463,14 +2203,16 @@ export function JumpHopRuntimeShell({ '--jump-hop-charge': chargeRatio, '--jump-hop-character-stretch-transform': characterMotionStyle.stretchTransform, - '--jump-hop-flight-from-x': characterMotionStyle.flightFromX, - '--jump-hop-flight-from-y': characterMotionStyle.flightFromY, + '--jump-hop-flight-from-x': + characterMotionStyle.flightFromX, + '--jump-hop-flight-from-y': + characterMotionStyle.flightFromY, '--jump-hop-recoil-x': characterMotionStyle.recoilX, '--jump-hop-recoil-y': characterMotionStyle.recoilY, } as CSSProperties } - > -
+ > +
- {isCharging && dragPointerPosition && characterPosition ? ( + {isCharging && characterPosition ? (