diff --git a/.codex/skills/gpt-image-2-apimart/SKILL.md b/.codex/skills/gpt-image-2-apimart/SKILL.md index 2cc40454..99caa76b 100644 --- a/.codex/skills/gpt-image-2-apimart/SKILL.md +++ b/.codex/skills/gpt-image-2-apimart/SKILL.md @@ -5,7 +5,7 @@ description: Generate or inspect project image assets through this repository's # gpt-image-2 VectorEngine -Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2-all` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references. +Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references. ## Workflow @@ -40,22 +40,14 @@ Default body: ```json { - "model": "gpt-image-2-all", + "model": "gpt-image-2", "prompt": "", "n": 1, "size": "1024x1024" } ``` -For weak visual references in text-to-image generation, add: - -```json -{ - "image": ["data:image/png;base64,..."] -} -``` - -For image-to-image work that must follow a reference image closely, use the VectorEngine edits endpoint instead of the generations `image` array: +For visual references, use the edit endpoint instead of the create endpoint: ```text POST {VECTOR_ENGINE_BASE_URL}/v1/images/edits @@ -73,9 +65,9 @@ size=1024x1024 image=@reference.png ``` -Prefer edits for workflows where the reference image controls composition, pose, container shape, or layout. In this repository, Match3D container UI generation uses edits with `public/match3d-background-references/pot-fused-reference.png` as the `image` part. +In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part. -Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2-all currently returns synchronously; do not poll APIMart task endpoints. +Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2 currently returns synchronously; do not poll APIMart task endpoints. ## Environment diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs index 1ba9eb69..e3b1f99d 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs @@ -245,7 +245,7 @@ async function downloadUrl(url, timeoutMs) { async function generateOne(env, entry, outDir) { const requestBody = { - model: 'gpt-image-2-all', + model: 'gpt-image-2', prompt: buildPrompt(entry), n: 1, size: '1024x1024', @@ -305,7 +305,7 @@ if (dryRun) { id: entry.id, title: entry.title, body: { - model: 'gpt-image-2-all', + model: 'gpt-image-2', prompt: buildPrompt(entry), n: 1, size: '1024x1024', diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs index 72e05646..165dac01 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -211,7 +211,7 @@ async function downloadUrl(url, timeoutMs) { async function generateOne(env, template, outDir) { const requestBody = { - model: 'gpt-image-2-all', + model: 'gpt-image-2', prompt: buildPrompt(template), n: 1, size: '1024x1024', @@ -275,7 +275,7 @@ if (dryRun) { id: template.id, title: template.title, body: { - model: 'gpt-image-2-all', + model: 'gpt-image-2', prompt: buildPrompt(template), n: 1, size: '1024x1024', diff --git a/.gitignore b/.gitignore index 610ac88f..c90efe5c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ temp*build*/ /.codex-temp /target/ /logs +/server-rs/crates/*/logs/ .worktrees/ .env.secrets.local spacetime.local.json diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 303282aa..ac9a6a15 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -27,7 +27,7 @@ ## 2026-05-21 外部 API 失败必须 OTLP 上报并落库 - 背景:图片生成等外部供应商调用失败时,仅返回 502/504 或普通日志无法支持后续按 provider、阶段和重试属性聚合排障。 -- 决策:外部 API 调用未成功时,`api-server` 必须同时发送 OTLP 失败观测并写入 `tracking_event`。当前通用 VectorEngine `gpt-image-2-all` 图片生成 / 编辑适配器记录 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`,metadata 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。 +- 决策:外部 API 调用未成功时,`api-server` 必须同时发送 OTLP 失败观测并写入 `tracking_event`。当前通用 VectorEngine `gpt-image-2` 图片生成 / 编辑适配器记录 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`,metadata 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。 - 落库方式:优先复用 tracking outbox 异步批量写入;outbox 不可写或因保护阈值拒绝时回退同步直写 SpacetimeDB。不新增 SpacetimeDB 表,不让 reducer 做外部 I/O。 - 影响范围:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/telemetry.rs`、tracking outbox、后端架构文档和开发运维文档。 - 验证方式:执行 `cargo test -p api-server external_api_audit --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 @@ -50,6 +50,15 @@ - 验证方式:目标机 `nginx -T 2>/dev/null | grep client_max_body_size` 应看到 `client_max_body_size 64m;`;大于 1 MiB 的参考图请求不再在 Nginx 层直接 413,access log 应出现有效 `upstream_status`。 - 关联文档:`deploy/nginx/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-05-22 抓大鹅素材生成改为关卡整图派生三图 + +- 背景:旧抓大鹅素材链路按物品 5x5 sheet、纯背景和独立容器图分开生产,难以保证背景、UI、容器和物品风格一致,也让结果页继续暴露背景 / 容器重生成入口。 +- 决策:抓大鹅草稿生成先用 `gpt-image-2` 无参考图生成竖屏 `9:16` 完整关卡画面;关卡画面完成后,以它作为参考并发生成三张可运行资产:`1K 1:1` UI spritesheet、`1K 9:16` 关卡背景图、`2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都固定要求纯绿色绿幕背景,后端上传 OSS 前扣成真实透明 PNG。物品 spritesheet 固定 `10*10`,每行两种物品、每种五个形态。运行态和编辑器都按 alpha 连通域矩形检测解析 UI 和物品图集,不按固定像素坐标切图。 +- 兼容:新增字段继续存入现有 `generatedItemAssets[].backgroundAsset` / `generatedBackgroundAsset` JSON,不新增 SpacetimeDB schema 字段。历史 `containerImage*` 字段只作兼容;如果它与 `uiSpritesheetImage*` 同源,不得再作为运行态中心容器图。 +- 影响范围:`server-rs/crates/api-server/src/match3d/*`、`server-rs/crates/shared-contracts/src/match3d_*`、`packages/shared/src/contracts/match3dWorks.ts`、`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/services/match3dSpritesheetParser.ts`。 +- 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-18 Rust 手写模块入口统一不用 mod.rs @@ -172,6 +181,14 @@ - 验证方式:默认 `compile-draft` 返回的 `hitObjectAsset.generationProvider` 应为 `bundled-default` 且 `imageSrc=/wooden-fish/default-hit-object.png`;自定义关键词或参考图仍走 image2;前端静态资源可通过 Vite 直接访问。 - 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-05-21 RPG publish_world 设定文本以后端草稿真相派生 + +- 背景:RPG 结果页发布动作只保证提交 `{ action: 'publish_world' }`;旧 agent 会话可能没有 `seed_text`,但 `draft_profile_json` 已经通过 `publish_gate` 并可发布。 +- 决策:发布正式世界时,`spacetime-module` 不再把 `session.seed_text` 当作唯一 `setting_text` 兜底,而是调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)` 从 payload、当前草稿 profile 和 seed 依次派生。 +- 影响范围:RPG / custom-world agent 发布链路、`custom_world_profile` 编译入库、公开 gallery 投影。 +- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。 + ## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块 - 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。 @@ -255,6 +272,7 @@ ## 2026-05-14 抓大鹅物品素材 sheet 改用 VectorEngine Gemini +- 状态:历史决策,已被 `2026-05-22 抓大鹅素材生成改为关卡整图派生三图` 取代;当前物品 spritesheet 走 `gpt-image-2` 参考关卡整图编辑生成 `2K 1:1`、`10*10` 绿幕图,上传 OSS 前扣成透明 PNG。 - 背景:抓大鹅 2D 五视角物品素材仍沿用 5x5 sheet、绿幕去背、切图、OSS 转存和 `generatedItemAssets` 持久化,但用户要求物品素材图片生成步骤改用 VectorEngine Apifox `api-381740608` 对应的 Gemini 原生图片接口。 - 决策:抓大鹅物品素材 sheet 生图固定走 VectorEngine `POST {VECTOR_ENGINE_BASE_URL}/v1beta/models/gemini-3-pro-image-preview:generateContent?key={VECTOR_ENGINE_API_KEY}`,请求体使用 `contents[].parts[].text` 与 `generationConfig.responseModalities = ["TEXT", "IMAGE"]`、`imageConfig.aspectRatio = "1:1"`;响应从 `candidates[].content.parts[].inlineData.data` / `inline_data.data` 读取 base64 图片。封面、9:16 纯背景图、1:1 容器 UI 图、切图、OSS、扣费和运行态消费链路保持不变;音频以后续“拼图与抓大鹅音频生成入口临时关闭”决策为准。 - 影响范围:`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/api-server/src/config.rs`、`deploy/env/api-server.env.example`、抓大鹅素材生成技术文档。 @@ -306,7 +324,7 @@ ## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格 - 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。 -- 决策:抓大鹅创作入口 `2D素材风格` 固定为 `扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义`;默认风格为 `flat-icon`。入口参考图统一由 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成,输出到 `public/match3d-style-references/`。旧 3D 风格参考图不再保留为入口资产。 +- 决策:抓大鹅创作入口 `2D素材风格` 固定为 `扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义`;默认风格为 `flat-icon`。入口参考图统一由 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2` 生成,输出到 `public/match3d-style-references/`。旧 3D 风格参考图不再保留为入口资产。 - 影响范围:`Match3DAgentWorkspace`、抓大鹅入口交互测试、Match3D PRD、素材生成流水线技术文档、F1 入口文档和 `public/match3d-style-references/` 静态资产。 - 验证方式:执行 `npm run test -- src\components\match3d-creation\Match3DAgentWorkspace.interaction.test.tsx`、`cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml`、`npm run typecheck`、`npm run check:encoding`,并人工抽查 `.tmp/match3d-style-preview.png`。 - 关联文档:`docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`、`docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md`。 @@ -322,8 +340,9 @@ ## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化 - 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。 -- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 +- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 - 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。 +- 2026-05-21 追加:拼图结果页独立“素材配置”Tab 已移除,UI spritesheet 与关卡纯背景收口到每关图片生成资产包。每次 `gpt-image-2` 预计 90 秒;草稿完整 AI 重绘路径约 298 秒,上传图且关闭 AI 重绘路径跳过首图生成约 208 秒。结果页关卡详情继续复用 `CreativeImageInputPanel`,本次上传/历史选择图优先成为主图卡片,正式图只作为无新参考图时的预览;仅有正式图时仍允许在画面描述框上传多张参考图。 - 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。 - 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 @@ -331,7 +350,7 @@ ## 2026-05-12 抓大鹅结果页素材编辑统一走作品级资产面板 - 背景:抓大鹅结果页需要支持封面图上传 / AI 重绘、物品素材独立预览、单项删除和批量新增,且不能把素材编辑继续做成列表内联展开或前端临时状态。 -- 决策:结果页 `作品信息` 的封面图点击打开独立面板,封面图面板对齐拼图入口上传卡。已有上传主图时,请求体传 `uploadedImageSrc`,AI 重绘走 VectorEngine `/v1/images/edits`;关闭 AI 重绘时只写回上传图,不调用生图。没有上传主图时,请求体传 `referenceImageSrcs`,可混合本地上传、物品素材和 UI 素材,多参考图作为 `gpt-image-2-all` generations 的 `image` 数组传入。生成结果统一调用 `POST /api/creation/match3d/works/{profileId}/cover-image` 并转存到 `generated-match3d-assets`。`素材配置 > 物品` 列表项点击打开独立预览面板,不再提供单项重新生成按钮;单项删除和批量新增都写回同一份 `generated_item_assets_json`。批量新增调用 `POST /api/creation/match3d/works/{profileId}/item-assets`,复用草稿生成的 2D 素材图、5x5 切图、OSS 上传和可选点击音效链路,仅作用于新增物品,不新增 SpacetimeDB 表。 +- 决策:结果页 `作品信息` 的封面图点击打开独立面板,封面图面板对齐拼图入口上传卡。已有上传主图时,请求体传 `uploadedImageSrc`,AI 重绘走 VectorEngine `/v1/images/edits`,后端把上传图作为 multipart `image` part 传入 `gpt-image-2`;关闭 AI 重绘时只写回上传图,不调用生图。没有上传主图但存在 `referenceImageSrcs` 时,多参考图同样走 edits 的多个 `image` part;完全无参考图时走 `/v1/images/generations`。生成结果统一调用 `POST /api/creation/match3d/works/{profileId}/cover-image` 并转存到 `generated-match3d-assets`。`素材配置 > 物品` 列表项点击打开独立预览面板,不再提供单项重新生成按钮;单项删除和批量新增都写回同一份 `generated_item_assets_json`。批量新增调用 `POST /api/creation/match3d/works/{profileId}/item-assets`;该接口的物品 spritesheet 生成口径已被 2026-05-22 决策更新为关卡整图参考、`10*10` 绿幕图和上传前透明化。 - 影响范围:Match3D 结果页、Match3D works shared contracts、`api-server` Match3D 作品路由、生成资产历史类型和草稿恢复路径。 - 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run typecheck`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 - 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 @@ -354,7 +373,7 @@ ## 2026-05-13 宝贝爱画先作为寓教于乐独立本地 Demo 落地 - 背景:第三关 `宝贝爱画` 需要默认出现在“发现 / 寓教于乐”板块下方,但本阶段只验证画板、手部绘制、绘画魔法和本地保存闭环,不进入创作模板、公开作品或正式持久化。 -- 决策:`baby-love-drawing / 宝贝爱画` 先作为独立运行态接入,入口由发现页寓教于乐默认卡片打开,并支持 `/runtime/baby-love-drawing` 直达;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时前端不展示频道/卡片且直达路由回落主应用。绘画魔法统一走 `POST /api/creation/edutainment/baby-love-drawing/magic` 后端安全代理,使用 VectorEngine `gpt-image-2-all` 与原始画布 Data URL 参考图生成绘本风图片;保存只写 localStorage,正式持久化后续再设计。 +- 决策:`baby-love-drawing / 宝贝爱画` 先作为独立运行态接入,入口由发现页寓教于乐默认卡片打开,并支持 `/runtime/baby-love-drawing` 直达;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时前端不展示频道/卡片且直达路由回落主应用。绘画魔法统一走 `POST /api/creation/edutainment/baby-love-drawing/magic` 后端安全代理,使用 VectorEngine `gpt-image-2` 与原始画布 Data URL 参考图生成绘本风图片;保存只写 localStorage,正式持久化后续再设计。 - 影响范围:`packages/shared/src/contracts/edutainmentBabyDrawing.ts`、`src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.tsx`、`src/services/edutainment-baby-drawing/`、`src/routing/appRoutes.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`server-rs/crates/api-server/src/edutainment_baby_drawing.rs`、`src/index.css`、宝贝爱画 PRD 与技术方案。 - 验证方式:执行宝贝爱画 model/runtime/service/route 定向测试、`npm run typecheck`、定向 ESLint、`cargo test -p api-server edutainment_baby_drawing --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server resolves_runtime_paths_to_creation_type_ids --manifest-path server-rs/Cargo.toml` 和编码检查;真实魔法生成需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。 - 关联文档:`docs/prd/BABY_LOVE_DRAWING_EDUTAINMENT_LEVEL_PRD_2026-05-13.md`、`docs/technical/BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md`。 @@ -387,7 +406,7 @@ ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 - 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。 -- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色指示器和 UI 已拆分为用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v3.png`、`picture-book-character-outline-v4.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png` 和 `picture-book-ui-button-v2.png`。其中角色指示器 v4 基于 v2 本地后处理为更细的白色描边样式,内部透明,耳朵、手指、脚趾等细节已弱化,页面显示尺寸相对上一版放大 50%。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 +- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色指示器和 UI 已拆分为用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v3.png`、`picture-book-character-outline-v4.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png` 和 `picture-book-ui-button-v2.png`。其中角色指示器 v4 基于 v2 本地后处理为更细的白色描边样式,内部透明,耳朵、手指、脚趾等细节已弱化,页面显示尺寸相对上一版放大 50%。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 - 影响范围:`src/index.css`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。 - 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 或 `--live --only ` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only ` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。 - 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。 @@ -408,6 +427,14 @@ - 验证方式:执行入口配置、创作 Hub、平台入口交互和 api-server 路由熔断定向测试,确认“视觉小说”不出现在创作页且 `/api/creation/visual-novel/*` 默认被熔断。 - 关联文档:`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`、`docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md`。 +## 2026-05-20 RPG 创作入口开放 + +- 背景:RPG 文字冒险能力已经具备历史 custom-world 创作和运行闭环,但入口默认种子仍 `visible=false`,创作页不展示。 +- 决策:SpacetimeDB `creation_entry_type_config` 默认种子中 `rpg.visible=true` 且 `open=true`,旧默认隐藏配置只在标题、subtitle、badge、图片、排序和开关完全匹配时迁移为可见可创建。`airp` 仍保持 AI RPG 占位,不接管当前 RPG 链路。结构化创作 / RPG JSON 链路默认关闭 Responses `web_search`,需要联网增强时才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true` 显式启用;未开通工具的上游会返回 `ToolNotOpen`,不能把这类失败暴露成“模型返回结果解析失败”。 +- 影响范围:创作入口默认种子、旧库入口纠偏、`api-server` 入口熔断、创作页模板 Tab、创作 Hub 测试、玩法链路文档和后端路由文档。 +- 验证方式:执行入口配置、api-server 路由熔断、创作 Hub 和平台入口交互定向测试,确认“文字冒险”出现在创作入口,`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*` 都按 `rpg` 入口开关熔断。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-05-10 运行态输入设备抽象层全项目通用化 - 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。 @@ -441,11 +468,19 @@ ## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine - 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。 -- 决策:所有 GPT-image-2 生图请求统一走 VectorEngine `POST /v1/images/generations`,基础配置读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,上游模型使用 `gpt-image-2-all`,请求体不再携带 `official_fallback`,参考图字段改为 `image`。APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。 +- 决策:所有 GPT-image-2 无参考图生图请求统一走 VectorEngine `POST /v1/images/generations`,有参考图请求走 `POST /v1/images/edits` multipart,基础配置读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,上游模型使用 `gpt-image-2`,请求体不再携带 `official_fallback`。APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。 - 影响范围:`api-server` 共享图片 helper、拼图图片生成、角色主图、RPG 场景图、开局 CG 故事板、方洞视觉资产、生产环境示例、gpt-image-2 本地 skill 和相关技术文档。 - 验证方式:执行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`,并用 `npm run dev:api-server` + `/healthz` 做后端 smoke。 - 关联文档:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。 +## 2026-05-21 GPT-image-2 参考图统一走 edits multipart + +- 背景:VectorEngine Apifox 创建 `api-446794806` 与编辑 `api-446794807` 明确区分无参考图创建和有参考图编辑;仓库旧实现曾把参考图塞入 `gpt-image-2` generations 的 `image` 数组,导致与供应商当前契约不一致。 +- 决策:所有 GPT-image-2 无参考图生成调用 `POST /v1/images/generations`,所有有参考图生成调用 `POST /v1/images/edits`,模型固定 `gpt-image-2`,参考图作为 multipart `image` part 传入;仓库不再调用 `gpt-image-2-all`。 +- 影响范围:`api-server` 共享图片 helper、拼图图片生成、Match3D 封面重绘和容器 UI 图、gpt-image-2 本地 skill、玩法链路文档和后端架构文档。 +- 验证方式:搜索仓库不应再出现 VectorEngine 图片编辑路径调用;执行 `cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d_background --manifest-path server-rs/Cargo.toml`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-05-08 Hyper3D Rodin Gen-2 只通过后端安全代理接入 - 背景:需要接入 Hyper3D Rodin Gen-2 的文生 3D 模型与图生 3D 模型,但供应商 API Key 不能进入前端、文档或 Git;本次只是外部副作用代理,不需要新增平台真相表。 @@ -619,7 +654,7 @@ ## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择 - 背景:视觉小说入口页要对齐抓大鹅式的线性创作入口,只保留最小可用输入,避免再暴露文档 / 空白 / 对话式工作台。 -- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风` 和 `画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。画风卡片主视觉固定消费 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的静态参考图,不在前端运行时现场调用生图接口。 +- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风` 和 `画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。画风卡片主视觉固定消费 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2` 生成的静态参考图,不在前端运行时现场调用生图接口。 - 影响范围:`VisualNovelAgentWorkspace`、`visualNovelEntryGeneration`、`PlatformEntryFlowShellImpl`、视觉小说 PRD 和创作 Tab 设计文档;不新增后端字段或数据库结构。 - 验证方式:执行 `npm run test -- VisualNovelAgentWorkspace`、视觉小说工作台相关 ESLint、`npx prettier --check` 和 `npm run check:encoding`;`npm run typecheck` 若失败需先区分是否来自无关 Match3D / RPG 既有改动。 - 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。 @@ -643,7 +678,7 @@ ## 2026-05-12 抓大鹅物品种类从消除次数中拆出并改为 2D 五视角素材 - 背景:结果页草稿素材已经能生成和预览,但标准 / 硬核难度仍可能按 `clearCount` 误判需要 12 / 20 种素材,且继续生产 GLB 会拉长草稿生成耗时。 -- 决策:难度配置统一使用 `物品种类`:轻松 3、标准 9、进阶 15、硬核 21;历史硬核 `clearCount=20` 在运行态升为 21 组三消。新草稿和批量新增不再调用 Rodin、不再生成 GLB。每个物品生成 5 个不同 2D 视角,单张 1K 素材图固定按 5x5 切割,最多承载 5 个物品;超过 5 个物品时由 `api-server` 自动分批并行生图。发布必须校验已生成 `image_ready` 且有 `imageViews[]` 或首图引用的素材数量满足当前难度;试玩通过 `itemTypeCountOverride` 自动降到可用 2D 素材数量。历史模型字段只作为旧数据兼容,不再进入新生产链路。 +- 决策:难度配置统一使用运行态 `物品种类`:轻松 3、标准 9、进阶 15、硬核 20;历史硬核 `clearCount=20` 在运行态仍升为 21 组三消,但类型池最多 20 种。新草稿和批量新增不再调用 Rodin、不再生成 GLB。每次固定从 `2K 1:1`、`10*10` 物品 spritesheet 解析并持久化 20 个物品、每个 5 个不同 2D 形态,物品信息列表全部展示 20 个;持久化行列索引按每行两种物品计算,不能超过 `1..=10`。发布必须校验已生成 `image_ready` 且有 `imageViews[]`、首图引用或可解析的物品 spritesheet 满足当前难度;试玩通过 `itemTypeCountOverride` 自动降到可用 2D 素材数量。历史模型字段只作为旧数据兼容,不再进入新生产链路。 - 影响范围:Match3D 结果页、运行态启动契约、`module-match3d` 初始 run 生成、SpacetimeDB start input / restart、发布校验和 Match3D 技术文档。 - 验证方式:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`、相关后端 check / tests。 - 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f2a48a48..0cd16c8a 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -14,6 +14,32 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 抓大鹅新 UI spritesheet 不要回退成中心容器图 + +- 现象:新素材流程生成后,运行态棋盘中心可能叠出一整张 UI spritesheet,导致按钮素材、方格和空白图集覆盖容器区域。 +- 原因:为了兼容旧 DTO,后端可能把 `uiSpritesheetImage*` 同步写入历史 `containerImage*` 字段;旧前端只看 `containerImage*`,会误把 UI 图集当透明中心容器。 +- 处理:读取中心容器图时先比较归一化后的 `containerImage*` 与 `uiSpritesheetImage*`。两者同源时忽略 `containerImage*`,只把它作为旧数据兼容字段;新流程背景图本身已经保留容器,运行态只需加载背景和解析 UI / 物品 spritesheet。 +- 验证:`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx` 应覆盖“运行态不把兼容写入的UI spritesheet当中心容器图”。 +- 关联:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`server-rs/crates/api-server/src/match3d/mappers.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## UI spritesheet 不要依赖模型直接生成透明背景 + +- 现象:拼图或抓大鹅运行态解析 UI spritesheet 时,把整张背景图、棋盘格、叶子或装饰图也当作 UI 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。 +- 原因:前端解析依赖 alpha 连通域检测,透明背景是前提;但生图模型收到“透明背景 spritesheet”提示后仍可能输出带实景背景或伪透明棋盘格的普通不透明 PNG,OSS 中保存的图没有真实 alpha。 +- 处理:UI spritesheet 提示词应要求统一纯绿色绿幕背景,而不是让模型直接产透明背景;后端在上传 OSS 前复用 `generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(...)` 把绿幕扣成真实透明 PNG,再把透明图写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`。 +- 验证:`cargo test -p api-server puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server puzzle_level_scene_spritesheet_and_background_requests_use_references --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server match3d_derived_asset_prompts_match_three_sheet_pipeline --manifest-path server-rs\Cargo.toml`。 +- 关联:`server-rs/crates/api-server/src/puzzle/generation.rs`、`server-rs/crates/api-server/src/match3d/works.rs`、`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 拼图 UI spritesheet 运行态不要二次包圆底或拉伸比例 + +- 现象:拼图运行态左上返回和右上设置按钮外面出现白色圆圈;底部“提示 / 原图 / 冻结”三枚素材被压扁、拉宽或拉成正圆,和图集原始按钮比例不一致。 +- 原因:UI spritesheet 已经包含按钮视觉本体,但运行态仍给顶部按钮套默认圆形 icon 容器;底部三枚素材用 `h-full w-full rounded-full` 铺满按钮格,覆盖了自动检测矩形的真实宽高比。 +- 处理:有 `uiSpritesheetImage*` 时,顶部返回 / 设置按钮容器只保留透明点击区和 focus 状态,不再叠加默认圆形底;`buildPuzzleUiSpriteBackgroundStyle(...)` 对检测到的矩形写入 `aspectRatio`,底部三枚素材按原始宽高比和最大尺寸渲染,不强制 `w-full`。 +- 验证:`npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run test -- src/services/puzzle-runtime/puzzleUiSpritesheetParser.test.ts`。 +- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/services/puzzle-runtime/puzzleUiSpritesheetParser.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +2026-05-22 补充:展示矩形和点击热区要分开处理。`puzzleUiSpritesheetParser` 的 `regions` 保留完整视觉裁切矩形,`hitRegions` 用较高 alpha 阈值只包住实心按钮主体;运行态底部 spritesheet 道具按钮启用 `puzzle-runtime-sprite-tool-button--precise-hit`,父按钮不吃整块透明留白,内部 `puzzle-runtime-ui-sprite-hit-zone` 才接收指针事件,避免透明区域成为点击热区。 + ## 图像输入组件不要把业务状态藏在页面内联实现里 - 现象:拼图页把参考图上传、缩略图、主图删除确认和 AI 重绘开关内联实现后,后续想复用到其它创作页时,页面级状态和通用 UI 状态混在一起,容易出现多套上传卡和参考图展示口径。 @@ -22,6 +48,54 @@ - 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。 - 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。 +## RPG 发布不能只依赖 agent session seed_text + +- 现象:RPG 结果页 `publish_world` 返回 `UPSTREAM_ERROR`,details 为 `custom_world.setting_text 不能为空`;同一 session 的 `result-view` 日志显示 `publish_ready=true`。 +- 原因:前端发布动作只提交 `{ action: 'publish_world' }`,旧 agent 会话的 `seed_text` 可能为空;如果后端只从 action payload 或 `seed_text` 取 `setting_text`,就会在最终 compile / publish 校验阶段失败。 +- 处理:`module-custom-world::resolve_custom_world_publish_setting_text(...)` 以当前 `draft_profile_json` 为草稿真相,优先读取 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title`,最后才回退 `seed_text`。 +- 验证:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`。 +- 关联:`server-rs/crates/module-custom-world/src/application.rs`、`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## RPG 已发布结果页进入世界不能重复 publish_world + +- 现象:RPG 草稿发布成功后,按钮文案已变为“进入世界”,但点击仍请求 `POST /api/runtime/custom-world/agent/sessions/{sessionId}/actions` 且 payload 为 `{"action":"publish_world"}`,后端返回 `publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`。 +- 原因:按钮文案依据 agent session `stage === 'published'` 切换,但点击处理仍走发布协调路径;如果前端只依赖草稿同步回包判断是否已发布,回包为空或缺少可进入状态时就会继续重复发送 `publish_world`。 +- 处理:进入世界协调器接收当前 agent session stage;当 stage 已为 `published` 时,只调用 `result-view` 回读已发布 profile 并启动运行态,不再调用 `sync_result_profile` 或 `publish_world`。 +- 验证:`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx`;确认已发布场景下 `syncAgentDraftResultProfile` 与 `executePublishWorld` 均未被调用。 +- 关联:`src/components/rpg-entry/useRpgCreationEnterWorld.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## RPG 点击启动黑屏 / 默认 profile 先查 profile 归一化和摘要覆盖 + +- 现象:作品详情点击“启动”后页面切到 RPG runtime,但用户只看到黑屏、空白,或进入默认角色 / 默认 profile;从作品详情点“作品编辑”后开局 CG、封面、角色图、技能动作预览、初始物品图标或场景背景图丢失;DevTools 里可能同时看到旧自动存档 `/api/runtime/save/snapshot` 被主动 cancel。 +- 原因:`/custom-world-library` / `/custom-world-gallery` 详情接口可能返回历史或摘要式 `profile`,缺少 `playableNpcs`、`storyNpcs`、`landmarks`、`attributeSchema` 等运行态字段;前端 client 若直接把该对象传给 runtime,角色选择首屏会在 `buildCustomWorldPlayableCharacters(profile)` 或后续属性解析处抛错。另一类常见原因是详情接口已回读完整 profile 后,`savedCustomWorldEntries` 里的列表摘要又把 `selectedDetailEntry` 覆盖回空 profile,导致启动或编辑时只剩卡片摘要。发布 / 回读 result-view 若返回字段更少的旧视图,也可能把当前结果页已编辑资产降级掉。`save/snapshot (canceled)` 通常是切 runtime 或卸载时 `AbortController` 取消旧自动存档,不是黑屏根因。 +- 处理:RPG 入口作品库 client 在所有返回 `CustomWorldLibraryEntry` 的接口边界统一调用 `normalizeCustomWorldProfileRecord`,并用 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺字段;详情页已拿到运行态字段或资产槽位更多的完整 profile 时,不允许列表摘要覆盖当前详情;同一 `profile.id` 下,正式进入世界发布 / 回读不得用字段更少的后端旧视图降级当前结果页 profile。`normalizeCustomWorldProfileRecord` 必须近似无损保留 `cover`、`openingCg`、`camp.narrativeResidues`、`landmark.visualDescription/narrativeResidues`、`skills[].actionPreviewConfig`、`initialItems[].iconSrc`、`attributeSchema`、角色 `attributeProfile` 和 `sceneChapterBlueprints[].acts[]` 的背景与结构字段;只有背景资产的 act 也不能被过滤。角色选择页对角色生成异常或空数组回退默认角色,并保留返回按钮/轻量空态;顶层 runtime 懒加载 fallback 不使用纯 `null`。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "creation hub published work start uses loaded detail profile instead of library summary|creation hub published work edit keeps loaded detail profile assets instead of library summary"`;`npm run test -- src/data/customWorldLibrary.test.ts -t "保留结果页封面和关键图片资产槽位|近似无损保留编辑态和运行态结构字段|保留只有背景资产的场景幕"`;`npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx -t "默认封面和角色编辑结构差异也不能被列表摘要覆盖"`;`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx -t "正式进入世界回读结果页字段更少时不降级当前完整 profile"`;`npm run typecheck`。 +- 关联:`src/components/rpg-entry/useRpgEntryLibraryDetail.ts`、`src/components/rpg-entry/useRpgCreationEnterWorld.ts`、`src/data/customWorldLibrary.ts`、`src/services/rpg-entry/rpgEntryLibraryClient.ts`、`src/components/rpg-entry/RpgEntryCharacterSelectView.tsx`、`src/App.tsx`、`src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## RPG 战后一轮战斗后卡在观察/试探/调息先查 post-battle finalization + +- 现象:RPG 一轮战斗胜利后,运行态只显示默认 `观察周围迹象 / 主动出声试探 / 原地调息`,这些按钮只有文字反馈;点“继续冒险”后又回到同样选项,点探索只播退场/进场动画,场景和剧情不推进。 +- 原因:终局战斗 action 如果只走通用 `resolve_story_runtime_action` fallback,而没有在后端调用 `finalize_post_battle_resolution(...)`,就不会持久写入 `story_continue_adventure`、`deferredOptions` 和下一幕 `currentSceneActState`。另外旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId`、没有 `connections`,战后选项生成若只读 `connections` 也会退回 `idle_explore_forward` 循环。 +- 处理:`module-runtime-story` 在 story action 投影后统一调用 post-battle finalization;`idle_explore_forward` 清理战斗态并生成下一段遭遇预览;`idle_travel_next_scene` / `camp_travel_home_scene` 由后端写入新 `currentScenePreset`、场景 act 状态、遭遇预览和 `runtimeStats.scenesTraveled`。前端只负责播放继续、探索和切场景动画,不承接正式剧情推进真相。 +- 验证:`cargo test -p module-runtime-story --manifest-path server-rs\Cargo.toml battle_tests -- --nocapture` 应覆盖战斗终局持久化 `story_continue_adventure`、`deferredOptions`、下一幕 act,以及 `idle_travel_next_scene` 真正切换场景。 +- 关联:`server-rs/crates/module-runtime-story/src/session_action.rs`、`server-rs/crates/module-runtime-story/src/post_battle.rs`、`server-rs/crates/module-runtime-story/src/battle_tests.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## RPG 战斗飘字不要只靠低对比红绿文字 + +- 现象:暗色或棕黑噪声背景下,战斗伤害飘字看起来像背景纹理,尤其是远端敌人头顶的小号红字几乎不可读。 +- 原因:旧 `CombatFloatingNumber` 主要依赖 `text-rose-200` / `text-emerald-200` 和 8px 同色 glow;在暗红、棕黑、像素噪声背景上,颜色与背景混在一起,1px 深色描边也不足以形成轮廓。 +- 处理:飘字本体使用高亮近白文字、小面积半透明深色底、明显深色描边和多层黑色阴影;只增强瞬时反馈,不新增说明面板,不遮挡主要战斗画面。 +- 验证:`npm run test -- src/components/game-canvas/GameCanvasEntityLayer.test.tsx` 覆盖伤害/治疗飘字样式策略;运行态截图中敌方头顶伤害数字应能在暗场景上辨认。 +- 关联:`src/components/game-canvas/GameCanvasEntityLayer.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 + +## 弹窗里复用 CreativeImageInputPanel 要保留画面卡高度 + +- 现象:拼图草稿结果页的关卡详情弹窗中仍能看到“画面图”标题、画面描述和生成按钮,但实际画面图卡片视觉上消失。 +- 原因:`CreativeImageInputPanel` 内部依赖 `flex-1`、`h-full` 和 `max-h-full` 撑开正方形画面卡;放进弹窗里的普通 `section` 后,父级没有可计算高度,卡片会被压到不可见。 +- 处理:通用画面卡 `puzzle-image-upload-card` 保持 `aspect-square` 的同时设置稳定 `min-height`,让入口页和关卡详情弹窗都能显示主图/上传区。 +- 验证:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "opens an independent level detail dialog"` 应断言关卡详情中的 `.puzzle-image-upload-card` 具备最小高度类;`npm run test -- src/components/common/CreativeImageInputPanel.test.tsx` 应继续通过。 +- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`src/components/puzzle-result/PuzzleResultView.test.tsx`。 + ## Windows provision 下载截断要断点续传而不是回退目标机下载 - 现象:`Genarrative-Server-Provision` 在 `Download Provision Tool Archives` 阶段出现 `curl: (18) end of response ... bytes missing`,常见于 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 等 GitHub release 大文件。 @@ -189,7 +263,7 @@ ## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行 -- 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。 +- 现象:使用 VectorEngine `gpt-image-2` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。 - 原因:复杂抽象 logo prompt 同时包含品牌解释、禁用元素、中文结构和多重隐喻时,上游排队与生成时长不稳定;并发或批量运行会放大单条慢请求的影响。 - 处理:先 `--dry-run` 看请求体;真实生成时优先短 prompt、单一造型、单张串行或小批量。失败后不要反复重试同一长 prompt,先压缩到“一个主体 + 一个负形 + 颜色 + 禁用文字/播放键/聊天气泡”再跑。联系表中的中文标签不要通过 PowerShell 管道内联 Python 写入,容易因编码链路显示为问号,可改用英文标签或脚本文件方式。 - 验证:生成文件落在 `public/branding/taonier-logo-*/`,用 Pillow 检查图片尺寸和非空;执行 `node --check scripts/generate-taonier-logo-concepts.mjs`、`npm run check:encoding`、`git diff --check`。 @@ -223,11 +297,11 @@ - 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run dev:api-server` 看似启动但生成接口不可用。 - 原因:配置缺失类错误通常在后端 `error.details.reason` 中给出具体缺项,前端如果只读 `details.message` 会吞掉原因;本地只配置 `ALIYUN_OSS_BUCKET` / `ALIYUN_OSS_ENDPOINT` 时,旧逻辑还会在启动期构造空 AccessKey 的 OSS 客户端并失败。抓大鹅新链路仍是 2D 生图切割,不需要也不应回退 Rodin/GLB。 -- 处理:前端 API 错误展示优先读取 `details.reason`,再读取 `details.message`,避免底层 `error sending request` 覆盖真正可操作的配置或网络原因;`api-server` 只有在 OSS 四件套齐全时初始化 OSS 客户端,部分缺失只记 warning 并让具体 generated 上传/换签接口返回 `OSS 未完成环境变量配置`。抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS,并通过 `details.missingEnv` 列出缺项;真实生成需补齐 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和完整 `ALIYUN_OSS_*` 四件套。抓大鹅 `5*5` 素材图提示词还必须要求相邻物体主体至少保留 `1/4` 单格宽度空白间距,避免切割后相邻格内容污染。 +- 处理:前端 API 错误展示优先读取 `details.reason`,再读取 `details.message`,避免底层 `error sending request` 覆盖真正可操作的配置或网络原因;`api-server` 只有在 OSS 四件套齐全时初始化 OSS 客户端,部分缺失只记 warning 并让具体 generated 上传/换签接口返回 `OSS 未完成环境变量配置`。抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS,并通过 `details.missingEnv` 列出缺项;真实生成需补齐 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和完整 `ALIYUN_OSS_*` 四件套。抓大鹅 UI spritesheet 和物品 spritesheet 的提示词必须要求纯绿色绿幕背景,后端上传 OSS 前统一扣成透明 PNG,避免运行态 alpha 连通域解析失败。 - 验证:`npm run test -- src/services/apiClient.test.ts` 覆盖 `details.reason`;`cargo test -p api-server state --manifest-path server-rs/Cargo.toml` 覆盖半配置 OSS 不阻断启动;`npm run dev:api-server` 后按实际 `GENARRATIVE_API_PORT` 请求 `/healthz`,不要默认打 `3100`。 - 关联:`packages/shared/src/http.ts`、`server-rs/crates/api-server/src/state.rs`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`、`docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md`。 -2026-05-14 补充:抓大鹅“物品素材 sheet”已改用 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,真实生成读取 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;封面和 `9:16` 背景图走 VectorEngine `/v1/images/generations`,`1:1` 容器 UI 走 VectorEngine `/v1/images/edits` multipart 参考图链路。排查素材 sheet 时看请求路径是否为 `/v1beta/models/gemini-3-pro-image-preview:generateContent?key=...`,响应图片在 `candidates[].content.parts[].inlineData.data` / `inline_data.data`,不要再按 APIMart `/images/generations` 或 `/tasks/{task_id}` 排查。 +2026-05-22 补充:抓大鹅“物品 spritesheet”不再按旧 Gemini `generateContent` / `5*5` sheet 路径排查;当前链路先用 `gpt-image-2` 无参考图生成 `9:16` 关卡整图,再以该关卡整图作为 multipart `image` 参考并发编辑生成 `1K 1:1` UI spritesheet、`1K 9:16` 背景图和 `2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都要求纯绿色绿幕背景,上传 OSS 前通过后端透明化处理写入真实 alpha PNG。 ## 抓大鹅发布按钮要先开发布面板,封面编辑收口到发布面板内 @@ -321,7 +395,7 @@ ## 儿童动作 Demo 绘本风资源未生成先查 VectorEngine 配置 - 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.png`、`picture-book-grass-floor.png`、`picture-book-ground-ring.png`、`picture-book-character-outline.png`、`picture-book-ui-panel.png` 或 `picture-book-ui-button.png` 不存在,Network 里对应图片返回 404,或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。 -- 原因:儿童动作 Demo 的真实背景、地面、UI、地面指示环和角色轮廓资源都使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key,缺配置时页面只能使用 CSS 草地绘本兜底。 +- 原因:儿童动作 Demo 的真实背景、地面、UI、地面指示环和角色轮廓资源都使用 VectorEngine `gpt-image-2` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key,缺配置时页面只能使用 CSS 草地绘本兜底。 - 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai` 与 `VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git;先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt,再运行 `npm run assets:child-motion-demo -- --live` 或 `npm run assets:child-motion-demo -- --live --only ui-panel` 等小批量命令生成资源。透明资源的品红底源图写入 `tmp/child-motion-demo-assets/`,不要把源图或预览图放入 `public/child-motion-demo/` 作为正式资产。 - 验证:生成后确认 `public/child-motion-demo/` 只保留页面引用的最终 PNG,重新打开 `/child-motion-demo` 可看到真实绘本草地背景、地面、圆环、角色轮廓和 UI 资源;`npm run check:encoding` 仍通过。 - 关联:`scripts/generate-child-motion-demo-assets.mjs`、`src/index.css`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。 @@ -345,8 +419,8 @@ ## GPT-image-2 不再读 APIMart 图片配置 - 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。 -- 原因:2026-05-09 后 GPT-image-2 图片生成已切到 VectorEngine `gpt-image-2-all`,APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。 -- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认路径为 `/v1/images/generations`、模型为 `gpt-image-2-all`、参考图字段为 `image`。 +- 原因:2026-05-21 后 GPT-image-2 图片生成按 VectorEngine 创建/编辑接口分流,APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。 +- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,模型为 `gpt-image-2`。 - 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。 - 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。 @@ -366,19 +440,19 @@ - 验证:后端单测覆盖 `build_puzzle_levels_with_primary_update` 和 `apply_generated_puzzle_candidates_to_session_snapshot`;结果页重新生成应在未重新上传时继续带入 `level.pictureReference`。 - 关联:`server-rs/crates/api-server/src/puzzle.rs`、`src/components/puzzle-result/PuzzleResultView.tsx`。 -## 拼图图生图仍不像参考图时先看是否走了 edits +## 拼图参考图不像时先看 edits multipart image - 现象:Network payload 已带 `referenceImageSrc`,但 VectorEngine 生成结果仍明显不像上传图。 -- 原因:`gpt-image-2-all` 的 `/v1/images/generations` 更适合纯文生图;有参考图且需要重绘时应切到 `/v1/images/edits` 的 multipart 图生图接口。 -- 处理:`referenceImageSrc` 存在且 `aiRedraw = true` 时直接走 edits,prompt 仍保留参考图强约束;入口页关闭 AI 重绘时直接应用上传图,不调用图片生成;前端把参考图压到单边 1024 内,后端解析后拒绝超过 8MB 的参考图字节。 -- 验证:后端单测应覆盖 `images/edits` 路由、`b64_json` 响应解码和参考图强提示;真实联调先看日志里是否命中 `拼图 VectorEngine 图片编辑 HTTP 返回`。 +- 原因:参考图只在 `aiRedraw = true` 时由后端解析并传给 `gpt-image-2` `/v1/images/edits` 的 multipart `image` part;若前端没传 `referenceImageSrc`、后端解析失败或 prompt 缺少参考图强约束,生成会退化为纯文生图。 +- 处理:`referenceImageSrc` 存在且 `aiRedraw = true` 时走 edits multipart,prompt 保留参考图强约束;入口页关闭 AI 重绘时直接应用上传图,不调用图片生成;前端把参考图压到单边 1024 内,后端解析后拒绝超过 8MB 的参考图字节。 +- 验证:后端单测应覆盖 `/v1/images/edits` 路由、`b64_json` 响应解码和参考图强提示;真实联调看日志里是否命中 `拼图 VectorEngine 图片编辑 HTTP 返回`。 - 关联:`server-rs/crates/api-server/src/puzzle.rs`、`src/services/puzzleReferenceImage.ts`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。 ## 拼图 edits 报 error sending request 先看网络分类 - 现象:拼图有参考图时返回 `拼图图片生成失败:创建拼图 VectorEngine 图片编辑任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/edits)`,后端没有 `拼图 VectorEngine 图片编辑 HTTP 返回` 日志。 - 原因:这是 `reqwest` 在 `send()` 阶段失败,尚未收到 VectorEngine HTTP 响应;常见原因是服务器网络 / DNS / 防火墙 / 代理问题,或上游网关中断 multipart 连接。 -- 处理:查看错误响应 `details.reason/source/connect/body/timeout/endpoint` 和 `拼图 VectorEngine 请求发送失败` 日志。拼图图片客户端已强制 HTTP/1.1,降低 multipart HTTP/2 兼容风险;若 `connect=true` 先查网络出口,若 `body=true` 先查参考图大小和 multipart 发送。 +- 处理:查看错误响应和 `拼图 VectorEngine 图片编辑` 相关日志;若请求发送阶段失败,先查网络出口、DNS、防火墙、代理、参考图大小和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`。 - 验证:`curl --http1.1 -i -X POST https://api.vectorengine.ai/v1/images/edits -H "Authorization: Bearer invalid" -F "model=gpt-image-2" -F "prompt=test" -F "n=1" -F "size=1024x1024" -F "image=@public/match3d-background-references/pot-fused-reference.png;type=image/png"` 至少应返回 HTTP `401`,说明域名、TLS、路径和 multipart 上传可达;执行 `cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`。 - 关联:`server-rs/crates/api-server/src/puzzle.rs`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。 @@ -409,17 +483,41 @@ ## 拼图草稿生成 180 秒后 502/504 先查 VectorEngine 超时与前端重试 - 现象:点击“生成拼图游戏草稿”后,`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 等待约 180 秒返回 `502 Bad Gateway` 或 `504 Gateway Timeout`;钱包流水里同一 session 可能出现连续两组 `puzzle_initial_image` 扣费后退款。 -- 原因:首图生成走 VectorEngine `gpt-image-2-all`,默认 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000`;若上游在该窗口内未返回,后端退款并返回超时错误。旧前端 action 写请求会对 502/503/504 自动重试一次,导致同一次点击重复触发生图与扣退费。 +- 原因:首图生成走 VectorEngine `gpt-image-2`,默认 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000`;若上游在该窗口内未返回,后端退款并返回超时错误。旧前端 action 写请求会对 502/503/504 自动重试一次,导致同一次点击重复触发生图与扣退费。 - 处理:拼图/创作 Agent 的 `executeAction` 默认不做前端自动重试;后端将 VectorEngine / 图片请求超时映射为 `504 Gateway Timeout`,`error.details.provider=vector-engine` 且 `timeout=true`。真实排障按日志同一 `session_id` 查 `拼图 VectorEngine 图片生成 HTTP 返回` 是否缺失,以及钱包流水扣费到退款的时间差是否接近 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`。 - 验证:运行 `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts`、`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实联调重启 `npm run dev:api-server` 后检查 `/healthz`。 - 关联:`src/services/creation-agent/creationAgentClientFactory.ts`、`server-rs/crates/api-server/src/puzzle.rs`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。 +## 开局 CG 故事板生图失败先查 VectorEngine 请求预算和旧进程 + +- 现象:RPG 结果页点击开局 CG 后,`POST /api/runtime/custom-world/opening-cg` 在较长等待后返回“开局 CG 故事板生成失败:创建图片生成任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/generations)”。 +- 原因:该故事板会把角色图和首幕背景图作为参考图一起传给 VectorEngine `gpt-image-2-all`,请求体和上游生成耗时都比普通单图更大;若运行中的 `api-server` 仍沿用旧 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,或者参考图过大,会在请求发送/等待阶段被 reqwest 截断。日志里 `timeout=false connect=false request=true body=false source=client error (SendRequest)` 表示还没拿到上游 HTTP 响应,通常优先怀疑大 JSON 请求体、上游网关中断或 HTTP 协议兼容,而不是业务响应解析失败。直接请求 VectorEngine 若无效 token 可快速返回 401,不能据此判断真实生图不会超时。 +- 处理:开局 CG 参考图入参先压到单边 768 的 JPEG;`/v1/images/generations` 保持 reqwest 默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。后端图片 helper 将 `request_body_bytes`、每张参考图 Data URL 长度、`timeout/connect/body/source/rootSource/sourceChain/endpoint` 分类写入日志和 `error.details`,前端优先展示 `details.reason`。修改 `.env.secrets.local` 后必须重启 `api-server`,`npm run dev` 终端用 `rs api-server`,否则旧进程仍按旧超时运行。 +- 验证:分别运行 `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml` 和 `cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实联调重启后再触发开局 CG,若仍失败看返回的 `details.reason/source/rootSource/sourceChain/timeout/connect/body/endpoint` 和 `logs/api-server/` 同一 request_id。 +- 关联:`server-rs/crates/api-server/src/custom_world_ai.rs`、`server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 开局 CG 成功后又变空白要保留 profile.openingCg + +- 现象:RPG 结果页里的开局 CG 成功显示一瞬后,窗口又退回空白占位。 +- 原因:`openingCg` 只存在于结果页 profile 槽位,如果父层在 `onProfileChange` 后重新同步了 profile,却经过 `normalizeCustomWorldProfileRecord` 或作品库写回时丢掉 `openingCg`,预览就会从视频 / 故事板回退为空白。 +- 处理:`src/data/customWorldLibrary.ts` 的 profile 归一化必须透传 `openingCg`;结果页和父层后续同步都应把它当作受控资产槽位,而不是临时 UI 状态。 +- 验证:`npm run test -- src/data/customWorldLibrary.test.ts src/components/CustomWorldResultView.test.tsx`,确认生成后即使父层做一次归一化回写,开局 CG 仍继续显示。 +- 关联:`src/data/customWorldLibrary.ts`、`src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx`、`src/components/CustomWorldEntityCatalog.tsx`。 + +## RPG 发布报 legacy_result_profile_json 非法先查 null 兼容 + +- 现象:RPG 结果页发布动作返回 `UPSTREAM_ERROR`,SpacetimeDB details 里是 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`。 +- 原因:`publish_world` 前端契约只要求 `{ action: 'publish_world' }`;`ExecuteCustomWorldAgentActionRequest.legacy_result_profile` 是可选字段,经 HTTP / serde / SpacetimeDB payload 传递时可能显式成为 JSON `null`。旧的编译器只接受 object 或缺省,把 `Some("null")` 当成非法 legacy JSON。 +- 处理:`module-custom-world` 的 optional JSON object 解析要把 `null` 视为未提供,仍拒绝数组、字符串、数字和坏 JSON;正式发布继续以 session `draft_profile_json` 为草稿真相。 +- 验证:`cargo test -p module-custom-world published_profile_compile --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/module-custom-world/src/application.rs`、`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 本地脚本调 VectorEngine 生图卡住先区分 fetch 首部超时 - 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 `AbortError`、`TypeError: fetch failed` 或 `UND_ERR_HEADERS_TIMEOUT`;同一 prompt 改用原生 `https.request` 可以在较短时间内成功返回图片。 - 原因:Node/Undici 的默认 headers timeout 可能早于业务脚本期望的长生图等待窗口触发,表现上容易被误判成 VectorEngine 上游本身超时。 - 处理:长期脚本优先复用后端 reqwest 或项目已有生成脚本;临时本地工具若必须用 Node,可改用原生 `http`/`https.request` 并显式设置 socket timeout,或为 Undici 单独配置 headers timeout。仍需隐藏 `VECTOR_ENGINE_API_KEY`,只报告配置是否存在。 -- 验证:同一 `gpt-image-2-all` 请求体、同一环境变量下,原生 HTTP 请求能返回 `url` / `b64_json` 并落盘;失败时错误里能区分请求发送、首部等待、下载和解码阶段。 +- 验证:同一 `gpt-image-2` 请求体、同一环境变量下,原生 HTTP 请求能返回 `url` / `b64_json` 并落盘;失败时错误里能区分请求发送、首部等待、下载和解码阶段。 - 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。 ## 旧后端路线文档造成判断漂移 @@ -852,7 +950,7 @@ - 现象:修改抓大鹅素材时容易沿用旧 Rodin/GLB 方案,导致新草稿生成耗时变长、进度停在模型阶段,或运行态等待不存在的 GLB。 - 原因:仓库里保留了 Hyper3D 通用代理和历史模型字段,旧文档也曾要求草稿阶段同步生成 GLB。当前产品口径已经改为 2D 多视角素材。 -- 处理:新 `match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个视角,单张 1K 素材图固定 5x5,最多承载 5 个物品,一行对应一个物品,不足 5 个物品也补齐到完整 5 行;超过 5 个物品自动分批并行生图。素材图 prompt 固定要求纯绿色绿幕背景,切割前先把绿幕处理为透明 alpha,再做格内内容前景边界校准并带留白,避免固定内缩切掉贴近格线的主体。`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]` 或首图引用。`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路。 +- 处理:新 `match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个形态,单张 `2K 1:1` 物品 spritesheet 固定 `10*10`,每行承载两种物品、每种五个形态,单张最多承载 20 种物品。素材图 prompt 固定要求纯绿色绿幕背景,上传 OSS 前先把整张 spritesheet 绿幕处理为透明 alpha,再由运行态和编辑器按 alpha 连通域解析;`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]`、首图引用或可解析的物品 spritesheet。`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路。 - 验证:`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`。 - 关联:`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 @@ -888,11 +986,11 @@ - 验证:执行 `npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx` 和 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "Match3D runtime"`;浏览器 Network 中背景和容器 generated path 应先请求 `/api/assets/read-url` 换签,局内出现 `match3d-background-image` 和 `match3d-container-image` 对应图片。 - 关联:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 -## 抓大鹅容器参考图必须走 edits 并接管棋盘外观 +## 抓大鹅容器参考图必须进入 edits multipart image 并接管棋盘外观 - 现象:抓大鹅结果页看似有容器生成入口,但真实生成出的局内容器不像 `pot-fused-reference.png`,或进入试玩后仍被默认圆形锅壳、金色边框和径向底色覆盖/裁切。 -- 原因:`/v1/images/generations` 的 `image` 数组更适合弱参考文生图,难以稳定锁定大尺寸轻俯视容器构图;即使生成了容器图,如果运行态继续保留默认 `rounded-full` 锅壳和 `overflow-hidden`,生成图也会被默认视觉覆盖或裁掉。 -- 处理:抓大鹅 `1:1` 容器 UI 图必须用 VectorEngine `POST /v1/images/edits` multipart,参考 `public/match3d-background-references/pot-fused-reference.png` 的透明容器图作为 `image` part;该参考图属于后端生图协议输入,需通过 `include_bytes!` 编译进 `api-server`,不能在运行时按当前工作目录读取 `public/`。共享 GPT-image-2 HTTP client 承载 multipart 时强制 HTTP/1.1。`Match3DRuntimeShell` 在容器图换签并成功加载后,把棋盘外壳切为透明和 `overflow-visible`,只在容器缺失或加载失败时使用默认圆形容器。 +- 原因:容器参考图必须进入 `gpt-image-2` `/v1/images/edits` multipart `image` part,并配合强 prompt 锁定大尺寸轻俯视容器构图;即使生成了容器图,如果运行态继续保留默认 `rounded-full` 锅壳和 `overflow-hidden`,生成图也会被默认视觉覆盖或裁掉。 +- 处理:抓大鹅 `1:1` 容器 UI 图统一调用 VectorEngine `POST /v1/images/edits`,参考 `public/match3d-background-references/pot-fused-reference.png` 的透明容器图由后端作为 `image` part 上传;该参考图属于后端生图协议输入,需通过 `include_bytes!` 编译进 `api-server`,不能在运行时按当前工作目录读取 `public/`。`Match3DRuntimeShell` 在容器图换签并成功加载后,把棋盘外壳切为透明和 `overflow-visible`,只在容器缺失或加载失败时使用默认圆形容器。 - 验证:执行 `cargo test -p api-server vector_engine --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d_background --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`;真实联调看容器生成请求是否命中 `/v1/images/edits`,局内 `match3d-container-image` 是否渲染且 `match3d-board` 不再含默认 `rounded-full`。 - 关联:`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 @@ -970,9 +1068,9 @@ ## 抓大鹅难度配置的物品种类和消除次数必须分离 -- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;素材不足时发布或试玩行为不一致。 +- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;或者把第 11 到 20 个物品持久化为第 11 到 20 行,触发“系列素材图集持久化的行列索引必须落在 n*n 范围内”。 - 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。 -- 处理:统一使用 `物品种类` 口径:轻松 3、标准 9、进阶 15、硬核 21;历史 `clearCount=20` 且难度为硬核的运行态按新硬核升为 21 组三消,避免 20 组却要求 21 种素材。发布前按 `image_ready` 且有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。 +- 处理:生成和持久化固定使用 20 个物品素材;运行态物品种类口径为轻松 3、标准 9、进阶 15、硬核 20,历史 `clearCount=20` 且难度为硬核的运行态仍可升为 21 组三消,但类型池不超过 20。10*10 sheet 每行两种物品、每种五个形态,持久化行列为 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1`。发布前按 `image_ready` 且有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。 - 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`。 - 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/services/match3d-runtime/match3dRuntimeClient.ts`、`server-rs/crates/module-match3d/src/application.rs`、`server-rs/crates/spacetime-module/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 @@ -989,7 +1087,7 @@ - 现象:抓大鹅生成的物品视角图裁剪后仍带白边,或者整块纯绿色绿幕背景没有被透明化,运行态看到绿色方块。 - 原因:素材 sheet 可能是“每格内部绿幕、整张图外圈近白底”,内部绿幕不一定连通到 sheet 外边缘;旧 flood fill 只从外边缘找背景会漏掉这种绿幕块。白底抗锯齿如果不纳入抠像和边缘去污染,也会随裁剪输出成一圈白边。即使顺序已是先整张 sheet 去绿再裁剪,较厚的半透明或混色软绿边仍可能低于高置信绿幕阈值,被当作前景带进独立 PNG。 - 处理:`api-server` 的 `slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,沿整张 sheet 透明背景继续吃掉软绿边,边缘近白和绿幕抗锯齿做透明或去污染;同时保护不够纯的绿色主体像素。不要改成先裁剪单格再去绿。 -- 验证:`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖非连通绿幕、白边、贴边主体保留和固定 5x5 切图。 +- 验证:`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖非连通绿幕、白边、贴边主体保留和固定 `10*10` 切图;`cargo test -p api-server match3d_spritesheet_green_screen_postprocess_turns_background_transparent --manifest-path server-rs\Cargo.toml` 覆盖完整 spritesheet 上传前绿幕透明化。 - 关联:`server-rs/crates/api-server/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 抓大鹅物品详情大方格只做单张大图查看 @@ -1056,6 +1154,14 @@ - 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "拖拽合并大块时底层单格不显示选中色块"`,并确认合并块拖拽时底层 `[data-piece-id]` 仍为 `puzzle-runtime-piece--merged`。 - 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 +## 推荐页嵌入拼图通关结算不要放在运行态内部 absolute 层 + +- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜、下一关按钮或相似作品卡被截断。 +- 原因:推荐页把运行态放在滑动作品卡的视觉区内,`platform-recommend-swipe-page`、`platform-recommend-swipe-card__visual` 和 `platform-recommend-runtime-viewport` 都是 `overflow: hidden`;拼图通关结算如果仍是运行态内部 `absolute inset-0` 弹层,就只能在半屏卡片区域里显示。 +- 处理:`PuzzleRuntimeShell` 在 `embedded` 模式下把通关结算层通过 portal 挂到 `document.body`,使用 `puzzle-runtime-modal-overlay--fixed` 页面级 fixed 浮层;非嵌入态继续使用运行态内部覆盖层。 +- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪"`,确认弹层不再位于 `.platform-recommend-runtime-viewport` 内。 +- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/index.css`、`src/components/rpg-entry/RpgEntryHomeView.tsx`。 + ## 拼图历史图片列表不要把账号归属当图片名 - 现象:拼图创作页或结果页打开“选择历史图片”后,历史列表显示 `账号 user-1` 之类归属文案而不是图片名;`1713686400.000000Z` 这类时间显示为未知;选中后预览或生成参考图可能被怀疑不可用。 @@ -1074,12 +1180,22 @@ ## 拼图结果页局部生图不要污染草稿生成态 -- 现象:拼图草稿已经生成完成后,在结果页重新生成 UI 背景或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;UI 背景生成中还会禁用“新增关卡”和关卡图生成。 +- 现象:拼图草稿已经生成完成后,在结果页重新生成关卡图片或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;关卡图片生成中还会禁用“新增关卡”和其它关卡详情编辑。 - 原因:结果页局部 action 复用了全局 `isPuzzleBusy` / 持久化 `generationStatus=generating` 语义,作品架没有区分“初始草稿不可查看”和“已有结果上的局部关卡生成”。 -- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页 UI 背景和关卡图走 background action,不设置全局 busy,UI 背景只禁用自己的按钮;SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`。 +- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页关卡图走 background action,不设置全局 busy,只标记对应关卡局部生成进度;SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`。 - 验证:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle --manifest-path server-rs\Cargo.toml`。 - 关联:`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle/mappers.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`。 +2026-05-22 补充:结果页关卡详情的“关卡测试”不能把单关 `draft` 传给父级再调用 `updatePuzzleWork`。`updatePuzzleWork` 会同步 `puzzle_work_profile.levels_json` 和 source session 草稿,单关快照会把整份多关卡草稿覆盖成一个关卡,退出重进后只剩最后测试的关卡且序号表现为第一关。修复口径是 `PuzzleResultView` 始终传完整 `syncedDraft`,额外用 `{ levelId }` 指定起始关卡;父级持久化完整 levels 后调用 `startLocalPuzzleRun(item, levelId)`。 + +## 拼图上传图关闭 AI 重绘不要走首图生图 + +- 现象:用户在拼图入口页或结果页关卡详情上传图片并关闭 AI 重绘后,生成页仍显示“生成拼图首图”,或者后端仍调用 `generate_puzzle_image_candidates` 生成第一张 1:1 候选图。 +- 原因:上传图直用路径应把 Data URL 或 `/generated-*` 历史图解析后持久化为 `sourceType=uploaded` 的正式候选,再继续生成 9:16 关卡画面、UI spritesheet 和纯背景;如果只把 `aiRedraw=false` 当作“不参考图片生成”,就会误走首图生成。 +- 处理:入口页用 payload 的 `aiRedraw` 写入生成页 metadata,`puzzleAiRedraw=false` 时进度跳过 `生成拼图首图`;后端 `compile_puzzle_draft` 和结果页 `generate_puzzle_images` 都在 `aiRedraw=false && referenceImageSrc 非空` 时走上传图直用候选。结果页关卡详情必须复用 `CreativeImageInputPanel`,不要把正式图当成可重绘参考图;本次上传或历史选择的图才显示 AI 重绘开关并可删除。 +- 验证:`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_result_level_direct_upload_skips_cover_image_generation --manifest-path server-rs\Cargo.toml`。 +- 关联:`src/services/miniGameDraftGenerationProgress.ts`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/generation.rs`。 + ## Jenkins 数据库导入导出脚本先补 Node 工具链 PATH - 现象:`Genarrative-Database-Import` 或 `Genarrative-Database-Export` 运行到迁移脚本时,`bash` 报 `node: command not found`,常见在日志里表现为某个 `sh` 块内第 61 行直接调用 `node` 失败。 @@ -1135,3 +1251,11 @@ - 处理:优先切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布;若只是本地验证,可用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。debug 构建的 `api-server` 对入口配置缺 procedure 会使用后端默认入口配置兜底,避免作品架因本地库漂移整块空白。 - 验证:`curl.exe -i http://127.0.0.1:8082/api/creation-entry/config` 返回 `200` 且包含 `baby-object-match`;前端草稿页作品架重新渲染。 - 关联:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/creation_entry_config.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 存档选择入口不要只藏在“玩过”弹窗里 + +- 现象:用户有 RPG / 拼图运行态存档,但平台底部 `草稿` Tab 只展示作品架,个人中心只有点击 `玩过` 后才可能看到“可继续”,导致看起来没有存档选择入口。 +- 原因:`/api/profile/save-archives` 已在入口 bootstrap 加载,但前端只把 `saveEntries` 注入 `ProfilePlayedWorksModal`;没有独立的存档入口。 +- 处理:个人中心 `常用功能` 必须保留 `存档` 快捷入口,点击后打开独立存档选择弹窗并复用 `SaveArchiveCard`;恢复仍走 `/api/profile/save-archives/{worldKey}`,拼图存档继续走拼图 resume 分支,RPG 走 `handleContinueGame(snapshot)`。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile page exposes save archive picker"`。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/useRpgEntryBootstrap.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md b/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md index ca2cedf0..95c74dd9 100644 --- a/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md +++ b/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md @@ -1,4 +1,4 @@ -# 宝贝识物寓教于乐模板 PRD 2026-05-11 +# 宝贝识物寓教于乐模板 PRD 2026-05-11 ## 1. 目标 @@ -32,7 +32,7 @@ 5. 游戏视觉主题包; 6. 作品标签。 -素材使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端接口,前端不得读取、拼接或暴露 `VECTOR_ENGINE_API_KEY`。 +素材使用 VectorEngine `gpt-image-2` / image-2 生成。图片生成只能走后端接口,前端不得读取、拼接或暴露 `VECTOR_ENGINE_API_KEY`。 为降低生成成本,创作提交后只生成两张原始图片:一张 `2x2` 素材 sheet 和一张单独场景背景图。`2x2` 素材 sheet 固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒。服务端必须按固定格切图,并把物品、篮子和礼物盒转成透明 PNG。只有透明抠图后的两个物品素材才允许写入草稿 `itemAssets` 并进入游戏运行态。左右手位置指示器属于运行态默认规则,使用项目内置静态素材,不在每次创作时生成。 diff --git a/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md b/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md index 36aa7c74..0a3f9fbf 100644 --- a/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md +++ b/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md @@ -1,4 +1,4 @@ -# 宝贝识物创作发布实现方案 2026-05-11 +# 宝贝识物创作发布实现方案 2026-05-11 ## 1. 范围 @@ -144,7 +144,7 @@ PUT /api/creation/edutainment/baby-object-match/drafts/{draftId} POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish ``` -图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。 +图片生成必须在后端调用 VectorEngine `gpt-image-2`,不得从前端直接调用外部图片接口。 后端 `2x2` 素材 sheet prompt 约束: diff --git a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md index 2d6c4240..9c15c72e 100644 --- a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md +++ b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md @@ -1,4 +1,4 @@ -# 儿童动作识别互动玩法 Demo 热身关开发规格文档 +# 儿童动作识别互动玩法 Demo 热身关开发规格文档 > 日期:2026-05-09 > 关联设计文档:[CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md](../design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md) @@ -686,7 +686,7 @@ 1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。 2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风。 3. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格接入真实资源;资源加载失败时保留 CSS 兜底。 -4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`。 +4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`。 5. 当前已生成并接入以下正式 Demo 资源: - `public/child-motion-demo/picture-book-grass-stage.png`:默认草地舞台背景。 - `public/child-motion-demo/picture-book-foreground-grass-v2.png`:底部前景草坪条,只覆盖舞台下沿,不作为整块地板拉伸。 diff --git a/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md b/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md index 99a81aa0..6ace57c1 100644 --- a/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md +++ b/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md @@ -214,7 +214,7 @@ Handler 主要在 `story.rs`、`combat.rs`、`runtime_inventory.rs`: | Big Fish 正式图 | DashScope `wan2.2-t2i-flash` | 轮询 task 后 HTTP GET 图片 URL | `LegacyAssetPrefix::BigFishAssets` | 由 assetKind 映射主图/动作图/舞台背景等 | `big_fish_session` + session/entity id + slot | `big_fish.rs` 调用方 `execute_billable_asset_operation` | 配置缺失/上游失败直接错误;gallery 对部分 Spacetime 运行错误软降级 | | 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 | multipart/base64/URL 结果归一 | `LegacyAssetPrefix::PuzzleAssets` | puzzle level/background/generated image,另有 `puzzle_background_music` | puzzle profile/run/level slot | `puzzle.rs` 调用方包裹 | connectivity 可按既有规则跳过部分计费;运行态 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;部分连接错误按现有计费跳过规则处理 | | 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 场景语义混用 | diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 5319448a..a0cb47fe 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -106,7 +106,7 @@ npm run check:server-rs-ddd - `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。 - `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。 - `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。 -- `server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs` 承接 VectorEngine Gemini 请求体、响应解析、base64 图片下载和上游错误归一。 +- `server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs` 仅保留历史 VectorEngine Gemini 物品 sheet helper;当前草稿物品 spritesheet 以关卡整图为参考走 `gpt-image-2` 编辑链路,提示词、绿幕透明化和 OSS 持久化由 `item_assets.rs` / `works.rs` 约束。 - `server-rs/crates/api-server/src/match3d/runtime.rs` 保留运行态轻量归一 helper;`mappers.rs` / `tags.rs` / `tests.rs` 分别承接 DTO 映射、标签 / 通用错误 helper 和原有单测。 该拆分只改变 `api-server` 文件组织,不改变 `/api/creation/match3d/*`、`/api/runtime/match3d/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义、VectorEngine / OSS 副作用边界或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉到 `module-match3d`。 @@ -157,8 +157,8 @@ npm run check:server-rs-ddd - LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。 - 图片生成:VectorEngine / APIMart / DashScope,密钥只在后端环境变量中。 -- Match3D 物品 sheet:VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`;图集 prompt、切图、透明化和切片持久化走 `generated_asset_sheets` 通用模块,Match3D 只补题材 / 风格 / 五视角设定和字段映射。 -- Match3D 封面和 9:16 纯背景:VectorEngine `/v1/images/generations`。 +- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端固定从该 sheet 解析并持久化 20 个物品、每个 5 个形态;通用系列素材图集的行列索引按每行 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/` 时生成失败。 - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;背景环境图只使用新敲击物图作为参考。 - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;新 Match3D 草稿和批量新增不再生成 GLB。 @@ -332,6 +332,7 @@ npm run check:server-rs-ddd - Rust 结构体:`CustomWorldAgentSession` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` +- 发布约束:`publish_world` 的 action payload 不要求携带 `settingText`;`spacetime-module` 调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)`,优先从当前 `draft_profile_json` 草稿真相派生正式 `setting_text`,避免旧会话 `seed_text` 为空时在最终 compile / publish 阶段触发 `custom_world.setting_text 不能为空`。 ### `custom_world_draft_card` @@ -639,6 +640,10 @@ npm run check:server-rs-ddd `GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。 +RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*`、`/api/story/*` 和 `/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。 + +结构化创作和 RPG 的 LLM JSON 链路默认不启用 Responses `web_search`;只有在明确需要联网增强时,才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` 显式打开。否则未开通工具的上游会先吐自然语言再返回 `ToolNotOpen`,这类失败要按上游工具不可用处理,不要误判成模型返回结果解析失败。 + 未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表;BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback。 ### `quest_log` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0f9df588..ab3a076e 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -49,6 +49,8 @@ npm run dev:api-server 本地 `npm run dev:spacetime` 发布模块时必须显式忽略仓库根目录的 `spacetime.json`,由脚本固定追加 `--no-config` 并使用命令参数里传入的数据库名和 `--server http://127.0.0.1:3101`。否则 CLI 可能把发布目标改写到配置文件里的其他数据库,导致 `dev:spacetime` 启动后又因发布失败自动退出,浏览器随后会在 `ws://127.0.0.1:3101/v1/database/.../subscribe` 看到连接拒绝。 +本地 `.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。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes`、`reference_data_url_bytes`、`sourceChain` 和 `rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 + 查看本地 Rust / SpacetimeDB 日志: ```bash @@ -246,6 +248,8 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - `WECHAT_*` - `ALIYUN_OSS_*` +结构化创作 / RPG 的 Responses JSON 链路默认不打开 `web_search`;本地和生产如需联网增强,必须显式配置 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses 可能先吐自然语言再返回 `ToolNotOpen`,这类报错应按工具不可用排查,不要先当成 JSON 解析 bug。 + ### 手机验证码短信 手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index c0982b00..d69dfd9e 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -10,6 +10,8 @@ `PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 +移动端底部一级导航是全局平台样式,不按单一玩法分叉。当前视觉统一为米白浮动胶囊底座、浅棕分隔线、棕色线性图标、橘色选中态和底部短下划线;中间 `创作` 入口保持凸起圆形主按钮,但凸起位移只能作用在按钮内容层,不能移动承载分隔线的 Tab 按钮容器,确保创作左右分隔线与其他分隔线垂直位置一致。Tab 名称和可见性仍由现有 `PlatformHomeTab` / 登录态规则决定,样式调整不得改写 Tab 文案或导航状态。 + ## 新增玩法创作工具平台 SOP 新增玩法默认采用表单/图片输入创作工作台,链路为: @@ -34,6 +36,34 @@ 6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。 7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 +## RPG / 自定义世界 + +当前 RPG 创作入口使用 `playId = rpg`,工程域和运行态源类型沿用历史 `custom-world`。默认入口状态为 `visible=true`、`open=true`,对外展示为“文字冒险”;`airp` 仍是独立的“AI RPG”占位入口,保持 `open=false`,不要把它当作当前 RPG 创作链路开放。 + +当前链路为: + +```text +创作入口 -> RPG Agent 共创工作台 -> 生成过程页 -> 结果页 -> 进入世界/试玩 -> 发布 -> RPG 运行态 +``` + +RPG 是历史既有链路例外:当前仍使用对话式 Agent 共创工作台和 RPG 资产编辑器体系,不作为新增玩法默认模板复制。新增玩法继续遵循本文默认的表单/图片输入工作台、`CreativeImageInputPanel` 单图槽位和通用系列素材图集生成流程;如果要把 RPG 逐步迁回默认模式,应先补 PRD 和迁移方案,再改代码。 + +RPG API 仍沿用历史命名空间:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。这些路由在 `api-server` 入口熔断中统一映射到 `rpg`,只按 `open` 判断是否允许调用;`visible` 只控制创作页入口展示和作品架可见性。 + +RPG Agent 结果页点击发布或发布并进入世界时,必须先把结果页当前 profile 通过 `sync_result_profile` 保存回 `custom_world_agent_session.draft_profile_json`,再发送发布动作;发布动作前端契约只允许提交 `{ action: 'publish_world' }`,`api-server` 只补作者公开信息,不转发 `profile`、`draftProfile`、`legacyResultProfile` 或 `settingText`。`spacetime-module` 发布时只读取当前 session 的 `draft_profile_json` 作为草稿真相,从 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title` 依次派生正式 `setting_text`,最后才回退 `seed_text`。不要把 `seed_text` 当作唯一设定来源,旧会话可能为空。 + +Agent session 已进入 `published` 后,结果页按钮只能执行“进入世界”:前端需先通过 `result-view` 回读已发布 profile 并启动运行态,不得再次调用 `sync_result_profile` 或发送 `{ action: 'publish_world' }`。`publish_world` 只允许在 `object_refining`、`visual_refining`、`long_tail_review`、`ready_to_publish` 等发布前阶段触发;否则会被后端阶段门槛拒绝。 + +`legacyResultProfile` 只作为历史结果页 profile 兼容兜底;编译正式 profile 时,session 草稿内已保存字段优先于 legacy 字段,legacy 只能补缺失字段。`publish_world` 不再接受前端临时传入的 legacy 载荷;历史兼容路径中 legacy 缺省或显式为 `null` 时等价于未提供,不得因此报 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`。真正的数组、字符串、数字等非 object legacy 载荷仍应拒绝。 + +RPG 结果页开局 CG 是 `profile.openingCg` 资产槽位:`api-server` 负责 VectorEngine / OSS 副作用并返回故事板和视频引用,前端只把结果写回当前 profile;`sync_result_profile`、作品库保存和 `normalizeCustomWorldProfileRecord` 都必须保留该槽位。封面是 `profile.cover` 资产槽位,默认封面也要保留 `sourceType='default'` 和 `characterRoleIds`,不能因为没有 `imageSrc` 就当作空封面。若生成成功后画面短暂显示又变回空白,优先检查父层重新同步或 profile 归一化是否把 `openingCg` / `cover` 丢掉,而不是先怀疑已生成资源本身失效。 + +RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 client 必须把后端返回的完整 `profile` 先经过 `normalizeCustomWorldProfileRecord`,并用作品条目的 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺失字段;运行态和详情页不得直接消费未归一化的旧 profile。作品架列表或 `savedCustomWorldEntries` 中的摘要 profile 只可用于卡片展示,不可在详情接口已回读完整 profile 后覆盖 `selectedDetailEntry`;若摘要缺少 `playableNpcs`、`storyNpcs`、`landmarks`、`items`、`sceneChapterBlueprints`、`cover`、`openingCg`、`skills[].actionPreviewConfig`、`initialItems[].iconSrc`、`attributeSchema`、角色 `attributeProfile`、场景残留或场景幕背景资产,启动和编辑必须继续使用详情 profile,否则会进入默认角色 / 默认 profile,或在编辑页丢 CG、封面、技能预览和初始物品图标。正式“进入世界”发布 / 回读结果页时,同一 `profile.id` 下也不得用字段更少的后端旧视图降级当前结果页完整 profile。角色选择页还需要在角色数组异常或为空时回退默认角色,并显示可返回的轻量空态,不能 `return null` 造成黑屏。运行态懒加载 fallback 必须可见,不能用纯 `null` 让用户误判为黑屏。 + +RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization,持久写入 `story_continue_adventure`、`deferredOptions`、`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset`、`currentSceneActState`、`currentEncounter` 和 `runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。 + +RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > 存档` 暴露为独立弹窗;“玩过”弹窗可以继续合并展示可继续存档,但不能成为唯一入口。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 + ## 拼图 当前拼图链路: @@ -45,23 +75,26 @@ 当前口径: - 图像输入复用 `CreativeImageInputPanel`。 -- 结果页每关画面编辑和素材配置里的 UI 背景生成也复用 `CreativeImageInputPanel`;三处只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`,UI 背景写 `levels[0].uiBackgroundPrompt/uiBackgroundImage*` 并触发 `generate_puzzle_ui_background`。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在 `displayImageSrc` 自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。 -- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端换取 OSS 只读签名 URL 给 VectorEngine 读取;本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入。 -- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。 +- 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。 +- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。 +- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页进度不再按固定 5 分钟展示,而按实际开始时间和当前路径的分步骤预计时长推进;任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。 - 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。 -- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试,生成页预计完成时间按 `5` 分钟展示;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。 +- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 90 秒、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 298 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。 - 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。 -- 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`;若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败。 -- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。 +- 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。 +- 每次新建关卡生成或重新生成关卡图都必须由 `api-server` 串起当前关卡资产包:AI 重绘开启时第一段沿用草稿生成第一关的拼图主图提示词配置和模型 / 尺寸 / 参考图规则生成 `coverImageSrc/coverAssetId` 作为关卡拼图画面和结果页预览图,提示词来源同样按显式画面描述、关卡画面描述、草稿摘要顺序回退,且固定要求输出画面比例为 `1:1`;上传图且关闭 AI 重绘时跳过这一段,把上传图或历史图持久化为 `sourceType=uploaded` 的正式候选。随后用正式候选图作为参考,`9:16` 生成完整拼图游戏关卡画面并写入 `levelSceneImageSrc/levelSceneImageObjectKey`,提示词必须要求道具按钮上不要显示次数标注,且返回按钮和设置按钮旁禁止标注文字;UI spritesheet 与关卡纯背景在关卡画面完成后并发生成,spritesheet 用 `1:1`、`1k` 先生成纯绿色绿幕背景图,后端上传 OSS 前必须把绿幕扣成透明 PNG,再写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,按钮顺序固定为返回、设置、下一关、提示、原图、冻结,按钮素材自身保留对应中文文字,返回和设置按钮不得额外生成白色外圈、白底圆环或浮雕外框;纯背景用 `9:16`、`1k` 写入 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,提示词必须包含“禁止在背景中出现人像或和拼图画面中主体一致的内容”。运行态不直接使用第二段完整关卡画面,但必须持久化它用于追踪和后续再生成。结果页局部关卡生成进度按 AI 重绘开启约 270 秒、关闭 AI 重绘约 180 秒展示。 - 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。 -- 结果页 UI 背景重生成只禁用 UI 背景自己的按钮和确认动作,不禁用“新增关卡”、关卡图片生成、关卡详情编辑和结果页导航;关卡图片生成也只标记对应关卡的局部生成进度。 +- 结果页关卡图片生成只标记对应关卡的局部生成进度,不禁用“新增关卡”、其它关卡详情编辑和结果页导航。 +- 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。 - 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。 -- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。 +- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回和设置按钮的点击容器只提供透明点击区,不再叠加默认白色圆形底;底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 - 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。 +- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。 - 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。 +- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。 - 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 @@ -116,9 +149,10 @@ 入口表单只展示: - 题材主题。 -- `2D素材风格` 横向风格卡:扁平图标、赛璐璐卡通、像素复古、手绘水彩、贴纸描边、厚涂图标、自定义。 - 难度:轻松、标准、进阶、硬核。 +入口不再要求用户选择素材风格;历史草稿和旧接口中的 `assetStyleId` / `assetStyleLabel` / `assetStylePrompt` 仅作为兼容字段保留,新入口提交不再写入这些字段。 + 难度映射: | 难度 | clearCount | difficulty | 总物品数 | 物品种类 | @@ -126,29 +160,27 @@ | 轻松 | 8 | 2 | 24 | 3 | | 标准 | 12 | 4 | 36 | 9 | | 进阶 | 16 | 6 | 48 | 15 | -| 硬核 | 21 | 8 | 63 | 21 | +| 硬核 | 21 | 8 | 63 | 20 | 当前素材生成流水线: 1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。 -2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;作品摘要在素材或背景未完整时下发 `generationStatus=generating`,素材和背景完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。 -3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。 -4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。 -5. 物品 sheet prompt、切图、透明化和五视角切片持久化复用 `generated_asset_sheets` 通用模块;Match3D 只传入题材 / 风格 subject、物品行 prompt 模板和“同一行五格必须是同一物品五个不同视角”的特殊设定,并把通用切片结果映射回 `generatedItemAssets[].imageViews[]`。 -6. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。 -7. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。 +2. 先写入可恢复草稿 profile,再执行文本计划、关卡整图生成、三张派生图生成、OSS 上传和素材解析;作品摘要在背景、UI spritesheet 或物品 spritesheet 未完整时下发 `generationStatus=generating`,完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。 +3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮。 +4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K`、`1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`;`1K`、`9:16` 的背景图写入 `imageSrc/imageObjectKey`;`2K`、`1:1` 的物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。 +5. UI spritesheet 提示词固定要求按从上到下、从左到右整理纯绿色绿幕背景素材:返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮;后端上传 OSS 前必须把绿幕扣成透明 PNG。背景图提示词固定要求移除全部 UI 组件和容器内含物,完整保留容器和背景,并补全被 UI 覆盖的背景内容。 +6. 物品 spritesheet 固定 `10行*10列`、统一纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次固定解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;持久化单格映射必须按 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1`。`generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退。 +7. 前端和运行态统一使用 alpha 连通域矩形检测解析 spritesheet:UI 图按返回、设置、方格、移出、凑齐、打乱顺序映射回原 UI 位置;物品图按检测顺序每 `5` 个区域组成一个物品的五个形态,最多 `20` 个物品。透明背景是解析前提,不能在前端按固定像素坐标写死切片。 8. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 -9. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。该容器参考图由后端内嵌到 `api-server`,不要依赖运行时当前目录下的 `public/` 文件。 -10. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 -11. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器。 +9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 +10. 背景、UI spritesheet、物品 spritesheet 和历史容器兼容字段的持久化真相仍在 `generatedItemAssets[].backgroundAsset` 与提升后的 `generatedBackgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 图集。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景。 结果页当前结构: - `作品信息`:名称、描述、标签;封面编辑收口到发布面板。 - `难度配置`:四档离散拖动条,显示需要消除、总物品数、物品种类、已生成物品种类。 -- `素材配置 > 物品`:两列素材卡,点击打开独立五视角预览面板;支持删除、批量新增和批量重新生成。替换模式必须保留原 `itemId` 和列表顺序。 -- `素材配置 > UI`:纯背景图与运行态 UI 预览,重生成消耗 `2` 泥点;UI 预览必须复用运行态顶部 HUD、中央容器棋盘、容器图定位和底部槽位样式,不单独维护一套简化预览 UI。 -- `素材配置 > 容器形象`:单独预览和重生成中心容器,消耗 `2` 泥点。 +- `素材配置 > 物品`:两列素材卡固定展示 20 个物品,点击打开独立五视角预览面板;支持删除、批量新增和批量重新生成。替换模式必须保留原 `itemId` 和列表顺序。 +- `素材配置 > UI素材`:预览背景图、UI spritesheet 原图、物品 spritesheet 原图和物品 spritesheet 自动解析缩略图;背景图只支持预览,不提供重新生成入口。UI 预览必须复用运行态顶部 HUD、中央容器棋盘和底部槽位样式,不单独维护一套简化预览 UI。 运行态当前口径: @@ -158,8 +190,9 @@ - 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。 - 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。 - 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。 -- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。 -- 局内容器图在移动端宽度大于屏幕宽度并略向下压,当前运行态使用 `w-[min(116vw,42rem)]` 与 `top-[54%]` 放大和下移容器图本体,保持原图比例不拉伸且不改变后端物品布局、点击半径或消除规则;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。 +- 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。 +- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。 +- 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。 - generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。 - `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。 - 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 607782c8..e19cc841 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -94,6 +94,7 @@ server-rs + Axum + SpacetimeDB 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 10. “我的”页泥点、游戏时长、玩过三张统计卡只展示各自标签和值,内容居中且不换行,不在统计区底部展示“更新于”时间。 +11. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 11. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 ## 文案与编码 diff --git a/media/logo.png b/media/logo.png new file mode 100644 index 00000000..40a5c4fe Binary files /dev/null and b/media/logo.png differ diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts index f54ac624..146ca360 100644 --- a/packages/shared/src/contracts/match3dWorks.ts +++ b/packages/shared/src/contracts/match3dWorks.ts @@ -19,8 +19,17 @@ export type Match3DGeneratedItemSize = '大' | '中' | '小' | string; export interface Match3DGeneratedBackgroundAsset { prompt: string; + levelScenePrompt?: string | null; + levelSceneImageSrc?: string | null; + levelSceneImageObjectKey?: string | null; imageSrc?: string | null; imageObjectKey?: string | null; + uiSpritesheetPrompt?: string | null; + uiSpritesheetImageSrc?: string | null; + uiSpritesheetImageObjectKey?: string | null; + itemSpritesheetPrompt?: string | null; + itemSpritesheetImageSrc?: string | null; + itemSpritesheetImageObjectKey?: string | null; containerPrompt?: string | null; containerImageSrc?: string | null; containerImageObjectKey?: string | null; diff --git a/packages/shared/src/contracts/puzzleAgentDraft.ts b/packages/shared/src/contracts/puzzleAgentDraft.ts index 228065f0..38fa22fb 100644 --- a/packages/shared/src/contracts/puzzleAgentDraft.ts +++ b/packages/shared/src/contracts/puzzleAgentDraft.ts @@ -51,6 +51,12 @@ export interface PuzzleDraftLevel { uiBackgroundPrompt?: string | null; uiBackgroundImageSrc?: string | null; uiBackgroundImageObjectKey?: string | null; + levelSceneImageSrc?: string | null; + levelSceneImageObjectKey?: string | null; + uiSpritesheetImageSrc?: string | null; + uiSpritesheetImageObjectKey?: string | null; + levelBackgroundImageSrc?: string | null; + levelBackgroundImageObjectKey?: string | null; backgroundMusic?: CreationAudioAsset | null; candidates: PuzzleGeneratedImageCandidate[]; selectedCandidateId: string | null; diff --git a/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index 10f3532a..5e705782 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -59,6 +59,10 @@ export interface PuzzleRuntimeLevelSnapshot { coverImageSrc: string | null; uiBackgroundImageSrc?: string | null; uiBackgroundImageObjectKey?: string | null; + levelBackgroundImageSrc?: string | null; + levelBackgroundImageObjectKey?: string | null; + uiSpritesheetImageSrc?: string | null; + uiSpritesheetImageObjectKey?: string | null; backgroundMusic?: CreationAudioAsset | null; board: PuzzleBoardSnapshot; status: PuzzleRuntimeLevelStatus; diff --git a/scripts/generate-bark-battle-assets.mjs b/scripts/generate-bark-battle-assets.mjs index 1a605519..f7e01211 100644 --- a/scripts/generate-bark-battle-assets.mjs +++ b/scripts/generate-bark-battle-assets.mjs @@ -1,4 +1,4 @@ -import { Buffer } from 'node:buffer'; +import { Buffer } from 'node:buffer'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; @@ -101,7 +101,7 @@ const onlyIds = process.argv .filter(Boolean); const templates = rawTemplates.filter((template) => !onlyIds.length || onlyIds.includes(template.id)); const dryRun = args.has('--dry-run') || !args.has('--live'); -const requests = templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2-all', prompt: template.prompt, n: 1, size: '1024x1024' } })); +const requests = templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2', prompt: template.prompt, n: 1, size: '1024x1024' } })); if (dryRun) { console.log(JSON.stringify({ mode: 'dry-run', outDir, count: requests.length, requests }, null, 2)); process.exit(0); diff --git a/scripts/generate-child-motion-demo-assets.mjs b/scripts/generate-child-motion-demo-assets.mjs index e3a81fbb..f39f6928 100644 --- a/scripts/generate-child-motion-demo-assets.mjs +++ b/scripts/generate-child-motion-demo-assets.mjs @@ -772,6 +772,12 @@ function buildVectorEngineImagesGenerationUrl(baseUrl) { : `${baseUrl}/v1/images/generations`; } +function buildVectorEngineImagesEditUrl(baseUrl) { + return baseUrl.endsWith('/v1') + ? `${baseUrl}/images/edits` + : `${baseUrl}/v1/images/edits`; +} + function collectStringsByKey(value, targetKey, output) { if (Array.isArray(value)) { value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); @@ -828,28 +834,41 @@ function inferExtensionFromBytes(bytes, preferredPath) { return path.extname(preferredPath).replace(/^\./u, '') || 'png'; } -function toDataUrl(filePath) { +function mimeFromExtension(extension) { + if (extension === 'jpg' || extension === 'jpeg') { + return 'image/jpeg'; + } + if (extension === 'webp') { + return 'image/webp'; + } + return 'image/png'; +} + +function readReferenceImage(filePath) { if (!existsSync(filePath)) { return null; } const bytes = readFileSync(filePath); const extension = inferExtensionFromBytes(bytes, filePath); - const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`; - return `data:${mime};base64,${bytes.toString('base64')}`; + return { + fileName: path.basename(filePath).replace(/"/gu, '_'), + mimeType: mimeFromExtension(extension), + bytes, + }; } function pushReferenceImage(body, filePath) { - const reference = toDataUrl(filePath); + const reference = readReferenceImage(filePath); if (!reference) { return false; } - body.image = [...(body.image || []), reference]; + body.referenceImages = [...(body.referenceImages || []), reference]; return true; } function buildRequestBody(asset, size) { const body = { - model: 'gpt-image-2-all', + model: 'gpt-image-2', prompt: asset.prompt, n: 1, size: size || asset.size, @@ -1624,18 +1643,49 @@ async function generateAsset(asset, env, size, force) { }; } - const requestBody = buildRequestBody(asset, size); + const { referenceImages = [], ...requestBody } = buildRequestBody(asset, size); + const hasReferenceImages = referenceImages.length > 0; + const requestOptions = hasReferenceImages + ? (() => { + const formData = new FormData(); + formData.set('model', requestBody.model); + formData.set('prompt', requestBody.prompt); + formData.set('n', String(requestBody.n)); + formData.set('size', requestBody.size); + for (const referenceImage of referenceImages) { + formData.append( + 'image', + new Blob([referenceImage.bytes], { type: referenceImage.mimeType }), + referenceImage.fileName, + ); + } + return { + url: buildVectorEngineImagesEditUrl(env.baseUrl), + options: { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + }, + body: formData, + }, + }; + })() + : { + url: buildVectorEngineImagesGenerationUrl(env.baseUrl), + options: { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + }; const payloadText = await fetchWithTimeout( - buildVectorEngineImagesGenerationUrl(env.baseUrl), - { - method: 'POST', - headers: { - Authorization: `Bearer ${env.apiKey}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }, + requestOptions.url, + requestOptions.options, env.timeoutMs, ); @@ -1687,7 +1737,7 @@ async function generateAsset(asset, env, size, force) { size: requestBody.size, extension: actualExtension, source: urls[0] ? 'url' : 'b64_json', - usedReferenceImage: Boolean(requestBody.image), + usedReferenceImage: hasReferenceImages, }; } @@ -1715,19 +1765,27 @@ function dryRun(selectedAssets, size) { { mode: 'dry-run', assets: selectedAssets.map((asset) => { - const body = buildRequestBody(asset, size); + const { referenceImages = [], ...body } = buildRequestBody(asset, size); return { id: asset.id, + endpoint: referenceImages.length + ? '/v1/images/edits' + : '/v1/images/generations', outputPath: outputPathFor(asset), sourceOutputPath: asset.transparent ? sourceOutputPathFor(asset) : undefined, transparent: asset.transparent, localPostprocess: asset.localPostprocess, - body: { - ...body, - image: body.image ? [''] : undefined, - }, + body: referenceImages.length ? undefined : body, + form: referenceImages.length + ? { + ...body, + imageParts: referenceImages.map( + (referenceImage) => referenceImage.fileName, + ), + } + : undefined, }; }), }, diff --git a/scripts/generate-match3d-style-references.mjs b/scripts/generate-match3d-style-references.mjs index 653b5fd0..fe5ad729 100644 --- a/scripts/generate-match3d-style-references.mjs +++ b/scripts/generate-match3d-style-references.mjs @@ -1,4 +1,4 @@ -import { Buffer } from 'node:buffer'; +import { Buffer } from 'node:buffer'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -215,7 +215,7 @@ async function downloadImage(url, timeoutMs) { async function generateOne(env, template, outDir, size) { const requestBody = { - model: 'gpt-image-2-all', + model: 'gpt-image-2', prompt: buildPrompt(template), n: 1, size, @@ -278,7 +278,7 @@ if (dryRun) { id: template.id, title: template.title, body: { - model: 'gpt-image-2-all', + model: 'gpt-image-2', prompt: buildPrompt(template), n: 1, size, diff --git a/scripts/generate-taonier-logo-concepts.mjs b/scripts/generate-taonier-logo-concepts.mjs index 2faf51b9..a26a9587 100644 --- a/scripts/generate-taonier-logo-concepts.mjs +++ b/scripts/generate-taonier-logo-concepts.mjs @@ -817,12 +817,18 @@ function resolveEnv() { }; } -function buildUrl(baseUrl) { +function buildGenerationUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`; } +function buildEditUrl(baseUrl) { + return baseUrl.endsWith('/v1') + ? `${baseUrl}/images/edits` + : `${baseUrl}/v1/images/edits`; +} + function hasHeader(headers, targetName) { return Object.keys(headers).some( (name) => name.toLowerCase() === targetName.toLowerCase(), @@ -954,7 +960,7 @@ function inferExtensionFromBytes(bytes) { return 'png'; } -function imagePathToDataUrl(imagePath) { +function imagePathToReferenceImage(imagePath) { if (!existsSync(imagePath)) { throw new Error(`Reference image not found: ${imagePath}`); } @@ -967,7 +973,44 @@ function imagePathToDataUrl(imagePath) { : extension === '.webp' ? 'image/webp' : 'image/png'; - return `data:${mimeType};base64,${bytes.toString('base64')}`; + return { + fieldName: 'image', + fileName: path.basename(imagePath).replace(/"/gu, '_'), + mimeType, + bytes, + }; +} + +function buildMultipartBody(fields, files) { + const boundary = `----genarrative-${Date.now().toString(16)}-${Math.random() + .toString(16) + .slice(2)}`; + const chunks = []; + const push = (value) => { + chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value)); + }; + + for (const [name, value] of Object.entries(fields)) { + push(`--${boundary}\r\n`); + push(`Content-Disposition: form-data; name="${name}"\r\n\r\n`); + push(`${value}\r\n`); + } + + for (const file of files) { + push(`--${boundary}\r\n`); + push( + `Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\n`, + ); + push(`Content-Type: ${file.mimeType}\r\n\r\n`); + push(file.bytes); + push('\r\n'); + } + + push(`--${boundary}--\r\n`); + return { + body: Buffer.concat(chunks), + contentType: `multipart/form-data; boundary=${boundary}`, + }; } async function fetchJson(url, options, timeoutMs) { @@ -1011,27 +1054,45 @@ async function downloadUrl(url, timeoutMs) { async function generateConcept(env, concept) { const requestBody = { - model: 'gpt-image-2-all', + model: 'gpt-image-2', prompt: concept.prompt, n: 1, size: '1024x1024', }; - if (concept.referenceImages?.length) { - requestBody.image = concept.referenceImages.map(imagePathToDataUrl); - } - const payload = await fetchJson( - buildUrl(env.baseUrl), - { - method: 'POST', - headers: { - Authorization: `Bearer ${env.apiKey}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }, - env.timeoutMs, + + const referenceImages = (concept.referenceImages || []).map( + imagePathToReferenceImage, ); + const payload = referenceImages.length + ? await (async () => { + const multipart = buildMultipartBody(requestBody, referenceImages); + return fetchJson( + buildEditUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': multipart.contentType, + }, + body: multipart.body, + }, + env.timeoutMs, + ); + })() + : await fetchJson( + buildGenerationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + env.timeoutMs, + ); const urls = extractImageUrls(payload); const b64Images = extractBase64Images(payload); @@ -1072,19 +1133,28 @@ if (dryRun) { requests: selected.map((concept) => ({ id: concept.id, title: concept.title, - body: { - model: 'gpt-image-2-all', - prompt: concept.prompt, - n: 1, - size: '1024x1024', - ...(concept.referenceImages?.length - ? { - image: concept.referenceImages.map((imagePath) => - path.relative(repoRoot, imagePath), - ), - } - : {}), - }, + endpoint: concept.referenceImages?.length + ? '/v1/images/edits' + : '/v1/images/generations', + body: concept.referenceImages?.length + ? undefined + : { + model: 'gpt-image-2', + prompt: concept.prompt, + n: 1, + size: '1024x1024', + }, + form: concept.referenceImages?.length + ? { + model: 'gpt-image-2', + prompt: concept.prompt, + n: 1, + size: '1024x1024', + imageParts: concept.referenceImages.map((imagePath) => + path.relative(repoRoot, imagePath), + ), + } + : undefined, })), }, null, diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 2b3458e1..eb6d9164 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -620,6 +620,31 @@ mod tests { assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel"); } + #[tokio::test] + async fn disabled_rpg_route_returns_service_unavailable() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state.set_test_creation_entry_route_enabled("rpg", false); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/api/runtime/custom-world/agent/sessions") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = read_json_response(response).await; + assert_eq!( + body["error"]["details"]["reason"], + "creation_entry_disabled" + ); + assert_eq!(body["error"]["details"]["creationTypeId"], "rpg"); + } + #[tokio::test] async fn healthz_returns_standard_envelope_when_requested() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 0398c948..079f7b5a 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -260,8 +260,11 @@ impl Default for AppConfig { llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS, llm_max_retries: DEFAULT_MAX_RETRIES, llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, - rpg_llm_web_search_enabled: true, - creation_agent_llm_web_search_enabled: true, + // 中文注释:创作/RPG 的结构化 JSON 链路默认不启用 Responses web_search。 + // 未开通工具的上游会先吐自然语言再返回 ToolNotOpen,容易污染严格 JSON 结果; + // 需要联网增强时由部署环境显式打开对应开关。 + rpg_llm_web_search_enabled: false, + creation_agent_llm_web_search_enabled: false, dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), dashscope_api_key: None, dashscope_scene_image_model: String::new(), @@ -1467,6 +1470,14 @@ mod tests { } } + #[test] + fn default_keeps_structured_llm_web_search_disabled() { + let config = AppConfig::default(); + + assert!(!config.rpg_llm_web_search_enabled); + assert!(!config.creation_agent_llm_web_search_enabled); + } + #[test] fn from_env_reads_rpg_llm_web_search_switch() { let _guard = ENV_LOCK @@ -1476,11 +1487,11 @@ mod tests { unsafe { std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED"); - std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false"); + std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "true"); } let config = AppConfig::from_env(); - assert!(!config.rpg_llm_web_search_enabled); + assert!(config.rpg_llm_web_search_enabled); unsafe { std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED"); @@ -1496,11 +1507,11 @@ mod tests { unsafe { std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); - std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false"); + std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "true"); } let config = AppConfig::from_env(); - assert!(!config.creation_agent_llm_web_search_enabled); + assert!(config.creation_agent_llm_web_search_enabled); unsafe { std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 3a2cf825..86bbfa93 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -93,15 +93,74 @@ where F: FnMut(&str), { let mut latest_reply_text = String::new(); + let turn_output = match request_stream_creation_agent_json_turn_once( + llm_client, + system_prompt.clone(), + user_prompt.clone(), + enable_web_search, + on_reply_update, + &mut latest_reply_text, + !enable_web_search, + ) + .await + { + Ok(turn_output) => turn_output, + Err(CreationAgentJsonTurnFailure::Stream(error)) + if enable_web_search && is_web_search_tool_unavailable(&error) => + { + tracing::warn!( + error = %error, + "创作 Agent 流式联网搜索插件不可用,自动降级为无联网搜索重试" + ); + latest_reply_text.clear(); + request_stream_creation_agent_json_turn_once( + llm_client, + system_prompt, + user_prompt, + false, + on_reply_update, + &mut latest_reply_text, + true, + ) + .await? + } + Err(error) => return Err(error), + }; + + let reply_text = read_reply_text(&turn_output.parsed); + if let Some(reply_text) = reply_text.as_deref() + && reply_text != latest_reply_text + { + on_reply_update(reply_text); + } + + Ok(turn_output) +} + +async fn request_stream_creation_agent_json_turn_once( + llm_client: &LlmClient, + system_prompt: String, + user_prompt: String, + enable_web_search: bool, + on_reply_update: &mut F, + latest_reply_text: &mut String, + emit_reply_updates: bool, +) -> Result +where + F: FnMut(&str), +{ let response = llm_client .stream_text( build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search), |delta: &LlmStreamDelta| { + if !emit_reply_updates { + return; + } if let Some(reply_progress) = extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) - && reply_progress != latest_reply_text + && reply_progress != *latest_reply_text { - latest_reply_text = reply_progress.clone(); + *latest_reply_text = reply_progress.clone(); on_reply_update(reply_progress.as_str()); } }, @@ -110,12 +169,6 @@ where .map_err(CreationAgentJsonTurnFailure::Stream)?; let parsed = parse_json_response_text(response.content.as_str()) .map_err(|_| CreationAgentJsonTurnFailure::Parse)?; - let reply_text = read_reply_text(&parsed); - if let Some(reply_text) = reply_text.as_deref() - && reply_text != latest_reply_text - { - on_reply_update(reply_text); - } Ok(CreationAgentJsonTurnOutput { parsed }) } @@ -327,6 +380,7 @@ mod tests { let server = spawn_capturing_mock_server(vec![ MockResponse { body: concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"我需要先搜索玩具王国资料。\"}\n\n", "data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n", "data: [DONE]\n\n" ) @@ -391,6 +445,55 @@ mod tests { } } + #[tokio::test] + async fn stream_turn_keeps_partial_updates_when_web_search_is_disabled() { + let server = spawn_capturing_mock_server(vec![MockResponse { + body: concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"{\\\"replyText\\\":\\\"我先\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"把玩具王国定住。\\\",\\\"progressPercent\\\":12}\"}\n\n", + "data: {\"type\":\"response.completed\"}\n\n", + ) + .to_string(), + }]); + let config = LlmConfig::new( + LlmProvider::Ark, + server.base_url, + "test-key".to_string(), + "test-model".to_string(), + 30_000, + 0, + 1, + ) + .expect("LLM config should build"); + let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build"); + let mut visible_replies = Vec::new(); + + let output = stream_creation_agent_json_turn( + Some(&llm_client), + "系统提示".to_string(), + "用户提示", + false, + CreationAgentLlmTurnErrorMessages { + model_unavailable: "模型不可用", + generation_failed: "生成失败", + parse_failed: "解析失败", + }, + |text| visible_replies.push(text.to_string()), + |message| message, + ) + .await + .expect("stream without web search should succeed"); + + assert_eq!( + output.parsed["replyText"].as_str(), + Some("我先把玩具王国定住。") + ); + assert_eq!( + visible_replies, + vec!["我先".to_string(), "我先把玩具王国定住。".to_string()] + ); + } + struct MockResponse { body: String, } 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 024711a3..07568dd8 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -96,6 +96,14 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { if normalized.starts_with("/api/runtime/big-fish") { return Some("big-fish"); } + if normalized.starts_with("/api/runtime/custom-world") + || normalized.starts_with("/api/runtime/custom-world-library") + || normalized.starts_with("/api/runtime/custom-world-gallery") + || normalized.starts_with("/api/runtime/chat") + || normalized.starts_with("/api/story") + { + return Some("rpg"); + } if normalized.starts_with("/api/runtime/visual-novel") { return Some("visual-novel"); } @@ -165,6 +173,26 @@ mod tests { resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"), Some("visual-novel"), ); + 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-library/profile-1"), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/story/sessions/runtime"), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"), + Some("rpg"), + ); assert_eq!( resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"), Some("bark-battle"), diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index d5fcb403..8695904b 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -1626,39 +1626,20 @@ pub async fn execute_custom_world_agent_action( ) })? } else if action == "publish_world" { - let mut publish_payload = serde_json::to_value(&payload).map_err(|error| { + let publish_payload = serialize_publish_world_action_payload( + resolve_author_public_user_code(&state, &authenticated, &request_context)?, + resolve_author_display_name(&state, &authenticated), + ) + .map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", - "message": format!("action payload JSON 序列化失败:{error}"), + "message": error, })), ) })?; - if let Some(object) = publish_payload.as_object_mut() { - // 发布到广场时必须写入真实作者公开信息,避免 gallery 投影落成匿名兜底数据。 - object.insert( - "authorPublicUserCode".to_string(), - Value::String(resolve_author_public_user_code( - &state, - &authenticated, - &request_context, - )?), - ); - object.insert( - "authorDisplayName".to_string(), - Value::String(resolve_author_display_name(&state, &authenticated)), - ); - } - serde_json::to_string(&publish_payload).map_err(|error| { - custom_world_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "custom-world-agent", - "message": format!("action payload JSON 序列化失败:{error}"), - })), - ) - })? + publish_payload } else { serde_json::to_string(&payload).map_err(|error| { custom_world_error_response( @@ -1734,6 +1715,23 @@ fn serialize_sync_result_profile_action_payload( .map_err(|error| format!("action payload JSON 序列化失败:{error}")) } +fn serialize_publish_world_action_payload( + author_public_user_code: String, + author_display_name: String, +) -> Result { + // 中文注释:发布动作只提交动作名和作者公开信息。 + // 结果页当前 profile 必须先通过 sync_result_profile 写入 session; + // SpacetimeDB 发布时再从 session.draft_profile_json 读取草稿真相,避免前端 + // draftProfile / legacyResultProfile / profile 旧载荷覆盖刚保存的内容。 + let payload_value = json!({ + "action": "publish_world", + "authorPublicUserCode": author_public_user_code, + "authorDisplayName": author_display_name, + }); + serde_json::to_string(&payload_value) + .map_err(|error| format!("action payload JSON 序列化失败:{error}")) +} + fn canonicalize_custom_world_library_profile_payload( mut profile: Value, ) -> Result<(Value, CustomWorldProfileMetadata), String> { @@ -3414,6 +3412,36 @@ mod tests { ); } + #[test] + fn publish_world_payload_only_contains_action_and_author_identity() { + let payload_json = + serialize_publish_world_action_payload("TN-0001".to_string(), "潮汐作者".to_string()) + .expect("publish payload serializes"); + let payload_value: Value = + serde_json::from_str(&payload_json).expect("payload should be valid JSON"); + let object = payload_value + .as_object() + .expect("publish payload should be object"); + + assert_eq!(object.len(), 3); + assert_eq!( + object.get("action").and_then(Value::as_str), + Some("publish_world") + ); + assert_eq!( + object.get("authorPublicUserCode").and_then(Value::as_str), + Some("TN-0001") + ); + assert_eq!( + object.get("authorDisplayName").and_then(Value::as_str), + Some("潮汐作者") + ); + assert!(!object.contains_key("profile")); + assert!(!object.contains_key("draftProfile")); + assert!(!object.contains_key("legacyResultProfile")); + assert!(!object.contains_key("settingText")); + } + #[test] fn custom_world_library_profile_payload_is_canonicalized_on_server() { let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({ 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 12280439..649999dd 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -10,7 +10,9 @@ use axum::{ response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use image::{DynamicImage, GenericImageView, imageops::FilterType}; +use image::{ + DynamicImage, GenericImageView, ImageFormat, 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, @@ -375,6 +377,8 @@ const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile"; const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard"; const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video"; const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000; +const OPENING_CG_REFERENCE_MAX_EDGE: u32 = 768; +const OPENING_CG_REFERENCE_JPEG_QUALITY: u8 = 82; struct CoverPromptContext { opening_act_title: String, @@ -1025,6 +1029,16 @@ pub async fn generate_custom_world_opening_cg( "openingSceneImageSrc", ) .await?; + let player_role_reference = resize_image_reference_data_url( + player_role_reference, + OPENING_CG_REFERENCE_MAX_EDGE, + OPENING_CG_REFERENCE_JPEG_QUALITY, + )?; + let opening_scene_reference = resize_image_reference_data_url( + opening_scene_reference, + OPENING_CG_REFERENCE_MAX_EDGE, + OPENING_CG_REFERENCE_JPEG_QUALITY, + )?; let storyboard = generate_opening_cg_storyboard( &state, &owner_user_id, @@ -1617,6 +1631,52 @@ async fn resolve_reference_image_as_data_url( )) } +fn resize_image_reference_data_url( + data_url: String, + max_edge: u32, + jpeg_quality: u8, +) -> Result { + if max_edge == 0 { + return Ok(data_url); + } + let Some(parsed) = parse_image_data_url(data_url.as_str()) else { + return Ok(data_url); + }; + let image = image::load_from_memory(parsed.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": format!("无法解析参考图:{error}"), + })) + })?; + let (width, height) = image.dimensions(); + let already_within_budget = width <= max_edge && height <= max_edge; + if already_within_budget && parsed.mime_type == "image/jpeg" { + return Ok(data_url); + } + + // 中文注释:开局 CG 故事板会同时带角色和场景两张参考图;先压到较小 JPEG,避免大图 PNG Data URL 让 VectorEngine 网关在请求发送阶段中断。 + let resized = if already_within_budget { + image + } else { + image.resize(max_edge, max_edge, FilterType::Triangle) + }; + let encoded_image = DynamicImage::ImageRgb8(resized.to_rgb8()); + let mut encoded = Vec::new(); + JpegEncoder::new_with_quality(&mut encoded, jpeg_quality) + .encode_image(&encoded_image) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "custom-world-ai", + "message": format!("压缩参考图失败:{error}"), + })) + })?; + + Ok(format!( + "data:image/jpeg;base64,{}", + BASE64_STANDARD.encode(encoded) + )) +} + async fn create_text_to_image_generation( http_client: &reqwest::Client, settings: &DashScopeSettings, @@ -3065,6 +3125,34 @@ mod tests { assert_eq!(parsed.bytes, b"hello".to_vec()); } + #[test] + fn opening_cg_reference_data_url_is_resized_to_request_budget() { + let image = DynamicImage::ImageRgb8(image::RgbImage::new(2048, 1152)); + let mut cursor = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::Png) + .expect("test image should encode"); + let data_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(cursor.into_inner()) + ); + + let resized = resize_image_reference_data_url( + data_url, + OPENING_CG_REFERENCE_MAX_EDGE, + OPENING_CG_REFERENCE_JPEG_QUALITY, + ) + .expect("reference should resize"); + let parsed = parse_image_data_url(resized.as_str()).expect("resized data url should parse"); + let resized_image = + image::load_from_memory(parsed.bytes.as_slice()).expect("resized image should decode"); + let (width, height) = resized_image.dimensions(); + + assert!(width <= OPENING_CG_REFERENCE_MAX_EDGE); + assert!(height <= OPENING_CG_REFERENCE_MAX_EDGE); + assert_eq!(parsed.mime_type, "image/jpeg"); + } + #[test] fn push_cover_reference_source_keeps_full_data_url() { let mut sources = Vec::new(); 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 a5308897..18c1423b 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -197,6 +197,86 @@ pub(crate) fn slice_generated_asset_sheet( Ok(slices) } +pub(crate) fn slice_generated_asset_sheet_two_items_per_row( + image: &DownloadedOpenAiImage, + item_names: &[String], + grid_size: usize, + views_per_item: usize, +) -> Result>, AppError> { + if grid_size == 0 || views_per_item == 0 { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的 n 和每物品视图数必须大于 0。", + })), + ); + } + if !grid_size.is_multiple_of(views_per_item) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集每行必须能均分为若干物品。", + "gridSize": grid_size, + "viewsPerItem": views_per_item, + })), + ); + } + + let grid_size_u32 = u32::try_from(grid_size).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的 n 超出可支持范围。", + })) + })?; + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": format!("系列素材图集解码失败:{error}"), + })) + })?; + let source = apply_generated_asset_sheet_green_screen_alpha(source); + let (width, height) = source.dimensions(); + if width / grid_size_u32 == 0 || height / grid_size_u32 == 0 { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集尺寸过小,无法切割。", + })), + ); + } + + let items_per_row = grid_size / views_per_item; + let max_item_count = grid_size.saturating_mul(items_per_row); + let mut slices = Vec::with_capacity(item_names.len().min(max_item_count)); + for item_index in 0..item_names.len().min(max_item_count) { + let row = (item_index / items_per_row) as u32; + let start_col = ((item_index % items_per_row) * views_per_item) as u32; + let mut views = Vec::with_capacity(views_per_item); + for view_offset in 0..views_per_item { + let col = start_col + view_offset as u32; + let (crop_x, crop_y, crop_width, crop_height) = + resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col); + let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); + let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": format!("系列素材图集切割失败:{error}"), + })) + })?; + views.push(GeneratedAssetSheetSliceImage { + bytes: cursor.into_inner(), + }); + } + slices.push(views); + } + + Ok(slices) +} + pub(crate) fn crop_generated_asset_sheet_view_edge_matte( image: image::DynamicImage, ) -> image::DynamicImage { @@ -958,7 +1038,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color( )) } -fn apply_generated_asset_sheet_green_screen_alpha( +pub(crate) fn apply_generated_asset_sheet_green_screen_alpha( source: image::DynamicImage, ) -> image::DynamicImage { let mut image = source.to_rgba8(); diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 3a599422..62d73cf7 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -71,10 +71,12 @@ use crate::{ }, auth::AuthenticatedAccessToken, config::AppConfig, + generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, OpenAiGeneratedImages, OpenAiReferenceImage, - build_openai_image_http_client, create_openai_image_edit, create_openai_image_generation, + build_openai_image_http_client, create_openai_image_edit, + create_openai_image_edit_with_references, create_openai_image_generation, require_openai_image_settings, }, platform_errors::map_oss_error, @@ -95,10 +97,10 @@ const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4; const MATCH3D_DRAFT_GENERATION_POINTS_COST: u64 = 10; const MATCH3D_BACKGROUND_IMAGE_POINTS_COST: u64 = 2; const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH: u64 = 2; -const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 5; +const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 20; const MATCH3D_ITEM_VIEW_COUNT: usize = 5; -const MATCH3D_MATERIAL_GRID_SIZE: u32 = 5; -const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 25; +const MATCH3D_MATERIAL_GRID_SIZE: u32 = 10; +const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 20; const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview"; const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1"; const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000; @@ -118,7 +120,7 @@ const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作 const MATCH3D_CLICK_SOUND_ASSET_KIND: &str = "match3d_click_sound"; const MATCH3D_PIXEL_RETRO_STYLE_PROMPT: &str = "真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。"; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Match3DConfigJson { theme_text: String, @@ -170,15 +172,33 @@ struct Match3DGeneratedItemImageView { image_object_key: Option, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Match3DGeneratedBackgroundAsset { prompt: String, #[serde(default)] + level_scene_prompt: Option, + #[serde(default)] + level_scene_image_src: Option, + #[serde(default)] + level_scene_image_object_key: Option, + #[serde(default)] image_src: Option, #[serde(default)] image_object_key: Option, #[serde(default)] + ui_spritesheet_prompt: Option, + #[serde(default)] + ui_spritesheet_image_src: Option, + #[serde(default)] + ui_spritesheet_image_object_key: Option, + #[serde(default)] + item_spritesheet_prompt: Option, + #[serde(default)] + item_spritesheet_image_src: Option, + #[serde(default)] + item_spritesheet_image_object_key: Option, + #[serde(default)] container_prompt: Option, #[serde(default)] container_image_src: Option, @@ -445,8 +465,17 @@ impl From .background_asset .map(|asset| Match3DGeneratedBackgroundAsset { prompt: asset.prompt, + level_scene_prompt: asset.level_scene_prompt, + level_scene_image_src: asset.level_scene_image_src, + level_scene_image_object_key: asset.level_scene_image_object_key, image_src: asset.image_src, image_object_key: asset.image_object_key, + ui_spritesheet_prompt: asset.ui_spritesheet_prompt, + ui_spritesheet_image_src: asset.ui_spritesheet_image_src, + ui_spritesheet_image_object_key: asset.ui_spritesheet_image_object_key, + item_spritesheet_prompt: asset.item_spritesheet_prompt, + item_spritesheet_image_src: asset.item_spritesheet_image_src, + item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key, container_prompt: asset.container_prompt, container_image_src: asset.container_image_src, container_image_object_key: asset.container_image_object_key, diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index 1f73f265..79a0aa77 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -229,12 +229,27 @@ pub(super) async fn compile_match3d_draft_for_session( ) .await?; - let existing_assets = get_match3d_existing_generated_item_assets( + let mut existing_assets = get_match3d_existing_generated_item_assets( state, owner_user_id.as_str(), profile_id.as_str(), ) .await; + let generated_background_asset = resolve_or_generate_match3d_level_asset_bundle( + state, + request_context, + owner_user_id.as_str(), + session.session_id.as_str(), + profile_id.as_str(), + &config, + generated_work_metadata.background_prompt.as_str(), + &existing_assets, + ) + .await?; + attach_match3d_background_asset_to_assets( + &mut existing_assets, + generated_background_asset.clone(), + ); let generated_item_assets = generate_match3d_item_assets( state, request_context, @@ -245,18 +260,22 @@ pub(super) async fn compile_match3d_draft_for_session( &config, generated_work_metadata.items, existing_assets, + Some(generated_background_asset.clone()), ) .await?; - let generated_item_assets = ensure_match3d_background_asset( + let mut generated_item_assets = generated_item_assets; + attach_match3d_background_asset_to_assets( + &mut generated_item_assets, + generated_background_asset, + ); + persist_match3d_generated_item_assets_snapshot( state, request_context, authenticated, - owner_user_id.as_str(), session.session_id.as_str(), + owner_user_id.as_str(), profile_id.as_str(), - &config, - generated_work_metadata.background_prompt.as_str(), - generated_item_assets, + &generated_item_assets, ) .await?; let existing_cover_image_src = get_match3d_existing_cover_image_src( diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 51c270ed..1365faee 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -3,9 +3,8 @@ use super::*; use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte; use crate::generated_asset_sheets::{ GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, - GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, - build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes, - slice_generated_asset_sheet, + GeneratedAssetSheetSliceImage, persist_generated_asset_sheet_bytes, + slice_generated_asset_sheet_two_items_per_row, }; pub(super) async fn generate_match3d_item_assets( @@ -18,6 +17,7 @@ pub(super) async fn generate_match3d_item_assets( config: &Match3DConfigJson, item_plan: Vec, existing_assets: Vec, + generated_background_asset: Option, ) -> Result, Response> { // 中文注释:抓大鹅音频生成当前关闭;自动草稿只补齐 2D 物品图片和可选点击音效。 let target_item_count = resolve_match3d_generated_item_count(config); @@ -37,6 +37,7 @@ pub(super) async fn generate_match3d_item_assets( config, item_plan, assets, + generated_background_asset, ) .await?; } @@ -76,6 +77,7 @@ async fn ensure_match3d_item_image_assets( config: &Match3DConfigJson, item_plan: Vec, existing_assets: Vec, + generated_background_asset: Option, ) -> Result, Response> { let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); let target_item_count = resolve_match3d_generated_item_count(config); @@ -101,9 +103,11 @@ async fn ensure_match3d_item_image_assets( background_music_style: None, background_music_prompt: None, background_asset: if index == 0 { - assets - .first() - .and_then(|asset| asset.background_asset.clone()) + generated_background_asset.clone().or_else(|| { + assets + .first() + .and_then(|asset| asset.background_asset.clone()) + }) } else { None }, @@ -160,6 +164,8 @@ struct Match3DItemImageGenerationSeed { struct Match3DMaterialBatchOutput { task_id: String, prompt: String, + image_src: Option, + image_object_key: Option, generated_at_micros: i64, items: Vec<( Match3DItemImageGenerationSeed, @@ -194,12 +200,17 @@ async fn generate_match3d_item_image_assets_in_batches( .map(|chunk| { let chunk_seeds = chunk.to_vec(); async move { - let item_names = chunk_seeds - .iter() - .map(|item| item.item_name.clone()) - .collect::>(); - let material_sheet = - generate_match3d_material_sheet(state, config, &item_names).await?; + let material_sheet = generate_match3d_material_sheet_from_level_scene( + state, + owner_user_id, + session_id, + profile_id, + config, + chunk_seeds + .iter() + .find_map(|seed| seed.background_asset.as_ref()), + ) + .await?; let generated_at_micros = current_utc_micros(); let persisted_seed_count = chunk_seeds .iter() @@ -218,14 +229,17 @@ async fn generate_match3d_item_image_assets_in_batches( .iter() .map(|item| item.item_name.clone()) .collect::>(); - let item_images = slice_generated_asset_sheet( + let item_images = slice_generated_asset_sheet_two_items_per_row( &material_sheet.image, &persisted_item_names, MATCH3D_MATERIAL_GRID_SIZE as usize, + MATCH3D_ITEM_VIEW_COUNT, )?; Ok::<_, AppError>(Match3DMaterialBatchOutput { task_id: material_sheet.task_id, prompt: material_sheet.prompt, + image_src: material_sheet.image_src, + image_object_key: material_sheet.image_object_key, generated_at_micros, items: persisted_seeds .into_iter() @@ -248,14 +262,22 @@ async fn generate_match3d_item_image_assets_in_batches( for batch in batches { let sheet_task_id = batch.task_id; let sheet_prompt = batch.prompt; + let sheet_image_src = batch.image_src; + let sheet_image_object_key = batch.image_object_key; let generated_at_micros = batch.generated_at_micros; for (item_index, (seed, item_images)) in batch.items.into_iter().enumerate() { let item_slug = build_match3d_item_slug(seed.item_id.as_str(), seed.item_name.as_str()); let mut image_views = Vec::with_capacity(item_images.len()); for (view_index, item_image) in item_images.into_iter().enumerate() { let view_number = view_index + 1; - let item_name_prompt = - format!("第{}行:{} 的 5 个不同视角", item_index + 1, seed.item_name); + let (sheet_row_index, sheet_col_index) = + resolve_match3d_material_sheet_cell_indices(item_index, view_index); + let item_name_prompt = format!( + "第{}行第{}种:{} 的 5 个不同形态", + item_index / 2 + 1, + item_index % 2 + 1, + seed.item_name + ); let view_upload = persist_generated_asset_sheet_bytes( state, GeneratedAssetSheetPersistInput { @@ -277,8 +299,8 @@ async fn generate_match3d_item_image_assets_in_batches( (item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1, ), grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize, - row_index: item_index + 1, - view_index: view_number, + row_index: sheet_row_index, + view_index: sheet_col_index, prompt: GeneratedAssetSheetPersistPrompt { sheet_prompt: Some(sheet_prompt.clone()), item_name_prompt: Some(item_name_prompt), @@ -322,7 +344,12 @@ async fn generate_match3d_item_image_assets_in_batches( background_music_prompt: seed.background_music_prompt, background_music: None, click_sound: None, - background_asset: seed.background_asset, + background_asset: merge_match3d_item_spritesheet_asset_metadata( + seed.background_asset, + sheet_prompt.clone(), + sheet_image_src.clone(), + sheet_image_object_key.clone(), + ), status: "image_ready".to_string(), error: None, }, @@ -512,6 +539,7 @@ async fn append_match3d_new_item_assets( return Ok(assets); } let mut next_item_index = next_match3d_generated_item_index(&assets); + let background_asset = find_match3d_generated_background_asset(&assets); let item_seeds = append_plan .padded_item_names .into_iter() @@ -527,7 +555,11 @@ async fn append_match3d_new_item_assets( background_music_title: None, background_music_style: None, background_music_prompt: None, - background_asset: None, + background_asset: if index == 0 { + background_asset.clone() + } else { + None + }, } }) .collect::>(); @@ -697,6 +729,8 @@ async fn replace_match3d_item_assets( pub(super) struct Match3DMaterialSheet { pub(super) task_id: String, pub(super) prompt: String, + pub(super) image_src: Option, + pub(super) image_object_key: Option, pub(super) image: DownloadedOpenAiImage, } @@ -710,6 +744,118 @@ pub(super) struct Match3DVectorEngineGeminiImageSettings { pub(super) struct Match3DSlicedItemImage { pub(super) bytes: Vec, } + +async fn generate_match3d_material_sheet_from_level_scene( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + background_asset: Option<&Match3DGeneratedBackgroundAsset>, +) -> Result { + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let prompt = build_match3d_item_spritesheet_prompt(); + let reference = load_match3d_level_scene_reference_image(state, background_asset).await?; + let generated = create_openai_image_edit( + &http_client, + &settings, + prompt.as_str(), + Some(build_match3d_material_sheet_negative_prompt(config).as_str()), + "2k", + &reference, + "抓大鹅物品 spritesheet 生成失败", + ) + .await?; + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "抓大鹅物品 spritesheet 生成失败:未返回图片", + })) + })?; + let image = make_match3d_spritesheet_image_transparent(image)?; + let upload = persist_match3d_generated_bytes( + state, + owner_user_id, + session_id, + profile_id, + &["item-spritesheet", generated.task_id.as_str()], + "item-spritesheet.png", + image.mime_type.as_str(), + image.bytes.clone(), + "match3d_item_spritesheet_image", + Some(generated.task_id.as_str()), + current_utc_micros(), + ) + .await?; + Ok(Match3DMaterialSheet { + task_id: generated.task_id, + prompt, + image_src: Some(upload.src), + image_object_key: Some(upload.object_key), + image, + }) +} + +fn merge_match3d_item_spritesheet_asset_metadata( + background_asset: Option, + prompt: String, + image_src: Option, + image_object_key: Option, +) -> Option { + background_asset.map(|mut asset| { + asset.item_spritesheet_prompt = Some(prompt); + asset.item_spritesheet_image_src = image_src; + asset.item_spritesheet_image_object_key = image_object_key; + asset + }) +} + +async fn load_match3d_level_scene_reference_image( + state: &AppState, + background_asset: Option<&Match3DGeneratedBackgroundAsset>, +) -> Result { + let Some(source) = background_asset + .and_then(|asset| { + asset + .level_scene_image_object_key + .as_deref() + .or(asset.level_scene_image_src.as_deref()) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_AGENT_PROVIDER, + "message": "抓大鹅物品 spritesheet 生成缺少关卡画面参考图", + })), + ); + }; + let bytes = if source.starts_with("data:image/") { + decode_match3d_data_url_bytes(source)? + } else if source.trim_start_matches('/').starts_with("generated-") { + read_match3d_generated_object_bytes( + state, + source, + "读取抓大鹅关卡画面参考图失败", + MATCH3D_ITEM_IMAGE_MAX_BYTES, + ) + .await? + } else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_AGENT_PROVIDER, + "message": "抓大鹅关卡画面参考图必须是图片 Data URL 或 /generated-* 路径", + })), + ); + }; + Ok(OpenAiReferenceImage { + bytes, + mime_type: "image/png".to_string(), + file_name: "match3d-level-scene.png".to_string(), + }) +} pub(super) fn normalize_match3d_item_name(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) @@ -1115,20 +1261,20 @@ pub(super) fn resolve_match3d_gameplay_item_count(config: &Match3DConfigJson) -> 8 => 3, 12 => 9, 16 => 15, - 20 | 21 => 21, + 20 | 21 => 20, _ => match config.difficulty { 0..=2 => 3, 3..=4 => 9, 5..=6 => 15, - _ => 21, + _ => 20, }, } .min(MATCH3D_MAX_GENERATED_ITEM_COUNT) } pub(super) fn resolve_match3d_generated_item_count(config: &Match3DConfigJson) -> usize { - round_match3d_item_count_to_full_sheet(resolve_match3d_gameplay_item_count(config)) - .min(MATCH3D_MAX_GENERATED_ITEM_COUNT) + let _ = config; + MATCH3D_MAX_GENERATED_ITEM_COUNT } fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize { @@ -1138,6 +1284,16 @@ fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize { item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) * MATCH3D_MATERIAL_ITEM_BATCH_SIZE } +pub(super) fn resolve_match3d_material_sheet_cell_indices( + item_index: usize, + view_index: usize, +) -> (usize, usize) { + let items_per_row = (MATCH3D_MATERIAL_GRID_SIZE as usize / MATCH3D_ITEM_VIEW_COUNT).max(1); + let row_index = item_index / items_per_row + 1; + let col_index = (item_index % items_per_row) * MATCH3D_ITEM_VIEW_COUNT + view_index + 1; + (row_index, col_index) +} + pub(super) fn sort_match3d_generated_assets( mut assets: Vec, ) -> Vec { @@ -1295,11 +1451,23 @@ pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgrou .filter(|value| !value.is_empty()) .is_some()) && (asset - .container_image_object_key + .ui_spritesheet_image_object_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() + || asset + .ui_spritesheet_image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + || asset + .container_image_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() || asset .container_image_src .as_deref() @@ -1312,34 +1480,16 @@ pub(super) fn build_match3d_material_sheet_prompt( config: &Match3DConfigJson, item_names: &[String], ) -> String { - let asset_style_prompt = resolve_match3d_asset_style_prompt(config); - let style_clause = asset_style_prompt - .as_ref() - .map(|prompt| format!("整体画风遵循:{prompt}。")) - .unwrap_or_default(); - let subject_text = format!( - "{}题材的抓大鹅游戏2D物品素材。{style_clause}", - config.theme_text - ); - let special_prompt = match3d_material_sheet_special_prompt(); - build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { - subject_text: subject_text.as_str(), - item_names, - grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize, - item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视角"), - special_prompt: Some(special_prompt.as_str()), - }) - .unwrap_or_else(|_| { - format!( - "生成一张1:1图片。固定生成5行*5列网格素材图,画面是{}题材的抓大鹅游戏2D物品素材。{}", - config.theme_text, - match3d_material_sheet_special_prompt(), - ) - }) + let _ = (config, item_names); + build_match3d_item_spritesheet_prompt() +} + +pub(super) fn build_match3d_item_spritesheet_prompt() -> String { + "固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string() } fn match3d_material_sheet_special_prompt() -> String { - "同一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;".to_string() + "每一行包含两种物品,每种物品的五个不同形态。".to_string() } pub(super) fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String { @@ -1389,18 +1539,22 @@ pub(super) fn slice_match3d_material_sheet( image: &DownloadedOpenAiImage, item_names: &[String], ) -> Result>, AppError> { - slice_generated_asset_sheet(image, item_names, MATCH3D_MATERIAL_GRID_SIZE as usize).map( - |rows| { - rows.into_iter() - .map(|views| { - views - .into_iter() - .map(|view| Match3DSlicedItemImage { bytes: view.bytes }) - .collect() - }) - .collect() - }, + slice_generated_asset_sheet_two_items_per_row( + image, + item_names, + MATCH3D_MATERIAL_GRID_SIZE as usize, + MATCH3D_ITEM_VIEW_COUNT, ) + .map(|rows| { + rows.into_iter() + .map(|views| { + views + .into_iter() + .map(|view| Match3DSlicedItemImage { bytes: view.bytes }) + .collect() + }) + .collect() + }) } #[cfg(test)] diff --git a/server-rs/crates/api-server/src/match3d/mappers.rs b/server-rs/crates/api-server/src/match3d/mappers.rs index c3b0067e..3fe49eb6 100644 --- a/server-rs/crates/api-server/src/match3d/mappers.rs +++ b/server-rs/crates/api-server/src/match3d/mappers.rs @@ -282,13 +282,25 @@ pub(super) fn map_match3d_image_view_from_work( pub(super) fn map_match3d_background_asset_for_agent( asset: Match3DGeneratedBackgroundAsset, ) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse { + let ui_spritesheet_image_src = asset.ui_spritesheet_image_src.clone(); + let ui_spritesheet_image_object_key = asset.ui_spritesheet_image_object_key.clone(); shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse { prompt: asset.prompt, + level_scene_prompt: asset.level_scene_prompt, + level_scene_image_src: asset.level_scene_image_src, + level_scene_image_object_key: asset.level_scene_image_object_key, image_src: asset.image_src, image_object_key: asset.image_object_key, + ui_spritesheet_prompt: asset.ui_spritesheet_prompt, + ui_spritesheet_image_src: ui_spritesheet_image_src.clone(), + ui_spritesheet_image_object_key: ui_spritesheet_image_object_key.clone(), + item_spritesheet_prompt: asset.item_spritesheet_prompt, + item_spritesheet_image_src: asset.item_spritesheet_image_src, + item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key, container_prompt: asset.container_prompt, - container_image_src: asset.container_image_src, - container_image_object_key: asset.container_image_object_key, + container_image_src: ui_spritesheet_image_src.or(asset.container_image_src), + container_image_object_key: ui_spritesheet_image_object_key + .or(asset.container_image_object_key), status: asset.status, error: asset.error, } @@ -299,8 +311,17 @@ pub(super) fn map_match3d_background_asset_for_work( ) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse { shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse { prompt: asset.prompt, + level_scene_prompt: asset.level_scene_prompt, + level_scene_image_src: asset.level_scene_image_src, + level_scene_image_object_key: asset.level_scene_image_object_key, image_src: asset.image_src, image_object_key: asset.image_object_key, + ui_spritesheet_prompt: asset.ui_spritesheet_prompt, + ui_spritesheet_image_src: asset.ui_spritesheet_image_src, + ui_spritesheet_image_object_key: asset.ui_spritesheet_image_object_key, + item_spritesheet_prompt: asset.item_spritesheet_prompt, + item_spritesheet_image_src: asset.item_spritesheet_image_src, + item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key, container_prompt: asset.container_prompt, container_image_src: asset.container_image_src, container_image_object_key: asset.container_image_object_key, @@ -327,6 +348,14 @@ pub(super) fn resolve_match3d_default_cover_image_src( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) + .or_else(|| { + asset + .ui_spritesheet_image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) .or_else(|| { asset .container_image_object_key @@ -335,6 +364,14 @@ pub(super) fn resolve_match3d_default_cover_image_src( .filter(|value| !value.is_empty()) .map(str::to_string) }) + .or_else(|| { + asset + .ui_spritesheet_image_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) .or_else(|| { asset .image_src @@ -408,6 +445,10 @@ fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool { fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool { match3d_text_present(asset.image_src.as_ref()) || match3d_text_present(asset.image_object_key.as_ref()) + || match3d_text_present(asset.ui_spritesheet_image_src.as_ref()) + || match3d_text_present(asset.ui_spritesheet_image_object_key.as_ref()) + || match3d_text_present(asset.item_spritesheet_image_src.as_ref()) + || match3d_text_present(asset.item_spritesheet_image_object_key.as_ref()) || match3d_text_present(asset.container_image_src.as_ref()) || match3d_text_present(asset.container_image_object_key.as_ref()) } diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 74417201..40900f90 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -147,17 +147,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() { } #[test] -fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { - let width = 500; - let height = 500; +fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() { + let width = 1000; + let height = 1000; let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; let mut sheet = image::RgbaImage::new(width, height); - for row in 0..5 { - for col in 0..5 { + for row in 0..10 { + for col in 0..10 { let color = image::Rgba([ - 32 + row as u8 * 40, - 24 + col as u8 * 36, - 210 - row as u8 * 30, + 32 + row as u8 * 16, + 24 + col as u8 * 18, + 210 - row as u8 * 12, 255, ]); for y in row * 100..(row + 1) * 100 { @@ -180,9 +180,12 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); assert_eq!(slices.len(), 3); - for (row, views) in slices.iter().enumerate() { + for (item_index, views) in slices.iter().enumerate() { + let row = item_index / 2; + let start_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT; assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT); - for (col, view) in views.iter().enumerate() { + for (view_index, view) in views.iter().enumerate() { + let col = start_col + view_index; let decoded = image::load_from_memory(view.bytes.as_slice()) .expect("view should decode") .to_rgba8(); @@ -190,12 +193,12 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { assert_eq!( pixel.0, [ - 32 + row as u8 * 40, - 24 + col as u8 * 36, - 210 - row as u8 * 30, + 32 + row as u8 * 16, + 24 + col as u8 * 18, + 210 - row as u8 * 12, 255, ], - "row {row} col {col} should be cut from the fixed 5*5 grid row" + "item {item_index} view {view_index} should be cut from the fixed 10*10 grid" ); } } @@ -203,8 +206,8 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { #[test] fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() { - let width = 500; - let height = 500; + let width = 1000; + let height = 1000; let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); for y in 1..5 { @@ -616,6 +619,52 @@ fn match3d_background_image_postprocess_removes_transparent_pixels() { ); } +#[test] +fn match3d_level_scene_prompt_uses_requested_theme_and_full_ui_layout() { + let prompt = build_match3d_level_scene_generation_prompt(&config("重庆火锅", 12, 4)); + + assert!(prompt.contains("重庆火锅")); + assert!(prompt.contains("第1关 重庆火锅")); + assert!(prompt.contains("返回按钮位于顶部左上角")); + assert!(prompt.contains("设置按钮")); + assert!(prompt.contains("和主题匹配的容器")); + assert!(prompt.contains("移出")); + assert!(prompt.contains("凑齐")); + assert!(prompt.contains("打乱")); +} + +#[test] +fn match3d_derived_asset_prompts_match_three_sheet_pipeline() { + let config = config("水果", 12, 4); + let ui_prompt = build_match3d_ui_spritesheet_prompt(); + let background_prompt = build_match3d_background_from_scene_prompt(); + let item_prompt = build_match3d_material_sheet_prompt( + &config, + &["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()], + ); + + assert!(ui_prompt.contains("返回按钮")); + assert!(ui_prompt.contains("设置按钮")); + assert!(ui_prompt.contains("方格素材")); + assert!(ui_prompt.contains("纯绿色绿幕背景spritesheet")); + assert!(ui_prompt.contains("绿幕扣成透明")); + assert!(background_prompt.contains("移除画面中的所有UI组件")); + assert!(background_prompt.contains("完整保留容器和背景")); + assert!(item_prompt.contains("10行*10列")); + assert!(item_prompt.contains("纯绿色绿幕背景")); + assert!(item_prompt.contains("扣成透明")); + assert!(item_prompt.contains("每一行包含两种物品")); + assert!(item_prompt.contains("五个不同形态")); +} + +#[test] +fn match3d_hardcore_generated_item_count_is_capped_by_ten_by_ten_sheet() { + assert_eq!( + resolve_match3d_generated_item_count(&config("水果", 21, 8)), + 20 + ); +} + #[test] fn match3d_work_metadata_parses_gpt4o_json() { let metadata = parse_match3d_work_metadata( @@ -687,38 +736,69 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() { } #[test] -fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() { +fn match3d_draft_item_plan_rounds_up_to_full_ten_by_ten_sheet() { let plan = parse_match3d_draft_plan( r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#, &config("水果", 12, 4), ) .expect("draft plan should parse"); - assert_eq!(plan.items.len(), 10); + assert_eq!(plan.items.len(), 20); assert_eq!(plan.items[8].name, "蓝莓"); assert_ne!(plan.items[9].name, "蓝莓"); } #[test] -fn match3d_generated_item_count_rounds_up_to_five_multiples() { +fn match3d_generated_item_count_uses_full_ten_by_ten_sheet_capacity() { assert_eq!( resolve_match3d_generated_item_count(&config("水果", 8, 2)), - 5 + 20 ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 12, 4)), - 10 + 20 ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 16, 6)), - 15 + 20 ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 21, 8)), - 25 + 20 ); } +#[test] +fn match3d_gameplay_item_count_uses_difficulty_loading_limit() { + assert_eq!( + resolve_match3d_gameplay_item_count(&config("水果", 8, 2)), + 3 + ); + assert_eq!( + resolve_match3d_gameplay_item_count(&config("水果", 12, 4)), + 9 + ); + assert_eq!( + resolve_match3d_gameplay_item_count(&config("水果", 16, 6)), + 15 + ); + assert_eq!( + resolve_match3d_gameplay_item_count(&config("水果", 21, 8)), + 20 + ); +} + +#[test] +fn match3d_material_sheet_cell_indices_stay_inside_ten_by_ten_grid() { + let first = resolve_match3d_material_sheet_cell_indices(0, 0); + let second = resolve_match3d_material_sheet_cell_indices(1, 0); + let twentieth_last_view = resolve_match3d_material_sheet_cell_indices(19, 4); + + assert_eq!(first, (1, 1)); + assert_eq!(second, (1, 6)); + assert_eq!(twentieth_last_view, (10, 10)); +} + #[test] fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { let assets = vec![test_match3d_generated_item_asset(1, "草莓")]; @@ -731,12 +811,11 @@ fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { } #[test] -fn match3d_item_asset_points_cost_counts_five_item_batches() { +fn match3d_item_asset_points_cost_counts_ten_by_ten_sheet_batches() { assert_eq!(calculate_match3d_item_assets_points_cost(0), 0); assert_eq!(calculate_match3d_item_assets_points_cost(1), 2); - assert_eq!(calculate_match3d_item_assets_points_cost(5), 2); - assert_eq!(calculate_match3d_item_assets_points_cost(6), 4); - assert_eq!(calculate_match3d_item_assets_points_cost(10), 4); + assert_eq!(calculate_match3d_item_assets_points_cost(20), 2); + assert_eq!(calculate_match3d_item_assets_points_cost(21), 4); } #[test] @@ -775,7 +854,7 @@ fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() { ); assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]); - assert_eq!(plan.padded_item_names.len(), 5); + assert_eq!(plan.padded_item_names.len(), 20); assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]); assert_eq!( calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()), @@ -872,6 +951,7 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { container_image_object_key: None, status: "image_ready".to_string(), error: None, + ..Default::default() }); let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓"); generated_asset.image_src = @@ -897,20 +977,19 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { } #[test] -fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() { +fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_transparent_layout() { let prompt = build_match3d_material_sheet_prompt( &config("水果", 12, 4), &["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()], ); - assert!(prompt.contains("5行*5列")); - assert!(prompt.contains("严格5*5均匀排布")); - assert!(prompt.contains("绿幕背景")); + assert!(prompt.contains("10行*10列spritesheet图")); + assert!(prompt.contains("纯绿色绿幕背景")); assert!(prompt.contains("#00FF00")); - assert!(prompt.contains("单个素材格宽度的1/4空白间距")); - assert!(prompt.contains("约25%单格宽度")); - assert!(prompt.contains("禁止主体跨格")); - assert!(prompt.contains("贴边或越界")); + assert!(prompt.contains("素材间距严格均匀分布")); + assert!(prompt.contains("每一行包含两种物品")); + assert!(prompt.contains("每种物品的五个不同形态")); + assert!(prompt.contains("严禁出现两种高相似度的物品")); } #[test] @@ -921,16 +1000,53 @@ fn match3d_material_sheet_prompt_hardens_pixel_retro_style() { let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]); let negative_prompt = build_match3d_material_sheet_negative_prompt(&config); - assert!(prompt.contains("64x64")); - assert!(prompt.contains("整数倍放大")); - assert!(prompt.contains("禁止抗锯齿")); - assert!(prompt.contains("真实 3D 渲染")); - assert!(prompt.contains("PBR 材质")); + assert!(prompt.contains("10行*10列spritesheet图")); + assert!(prompt.contains("纯绿色绿幕背景")); assert!(negative_prompt.contains("抗锯齿")); assert!(negative_prompt.contains("平滑插画")); assert!(negative_prompt.contains("真实 3D 渲染")); } +#[test] +fn match3d_spritesheet_green_screen_postprocess_turns_background_transparent() { + let width = 100; + let height = 100; + let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 32..68 { + for x in 32..68 { + image.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("spritesheet should encode"); + + let processed = make_match3d_spritesheet_image_transparent(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) + .expect("spritesheet should postprocess"); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed spritesheet should decode") + .to_rgba8(); + + assert_eq!(processed.mime_type, "image/png"); + assert_eq!(processed.extension, "png"); + assert_eq!( + decoded.get_pixel(0, 0).0[3], + 0, + "绿幕背景必须在上传 OSS 前扣成透明 alpha" + ); + assert_eq!( + decoded.get_pixel(width / 2, height / 2).0, + [220, 32, 48, 255], + "物品主体不能被绿幕去背误删" + ); +} + #[test] fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() { let body = build_match3d_vector_engine_gemini_image_request_body( @@ -1060,6 +1176,7 @@ fn match3d_background_asset_requires_background_and_container_images() { container_image_object_key: None, status: "image_ready".to_string(), error: None, + ..Default::default() }; let with_container = Match3DGeneratedBackgroundAsset { container_prompt: Some("果园容器".to_string()), @@ -1106,6 +1223,7 @@ fn match3d_default_cover_prefers_generated_container_ui_image() { container_image_object_key: None, status: "image_ready".to_string(), error: None, + ..Default::default() }), status: "image_ready".to_string(), error: None, @@ -1181,8 +1299,8 @@ fn match3d_cover_reference_prompt_marks_reference_images() { } #[test] -fn match3d_cover_edit_prompt_preserves_uploaded_image() { - let prompt = build_match3d_cover_edit_prompt("水果封面"); +fn match3d_cover_reference_generation_prompt_preserves_uploaded_image() { + let prompt = build_match3d_cover_uploaded_reference_prompt("水果封面"); assert!(prompt.contains("上传的封面图作为第一优先级")); assert!(prompt.contains("保留主图的主体、构图、视角和主要配色")); @@ -1225,6 +1343,7 @@ fn match3d_fallback_work_profile_keeps_generated_background_asset() { ), status: "image_ready".to_string(), error: None, + ..Default::default() }), status: "image_ready".to_string(), error: None, @@ -1362,6 +1481,7 @@ fn match3d_agent_session_response_hydrates_persisted_ui_assets() { ), status: "image_ready".to_string(), error: None, + ..Default::default() }), status: "image_ready".to_string(), error: None, @@ -1437,6 +1557,7 @@ fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydr ), status: "image_ready".to_string(), error: None, + ..Default::default() }), status: "image_ready".to_string(), error: None, @@ -1820,6 +1941,7 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() { ), status: "image_ready".to_string(), error: None, + ..Default::default() }), ..test_match3d_generated_item_asset(1, "草莓") }]; diff --git a/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs b/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs index 9605c84f..c3a078a6 100644 --- a/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs +++ b/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs @@ -27,6 +27,8 @@ pub(super) async fn generate_match3d_material_sheet( Ok(Match3DMaterialSheet { task_id: generated.task_id, prompt, + image_src: None, + image_object_key: None, image, }) } diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index 7359dbe6..7e04cef0 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -212,7 +212,7 @@ pub(super) async fn ensure_match3d_background_asset( } } - let generated_background = generate_match3d_background_image( + let generated_background = generate_match3d_level_asset_bundle( state, owner_user_id, session_id, @@ -236,6 +236,40 @@ pub(super) async fn ensure_match3d_background_asset( Ok(assets) } +pub(super) async fn resolve_or_generate_match3d_level_asset_bundle( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + background_prompt: &str, + assets: &[Match3DGeneratedItemAsset], +) -> Result { + if let Some(existing_background) = find_match3d_generated_background_asset(assets) { + if is_match3d_background_asset_ready(&existing_background) { + return Ok(existing_background); + } + } + + let normalized_prompt = normalize_match3d_background_prompt(background_prompt); + let resolved_prompt = if normalized_prompt.is_empty() { + build_fallback_match3d_background_prompt(config) + } else { + normalized_prompt + }; + generate_match3d_level_asset_bundle( + state, + owner_user_id, + session_id, + profile_id, + config, + resolved_prompt.as_str(), + ) + .await + .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error)) +} + pub(super) fn attach_match3d_background_asset_to_assets( assets: &mut Vec, background_asset: Match3DGeneratedBackgroundAsset, @@ -281,7 +315,7 @@ pub(super) async fn generate_match3d_cover_image_asset( create_openai_image_edit( &http_client, &settings, - build_match3d_cover_edit_prompt(cover_prompt.as_str()).as_str(), + build_match3d_cover_uploaded_reference_prompt(cover_prompt.as_str()).as_str(), Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), "1:1", &uploaded_image, @@ -289,27 +323,38 @@ pub(super) async fn generate_match3d_cover_image_asset( ) .await? } else { - let reference_images = resolve_match3d_cover_reference_image_data_urls( + let reference_images = resolve_match3d_cover_reference_images_for_edit( state, reference_image_srcs, MATCH3D_ITEM_IMAGE_MAX_BYTES, ) .await?; - create_openai_image_generation( - &http_client, - &settings, - build_match3d_cover_reference_generation_prompt( + if reference_images.is_empty() { + create_openai_image_generation( + &http_client, + &settings, cover_prompt.as_str(), - !reference_images.is_empty(), + Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), + "1:1", + 1, + &[], + "抓大鹅封面图生成失败", ) - .as_str(), - Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), - "1:1", - 1, - reference_images.as_slice(), - "抓大鹅封面图生成失败", - ) - .await? + .await? + } else { + create_openai_image_edit_with_references( + &http_client, + &settings, + build_match3d_cover_reference_generation_prompt(cover_prompt.as_str(), true) + .as_str(), + Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), + "1:1", + 1, + reference_images.as_slice(), + "抓大鹅封面图生成失败", + ) + .await? + } }; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ @@ -347,7 +392,7 @@ fn build_match3d_cover_generation_prompt(config: &Match3DConfigJson, prompt: &st ) } -pub(super) fn build_match3d_cover_edit_prompt(prompt: &str) -> String { +pub(super) fn build_match3d_cover_uploaded_reference_prompt(prompt: &str) -> String { format!( concat!( "请以随请求上传的封面图作为第一优先级重绘依据,保留主图的主体、构图、视角和主要配色;", @@ -382,24 +427,113 @@ pub(super) async fn generate_match3d_background_image( profile_id: &str, config: &Match3DConfigJson, prompt: &str, +) -> Result { + generate_match3d_level_asset_bundle( + state, + owner_user_id, + session_id, + profile_id, + config, + prompt, + ) + .await +} + +pub(super) async fn generate_match3d_level_asset_bundle( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + prompt: &str, ) -> Result { require_match3d_oss_client(state)?; let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; - let reference_image = load_match3d_container_reference_image()?; - let generated_background = create_openai_image_generation( + + let level_scene_prompt = build_match3d_level_scene_generation_prompt(config); + let generated_scene = create_openai_image_generation( &http_client, &settings, - build_match3d_background_generation_prompt(config, prompt).as_str(), - Some( - "文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单、透明区域、透明 alpha、镂空、棋盘格透明底", - ), + level_scene_prompt.as_str(), + Some("水印、教程浮层、菜单、广告、真实手机外框、浏览器 UI"), "9:16", 1, &[], - "抓大鹅背景图生成失败", + "抓大鹅关卡画面生成失败", ) .await?; + let level_scene_image = generated_scene.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "抓大鹅关卡画面生成失败:未返回图片", + })) + })?; + let level_scene_reference = OpenAiReferenceImage { + bytes: level_scene_image.bytes.clone(), + mime_type: level_scene_image.mime_type.clone(), + file_name: "match3d-level-scene.png".to_string(), + }; + let level_scene_upload = persist_match3d_generated_bytes( + state, + owner_user_id, + session_id, + profile_id, + &["level-scene", generated_scene.task_id.as_str()], + "scene.png", + level_scene_image.mime_type.as_str(), + level_scene_image.bytes, + "match3d_level_scene_image", + Some(generated_scene.task_id.as_str()), + current_utc_micros(), + ) + .await?; + + let ui_prompt = build_match3d_ui_spritesheet_prompt(); + let background_extract_prompt = build_match3d_background_from_scene_prompt(); + let generated_ui_future = create_openai_image_edit( + &http_client, + &settings, + ui_prompt.as_str(), + Some("整页背景、中心物品、容器内物品、重复按钮、文字说明、白底、纯色底、网格线"), + "1:1", + &level_scene_reference, + "抓大鹅 UI spritesheet 生成失败", + ); + let generated_background_future = create_openai_image_edit( + &http_client, + &settings, + background_extract_prompt.as_str(), + Some("返回按钮、设置按钮、倒计时、标题文字、道具按钮、物品、容器内含物、菜单、教程浮层"), + "9:16", + &level_scene_reference, + "抓大鹅背景图生成失败", + ); + let (generated_ui, generated_background) = + tokio::try_join!(generated_ui_future, generated_background_future)?; + + let ui_image = generated_ui.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "抓大鹅 UI spritesheet 生成失败:未返回图片", + })) + })?; + let ui_image = make_match3d_spritesheet_image_transparent(ui_image)?; + let ui_upload = persist_match3d_generated_bytes( + state, + owner_user_id, + session_id, + profile_id, + &["ui-spritesheet", generated_ui.task_id.as_str()], + "ui-spritesheet.png", + ui_image.mime_type.as_str(), + ui_image.bytes, + "match3d_ui_spritesheet_image", + Some(generated_ui.task_id.as_str()), + current_utc_micros(), + ) + .await?; + let background_image = generated_background .images .into_iter() @@ -426,50 +560,22 @@ pub(super) async fn generate_match3d_background_image( ) .await?; - let container_prompt = build_match3d_container_generation_prompt(config, prompt); - let generated_container = create_openai_image_edit( - &http_client, - &settings, - container_prompt.as_str(), - Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"), - "1:1", - &reference_image, - "抓大鹅容器 UI 图生成失败", - ) - .await?; - let container_image = generated_container - .images - .into_iter() - .next() - .ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "抓大鹅容器 UI 图生成失败:未返回图片", - })) - })?; - let container_image = make_match3d_container_image_transparent(container_image)?; - let container_upload = persist_match3d_generated_bytes( - state, - owner_user_id, - session_id, - profile_id, - &["ui-container", generated_container.task_id.as_str()], - "container.png", - container_image.mime_type.as_str(), - container_image.bytes, - "match3d_ui_container_image", - Some(generated_container.task_id.as_str()), - current_utc_micros(), - ) - .await?; - Ok(Match3DGeneratedBackgroundAsset { prompt: prompt.to_string(), + level_scene_prompt: Some(level_scene_prompt), + level_scene_image_src: Some(level_scene_upload.src), + level_scene_image_object_key: Some(level_scene_upload.object_key), image_src: Some(background_upload.src), image_object_key: Some(background_upload.object_key), - container_prompt: Some(container_prompt), - container_image_src: Some(container_upload.src), - container_image_object_key: Some(container_upload.object_key), + ui_spritesheet_prompt: Some(ui_prompt.clone()), + ui_spritesheet_image_src: Some(ui_upload.src.clone()), + ui_spritesheet_image_object_key: Some(ui_upload.object_key.clone()), + item_spritesheet_prompt: None, + item_spritesheet_image_src: None, + item_spritesheet_image_object_key: None, + container_prompt: Some(ui_prompt), + container_image_src: Some(ui_upload.src), + container_image_object_key: Some(ui_upload.object_key), status: "image_ready".to_string(), error: None, }) @@ -533,6 +639,7 @@ pub(super) async fn generate_match3d_container_image( container_image_object_key: Some(container_upload.object_key), status: "image_ready".to_string(), error: None, + ..Default::default() }) } @@ -549,12 +656,39 @@ pub(super) fn merge_match3d_container_image_into_background_asset( .unwrap_or_else(|| container_asset.prompt.clone()); Match3DGeneratedBackgroundAsset { prompt, + level_scene_prompt: existing_background + .as_ref() + .and_then(|asset| asset.level_scene_prompt.clone()), + level_scene_image_src: existing_background + .as_ref() + .and_then(|asset| asset.level_scene_image_src.clone()), + level_scene_image_object_key: existing_background + .as_ref() + .and_then(|asset| asset.level_scene_image_object_key.clone()), image_src: existing_background .as_ref() .and_then(|asset| asset.image_src.clone()), image_object_key: existing_background .as_ref() .and_then(|asset| asset.image_object_key.clone()), + ui_spritesheet_prompt: existing_background + .as_ref() + .and_then(|asset| asset.ui_spritesheet_prompt.clone()), + ui_spritesheet_image_src: existing_background + .as_ref() + .and_then(|asset| asset.ui_spritesheet_image_src.clone()), + ui_spritesheet_image_object_key: existing_background + .as_ref() + .and_then(|asset| asset.ui_spritesheet_image_object_key.clone()), + item_spritesheet_prompt: existing_background + .as_ref() + .and_then(|asset| asset.item_spritesheet_prompt.clone()), + item_spritesheet_image_src: existing_background + .as_ref() + .and_then(|asset| asset.item_spritesheet_image_src.clone()), + item_spritesheet_image_object_key: existing_background + .as_ref() + .and_then(|asset| asset.item_spritesheet_image_object_key.clone()), container_prompt: container_asset.container_prompt, container_image_src: container_asset.container_image_src, container_image_object_key: container_asset.container_image_object_key, @@ -582,6 +716,44 @@ pub(super) fn load_match3d_container_reference_image() -> Result String { + let theme = config.theme_text.trim(); + let theme = if theme.is_empty() { + MATCH3D_DEFAULT_THEME + } else { + theme + }; + let style_clause = resolve_match3d_asset_style_prompt(config) + .map(|style| format!("\n整体美术风格要求:{style}")) + .unwrap_or_default(); + + format!( + concat!( + "生成抓大鹅游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n", + "抓大鹅主题描述:\n", + "{theme}{style_clause}\n\n", + "画面元素:\n", + "返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮\n", + "画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘\n", + "底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”" + ), + theme = theme, + style_clause = style_clause, + ) +} + +pub(super) fn build_match3d_ui_spritesheet_prompt() -> String { + "提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。".to_string() +} + +pub(super) fn build_match3d_background_from_scene_prompt() -> String { + "移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容".to_string() +} + +pub(super) fn build_match3d_item_spritesheet_prompt() -> String { + "固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string() +} + pub(super) fn build_match3d_background_generation_prompt( config: &Match3DConfigJson, prompt: &str, @@ -761,6 +933,32 @@ pub(super) fn make_match3d_container_image_transparent( extension: "png".to_string(), }) } + +pub(super) fn make_match3d_spritesheet_image_transparent( + image: DownloadedOpenAiImage, +) -> Result { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅 spritesheet 图解码失败:{error}"), + })) + })?; + let mut encoded = std::io::Cursor::new(Vec::new()); + apply_generated_asset_sheet_green_screen_alpha(source) + .write_to(&mut encoded, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅 spritesheet 图透明化失败:{error}"), + })) + })?; + + Ok(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} pub(super) async fn download_match3d_legacy_model( file: &hyper3d_contract::Hyper3dDownloadFilePayload, ) -> Result { @@ -864,7 +1062,7 @@ pub(super) fn is_match3d_glb_binary_payload(bytes: &[u8]) -> bool { magic == 0x4654_6c67 && version == 2 && declared_length == bytes.len() } -async fn read_match3d_generated_object_bytes( +pub(super) async fn read_match3d_generated_object_bytes( state: &AppState, object_key: &str, message_prefix: &str, @@ -915,57 +1113,6 @@ async fn read_match3d_generated_object_bytes( Ok(bytes.to_vec()) } -async fn resolve_match3d_reference_image_data_url( - state: &AppState, - source: Option<&str>, - max_size_bytes: usize, -) -> Result, AppError> { - let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - if source.starts_with("data:image/") { - return Ok(Some(source.to_string())); - } - if let Some(public_path) = normalize_match3d_public_reference_image_path(source) { - let bytes = tokio::fs::read(public_path.as_str()) - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "message": format!("读取抓大鹅本地参考图失败:{error}"), - "path": public_path, - })) - })?; - if bytes.is_empty() || bytes.len() > max_size_bytes { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "field": "referenceImageSrcs", - "message": "封面参考图过大,请压缩后重试。", - "maxBytes": max_size_bytes, - "actualBytes": bytes.len(), - })), - ); - } - return Ok(Some(format!( - "data:{};base64,{}", - infer_match3d_image_mime_type(bytes.as_slice()), - BASE64_STANDARD.encode(bytes) - ))); - } - if !source.trim_start_matches('/').starts_with("generated-") { - return Ok(Some(source.to_string())); - } - let bytes = - read_match3d_generated_object_bytes(state, source, "读取抓大鹅参考图失败", max_size_bytes) - .await?; - Ok(Some(format!( - "data:{};base64,{}", - infer_match3d_image_mime_type(bytes.as_slice()), - BASE64_STANDARD.encode(bytes) - ))) -} - pub(super) fn normalize_match3d_public_reference_image_path(source: &str) -> Option { let source = source .trim() @@ -1018,18 +1165,22 @@ pub(super) fn collect_match3d_cover_reference_image_sources( sources } -async fn resolve_match3d_cover_reference_image_data_urls( +async fn resolve_match3d_cover_reference_images_for_edit( state: &AppState, sources: Vec, max_size_bytes: usize, -) -> Result, AppError> { +) -> Result, AppError> { let mut resolved = Vec::new(); - for source in sources { - if let Some(data_url) = - resolve_match3d_reference_image_data_url(state, Some(source.as_str()), max_size_bytes) - .await? + for (index, source) in sources.into_iter().enumerate() { + if let Some(image) = resolve_match3d_reference_image_for_edit( + state, + Some(source.as_str()), + max_size_bytes, + format!("match3d-cover-reference-{index}").as_str(), + ) + .await? { - resolved.push(data_url); + resolved.push(image); } } Ok(resolved) @@ -1046,6 +1197,16 @@ async fn resolve_match3d_reference_image_for_edit( }; let bytes = if source.starts_with("data:image/") { decode_match3d_data_url_bytes(source)? + } else if let Some(public_path) = normalize_match3d_public_reference_image_path(source) { + tokio::fs::read(public_path.as_str()) + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "message": format!("读取抓大鹅本地参考图失败:{error}"), + "path": public_path, + })) + })? } else if source.trim_start_matches('/').starts_with("generated-") { read_match3d_generated_object_bytes( state, @@ -1059,7 +1220,7 @@ async fn resolve_match3d_reference_image_for_edit( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "field": "uploadedImageSrc", - "message": "封面上传图必须是图片 Data URL 或 /generated-* 路径。", + "message": "封面参考图必须是图片 Data URL、本地 public 参考图或 /generated-* 路径。", })), ); }; @@ -1086,7 +1247,7 @@ async fn resolve_match3d_reference_image_for_edit( })) } -fn decode_match3d_data_url_bytes(source: &str) -> Result, AppError> { +pub(super) fn decode_match3d_data_url_bytes(source: &str) -> Result, AppError> { let Some((header, data)) = source.split_once(',') else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 6689365a..f9422db4 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -15,7 +15,7 @@ use crate::{ }; pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; -pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = "gpt-image-2-all"; +pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; #[derive(Clone)] @@ -62,7 +62,7 @@ pub(crate) struct OpenAiReferenceImage { pub file_name: String, } -// 中文注释:RPG、方洞等图片资产统一走 VectorEngine GPT-image-2-all,避免把密钥或供应商协议暴露到前端。 +// 中文注释:RPG、方洞等图片资产统一走后端 VectorEngine GPT-image-2,避免把密钥或供应商协议暴露到前端。 pub(crate) fn require_openai_image_settings( state: &AppState, ) -> Result { @@ -106,7 +106,7 @@ pub(crate) fn build_openai_image_http_client( ) -> Result { reqwest::Client::builder() .timeout(Duration::from_millis(settings.request_timeout_ms)) - // 中文注释:同一客户端也会承载 `/v1/images/edits` multipart 图生图请求,强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的兼容问题。 + // 中文注释:参考图会走 multipart edits;强制 HTTP/1.1 可避开部分网关对长耗时上传流的兼容问题。 .http1_only() .build() .map_err(|error| { @@ -127,6 +127,22 @@ pub(crate) async fn create_openai_image_generation( reference_images: &[String], failure_context: &str, ) -> Result { + if !reference_images.is_empty() { + let resolved_references = + resolve_openai_reference_images(http_client, reference_images, failure_context).await?; + return create_openai_image_edit_with_references( + http_client, + settings, + prompt, + negative_prompt, + size, + candidate_count, + resolved_references.as_slice(), + failure_context, + ) + .await; + } + let request_url = vector_engine_images_generation_url(settings); let normalized_size = normalize_image_size(size); let request_body = build_openai_image_request_body( @@ -386,6 +402,7 @@ pub(crate) async fn create_openai_image_edit( prompt, negative_prompt, size, + 1, std::slice::from_ref(reference_image), failure_context, ) @@ -398,6 +415,7 @@ pub(crate) async fn create_openai_image_edit_with_references( prompt: &str, negative_prompt: Option<&str>, size: &str, + candidate_count: u32, reference_images: &[OpenAiReferenceImage], failure_context: &str, ) -> Result { @@ -405,12 +423,11 @@ pub(crate) async fn create_openai_image_edit_with_references( return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:缺少参考图"), + "message": format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), })), ); } - let task_id = format!("vector-engine-edit-{}", current_utc_micros()); let request_url = vector_engine_images_edit_url(settings); let normalized_size = normalize_image_size(size); @@ -420,9 +437,10 @@ pub(crate) async fn create_openai_image_edit_with_references( "prompt", build_prompt_with_negative(prompt, negative_prompt), ) - .text("n", "1") + .text("n", candidate_count.clamp(1, 4).to_string()) .text("size", normalized_size.clone()); - for reference_image in reference_images { + + for reference_image in reference_images.iter().take(5) { let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) .file_name(reference_image.file_name.clone()) .mime_str(reference_image.mime_type.as_str()) @@ -434,8 +452,8 @@ pub(crate) async fn create_openai_image_edit_with_references( form = form.part("image", image_part); } + let reference_image_count = reference_images.iter().take(5).count(); let started_at = std::time::Instant::now(); - let reference_image_count = reference_images.len(); let response = match http_client .post(request_url.as_str()) .header( @@ -578,43 +596,51 @@ pub(crate) async fn create_openai_image_edit_with_references( return Err(error); } }; + let task_id = extract_generation_id(&response_json.payload) + .unwrap_or_else(|| format!("vector-engine-edit-{}", current_utc_micros())); let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); let image_urls = extract_image_urls(&response_json.payload); if !image_urls.is_empty() { let download_started_at = std::time::Instant::now(); - let mut generated = - match download_images_from_urls(http_client, task_id, image_urls, 1).await { - Ok(generated) => generated, - Err(error) => { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "image_download", - Some(response_status.as_u16()), - Some(app_error_status_class(error.status_code())), - false, - false, - error.body_text().as_str(), - None, - None, - Some(download_started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(error); - } - }; + let mut generated = match download_images_from_urls( + http_client, + task_id, + image_urls, + candidate_count, + ) + .await + { + Ok(generated) => generated, + Err(error) => { + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "image_download", + Some(response_status.as_u16()), + Some(app_error_status_class(error.status_code())), + false, + false, + error.body_text().as_str(), + None, + None, + Some(download_started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(reference_image_count), + ), + ) + .await; + return Err(error); + } + }; generated.actual_prompt = actual_prompt; return Ok(generated); } let b64_images = extract_b64_images(&response_json.payload); if !b64_images.is_empty() { - let mut generated = images_from_base64(task_id, b64_images, 1); + let mut generated = images_from_base64(task_id, b64_images, candidate_count); generated.actual_prompt = actual_prompt; return Ok(generated); } @@ -641,7 +667,7 @@ pub(crate) async fn create_openai_image_edit_with_references( Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:VectorEngine 未返回编辑图片"), + "message": format!("{failure_context}:VectorEngine 未返回图片"), })), ) } @@ -651,12 +677,12 @@ pub(crate) fn build_openai_image_request_body( negative_prompt: Option<&str>, size: &str, candidate_count: u32, - reference_images: &[String], + _reference_images: &[String], ) -> Value { - let mut body = Map::from_iter([ + let body = Map::from_iter([ ( "model".to_string(), - Value::String(VECTOR_ENGINE_GPT_IMAGE_2_MODEL.to_string()), + Value::String(GPT_IMAGE_2_MODEL.to_string()), ), ( "prompt".to_string(), @@ -669,10 +695,6 @@ pub(crate) fn build_openai_image_request_body( ), ]); - if !reference_images.is_empty() { - body.insert("image".to_string(), json!(reference_images)); - } - Value::Object(body) } @@ -784,6 +806,100 @@ pub(crate) async fn download_remote_image( }) } +async fn resolve_openai_reference_images( + http_client: &reqwest::Client, + reference_images: &[String], + failure_context: &str, +) -> Result, AppError> { + let mut resolved = Vec::new(); + for (index, source) in reference_images.iter().take(5).enumerate() { + let source = source.trim(); + if source.is_empty() { + continue; + } + if let Some(reference_image) = parse_openai_reference_image_data_url(source, index)? { + resolved.push(reference_image); + continue; + } + if source.starts_with("http://") || source.starts_with("https://") { + let downloaded = download_remote_image(http_client, source) + .await + .map_err(|error| { + map_openai_image_request_error(format!( + "{failure_context}:下载参考图失败:{}", + error.body_text() + )) + })?; + resolved.push(OpenAiReferenceImage { + bytes: downloaded.bytes, + mime_type: downloaded.mime_type.clone(), + file_name: format!( + "reference-{index}.{}", + mime_to_extension(downloaded.mime_type.as_str()) + ), + }); + continue; + } + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), + })), + ); + } + + if resolved.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("{failure_context}:图片编辑需要至少一张参考图。"), + })), + ); + } + + Ok(resolved) +} + +fn parse_openai_reference_image_data_url( + source: &str, + index: usize, +) -> Result, AppError> { + let Some(body) = source.strip_prefix("data:") else { + return Ok(None); + }; + let Some((mime_type, data)) = body.split_once(";base64,") else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": "参考图 Data URL 必须是 base64 图片。", + })), + ); + }; + if !mime_type.starts_with("image/") { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": "参考图 Data URL 必须是图片类型。", + })), + ); + } + let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("参考图 Data URL 解码失败:{error}"), + })) + })?; + let mime_type = normalize_downloaded_image_mime_type(mime_type); + Ok(Some(OpenAiReferenceImage { + bytes, + file_name: format!( + "reference-{index}.{}", + mime_to_extension(mime_type.as_str()) + ), + mime_type, + })) +} + fn parse_json_payload( raw_text: &str, failure_context: &str, @@ -1095,7 +1211,7 @@ mod tests { use super::*; #[test] - fn gpt_image_2_request_uses_vector_engine_contract() { + fn gpt_image_2_generation_request_uses_create_model_without_reference_images() { let body = build_openai_image_request_body( "雾海神殿", Some("文字,水印"), @@ -1104,16 +1220,41 @@ mod tests { &["data:image/png;base64,abcd".to_string()], ); - assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL); + assert_eq!(body["model"], GPT_IMAGE_2_MODEL); assert_eq!(body["size"], "1536x1024"); assert_eq!(body["n"], 2); assert!(body.get("official_fallback").is_none()); - assert_eq!(body["image"][0], "data:image/png;base64,abcd"); + assert!(body.get("image").is_none()); assert!(body["prompt"].as_str().unwrap_or_default().contains("避免")); } #[test] - fn vector_engine_edit_url_uses_images_edits_endpoint() { + fn vector_engine_generation_url_normalizes_base_url() { + let root_settings = OpenAiImageSettings { + base_url: "https://vector.example".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000_000, + external_api_audit_state: None, + }; + let v1_settings = OpenAiImageSettings { + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000_000, + external_api_audit_state: None, + }; + + assert_eq!( + vector_engine_images_generation_url(&root_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + vector_engine_images_generation_url(&v1_settings), + "https://vector.example/v1/images/generations" + ); + } + + #[test] + fn vector_engine_edit_url_normalizes_base_url() { let root_settings = OpenAiImageSettings { base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), @@ -1153,6 +1294,7 @@ mod tests { "提示词", None, "1:1", + 1, &[], "测试图片编辑失败", ) @@ -1163,6 +1305,21 @@ mod tests { assert!(error.body_text().contains("缺少参考图")); } + #[test] + fn reference_data_url_resolves_to_edit_image_part() { + let source = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"pngbytes") + ); + let image = parse_openai_reference_image_data_url(source.as_str(), 2) + .expect("data url should parse") + .expect("data url should resolve image"); + + assert_eq!(image.bytes, b"pngbytes"); + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.file_name, "reference-2.png"); + } + #[test] fn b64_json_response_decodes_png_image() { let images = images_from_base64( diff --git a/server-rs/crates/api-server/src/prompt/puzzle/draft.rs b/server-rs/crates/api-server/src/prompt/puzzle/draft.rs index f1eb2b9b..913b9dc8 100644 --- a/server-rs/crates/api-server/src/prompt/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/prompt/puzzle/draft.rs @@ -40,9 +40,11 @@ pub(crate) fn resolve_puzzle_draft_cover_prompt( pub(crate) fn resolve_puzzle_level_image_prompt( explicit_prompt: Option<&str>, level_picture_description: &str, + draft_summary: &str, ) -> String { normalize_prompt_part(explicit_prompt) .or_else(|| normalize_prompt_part(Some(level_picture_description))) + .or_else(|| normalize_prompt_part(Some(draft_summary))) .unwrap_or_default() .to_string() } @@ -76,8 +78,15 @@ mod tests { #[test] fn level_image_prompt_falls_back_to_level_description() { - let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述"); + let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述", "作品简介"); assert_eq!(prompt, "关卡画面描述"); } + + #[test] + fn level_image_prompt_falls_back_to_draft_summary_like_initial_cover() { + let prompt = resolve_puzzle_level_image_prompt(Some(" "), " ", "作品简介"); + + assert_eq!(prompt, "作品简介"); + } } diff --git a/server-rs/crates/api-server/src/prompt/puzzle/image.rs b/server-rs/crates/api-server/src/prompt/puzzle/image.rs index 667e7bcd..8a7ae996 100644 --- a/server-rs/crates/api-server/src/prompt/puzzle/image.rs +++ b/server-rs/crates/api-server/src/prompt/puzzle/image.rs @@ -43,7 +43,7 @@ fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String { concat!( "请生成一张高清插画。", "画面主体:{prompt}。", - "画面要求:1:1", + "画面要求:输出画面比例为1:1,", "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", "避免文字、水印、边框和 UI 元素。" ), @@ -77,7 +77,7 @@ mod tests { let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索"); assert!(prompt.contains("猫咪在发光遗迹前寻找线索")); - assert!(prompt.contains("1:1")); + assert!(prompt.contains("输出画面比例为1:1")); assert!(prompt.contains("主体要清晰集中")); assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); } @@ -90,7 +90,7 @@ mod tests { let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str()); assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS); - assert!(prompt.contains("1:1")); + assert!(prompt.contains("输出画面比例为1:1")); assert!(prompt.contains("主体要清晰集中")); assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 3c1afb06..84dfac4f 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,6 +1,5 @@ use std::{ collections::BTreeMap, - error::Error as StdError, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; @@ -78,10 +77,11 @@ use crate::{ should_skip_asset_operation_billing_for_connectivity, }, auth::AuthenticatedAccessToken, + generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha, http_error::AppError, llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL}, openai_image_generation::{ - DownloadedOpenAiImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, build_openai_image_http_client, + DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, build_openai_image_http_client, create_openai_image_generation, require_openai_image_settings, }, platform_errors::map_oss_error, @@ -124,9 +124,13 @@ const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768; const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512; const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024; const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5; -const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2"; const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = "移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素"; +const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024"; +const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536"; +const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面,生成对应的拼图游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图,拼图区域宽度与画面宽度同宽,紧贴画面横向边缘,拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字"; +const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够纯绿色绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。"; +const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容"; mod handlers; pub(crate) use self::handlers::*; diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 55216125..d639f8f2 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -184,6 +184,12 @@ pub(crate) fn parse_puzzle_level_records_from_module_json( ui_background_prompt: level.ui_background_prompt, ui_background_image_src: level.ui_background_image_src, ui_background_image_object_key: level.ui_background_image_object_key, + level_scene_image_src: level.level_scene_image_src, + level_scene_image_object_key: level.level_scene_image_object_key, + ui_spritesheet_image_src: level.ui_spritesheet_image_src, + ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key, + level_background_image_src: level.level_background_image_src, + level_background_image_object_key: level.level_background_image_object_key, background_music: level .background_music .map(map_puzzle_audio_asset_domain_record), @@ -357,6 +363,12 @@ pub(crate) fn serialize_puzzle_levels_response( "ui_background_prompt": level.ui_background_prompt, "ui_background_image_src": level.ui_background_image_src, "ui_background_image_object_key": level.ui_background_image_object_key, + "level_scene_image_src": level.level_scene_image_src, + "level_scene_image_object_key": level.level_scene_image_object_key, + "ui_spritesheet_image_src": level.ui_spritesheet_image_src, + "ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key, + "level_background_image_src": level.level_background_image_src, + "level_background_image_object_key": level.level_background_image_object_key, "background_music": puzzle_audio_asset_response_module_json(&level.background_music), "candidates": level .candidates @@ -411,6 +423,12 @@ pub(crate) fn normalize_puzzle_levels_json_for_module( "ui_background_prompt": level.ui_background_prompt, "ui_background_image_src": level.ui_background_image_src, "ui_background_image_object_key": level.ui_background_image_object_key, + "level_scene_image_src": level.level_scene_image_src, + "level_scene_image_object_key": level.level_scene_image_object_key, + "ui_spritesheet_image_src": level.ui_spritesheet_image_src, + "ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key, + "level_background_image_src": level.level_background_image_src, + "level_background_image_object_key": level.level_background_image_object_key, "background_music": puzzle_audio_asset_response_module_json(&level.background_music), "candidates": level .candidates @@ -918,6 +936,15 @@ pub(crate) fn build_puzzle_levels_with_primary_update( levels[index].ui_background_image_src = target_level.ui_background_image_src.clone(); levels[index].ui_background_image_object_key = target_level.ui_background_image_object_key.clone(); + levels[index].level_scene_image_src = target_level.level_scene_image_src.clone(); + levels[index].level_scene_image_object_key = + target_level.level_scene_image_object_key.clone(); + levels[index].ui_spritesheet_image_src = target_level.ui_spritesheet_image_src.clone(); + levels[index].ui_spritesheet_image_object_key = + target_level.ui_spritesheet_image_object_key.clone(); + levels[index].level_background_image_src = target_level.level_background_image_src.clone(); + levels[index].level_background_image_object_key = + target_level.level_background_image_object_key.clone(); if let Some(picture_reference) = picture_reference .map(str::trim) .filter(|value| !value.is_empty()) @@ -1033,6 +1060,29 @@ pub(crate) fn attach_puzzle_level_ui_background( levels[index].ui_background_image_object_key = Some(generated.object_key); } +pub(crate) fn attach_puzzle_level_asset_bundle( + levels: &mut [PuzzleDraftLevelRecord], + level_id: &str, + generated: GeneratedPuzzleLevelAssetBundle, +) { + let Some(index) = levels + .iter() + .position(|level| level.level_id == level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + else { + return; + }; + let level = &mut levels[index]; + level.level_scene_image_src = Some(generated.level_scene.image_src); + level.level_scene_image_object_key = Some(generated.level_scene.object_key); + level.ui_spritesheet_image_src = Some(generated.ui_spritesheet.image_src); + level.ui_spritesheet_image_object_key = Some(generated.ui_spritesheet.object_key); + level.level_background_image_src = Some(generated.level_background.image_src.clone()); + level.level_background_image_object_key = Some(generated.level_background.object_key.clone()); + level.ui_background_image_src = Some(generated.level_background.image_src); + level.ui_background_image_object_key = Some(generated.level_background.object_key); +} + pub(crate) async fn generate_puzzle_initial_ui_background_required( state: &PuzzleApiState, owner_user_id: &str, @@ -1052,26 +1102,56 @@ pub(crate) async fn generate_puzzle_initial_ui_background_required( Ok((prompt, generated)) } +pub(crate) async fn generate_puzzle_level_asset_bundle_required( + state: &PuzzleApiState, + owner_user_id: &str, + session_id: &str, + target_level: &PuzzleDraftLevelRecord, + puzzle_image: &PuzzleDownloadedImage, +) -> Result { + generate_puzzle_level_asset_bundle( + state, + owner_user_id, + session_id, + target_level.level_name.as_str(), + puzzle_image, + ) + .await +} + pub(crate) fn ensure_puzzle_initial_level_assets_ready( level: &PuzzleDraftLevelRecord, ) -> Result<(), AppError> { - let has_ui_background = level - .ui_background_image_src + let has_level_background = level + .level_background_image_src .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) || level - .ui_background_image_object_key + .level_background_image_object_key .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()); - if has_ui_background { + let has_ui_spritesheet = level + .ui_spritesheet_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || level + .ui_spritesheet_image_object_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + if has_level_background && has_ui_spritesheet { return Ok(()); } let mut missing = Vec::new(); - if !has_ui_background { - missing.push("UI背景图"); + if !has_level_background { + missing.push("关卡背景图"); + } + if !has_ui_spritesheet { + missing.push("UI spritesheet"); } Err( @@ -1125,8 +1205,8 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( target_level.level_name = generated_naming.level_name.clone(); target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); let mut generated_metadata = generated_naming; - // 点击生成草稿时一次性完成首图生成、UI 背景生成与正式图选定,前端只展示进度,不再承担业务编排。 - let candidates_future = generate_puzzle_image_candidates( + // 点击生成草稿时一次性完成拼图主图和运行态资产包,前端只展示进度,不再承担业务编排。 + let mut candidates = generate_puzzle_image_candidates( state, owner_user_id.as_str(), &compiled_session.session_id, @@ -1137,18 +1217,8 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( image_model, 1, target_level.candidates.len(), - ); - let ui_background_future = generate_puzzle_initial_ui_background_required( - state, - owner_user_id.as_str(), - compiled_session.session_id.as_str(), - &draft, - &target_level, - ); - // 中文注释:命名稳定后并行发起首关图与 UI 背景,避免两次外部生图串行等待。 - let (candidates_result, ui_background_result) = - tokio::join!(candidates_future, ui_background_future); - let mut candidates = candidates_result?; + ) + .await?; if let Some(first_candidate) = candidates.first() && let Some(refined_naming) = generate_puzzle_first_level_name_from_image( state, @@ -1184,19 +1254,25 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( "message": "拼图候选图生成结果为空", })) })?; - // 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。 - let (ui_prompt, ui_background) = ui_background_result?; - attach_puzzle_level_ui_background( - &mut updated_levels, - target_level.level_id.as_str(), - ui_prompt, - ui_background, - ); + // 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图、关卡背景和 UI spritesheet。 if let Some(selected_candidate) = candidates .iter() .find(|candidate| candidate.record.selected) .or_else(|| candidates.first()) { + let asset_bundle = generate_puzzle_level_asset_bundle_required( + state, + owner_user_id.as_str(), + compiled_session.session_id.as_str(), + &target_level, + &selected_candidate.downloaded_image, + ) + .await?; + attach_puzzle_level_asset_bundle( + &mut updated_levels, + target_level.level_id.as_str(), + asset_bundle, + ); attach_selected_puzzle_candidate_to_levels( &mut updated_levels, target_level.level_id.as_str(), @@ -1455,7 +1531,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( let generated_level_name = target_level.level_name.clone(); let mut updated_levels = build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); - let persist_upload_future = persist_puzzle_generated_asset( + let persisted_upload = persist_puzzle_generated_asset( state, owner_user_id.as_str(), &compiled_session.session_id, @@ -1464,24 +1540,20 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( "uploaded-direct", uploaded_downloaded_image.clone(), current_utc_micros(), - ); - let ui_background_future = generate_puzzle_initial_ui_background_required( + ) + .await?; + let asset_bundle = generate_puzzle_level_asset_bundle_required( state, owner_user_id.as_str(), compiled_session.session_id.as_str(), - &draft, &target_level, - ); - // 中文注释:直用上传图时并行完成上传图持久化与 UI 背景生成;音频生成入口临时关闭。 - let (persisted_upload_result, ui_background_result) = - tokio::join!(persist_upload_future, ui_background_future); - let persisted_upload = persisted_upload_result?; - let (ui_prompt, ui_background) = ui_background_result?; - attach_puzzle_level_ui_background( + &uploaded_downloaded_image, + ) + .await?; + attach_puzzle_level_asset_bundle( &mut updated_levels, target_level.level_id.as_str(), - ui_prompt, - ui_background, + asset_bundle, ); attach_selected_puzzle_candidate_to_levels( &mut updated_levels, diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs index 68079bc5..5a3d9a2a 100644 --- a/server-rs/crates/api-server/src/puzzle/generation.rs +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -12,9 +12,67 @@ pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError error } -pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool { - error.status_code() == StatusCode::GATEWAY_TIMEOUT - || is_puzzle_request_timeout_message(error.body_text().as_str()) +pub(crate) fn should_use_uploaded_puzzle_image_directly( + reference_image_src: Option<&str>, + ai_redraw: bool, +) -> bool { + !ai_redraw + && reference_image_src + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + +pub(crate) async fn create_uploaded_puzzle_image_candidate( + state: &PuzzleApiState, + owner_user_id: &str, + session_id: &str, + level_name: &str, + prompt: &str, + reference_image_src: &str, + candidate_start_index: usize, +) -> Result { + let http_client = reqwest::Client::new(); + let downloaded_image = + resolve_puzzle_reference_image_as_data_url(state, &http_client, reference_image_src) + .await + .map(PuzzleDownloadedImage::from_resolved_reference_image) + .map_err(|error| { + if error.status_code() == StatusCode::BAD_REQUEST { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "field": "referenceImageSrc", + "message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。", + })) + } else { + error + } + })?; + let candidate_id = format!("{session_id}-candidate-{}", candidate_start_index + 1); + let asset = persist_puzzle_generated_asset( + state, + owner_user_id, + session_id, + level_name, + candidate_id.as_str(), + "uploaded-direct", + downloaded_image.clone(), + current_utc_micros(), + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; + + Ok(GeneratedPuzzleImageCandidate { + record: PuzzleGeneratedImageCandidateRecord { + candidate_id, + image_src: asset.image_src, + asset_id: asset.asset_id, + prompt: prompt.to_string(), + actual_prompt: None, + source_type: "uploaded".to_string(), + selected: true, + }, + downloaded_image, + }) } pub(crate) async fn generate_puzzle_image_candidates( @@ -24,7 +82,7 @@ pub(crate) async fn generate_puzzle_image_candidates( level_name: &str, prompt: &str, reference_image_src: Option<&str>, - use_reference_image_edit: bool, + use_reference_image_generation: bool, image_model: Option<&str>, candidate_count: u32, candidate_start_index: usize, @@ -34,11 +92,13 @@ pub(crate) async fn generate_puzzle_image_candidates( let resolved_model = resolve_puzzle_image_model(image_model); let http_client = build_puzzle_image_http_client(state, resolved_model)?; let has_reference_image = has_puzzle_reference_image(reference_image_src); - let should_use_reference_image_edit = - should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit); + let should_use_reference_image_generation = should_use_puzzle_reference_image_generation( + reference_image_src, + use_reference_image_generation, + ); let actual_prompt = build_puzzle_vector_engine_generation_prompt( build_puzzle_image_prompt(level_name, prompt).as_str(), - should_use_reference_image_edit, + should_use_reference_image_generation, ); tracing::info!( provider = resolved_model.provider_name(), @@ -48,23 +108,19 @@ pub(crate) async fn generate_puzzle_image_candidates( prompt_chars = prompt.chars().count(), actual_prompt_chars = actual_prompt.chars().count(), has_reference_image, - use_reference_image_edit = should_use_reference_image_edit, + use_reference_image_generation = should_use_reference_image_generation, "拼图图片生成请求已准备" ); let reference_image_started_at = Instant::now(); let reference_image = match reference_image_src .map(str::trim) .filter(|value| !value.is_empty()) - .filter(|_| should_use_reference_image_edit) + .filter(|_| should_use_reference_image_generation) { Some(source) => { - let resolved = resolve_puzzle_reference_image( - state, - &http_client, - source, - Some(owner_user_id), - ) - .await?; + let resolved = + resolve_puzzle_reference_image(state, &http_client, source, Some(owner_user_id)) + .await?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), @@ -79,14 +135,14 @@ pub(crate) async fn generate_puzzle_image_candidates( } None => None, }; - if !should_use_reference_image_edit { + if !should_use_reference_image_generation { tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, has_reference_image, - use_reference_image_edit = should_use_reference_image_edit, + use_reference_image_generation = should_use_reference_image_generation, elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, "拼图参考图解析跳过" ); @@ -95,7 +151,7 @@ pub(crate) async fn generate_puzzle_image_candidates( // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 let settings = require_puzzle_vector_engine_settings(state)?; let vector_engine_started_at = Instant::now(); - let generated = if should_use_reference_image_edit { + let generated = if should_use_reference_image_generation { let reference_image = reference_image.as_ref().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", @@ -103,43 +159,17 @@ pub(crate) async fn generate_puzzle_image_candidates( "message": "AI 重绘需要提供参考图。", })) })?; - let edit_result = create_puzzle_vector_engine_image_edit( + create_puzzle_vector_engine_image_generation( &http_client, &settings, + resolved_model, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, - reference_image, + Some(reference_image), ) - .await; - match edit_result { - Ok(generated) => Ok(generated), - Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => { - tracing::warn!( - provider = resolved_model.provider_name(), - image_model = resolved_model.request_model_name(), - session_id, - level_name, - reference_mime = %reference_image.mime_type, - reference_bytes = reference_image.bytes_len, - error = %error, - "拼图参考图编辑接口超时,降级为带参考图的生成接口" - ); - create_puzzle_vector_engine_image_generation( - &http_client, - &settings, - resolved_model, - actual_prompt.as_str(), - PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, - count, - Some(reference_image), - ) - .await - } - Err(error) => Err(error), - } + .await } else { create_puzzle_vector_engine_image_generation( &http_client, @@ -260,6 +290,175 @@ pub(crate) async fn generate_puzzle_ui_background_image( .await } +pub(crate) async fn generate_puzzle_level_asset_bundle( + state: &PuzzleApiState, + owner_user_id: &str, + session_id: &str, + level_name: &str, + puzzle_image: &PuzzleDownloadedImage, +) -> Result { + let settings = require_puzzle_vector_engine_settings(state)?; + let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?; + let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image); + let scene_generated = create_puzzle_vector_engine_image_generation( + &http_client, + &settings, + PuzzleImageModel::GptImage2, + PUZZLE_LEVEL_SCENE_IMAGE_PROMPT, + "", + PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE, + 1, + Some(&puzzle_reference), + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; + let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": "拼图关卡画面图生成失败:未返回图片", + })) + })?; + let scene_reference = build_puzzle_downloaded_image_reference(&scene_image); + let scene_persist_future = persist_puzzle_level_asset_image( + state, + owner_user_id, + session_id, + level_name, + scene_generated.task_id.as_str(), + "level-scene", + "puzzle_level_scene_image", + "level_scene", + "scene", + scene_image, + ); + let spritesheet_future = generate_and_persist_puzzle_level_asset( + state, + &http_client, + &settings, + owner_user_id, + session_id, + level_name, + PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT, + PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE, + &scene_reference, + "ui-spritesheet", + "puzzle_ui_spritesheet_image", + "ui_spritesheet", + "spritesheet", + ); + let background_future = generate_and_persist_puzzle_level_asset( + state, + &http_client, + &settings, + owner_user_id, + session_id, + level_name, + PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT, + PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE, + &scene_reference, + "level-background", + "puzzle_level_background_image", + "level_background", + "background", + ); + let (level_scene, ui_spritesheet, level_background) = + tokio::join!(scene_persist_future, spritesheet_future, background_future); + + Ok(GeneratedPuzzleLevelAssetBundle { + level_scene: level_scene?, + ui_spritesheet: ui_spritesheet?, + level_background: level_background?, + }) +} + +async fn generate_and_persist_puzzle_level_asset( + state: &PuzzleApiState, + http_client: &reqwest::Client, + settings: &PuzzleVectorEngineSettings, + owner_user_id: &str, + session_id: &str, + level_name: &str, + prompt: &str, + size: &str, + reference_image: &PuzzleResolvedReferenceImage, + path_segment: &str, + asset_kind: &str, + slot: &str, + file_stem: &str, +) -> Result { + let generated = create_puzzle_vector_engine_image_generation( + http_client, + settings, + PuzzleImageModel::GptImage2, + prompt, + "", + size, + 1, + Some(reference_image), + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("拼图关卡资产生成失败:{asset_kind} 未返回图片"), + })) + })?; + let image = if slot == "ui_spritesheet" { + make_puzzle_ui_spritesheet_image_transparent(image)? + } else { + image + }; + + persist_puzzle_level_asset_image( + state, + owner_user_id, + session_id, + level_name, + generated.task_id.as_str(), + path_segment, + asset_kind, + slot, + file_stem, + image, + ) + .await +} + +pub(crate) fn make_puzzle_ui_spritesheet_image_transparent( + image: PuzzleDownloadedImage, +) -> Result { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("拼图 UI spritesheet 图解码失败:{error}"), + })) + })?; + + let mut encoded = std::io::Cursor::new(Vec::new()); + apply_generated_asset_sheet_green_screen_alpha(source) + .write_to(&mut encoded, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("拼图 UI spritesheet 图透明化失败:{error}"), + })) + })?; + + Ok(PuzzleDownloadedImage { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + bytes: encoded.into_inner(), + }) +} + +#[cfg(test)] +pub(crate) fn make_puzzle_ui_spritesheet_image_transparent_for_test( + image: PuzzleDownloadedImage, +) -> Result { + make_puzzle_ui_spritesheet_image_transparent(image) +} + #[cfg(test)] pub(crate) fn build_puzzle_ui_background_request_prompt_for_test( level_name: &str, @@ -267,3 +466,45 @@ pub(crate) fn build_puzzle_ui_background_request_prompt_for_test( ) -> String { build_puzzle_ui_background_generation_prompt(level_name, prompt) } + +#[cfg(test)] +pub(crate) fn build_puzzle_level_scene_image_request_body_for_test( + reference_image: &PuzzleDownloadedImage, +) -> Result { + Ok(build_puzzle_vector_engine_image_request_body( + PuzzleImageModel::GptImage2, + PUZZLE_LEVEL_SCENE_IMAGE_PROMPT, + "", + PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE, + 1, + Some(&build_puzzle_downloaded_image_reference(reference_image)), + )) +} + +#[cfg(test)] +pub(crate) fn build_puzzle_ui_spritesheet_request_body_for_test( + reference_image: &PuzzleDownloadedImage, +) -> Result { + Ok(build_puzzle_vector_engine_image_request_body( + PuzzleImageModel::GptImage2, + PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT, + "", + PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE, + 1, + Some(&build_puzzle_downloaded_image_reference(reference_image)), + )) +} + +#[cfg(test)] +pub(crate) fn build_puzzle_level_background_request_body_for_test( + reference_image: &PuzzleDownloadedImage, +) -> Result { + Ok(build_puzzle_vector_engine_image_request_body( + PuzzleImageModel::GptImage2, + PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT, + "", + PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE, + 1, + Some(&build_puzzle_downloaded_image_reference(reference_image)), + )) +} diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 795ae397..63be5836 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -113,6 +113,12 @@ pub async fn generate_puzzle_onboarding_work( ui_background_prompt: naming.ui_background_prompt.clone(), ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates, selected_candidate_id: Some(selected.candidate_id.clone()), @@ -772,6 +778,7 @@ pub async fn execute_puzzle_agent_action( let prompt = resolve_puzzle_level_image_prompt( payload.prompt_text.as_deref(), &target_level.picture_description, + &draft.summary, ); let should_auto_name_level = payload .should_auto_name_level @@ -797,22 +804,40 @@ pub async fn execute_puzzle_agent_action( let primary_reference_image_src = reference_image_sources.first().map(String::as_str); // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 - let candidate_count = 1; let candidate_start_index = target_level.candidates.len(); - let candidates = generate_puzzle_image_candidates( - &state, - owner_user_id.as_str(), - &session.session_id, - &target_level.level_name, - &prompt, + let ai_redraw = payload.ai_redraw.unwrap_or(true); + let mut candidates = if should_use_uploaded_puzzle_image_directly( primary_reference_image_src, - payload.ai_redraw.unwrap_or(true), - payload.image_model.as_deref(), - candidate_count, - candidate_start_index, - ) - .await - .map_err(map_puzzle_generation_endpoint_error)?; + ai_redraw, + ) { + vec![ + create_uploaded_puzzle_image_candidate( + &state, + owner_user_id.as_str(), + &session.session_id, + &target_level.level_name, + &prompt, + primary_reference_image_src.expect("checked reference image"), + candidate_start_index, + ) + .await?, + ] + } else { + generate_puzzle_image_candidates( + &state, + owner_user_id.as_str(), + &session.session_id, + &target_level.level_name, + &prompt, + primary_reference_image_src, + ai_redraw, + payload.image_model.as_deref(), + 1, + candidate_start_index, + ) + .await + .map_err(map_puzzle_generation_endpoint_error)? + }; if candidates.is_empty() { return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( json!({ @@ -837,14 +862,44 @@ pub async fn execute_puzzle_agent_action( generated_naming = Some(refined_naming); } let generated_level_name = target_level.level_name.clone(); + let mut updated_levels = build_puzzle_levels_with_primary_update( + &draft, + &target_level, + primary_reference_image_src, + ); + for candidate in &mut candidates { + candidate.record.prompt = prompt.clone(); + } + let selected_candidate = candidates + .iter() + .find(|candidate| candidate.record.selected) + .or_else(|| candidates.first()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图候选图生成结果为空", + })) + })?; + let asset_bundle = generate_puzzle_level_asset_bundle_required( + &state, + owner_user_id.as_str(), + &session.session_id, + &target_level, + &selected_candidate.downloaded_image, + ) + .await?; + attach_puzzle_level_asset_bundle( + &mut updated_levels, + target_level.level_id.as_str(), + asset_bundle, + ); + attach_selected_puzzle_candidate_to_levels( + &mut updated_levels, + target_level.level_id.as_str(), + &selected_candidate.record, + ); let levels_json_with_generated_name = - Some(serialize_puzzle_level_records_for_module( - &build_puzzle_levels_with_primary_update( - &draft, - &target_level, - primary_reference_image_src, - ), - )?); + Some(serialize_puzzle_level_records_for_module(&updated_levels)?); let candidates_json = serde_json::to_string( &candidates .iter() @@ -896,7 +951,11 @@ pub async fn execute_puzzle_agent_action( }; let mut fallback_session = apply_generated_puzzle_candidates_to_session_snapshot( - fallback_session, + apply_generated_puzzle_levels_to_session_snapshot( + fallback_session, + updated_levels, + now, + ), target_level.level_id.as_str(), candidates.into_records(), primary_reference_image_src, diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index db33ea10..de8d2994 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -105,6 +105,12 @@ pub(super) fn map_puzzle_draft_level_response( ui_background_prompt: level.ui_background_prompt, ui_background_image_src: level.ui_background_image_src, ui_background_image_object_key: level.ui_background_image_object_key, + level_scene_image_src: level.level_scene_image_src, + level_scene_image_object_key: level.level_scene_image_object_key, + ui_spritesheet_image_src: level.ui_spritesheet_image_src, + ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key, + level_background_image_src: level.level_background_image_src, + level_background_image_object_key: level.level_background_image_object_key, background_music: level .background_music .map(map_puzzle_audio_asset_record_response), @@ -541,6 +547,10 @@ pub(super) fn map_puzzle_runtime_level_response( cover_image_src: level.cover_image_src, ui_background_image_src: level.ui_background_image_src, ui_background_image_object_key: level.ui_background_image_object_key, + level_background_image_src: level.level_background_image_src, + level_background_image_object_key: level.level_background_image_object_key, + ui_spritesheet_image_src: level.ui_spritesheet_image_src, + ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key, background_music: level .background_music .map(map_puzzle_audio_asset_record_response), diff --git a/server-rs/crates/api-server/src/puzzle/tags.rs b/server-rs/crates/api-server/src/puzzle/tags.rs index aee79739..f49cc84e 100644 --- a/server-rs/crates/api-server/src/puzzle/tags.rs +++ b/server-rs/crates/api-server/src/puzzle/tags.rs @@ -278,6 +278,12 @@ pub(super) fn serialize_puzzle_level_records_for_module( "ui_background_prompt": level.ui_background_prompt, "ui_background_image_src": level.ui_background_image_src, "ui_background_image_object_key": level.ui_background_image_object_key, + "level_scene_image_src": level.level_scene_image_src, + "level_scene_image_object_key": level.level_scene_image_object_key, + "ui_spritesheet_image_src": level.ui_spritesheet_image_src, + "ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key, + "level_background_image_src": level.level_background_image_src, + "level_background_image_object_key": level.level_background_image_object_key, "background_music": puzzle_audio_asset_record_module_json(&level.background_music), "candidates": level .candidates diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index cc5633e2..e0a780da 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::openai_image_generation::GPT_IMAGE_2_MODEL; #[test] fn puzzle_generated_image_size_is_square_1_1() { @@ -7,7 +8,7 @@ fn puzzle_generated_image_size_is_square_1_1() { } #[test] -fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() { +fn puzzle_vector_engine_create_request_uses_gpt_image_2_without_reference_images() { let body = build_puzzle_vector_engine_image_request_body( PuzzleImageModel::Gemini31FlashPreview, "一只猫在雨夜灯牌下回头。", @@ -17,7 +18,7 @@ fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() { None, ); - assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL); + assert_eq!(body["model"], GPT_IMAGE_2_MODEL); assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE); assert_eq!(body["n"], 1); assert!(body.get("official_fallback").is_none()); @@ -31,7 +32,7 @@ fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() { } #[test] -fn puzzle_vector_engine_generation_fallback_includes_reference_image() { +fn puzzle_vector_engine_create_request_never_embeds_reference_image() { let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4)); let mut cursor = std::io::Cursor::new(Vec::new()); image @@ -53,20 +54,148 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() { Some(&reference_image), ); - let images = body["image"] - .as_array() - .expect("fallback generation should include reference image array"); - assert_eq!(images.len(), 1); + assert!(body.get("image").is_none()); +} + +#[test] +fn puzzle_level_scene_spritesheet_and_background_requests_use_references() { + let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4)); + let mut cursor = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::Png) + .expect("test image should encode"); + let reference_image = PuzzleDownloadedImage { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + bytes: cursor.into_inner(), + }; + + let scene_body = build_puzzle_level_scene_image_request_body_for_test(&reference_image) + .expect("scene request should build"); + assert_eq!(scene_body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(scene_body["size"], "1024x1536"); + assert!(scene_body.get("image").is_none()); assert!( - images[0] + scene_body["prompt"] .as_str() .unwrap_or_default() - .starts_with("data:image/png;base64,") + .contains("参考图作为拼图画面") + ); + assert!( + scene_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("道具按钮上不要显示次数标注") + ); + assert!( + scene_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("返回按钮和设置按钮旁禁止标注文字") + ); + + let spritesheet_body = build_puzzle_ui_spritesheet_request_body_for_test(&reference_image) + .expect("spritesheet request should build"); + assert_eq!(spritesheet_body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(spritesheet_body["size"], "1024x1024"); + assert!( + spritesheet_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮") + ); + assert!( + spritesheet_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("纯绿色绿幕背景") + ); + assert!( + spritesheet_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("绿幕扣成透明") + ); + assert!( + spritesheet_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("自动边界检测") + ); + assert!( + spritesheet_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("按钮素材内必须保留对应中文文字") + ); + assert!( + spritesheet_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("不要额外画白色外圈") + ); + assert!( + spritesheet_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("白底圆环") + ); + assert!( + !spritesheet_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("禁止文字") + ); + + let background_body = build_puzzle_level_background_request_body_for_test(&reference_image) + .expect("background request should build"); + assert_eq!(background_body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(background_body["size"], "1024x1536"); + assert!( + background_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("移除参考图中所有UI元素") + ); + assert!( + background_body["prompt"] + .as_str() + .unwrap_or_default() + .contains("禁止在背景中出现人像或和拼图画面中主体一致的内容") ); } #[test] -fn puzzle_vector_engine_generation_prefers_signed_reference_url() { +fn puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent() { + let mut source = image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 255, 0, 255])); + for y in 2..6 { + for x in 2..6 { + source.put_pixel(x, y, image::Rgba([190, 78, 42, 255])); + } + } + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut cursor, ImageFormat::Png) + .expect("test image should encode"); + + let processed = make_puzzle_ui_spritesheet_image_transparent_for_test(PuzzleDownloadedImage { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + bytes: cursor.into_inner(), + }) + .expect("green screen postprocess should succeed"); + + assert_eq!(processed.extension, "png"); + assert_eq!(processed.mime_type, "image/png"); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed image should decode") + .to_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0[3], 0); + assert_eq!(decoded.get_pixel(3, 3).0[3], 255); +} + +#[test] +fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() { let reference_image = PuzzleResolvedReferenceImage { mime_type: "image/png".to_string(), bytes_len: 4, @@ -86,14 +215,24 @@ fn puzzle_vector_engine_generation_prefers_signed_reference_url() { Some(&reference_image), ); + assert!(body.get("image").is_none()); +} + +#[test] +fn puzzle_vector_engine_generation_url_normalizes_base_url() { + let settings = PuzzleVectorEngineSettings { + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + }; + assert_eq!( - body["image"][0], - "https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc" + puzzle_vector_engine_images_generation_url(&settings), + "https://vector.example/v1/images/generations" ); } #[test] -fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() { +fn puzzle_vector_engine_edit_url_normalizes_base_url() { let settings = PuzzleVectorEngineSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), @@ -135,18 +274,31 @@ fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() { } #[test] -fn puzzle_reference_image_edit_requires_ai_redraw() { - assert!(!should_use_puzzle_reference_image_edit(None, true)); - assert!(!should_use_puzzle_reference_image_edit( +fn puzzle_reference_image_generation_requires_ai_redraw() { + assert!(!should_use_puzzle_reference_image_generation(None, true)); + assert!(!should_use_puzzle_reference_image_generation( Some("data:image/png;base64,abcd"), false )); - assert!(should_use_puzzle_reference_image_edit( + assert!(should_use_puzzle_reference_image_generation( Some("data:image/png;base64,abcd"), true )); } +#[test] +fn puzzle_result_level_direct_upload_skips_cover_image_generation() { + assert!(should_use_uploaded_puzzle_image_directly( + Some("data:image/png;base64,abcd"), + false + )); + assert!(!should_use_uploaded_puzzle_image_directly( + Some("data:image/png;base64,abcd"), + true + )); + assert!(!should_use_uploaded_puzzle_image_directly(None, false)); +} + #[test] fn puzzle_reference_image_sources_are_deduped_and_limited() { let sources = collect_puzzle_reference_image_sources( @@ -239,51 +391,14 @@ fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() { fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() { let error = map_puzzle_vector_engine_upstream_error( reqwest::StatusCode::GATEWAY_TIMEOUT, - r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#, - "创建拼图 VectorEngine 图片编辑任务失败", + r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#, + "创建拼图 VectorEngine 图片生成任务失败", ); let response = error.into_response(); assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); } -#[test] -fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() { - let timeout_error = map_puzzle_vector_engine_upstream_error( - reqwest::StatusCode::GATEWAY_TIMEOUT, - r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#, - "创建拼图 VectorEngine 图片编辑任务失败", - ); - assert!(should_fallback_puzzle_reference_edit_to_generation( - &timeout_error - )); - - let auth_error = map_puzzle_vector_engine_upstream_error( - reqwest::StatusCode::UNAUTHORIZED, - r#"{"error":{"message":"invalid api key"}}"#, - "创建拼图 VectorEngine 图片编辑任务失败", - ); - assert!(!should_fallback_puzzle_reference_edit_to_generation( - &auth_error - )); -} - -#[test] -fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() { - let error = match reqwest::Client::new().get("http://[::1").build() { - Ok(_) => panic!("invalid url should fail request build"), - Err(error) => error, - }; - let app_error = map_puzzle_vector_engine_reqwest_error( - "创建拼图 VectorEngine 图片编辑任务失败", - "https://api.vectorengine.ai/v1/images/edits", - error, - ); - - let response = app_error.into_response(); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); -} - #[test] fn puzzle_compile_error_preserves_vector_engine_unavailable_status() { let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( @@ -601,6 +716,12 @@ fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() { ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: Some(CreationAudioAsset { task_id: "suno-task-1".to_string(), provider: "vector-engine-suno".to_string(), @@ -666,6 +787,12 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() { ui_background_image_object_key: Some( "generated-puzzle-assets/session/ui/background.png".to_string(), ), + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: vec![], selected_candidate_id: None, @@ -703,6 +830,81 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() { ); } +#[test] +fn puzzle_level_asset_bundle_fields_roundtrip_between_response_and_module_json() { + let level = PuzzleDraftLevelResponse { + level_id: "puzzle-level-1".to_string(), + level_name: "雨夜猫街".to_string(), + picture_description: "一只猫在雨夜灯牌下回头。".to_string(), + picture_reference: None, + ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()), + ui_background_image_src: Some( + "/generated-puzzle-assets/session/legacy-ui/background.png".to_string(), + ), + ui_background_image_object_key: Some( + "generated-puzzle-assets/session/legacy-ui/background.png".to_string(), + ), + level_scene_image_src: Some( + "/generated-puzzle-assets/session/level-scene/scene.png".to_string(), + ), + level_scene_image_object_key: Some( + "generated-puzzle-assets/session/level-scene/scene.png".to_string(), + ), + ui_spritesheet_image_src: Some( + "/generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(), + ), + ui_spritesheet_image_object_key: Some( + "generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(), + ), + level_background_image_src: Some( + "/generated-puzzle-assets/session/level-background/background.png".to_string(), + ), + level_background_image_object_key: Some( + "generated-puzzle-assets/session/level-background/background.png".to_string(), + ), + background_music: None, + candidates: vec![], + selected_candidate_id: None, + cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()), + cover_asset_id: Some("asset-1".to_string()), + generation_status: "ready".to_string(), + }; + let request_context = RequestContext::new( + "test-request".to_string(), + "PUT /api/runtime/puzzle/works/test".to_string(), + Duration::ZERO, + false, + ); + + let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) + .expect("levels should serialize"); + let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse"); + assert_eq!( + payload[0]["level_background_image_object_key"], + Value::String( + "generated-puzzle-assets/session/level-background/background.png".to_string() + ) + ); + assert!(payload[0].get("levelBackgroundImageObjectKey").is_none()); + + let records = parse_puzzle_level_records_from_module_json(&levels_json) + .expect("levels should map back into records"); + assert_eq!( + records[0].level_scene_image_src.as_deref(), + Some("/generated-puzzle-assets/session/level-scene/scene.png") + ); + assert_eq!( + records[0].ui_spritesheet_image_object_key.as_deref(), + Some("generated-puzzle-assets/session/ui-spritesheet/sheet.png") + ); + + let response = map_puzzle_draft_level_response(records[0].clone()); + assert_eq!( + response.level_background_image_src.as_deref(), + Some("/generated-puzzle-assets/session/level-background/background.png") + ); +} + #[test] fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { let app_state = crate::state::AppState::new(crate::config::AppConfig::default()) @@ -716,6 +918,12 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: vec![PuzzleGeneratedImageCandidateRecord { candidate_id: "candidate-1".to_string(), @@ -849,12 +1057,15 @@ fn puzzle_initial_draft_assets_must_include_ui_background() { let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0]) .expect_err("缺少自动生成资产时不能把草稿标记为完成"); assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY); - assert!(missing_all.body_text().contains("UI背景图")); + assert!(missing_all.body_text().contains("关卡背景图")); + assert!(missing_all.body_text().contains("UI spritesheet")); - draft.levels[0].ui_background_image_src = - Some("/generated-puzzle-assets/session/ui/background.png".to_string()); + draft.levels[0].level_background_image_src = + Some("/generated-puzzle-assets/session/background/background.png".to_string()); + draft.levels[0].ui_spritesheet_image_src = + Some("/generated-puzzle-assets/session/spritesheet/sheet.png".to_string()); ensure_puzzle_initial_level_assets_ready(&draft.levels[0]) - .expect("UI 背景存在时即可完成自动草稿资源检查"); + .expect("关卡背景和 UI spritesheet 存在时即可完成自动草稿资源检查"); } fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord { @@ -898,6 +1109,12 @@ fn test_puzzle_draft_record() -> PuzzleResultDraftRecord { ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: vec![], selected_candidate_id: None, diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index e2ebbad6..85ed78c1 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -12,7 +12,7 @@ impl PuzzleImageModel { } pub(crate) fn request_model_name(self) -> &'static str { - VECTOR_ENGINE_GPT_IMAGE_2_MODEL + GPT_IMAGE_2_MODEL } pub(crate) fn candidate_source_type(self) -> &'static str { @@ -95,13 +95,26 @@ pub(crate) struct GeneratedPuzzleUiBackgroundResponse { pub(crate) object_key: String, } +#[derive(Clone, Debug)] +pub(crate) struct GeneratedPuzzleLevelAssetResponse { + pub(crate) image_src: String, + pub(crate) object_key: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct GeneratedPuzzleLevelAssetBundle { + pub(crate) level_scene: GeneratedPuzzleLevelAssetResponse, + pub(crate) ui_spritesheet: GeneratedPuzzleLevelAssetResponse, + pub(crate) level_background: GeneratedPuzzleLevelAssetResponse, +} + pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel { match value.map(str::trim).filter(|value| !value.is_empty()) { Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => { tracing::warn!( requested_model = PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW, - effective_model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL, - "拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2-all" + effective_model = GPT_IMAGE_2_MODEL, + "拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2" ); PuzzleImageModel::Gemini31FlashPreview } @@ -150,7 +163,7 @@ pub(crate) fn build_puzzle_image_http_client( reqwest::Client::builder() .timeout(Duration::from_millis(request_timeout_ms.max(1))) - // 中文注释:VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。 + // 中文注释:参考图走 multipart edits;强制 HTTP/1.1 可降低部分网关对长耗时上传流的兼容风险。 .http1_only() .build() .map_err(|error| { @@ -186,6 +199,20 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation( candidate_count: u32, reference_image: Option<&PuzzleResolvedReferenceImage>, ) -> Result { + if let Some(reference_image) = reference_image { + return create_puzzle_vector_engine_image_edit( + http_client, + settings, + image_model, + prompt, + negative_prompt, + size, + candidate_count, + reference_image, + ) + .await; + } + let request_body = build_puzzle_vector_engine_image_request_body( image_model, prompt, @@ -262,6 +289,15 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation( return Ok(images); } + let b64_images = extract_puzzle_b64_images(&payload); + if !b64_images.is_empty() { + return Ok(puzzle_images_from_base64( + format!("vector-engine-{}", current_utc_micros()), + b64_images, + candidate_count, + )); + } + Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, @@ -273,6 +309,7 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation( pub(crate) async fn create_puzzle_vector_engine_image_edit( http_client: &reqwest::Client, settings: &PuzzleVectorEngineSettings, + image_model: PuzzleImageModel, prompt: &str, negative_prompt: &str, size: &str, @@ -295,7 +332,7 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit( })?; let form = reqwest::multipart::Form::new() .part("image", image_part) - .text("model", PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL.to_string()) + .text("model", image_model.request_model_name().to_string()) .text( "prompt", build_puzzle_vector_engine_prompt(prompt, negative_prompt), @@ -314,16 +351,14 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit( .send() .await .map_err(|error| { - map_puzzle_vector_engine_reqwest_error( - "创建拼图 VectorEngine 图片编辑任务失败", - &request_url, - error, - ) + map_puzzle_vector_engine_request_error(format!( + "创建拼图 VectorEngine 图片编辑任务失败:{error}" + )) })?; let status = response.status(); tracing::info!( provider = VECTOR_ENGINE_PROVIDER, - image_model = PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL, + image_model = image_model.request_model_name(), endpoint = %request_url, status = status.as_u16(), prompt_chars = prompt.chars().count(), @@ -372,6 +407,17 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit( ) } +pub(crate) fn build_puzzle_downloaded_image_reference( + image: &PuzzleDownloadedImage, +) -> PuzzleResolvedReferenceImage { + PuzzleResolvedReferenceImage { + mime_type: image.mime_type.clone(), + bytes_len: image.bytes.len(), + bytes: image.bytes.clone(), + signed_read_url: None, + } +} + pub(crate) fn build_puzzle_vector_engine_image_request_body( image_model: PuzzleImageModel, prompt: &str, @@ -380,7 +426,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body( candidate_count: u32, reference_image: Option<&PuzzleResolvedReferenceImage>, ) -> Value { - let mut body = Map::from_iter([ + let body = Map::from_iter([ ( "model".to_string(), Value::String(image_model.request_model_name().to_string()), @@ -392,20 +438,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body( ("n".to_string(), json!(candidate_count.clamp(1, 1))), ("size".to_string(), Value::String(size.to_string())), ]); - if let Some(reference_image) = reference_image { - if let Some(signed_read_url) = reference_image - .signed_read_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - body.insert("image".to_string(), json!([signed_read_url])); - } else if let Some(reference_data_url) = - build_puzzle_generation_reference_image_data_url(reference_image) - { - body.insert("image".to_string(), json!([reference_data_url])); - } - } + let _ = reference_image; Value::Object(body) } @@ -429,32 +462,6 @@ pub(crate) fn build_puzzle_vector_engine_generation_prompt( ) } -pub(crate) fn build_puzzle_generation_reference_image_data_url( - image: &PuzzleResolvedReferenceImage, -) -> Option { - let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice()) - .unwrap_or_else(|| image.bytes.clone()); - let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { - "image/png" - } else { - image.mime_type.as_str() - }; - - Some(format!( - "data:{};base64,{}", - normalize_puzzle_downloaded_image_mime_type(mime_type), - BASE64_STANDARD.encode(bytes) - )) -} - -pub(crate) fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option> { - let image = image::load_from_memory(bytes).ok()?; - let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle); - let mut cursor = std::io::Cursor::new(Vec::new()); - resized.write_to(&mut cursor, ImageFormat::Png).ok()?; - Some(cursor.into_inner()) -} - pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool { reference_image_src .map(str::trim) @@ -545,11 +552,11 @@ pub(crate) fn has_puzzle_reference_images( .is_empty() } -pub(crate) fn should_use_puzzle_reference_image_edit( +pub(crate) fn should_use_puzzle_reference_image_generation( reference_image_src: Option<&str>, - use_reference_image_edit: bool, + use_reference_image_generation: bool, ) -> bool { - use_reference_image_edit && has_puzzle_reference_image(reference_image_src) + use_reference_image_generation && has_puzzle_reference_image(reference_image_src) } pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String { @@ -1072,6 +1079,57 @@ pub(crate) async fn persist_puzzle_ui_background_image( }) } +pub(crate) async fn persist_puzzle_level_asset_image( + state: &PuzzleApiState, + owner_user_id: &str, + session_id: &str, + level_name: &str, + task_id: &str, + path_segment: &str, + asset_kind: &str, + slot: &str, + file_stem: &str, + image: PuzzleDownloadedImage, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let http_client = reqwest::Client::new(); + let put_result = oss_client + .put_object( + &http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::PuzzleAssets, + path_segments: vec![ + sanitize_path_segment(session_id, "session"), + sanitize_path_segment(level_name, "puzzle"), + sanitize_path_segment(path_segment, "level-asset"), + sanitize_path_segment(task_id, "task"), + ], + file_name: format!("{file_stem}.{}", image.extension), + content_type: Some(image.mime_type.clone()), + access: OssObjectAccess::Private, + metadata: build_puzzle_level_asset_metadata( + owner_user_id, + session_id, + asset_kind, + slot, + ), + body: image.bytes, + }, + ) + .await + .map_err(map_puzzle_asset_oss_error)?; + + Ok(GeneratedPuzzleLevelAssetResponse { + image_src: put_result.legacy_public_path, + object_key: put_result.object_key, + }) +} + pub(crate) fn handle_puzzle_asset_spacetime_index_error( error: SpacetimeClientError, owner_user_id: &str, @@ -1126,6 +1184,21 @@ pub(crate) fn build_puzzle_ui_background_asset_metadata( ]) } +pub(crate) fn build_puzzle_level_asset_metadata( + owner_user_id: &str, + session_id: &str, + asset_kind: &str, + slot: &str, +) -> BTreeMap { + BTreeMap::from([ + ("asset_kind".to_string(), asset_kind.to_string()), + ("owner_user_id".to_string(), owner_user_id.to_string()), + ("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()), + ("entity_id".to_string(), session_id.to_string()), + ("slot".to_string(), slot.to_string()), + ]) +} + pub(crate) fn parse_puzzle_json_payload( raw_text: &str, fallback_message: &str, @@ -1331,72 +1404,6 @@ pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppErro })) } -pub(crate) fn map_puzzle_vector_engine_reqwest_error( - context: &str, - request_url: &str, - error: reqwest::Error, -) -> AppError { - let message = format!( - "{context}:{}", - normalize_puzzle_reqwest_error_message(&error) - ); - let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str()); - let is_connect = error.is_connect(); - let status = if is_timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - let source = error.source().map(ToString::to_string).unwrap_or_default(); - - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - timeout = is_timeout, - connect = is_connect, - request = error.is_request(), - body = error.is_body(), - source = %source, - message = %message, - "拼图 VectorEngine 请求发送失败" - ); - - AppError::from_status(status).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - "reason": resolve_puzzle_vector_engine_request_failure_reason(&error), - "endpoint": request_url, - "timeout": is_timeout, - "connect": is_connect, - "request": error.is_request(), - "body": error.is_body(), - "source": source, - })) -} - -pub(crate) fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String { - error - .to_string() - .split_whitespace() - .collect::>() - .join(" ") -} - -pub(crate) fn resolve_puzzle_vector_engine_request_failure_reason( - error: &reqwest::Error, -) -> &'static str { - if error.is_timeout() { - return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"; - } - if error.is_connect() { - return "无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置"; - } - if error.is_body() { - return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小"; - } - "VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误" -} - pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool { let lower = message.to_ascii_lowercase(); lower.contains("timed out") diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 43ffe1f3..016cfa3a 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -687,6 +687,7 @@ async fn generate_wooden_fish_image_assets( hit_object_prompt.as_str(), None, "1:1", + 1, reference_images.as_slice(), "生成敲木鱼敲击物图案失败", ) diff --git a/server-rs/crates/module-custom-world/src/application.rs b/server-rs/crates/module-custom-world/src/application.rs index 0cf077d7..510a977c 100644 --- a/server-rs/crates/module-custom-world/src/application.rs +++ b/server-rs/crates/module-custom-world/src/application.rs @@ -544,7 +544,7 @@ pub fn build_custom_world_published_profile_compile_snapshot( let subtitle = resolve_text_field(&draft, &legacy, "subtitle").unwrap_or_default(); let summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default(); let cover_image_src = resolve_cover_image_src(&draft, &legacy); - let theme_mode = resolve_theme_mode(&legacy); + let theme_mode = resolve_theme_mode(&draft, &legacy); let playable_npc_count = count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs")); let landmark_count = to_array(draft.get("landmarks")).len() as u32; @@ -599,6 +599,37 @@ pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> boo true } +pub fn resolve_custom_world_publish_setting_text( + payload: &Map, + draft_profile: &Map, + seed_text: &str, +) -> String { + // 中文注释:发布按钮的前端契约只保证提交动作名;正式 settingText 必须从草稿真相补齐, + // 避免旧会话 seed_text 为空时通过 publish gate,却在最终 compile/publish 阶段失败。 + read_nested_text_field(payload, &["settingText"]) + .or_else(|| { + read_nested_text_field( + draft_profile, + &[ + "settingText", + "creatorIntent.rawSettingText", + "creatorIntent.worldHook", + "worldHook", + "anchorContent.worldPromise", + "anchorContent.worldPromise.hook", + "summary", + "name", + "title", + ], + ) + }) + .or_else(|| { + let seed = seed_text.trim(); + (!seed.is_empty()).then(|| seed.to_string()) + }) + .unwrap_or_default() +} + pub fn empty_agent_anchor_content_json() -> String { r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string() } @@ -663,7 +694,13 @@ fn parse_optional_json_object( error: CustomWorldFieldError, ) -> Result, CustomWorldFieldError> { match normalize_optional_json_slice(value) { - Some(value) => parse_required_json_object(&value, error), + Some(value) => match serde_json::from_str::(&value) { + Ok(Value::Object(object)) => Ok(object), + // 中文注释:跨层可选字段经 serde 结构体序列化后可能显式落成 null; + // 对 optional JSON object 而言 null 等价于未提供,不能阻断发布链路。 + Ok(Value::Null) => Ok(Map::new()), + _ => Err(error), + }, None => Ok(Map::new()), } } @@ -804,6 +841,32 @@ fn read_text(object: &Map, key: &str) -> Option { .map(ToOwned::to_owned) } +fn read_nested_text_field(object: &Map, keys: &[&str]) -> Option { + for key in keys { + let mut current = Value::Object(object.clone()); + let mut found = true; + for segment in key.split('.') { + if let Some(next) = current.get(segment) { + current = next.clone(); + } else { + found = false; + break; + } + } + if found { + if let Some(value) = current + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(value.to_string()); + } + } + } + + None +} + fn read_string_list(object: &Map, key: &str) -> Vec { object .get(key) @@ -849,11 +912,17 @@ fn resolve_text_field( legacy: &Map, key: &str, ) -> Option { + // 中文注释:发布链路的草稿真相来自 session.draft_profile_json, + // legacyResultProfile 只补历史草稿缺失字段,不能覆盖结果页刚保存的内容。 to_text(draft.get(key)).or_else(|| to_text(legacy.get(key))) } -fn resolve_theme_mode(legacy: &Map) -> CustomWorldThemeMode { - to_text(legacy.get("themeMode")) +fn resolve_theme_mode( + draft: &Map, + legacy: &Map, +) -> CustomWorldThemeMode { + to_text(draft.get("themeMode")) + .or_else(|| to_text(legacy.get("themeMode"))) .and_then(|value| CustomWorldThemeMode::from_client_str(&value)) .unwrap_or(CustomWorldThemeMode::Mythic) } @@ -955,3 +1024,139 @@ fn build_compiled_profile_payload_json( serde_json::to_string(&Value::Object(payload)) .map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn build_test_compile_input( + legacy_result_profile_json: Option, + ) -> CustomWorldPublishedProfileCompileInput { + CustomWorldPublishedProfileCompileInput { + session_id: "session-1".to_string(), + profile_id: "cwprof_001".to_string(), + owner_user_id: "user-1".to_string(), + draft_profile_json: json!({ + "name": "潮雾列岛", + "summary": "群岛与旧灯塔之间的沉船疑案。", + "playableNpcs": [], + "storyNpcs": [], + "landmarks": [] + }) + .to_string(), + legacy_result_profile_json, + setting_text: "海图会在午夜改写群岛航路。".to_string(), + author_display_name: "创作者".to_string(), + updated_at_micros: 1, + } + } + + #[test] + fn published_profile_compile_treats_null_legacy_result_profile_as_absent() { + let snapshot = build_custom_world_published_profile_compile_snapshot( + build_test_compile_input(Some("null".to_string())), + ) + .expect("null legacy result profile should be treated as absent"); + + assert_eq!(snapshot.profile_id, "cwprof_001"); + assert_eq!(snapshot.world_name, "潮雾列岛"); + } + + #[test] + fn published_profile_compile_rejects_non_object_legacy_result_profile() { + let error = build_custom_world_published_profile_compile_snapshot( + build_test_compile_input(Some("[]".to_string())), + ) + .expect_err("array legacy result profile should still be invalid"); + + assert_eq!(error, CustomWorldFieldError::InvalidLegacyResultProfileJson); + } + + #[test] + fn published_profile_compile_prefers_saved_draft_over_legacy_profile() { + let input = CustomWorldPublishedProfileCompileInput { + draft_profile_json: json!({ + "name": "结果页保存后的世界", + "summary": "发布前最后一次填写的摘要。", + "themeMode": "tide", + "playableNpcs": [], + "storyNpcs": [], + "landmarks": [] + }) + .to_string(), + legacy_result_profile_json: Some( + json!({ + "name": "旧结果页世界", + "summary": "旧摘要不应覆盖保存草稿。", + "themeMode": "mythic" + }) + .to_string(), + ), + ..build_test_compile_input(None) + }; + + let snapshot = build_custom_world_published_profile_compile_snapshot(input) + .expect("compile should prefer saved draft"); + let payload: Value = serde_json::from_str(&snapshot.compiled_profile_payload_json) + .expect("compiled payload should be json"); + + assert_eq!(snapshot.world_name, "结果页保存后的世界"); + assert_eq!(snapshot.summary_text, "发布前最后一次填写的摘要。"); + assert_eq!(snapshot.theme_mode, CustomWorldThemeMode::Tide); + assert_eq!( + payload.get("name").and_then(Value::as_str), + Some("结果页保存后的世界") + ); + assert_eq!( + payload.get("summary").and_then(Value::as_str), + Some("发布前最后一次填写的摘要。") + ); + } + + #[test] + fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() { + let payload = Map::new(); + let draft_profile = json!({ + "settingText": "海雾会吞掉记错航线的人。", + "worldHook": "在失真的海图上追查一场被篡改的沉船事故。", + "summary": "守灯人与群岛议会围绕沉船旧案对峙。" + }) + .as_object() + .cloned() + .expect("draft profile should be object"); + + let setting_text = resolve_custom_world_publish_setting_text(&payload, &draft_profile, ""); + + assert_eq!(setting_text, "海雾会吞掉记错航线的人。"); + } + + #[test] + fn publish_setting_text_prefers_payload_then_draft_then_seed() { + let mut payload = Map::new(); + payload.insert( + "settingText".to_string(), + Value::String("发布载荷设定".to_string()), + ); + let draft_profile = json!({ + "worldHook": "草稿世界一句话", + "summary": "草稿摘要" + }) + .as_object() + .cloned() + .expect("draft profile should be object"); + + assert_eq!( + resolve_custom_world_publish_setting_text(&payload, &draft_profile, "用户原始设定"), + "发布载荷设定" + ); + assert_eq!( + resolve_custom_world_publish_setting_text(&Map::new(), &draft_profile, "用户原始设定"), + "草稿世界一句话" + ); + assert_eq!( + resolve_custom_world_publish_setting_text(&Map::new(), &Map::new(), "用户原始设定"), + "用户原始设定" + ); + } +} diff --git a/server-rs/crates/module-match3d/src/application.rs b/server-rs/crates/module-match3d/src/application.rs index 64ddef75..fa4db934 100644 --- a/server-rs/crates/module-match3d/src/application.rs +++ b/server-rs/crates/module-match3d/src/application.rs @@ -419,12 +419,12 @@ pub fn resolve_match3d_item_type_count_for_difficulty(clear_count: u32, difficul 8 => 3, 12 => 9, 16 => 15, - 20 | 21 => 21, + 20 | 21 => 20, _ => match difficulty { 0..=2 => 3, 3..=4 => 9, 5..=6 => 15, - _ => 21, + _ => 20, }, }; @@ -432,8 +432,8 @@ pub fn resolve_match3d_item_type_count_for_difficulty(clear_count: u32, difficul } pub fn normalize_match3d_runtime_clear_count(clear_count: u32, difficulty: u32) -> u32 { - // 中文注释:旧硬核草稿曾保存 clear_count=20;新硬核固定 21 种物品, - // 运行态也升到 21 组三消,避免出现 20 组却要求 21 种素材的不可达状态。 + // 中文注释:旧硬核草稿曾保存 clear_count=20;运行态保留硬核 21 组三消节奏, + // 但本局物品类型池仍由难度映射到最多 20 种,避免超过 10*10 Sprite 解析素材上限。 if clear_count == 20 && difficulty >= 7 { 21 } else { @@ -885,7 +885,7 @@ mod tests { } #[test] - fn legacy_hardcore_clear_count_runs_as_twenty_one_groups() { + fn legacy_hardcore_clear_count_runs_with_twenty_item_types() { let run = start_run_with_seed_at( "run-types-legacy-hardcore".to_string(), "user-1".to_string(), @@ -903,8 +903,8 @@ mod tests { assert_eq!(run.clear_count, 21); assert_eq!(run.total_item_count, 63); - assert_eq!(counts.len(), 21); - assert!(counts.values().all(|count| *count == 3)); + assert_eq!(counts.len(), 20); + assert_eq!(counts.values().sum::(), 63); } #[test] @@ -931,8 +931,8 @@ mod tests { } #[test] - fn size_tier_plan_follows_ratio_for_twenty_five_types() { - let plan = resolve_size_tier_plan(25); + fn size_tier_plan_follows_ratio_for_twenty_types() { + let plan = resolve_size_tier_plan(20); let mut counts = BTreeMap::<&str, usize>::new(); for rule in plan { *counts.entry(rule.tier).or_default() += 1; @@ -946,10 +946,10 @@ mod tests { } } - assert_eq!(counts.get("XL"), Some(&5)); - assert_eq!(counts.get("L"), Some(&8)); - assert_eq!(counts.get("M"), Some(&7)); - assert_eq!(counts.get("XS"), Some(&4)); + assert_eq!(counts.get("XL"), Some(&4)); + assert_eq!(counts.get("L"), Some(&6)); + assert_eq!(counts.get("M"), Some(&6)); + assert_eq!(counts.get("XS"), Some(&3)); assert_eq!(counts.get("S"), Some(&1)); } @@ -962,7 +962,7 @@ mod tests { &test_config(30), 42, 1_000, - Some(25), + Some(20), ) .expect("run should start"); @@ -974,7 +974,7 @@ mod tests { .push((item.radius * 10_000.0).round() as u32); } - assert_eq!(radii_by_visual_key.len(), 25); + assert_eq!(radii_by_visual_key.len(), 20); assert!( radii_by_visual_key .values() @@ -1052,7 +1052,7 @@ mod tests { .filter(|item| { let dx = item.x - MATCH3D_BOARD_CENTER; let dy = item.y - MATCH3D_BOARD_CENTER; - (dx * dx + dy * dy).sqrt() > 0.32 + (dx * dx + dy * dy).sqrt() > 0.26 }) .count(); let mut quadrants = BTreeMap::::new(); @@ -1081,15 +1081,15 @@ mod tests { } #[test] - fn twenty_five_or_less_does_not_repeat_visual_keys() { + fn twenty_or_less_does_not_repeat_visual_keys() { let run = start_run_with_seed_at_and_item_type_count( "run-block-unique".to_string(), "user-1".to_string(), "profile-1".to_string(), - &test_config(25), + &test_config(20), 27, 1_000, - Some(25), + Some(20), ) .expect("run should start"); @@ -1098,7 +1098,7 @@ mod tests { *counts.entry(item.visual_key.clone()).or_default() += 1; } - assert_eq!(counts.len(), 25); + assert_eq!(counts.len(), 20); assert!(counts.values().all(|count| *count == 3)); } diff --git a/server-rs/crates/module-match3d/src/domain.rs b/server-rs/crates/module-match3d/src/domain.rs index 53e50b08..e25f7c47 100644 --- a/server-rs/crates/module-match3d/src/domain.rs +++ b/server-rs/crates/module-match3d/src/domain.rs @@ -9,8 +9,8 @@ pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-"; pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-"; pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3; -pub const MATCH3D_MAX_ITEM_TYPE_COUNT: u32 = 25; -pub(crate) const MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE: usize = 25; +pub const MATCH3D_MAX_ITEM_TYPE_COUNT: u32 = 20; +pub(crate) const MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE: usize = 20; pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000; @@ -18,8 +18,7 @@ pub const MATCH3D_BOARD_CENTER: f32 = 0.5; pub const MATCH3D_BOARD_RADIUS: f32 = 0.5; pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035; -// 中文注释:首版 demo 不接真实图片生成,当前先用程序化积木件作为稳定可辨认的默认素材。 -// 中文注释:当前 demo 使用 25 个积木件作为默认可消除物资源池,前端据 visual_key 程序化生成 3D 模型。 +// 中文注释:默认资源池对齐抓大鹅 10*10 物品 Sprite 的 20 种物品上限。 pub(crate) const MATCH3D_BLOCK_VISUAL_KEYS: [&str; MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE] = [ "block-red-2x4", "block-blue-1x2", @@ -29,19 +28,14 @@ pub(crate) const MATCH3D_BLOCK_VISUAL_KEYS: [&str; MATCH3D_MAX_ITEM_TYPE_COUNT_U "block-white-1x1", "block-black-1x8", "block-tan-2x3", - "block-lime-1x2", "block-darkred-2x2", "block-blue-1x4", "block-pink-2x4", "block-gray-1x6", "block-lavender-tile-2x2", "block-teal-tile-1x3", - "block-mint-tile-1x4", - "block-magenta-tile-2x2", "block-orange-tile-2x2-stud", "block-purple-slope-1x2", - "block-brown-slope-1x2", - "block-sky-slope-2x2", "block-green-cylinder", "block-clear-ring", "block-mint-arch", diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index eed25933..a3cdfa8b 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -189,6 +189,12 @@ pub fn compile_result_draft_from_seed( ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: Vec::new(), selected_candidate_id: None, @@ -249,6 +255,12 @@ pub fn build_form_draft_from_parts( ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: Vec::new(), selected_candidate_id: None, @@ -358,6 +370,12 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: draft.candidates.clone(), selected_candidate_id: draft.selected_candidate_id.clone(), @@ -448,6 +466,12 @@ pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: Vec::new(), selected_candidate_id: None, @@ -804,17 +828,89 @@ fn first_profile_ui_background_level(profile: &PuzzleWorkProfile) -> Option Option { + normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) + .unwrap_or_else(|_| profile.levels.clone()) + .into_iter() + .find(|level| { + level + .level_background_image_src + .as_deref() + .and_then(normalize_required_string) + .is_some() + || level + .level_background_image_object_key + .as_deref() + .and_then(normalize_required_string) + .is_some() + }) +} + fn resolve_puzzle_runtime_ui_background_fields( level: Option<&PuzzleDraftLevel>, fallback_level: Option<&PuzzleDraftLevel>, ) -> (Option, Option) { for candidate in [level, fallback_level].into_iter().flatten() { let image_src = candidate - .ui_background_image_src + .level_background_image_src + .as_deref() + .and_then(normalize_required_string) + .or_else(|| { + candidate + .ui_background_image_src + .as_deref() + .and_then(normalize_required_string) + }); + let object_key = candidate + .level_background_image_object_key + .as_deref() + .and_then(|value| normalize_required_string(value.trim_start_matches('/'))) + .or_else(|| { + candidate + .ui_background_image_object_key + .as_deref() + .and_then(|value| normalize_required_string(value.trim_start_matches('/'))) + }); + if image_src.is_some() || object_key.is_some() { + return (image_src, object_key); + } + } + + (None, None) +} + +fn resolve_puzzle_runtime_level_background_fields( + level: Option<&PuzzleDraftLevel>, + fallback_level: Option<&PuzzleDraftLevel>, +) -> (Option, Option) { + for candidate in [level, fallback_level].into_iter().flatten() { + let image_src = candidate + .level_background_image_src .as_deref() .and_then(normalize_required_string); let object_key = candidate - .ui_background_image_object_key + .level_background_image_object_key + .as_deref() + .and_then(|value| normalize_required_string(value.trim_start_matches('/'))); + if image_src.is_some() || object_key.is_some() { + return (image_src, object_key); + } + } + + (None, None) +} + +fn resolve_puzzle_runtime_ui_spritesheet_fields( + level: Option<&PuzzleDraftLevel>, + fallback_level: Option<&PuzzleDraftLevel>, +) -> (Option, Option) { + for candidate in [level, fallback_level].into_iter().flatten() { + let image_src = candidate + .ui_spritesheet_image_src + .as_deref() + .and_then(normalize_required_string); + let object_key = candidate + .ui_spritesheet_image_object_key .as_deref() .and_then(|value| normalize_required_string(value.trim_start_matches('/'))); if image_src.is_some() || object_key.is_some() { @@ -1092,6 +1188,17 @@ pub fn start_run_with_shuffle_seed_at( current_profile_level.as_ref(), ui_background_level.as_ref(), ); + let level_background_level = first_profile_level_background_level(entry_profile); + let (level_background_image_src, level_background_image_object_key) = + resolve_puzzle_runtime_level_background_fields( + current_profile_level.as_ref(), + level_background_level.as_ref(), + ); + let (ui_spritesheet_image_src, ui_spritesheet_image_object_key) = + resolve_puzzle_runtime_ui_spritesheet_fields( + current_profile_level.as_ref(), + entry_profile.levels.first(), + ); Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), @@ -1114,6 +1221,10 @@ pub fn start_run_with_shuffle_seed_at( cover_image_src: entry_profile.cover_image_src.clone(), ui_background_image_src, ui_background_image_object_key, + level_background_image_src, + level_background_image_object_key, + ui_spritesheet_image_src, + ui_spritesheet_image_object_key, background_music: current_profile_level .as_ref() .and_then(|level| level.background_music.clone()), @@ -1373,10 +1484,29 @@ pub fn advance_next_level_at( current_profile_level.as_ref(), ui_background_level.as_ref(), ); + let level_background_level = first_profile_level_background_level(next_profile); + let (mut level_background_image_src, mut level_background_image_object_key) = + resolve_puzzle_runtime_level_background_fields( + current_profile_level.as_ref(), + level_background_level.as_ref(), + ); + let (mut ui_spritesheet_image_src, mut ui_spritesheet_image_object_key) = + resolve_puzzle_runtime_ui_spritesheet_fields( + current_profile_level.as_ref(), + next_profile.levels.first(), + ); if ui_background_image_src.is_none() && ui_background_image_object_key.is_none() { ui_background_image_src = current_level.ui_background_image_src.clone(); ui_background_image_object_key = current_level.ui_background_image_object_key.clone(); } + if level_background_image_src.is_none() && level_background_image_object_key.is_none() { + level_background_image_src = current_level.level_background_image_src.clone(); + level_background_image_object_key = current_level.level_background_image_object_key.clone(); + } + if ui_spritesheet_image_src.is_none() && ui_spritesheet_image_object_key.is_none() { + ui_spritesheet_image_src = current_level.ui_spritesheet_image_src.clone(); + ui_spritesheet_image_object_key = current_level.ui_spritesheet_image_object_key.clone(); + } Ok(PuzzleRunSnapshot { run_id: run.run_id.clone(), @@ -1400,6 +1530,10 @@ pub fn advance_next_level_at( cover_image_src: next_profile.cover_image_src.clone(), ui_background_image_src, ui_background_image_object_key, + level_background_image_src, + level_background_image_object_key, + ui_spritesheet_image_src, + ui_spritesheet_image_object_key, background_music: current_profile_level .as_ref() .and_then(|level| level.background_music.clone()), @@ -1461,6 +1595,17 @@ pub fn advance_to_new_work_first_level_at( current_profile_level.as_ref(), ui_background_level.as_ref(), ); + let level_background_level = first_profile_level_background_level(next_profile); + let (level_background_image_src, level_background_image_object_key) = + resolve_puzzle_runtime_level_background_fields( + current_profile_level.as_ref(), + level_background_level.as_ref(), + ); + let (ui_spritesheet_image_src, ui_spritesheet_image_object_key) = + resolve_puzzle_runtime_ui_spritesheet_fields( + current_profile_level.as_ref(), + next_profile.levels.first(), + ); Ok(PuzzleRunSnapshot { run_id: run.run_id.clone(), @@ -1484,6 +1629,10 @@ pub fn advance_to_new_work_first_level_at( cover_image_src: next_profile.cover_image_src.clone(), ui_background_image_src, ui_background_image_object_key, + level_background_image_src, + level_background_image_object_key, + ui_spritesheet_image_src, + ui_spritesheet_image_object_key, background_music: current_profile_level .as_ref() .and_then(|level| level.background_music.clone()), @@ -2900,6 +3049,12 @@ mod tests { ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: Vec::new(), selected_candidate_id: None, @@ -3118,6 +3273,12 @@ mod tests { ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: Vec::new(), selected_candidate_id: None, @@ -3133,6 +3294,12 @@ mod tests { ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: Vec::new(), selected_candidate_id: None, @@ -3248,6 +3415,12 @@ mod tests { ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: Vec::new(), selected_candidate_id: None, diff --git a/server-rs/crates/module-puzzle/src/creative_tools.rs b/server-rs/crates/module-puzzle/src/creative_tools.rs index 212d63be..a4199928 100644 --- a/server-rs/crates/module-puzzle/src/creative_tools.rs +++ b/server-rs/crates/module-puzzle/src/creative_tools.rs @@ -172,6 +172,12 @@ pub fn build_puzzle_draft_from_creative_fields( ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: Vec::new(), selected_candidate_id: None, diff --git a/server-rs/crates/module-puzzle/src/domain.rs b/server-rs/crates/module-puzzle/src/domain.rs index 7cd827e2..444f58e0 100644 --- a/server-rs/crates/module-puzzle/src/domain.rs +++ b/server-rs/crates/module-puzzle/src/domain.rs @@ -138,6 +138,18 @@ pub struct PuzzleDraftLevel { #[serde(default)] pub ui_background_image_object_key: Option, #[serde(default)] + pub level_scene_image_src: Option, + #[serde(default)] + pub level_scene_image_object_key: Option, + #[serde(default)] + pub ui_spritesheet_image_src: Option, + #[serde(default)] + pub ui_spritesheet_image_object_key: Option, + #[serde(default)] + pub level_background_image_src: Option, + #[serde(default)] + pub level_background_image_object_key: Option, + #[serde(default)] pub background_music: Option, pub candidates: Vec, pub selected_candidate_id: Option, @@ -367,6 +379,14 @@ pub struct PuzzleRuntimeLevelSnapshot { #[serde(default)] pub ui_background_image_object_key: Option, #[serde(default)] + pub level_background_image_src: Option, + #[serde(default)] + pub level_background_image_object_key: Option, + #[serde(default)] + pub ui_spritesheet_image_src: Option, + #[serde(default)] + pub ui_spritesheet_image_object_key: Option, + #[serde(default)] pub background_music: Option, pub board: PuzzleBoardSnapshot, pub status: PuzzleRuntimeLevelStatus, diff --git a/server-rs/crates/module-runtime-story/src/battle_tests.rs b/server-rs/crates/module-runtime-story/src/battle_tests.rs index a7d41413..51b9c3fe 100644 --- a/server-rs/crates/module-runtime-story/src/battle_tests.rs +++ b/server-rs/crates/module-runtime-story/src/battle_tests.rs @@ -5,8 +5,8 @@ use shared_contracts::runtime_story::{ }; use crate::{ - battle::resolve_battle_action, build_status_patch, read_bool_field, read_i32_field, - read_optional_string_field, + StoryRuntimeActionResolveInput, battle::resolve_battle_action, build_status_patch, + read_bool_field, read_i32_field, read_optional_string_field, resolve_story_runtime_action, }; fn build_battle_fixture() -> serde_json::Value { @@ -61,6 +61,115 @@ fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequ } } +fn build_runtime_action_request( + function_id: &str, + action_text: &str, + payload: Option, +) -> shared_contracts::story::ResolveStoryRuntimeActionRequest { + shared_contracts::story::ResolveStoryRuntimeActionRequest { + story_session_id: "storysess-1".to_string(), + client_version: Some(1), + function_id: function_id.to_string(), + action_text: action_text.to_string(), + target_id: None, + payload, + } +} + +fn build_custom_world_profile_with_two_landmarks() -> serde_json::Value { + json!({ + "id": "profile-1", + "name": "雾桥旧约", + "summary": "雾桥边的旧约正在复苏。", + "camp": { + "id": "camp-1", + "name": "雾桥营地", + "description": "营火压着雾气。", + "connections": [ + { + "targetLandmarkId": "landmark-1", + "relativePosition": "forward", + "summary": "沿桥面继续前进" + }, + { + "targetLandmarkId": "landmark-2", + "relativePosition": "right", + "summary": "转入雾中支路" + } + ] + }, + "landmarks": [ + { + "id": "landmark-1", + "name": "断桥口", + "description": "桥口挂着旧灯。" + }, + { + "id": "landmark-2", + "name": "雾中渡", + "description": "渡口只有潮声。" + } + ], + "storyNpcs": [ + { + "id": "npc-bridge", + "name": "桥影", + "description": "桥下逼来的敌影", + "initialAffinity": -20 + }, + { + "id": "npc-ferryman", + "name": "摆渡人", + "description": "守着雾中渡的人", + "initialAffinity": 0 + } + ], + "sceneChapterBlueprints": [ + { + "id": "chapter-camp", + "sceneId": "camp-1", + "linkedLandmarkIds": ["camp-1"], + "acts": [ + { + "id": "act-camp-1", + "sceneId": "camp-1", + "oppositeNpcId": "npc-bridge" + }, + { + "id": "act-camp-2", + "sceneId": "camp-1", + "oppositeNpcId": "npc-ferryman" + } + ] + }, + { + "id": "chapter-landmark-1", + "sceneId": "landmark-1", + "linkedLandmarkIds": ["landmark-1"], + "acts": [ + { + "id": "act-landmark-1", + "sceneId": "landmark-1", + "oppositeNpcId": "npc-ferryman" + } + ] + } + ] + }) +} + +fn build_story_runtime_snapshot( + game_state: serde_json::Value, + current_story: Option, +) -> shared_contracts::story::StoryRuntimeSnapshotPayload { + shared_contracts::story::StoryRuntimeSnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state, + current_story, + } +} + #[test] fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() { let request = build_request("battle_all_in_crush", "全力压制"); @@ -89,3 +198,210 @@ fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() { Some("defeat".to_string()) ); } + +#[test] +fn terminal_battle_action_persists_post_battle_continue_story() { + let mut game_state = build_battle_fixture(); + game_state["runtimeSessionId"] = json!("runtime-1"); + game_state["currentScene"] = json!("Story"); + game_state["worldType"] = json!("CUSTOM"); + game_state["playerHp"] = json!(30); + game_state["customWorldProfile"] = build_custom_world_profile_with_two_landmarks(); + game_state["currentScenePreset"] = json!({ + "id": "custom-scene-camp", + "name": "雾桥营地", + "description": "营火压着雾气。", + "connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"], + "forwardSceneId": "custom-scene-landmark-1", + "treasureHints": [], + "npcs": [] + }); + game_state["storyEngineMemory"] = json!({ + "currentSceneActState": { + "sceneId": "camp-1", + "chapterId": "chapter-camp", + "currentActId": "act-camp-1", + "currentActIndex": 0, + "completedActIds": [], + "visitedActIds": ["act-camp-1"] + } + }); + + let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { + story_session_id: "storysess-1".to_string(), + runtime_session_id: "runtime-1".to_string(), + snapshot: build_story_runtime_snapshot(game_state, None), + request: build_runtime_action_request("battle_all_in_crush", "全力压制", None), + }) + .expect("terminal battle should resolve"); + + assert_eq!( + output.presentation.battle.unwrap().outcome.as_deref(), + Some("victory") + ); + assert_eq!( + output.presentation.options[0].function_id, + "story_continue_adventure" + ); + assert_eq!( + output.snapshot.current_story.as_ref().unwrap()["options"][0]["functionId"], + json!("story_continue_adventure") + ); + assert!( + output.snapshot.current_story.as_ref().unwrap()["deferredOptions"] + .as_array() + .is_some_and(|items| { + items + .iter() + .any(|item| item["functionId"] == json!("idle_travel_next_scene")) + }) + ); + assert_eq!( + output.snapshot.current_story.as_ref().unwrap()["deferredRuntimeState"]["storyEngineMemory"] + ["currentSceneActState"]["currentActId"], + json!("act-camp-2") + ); + assert_eq!( + output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"], + json!("act-camp-2") + ); +} + +#[test] +fn idle_travel_next_scene_changes_scene_from_target_payload() { + let game_state = json!({ + "runtimeSessionId": "runtime-1", + "runtimeActionVersion": 1, + "currentScene": "Story", + "worldType": "CUSTOM", + "customWorldProfile": build_custom_world_profile_with_two_landmarks(), + "playerHp": 30, + "playerMaxHp": 40, + "playerMana": 10, + "playerMaxMana": 20, + "playerCurrency": 0, + "playerInventory": [], + "playerEquipment": { "weapon": null, "armor": null, "relic": null }, + "runtimeStats": { + "hostileNpcsDefeated": 0, + "itemsUsed": 0, + "questsAccepted": 0, + "scenesTraveled": 0, + "playTimeMs": 0, + "lastPlayTickAt": null + }, + "currentScenePreset": { + "id": "custom-scene-camp", + "name": "雾桥营地", + "description": "营火压着雾气。", + "connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"], + "connections": [ + { + "sceneId": "custom-scene-landmark-1", + "relativePosition": "forward", + "summary": "沿桥面继续前进" + } + ], + "forwardSceneId": "custom-scene-landmark-1", + "treasureHints": [], + "npcs": [] + }, + "currentEncounter": null, + "npcInteractionActive": false, + "sceneHostileNpcs": [], + "inBattle": false, + "storyHistory": [], + "storyEngineMemory": {} + }); + + let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { + story_session_id: "storysess-1".to_string(), + runtime_session_id: "runtime-1".to_string(), + snapshot: build_story_runtime_snapshot(game_state, None), + request: build_runtime_action_request( + "idle_travel_next_scene", + "向前走,前往断桥口", + Some(json!({ "targetSceneId": "custom-scene-landmark-1" })), + ), + }) + .expect("travel action should resolve"); + + assert_eq!( + output.snapshot.game_state["currentScenePreset"]["id"], + json!("custom-scene-landmark-1") + ); + assert_eq!( + output.snapshot.game_state["runtimeStats"]["scenesTraveled"], + json!(1) + ); + assert_eq!( + output.snapshot.game_state["currentEncounter"]["id"], + json!("npc-ferryman") + ); + assert_eq!( + output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"], + json!("act-landmark-1") + ); + assert!(output.presentation.options.iter().any(|option| { + option.function_id == "idle_travel_next_scene" + || option.function_id == "idle_explore_forward" + })); +} + +#[test] +fn idle_travel_next_scene_normalizes_custom_landmark_id_payload() { + let game_state = json!({ + "runtimeSessionId": "runtime-1", + "runtimeActionVersion": 1, + "currentScene": "Story", + "worldType": "CUSTOM", + "customWorldProfile": build_custom_world_profile_with_two_landmarks(), + "playerHp": 30, + "playerMaxHp": 40, + "playerMana": 10, + "playerMaxMana": 20, + "playerCurrency": 0, + "playerInventory": [], + "playerEquipment": { "weapon": null, "armor": null, "relic": null }, + "runtimeStats": { + "hostileNpcsDefeated": 0, + "itemsUsed": 0, + "questsAccepted": 0, + "scenesTraveled": 0, + "playTimeMs": 0, + "lastPlayTickAt": null + }, + "currentScenePreset": { + "id": "custom-scene-camp", + "name": "雾桥营地", + "description": "营火压着雾气。", + "connectedSceneIds": ["landmark-1", "landmark-2"], + "forwardSceneId": "landmark-2", + "treasureHints": [], + "npcs": [] + }, + "currentEncounter": null, + "npcInteractionActive": false, + "sceneHostileNpcs": [], + "inBattle": false, + "storyHistory": [], + "storyEngineMemory": {} + }); + + let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { + story_session_id: "storysess-1".to_string(), + runtime_session_id: "runtime-1".to_string(), + snapshot: build_story_runtime_snapshot(game_state, None), + request: build_runtime_action_request( + "idle_travel_next_scene", + "前往雾中渡", + Some(json!({ "targetSceneId": "landmark-2" })), + ), + }) + .expect("raw custom landmark id should resolve"); + + assert_eq!( + output.snapshot.game_state["currentScenePreset"]["id"], + json!("custom-scene-landmark-2") + ); +} diff --git a/server-rs/crates/module-runtime-story/src/lib.rs b/server-rs/crates/module-runtime-story/src/lib.rs index 53e06300..485377f1 100644 --- a/server-rs/crates/module-runtime-story/src/lib.rs +++ b/server-rs/crates/module-runtime-story/src/lib.rs @@ -76,7 +76,9 @@ pub use options::{ build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope, }; pub use post_battle::{ - finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_post_battle_story_options, + clear_post_battle_state, ensure_scene_act_state, ensure_scene_encounter_preview, + finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_forward_scene_id, + resolve_post_battle_story_options, resolve_runtime_scene_preset, }; pub use projection::{StoryRuntimeProjectionSource, build_story_runtime_projection}; pub use prompt_context::{RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context}; diff --git a/server-rs/crates/module-runtime-story/src/post_battle.rs b/server-rs/crates/module-runtime-story/src/post_battle.rs index f804bfa4..efeabe8f 100644 --- a/server-rs/crates/module-runtime-story/src/post_battle.rs +++ b/server-rs/crates/module-runtime-story/src/post_battle.rs @@ -2,10 +2,11 @@ use serde_json::{Value, json}; use shared_contracts::runtime_story::RuntimeStoryOptionView; use crate::{ - CONTINUE_ADVENTURE_FUNCTION_ID, build_static_runtime_story_option, + CONTINUE_ADVENTURE_FUNCTION_ID, build_custom_scene_preset, build_static_runtime_story_option, build_story_option_from_runtime_option, ensure_json_object, read_array_field, read_bool_field, - read_field, read_i32_field, read_object_field, read_optional_string_field, write_bool_field, - write_i32_field, write_null_field, write_string_field, + read_field, read_i32_field, read_object_field, read_optional_string_field, + resolve_custom_runtime_scene_id, write_bool_field, write_i32_field, write_null_field, + write_string_field, }; const WUXIA_FIRST_SCENE_ID: &str = "wuxia-bamboo-road"; @@ -36,6 +37,8 @@ pub fn finalize_post_battle_resolution( return None; } + let original_scene_act_state = current_scene_act_state(game_state); + if outcome == "defeat" { return Some(finalize_defeat_revive(game_state, fallback_options)); } @@ -45,6 +48,7 @@ pub fn finalize_post_battle_resolution( game_state, result_text, fallback_options, + original_scene_act_state, )); } @@ -64,13 +68,14 @@ fn finalize_victory_or_spar( game_state: &mut Value, result_text: &str, fallback_options: Vec, + original_scene_act_state: Option, ) -> PostBattleFinalization { clear_post_battle_state(game_state); let is_last_act = is_current_scene_act_last(game_state); let next_act_state = if is_last_act { None } else { - resolve_next_scene_act_runtime_state(game_state) + resolve_next_scene_act_runtime_state(game_state, original_scene_act_state.as_ref()) }; if let Some(next_act_state) = next_act_state { write_current_scene_act_state(game_state, next_act_state); @@ -141,7 +146,7 @@ fn finalize_defeat_revive( { write_current_scene_act_state(game_state, first_act_state); } - ensure_first_scene_encounter_preview(game_state); + ensure_scene_encounter_preview(game_state); let story_text = if first_scene.name.is_empty() { "你在战斗中倒下,随后重新醒来。".to_string() @@ -160,7 +165,7 @@ fn finalize_defeat_revive( } } -fn clear_post_battle_state(game_state: &mut Value) { +pub fn clear_post_battle_state(game_state: &mut Value) { write_null_field(game_state, "currentEncounter"); write_bool_field(game_state, "npcInteractionActive", false); ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); @@ -421,7 +426,7 @@ fn write_first_scene(game_state: &mut Value, scene: &RuntimeScene) { ); } -fn ensure_first_scene_encounter_preview(game_state: &mut Value) { +pub fn ensure_scene_encounter_preview(game_state: &mut Value) { if read_bool_field(game_state, "inBattle").unwrap_or(false) { return; } @@ -436,7 +441,13 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) { }; let scene_id = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")); - let focus_npc_id = resolve_active_scene_act_focus_npc_id(profile, scene_id.as_deref()); + let current_act_id = current_scene_act_state(game_state) + .and_then(|state| read_optional_string_field(&state, "currentActId")); + let focus_npc_id = resolve_active_scene_act_focus_npc_id( + profile, + scene_id.as_deref(), + current_act_id.as_deref(), + ); let Some(focus_npc_id) = focus_npc_id else { return; }; @@ -450,6 +461,22 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) { ); } +pub fn ensure_scene_act_state(game_state: &mut Value) { + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + return; + } + let Some(scene_id) = read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")) + else { + return; + }; + let Some(act_state) = build_initial_scene_act_runtime_state(game_state, scene_id.as_str()) + else { + return; + }; + write_current_scene_act_state(game_state, act_state); +} + fn build_scene_travel_options(game_state: &Value) -> Vec { let Some(current_scene) = read_object_field(game_state, "currentScenePreset") else { return vec![build_static_runtime_story_option( @@ -459,32 +486,53 @@ fn build_scene_travel_options(game_state: &Value) -> Vec )]; }; let current_scene_id = read_optional_string_field(current_scene, "id"); - let mut options = read_array_field(current_scene, "connections") + let forward_scene_id = read_optional_string_field(current_scene, "forwardSceneId"); + let mut option_scene_ids = Vec::new(); + let mut options = Vec::new(); + + for connection in read_array_field(current_scene, "connections") { + let Some(scene_id) = read_optional_string_field(connection, "sceneId") else { + continue; + }; + if current_scene_id.as_deref() == Some(scene_id.as_str()) + || option_scene_ids.iter().any(|id| id == scene_id.as_str()) + { + continue; + } + let relative_position = read_optional_string_field(connection, "relativePosition") + .unwrap_or_else(|| "forward".to_string()); + options.push(build_scene_travel_option( + game_state, + scene_id.as_str(), + relative_position.as_str(), + )); + option_scene_ids.push(scene_id); + } + + for scene_id in read_array_field(current_scene, "connectedSceneIds") .into_iter() - .filter_map(|connection| { - let scene_id = read_optional_string_field(connection, "sceneId")?; - if current_scene_id.as_deref() == Some(scene_id.as_str()) { - return None; - } - let relative_position = read_optional_string_field(connection, "relativePosition") - .unwrap_or_else(|| "forward".to_string()); - let scene_name = resolve_scene_name(game_state, scene_id.as_str()) - .unwrap_or_else(|| scene_id.clone()); - Some(RuntimeStoryOptionView { - payload: Some(json!({ "targetSceneId": scene_id })), - ..build_static_runtime_story_option( - "idle_travel_next_scene", - format!( - "{},前往{}", - direction_text(relative_position.as_str()), - scene_name - ) - .as_str(), - "story", - ) - }) - }) - .collect::>(); + .filter_map(|scene_id| scene_id.as_str().map(str::to_string)) + .chain(forward_scene_id.clone()) + { + // 中文注释:bootstrap 生成的旧快照常只有 connectedSceneIds / forwardSceneId, + // 没有展开 connections;这里也要生成旅行 action,避免战后只剩默认 idle 选项循环。 + if current_scene_id.as_deref() == Some(scene_id.as_str()) + || option_scene_ids.iter().any(|id| id == scene_id.as_str()) + { + continue; + } + let relative_position = if forward_scene_id.as_deref() == Some(scene_id.as_str()) { + "forward" + } else { + "portal" + }; + options.push(build_scene_travel_option( + game_state, + scene_id.as_str(), + relative_position, + )); + option_scene_ids.push(scene_id); + } if options.is_empty() { options.push(build_static_runtime_story_option( @@ -497,6 +545,163 @@ fn build_scene_travel_options(game_state: &Value) -> Vec options } +fn build_scene_travel_option( + game_state: &Value, + scene_id: &str, + relative_position: &str, +) -> RuntimeStoryOptionView { + let scene_name = + resolve_scene_name(game_state, scene_id).unwrap_or_else(|| scene_id.to_string()); + RuntimeStoryOptionView { + payload: Some(json!({ "targetSceneId": scene_id })), + ..build_static_runtime_story_option( + "idle_travel_next_scene", + format!("{},前往{}", direction_text(relative_position), scene_name).as_str(), + "story", + ) + } +} + +pub fn resolve_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option { + let normalized_scene_id = scene_id.trim(); + if normalized_scene_id.is_empty() { + return None; + } + + if let Some(profile) = read_object_field(game_state, "customWorldProfile") + && let Some(scene) = build_custom_scene_preset( + profile, + resolve_custom_runtime_scene_id(profile, normalized_scene_id).as_str(), + ) + { + return Some(scene); + } + + resolve_builtin_runtime_scene_preset(game_state, normalized_scene_id) +} + +pub fn resolve_forward_scene_id(game_state: &Value) -> Option { + read_object_field(game_state, "currentScenePreset").and_then(|scene| { + read_optional_string_field(scene, "forwardSceneId") + .or_else(|| { + read_array_field(scene, "connections") + .into_iter() + .find_map(|connection| read_optional_string_field(connection, "sceneId")) + }) + .or_else(|| { + read_array_field(scene, "connectedSceneIds") + .into_iter() + .find_map(|scene_id| scene_id.as_str().map(str::to_string)) + }) + }) +} + +fn resolve_builtin_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option { + let template = builtin_runtime_scene_template(scene_id)?; + Some(json!({ + "id": template.id, + "name": template.name, + "description": template.description, + "imageSrc": read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "imageSrc")) + .unwrap_or_default(), + "connectedSceneIds": template.connected_scene_ids, + "connections": template.connections, + "forwardSceneId": template.forward_scene_id, + "treasureHints": template.treasure_hints, + "npcs": [], + })) +} + +fn builtin_runtime_scene_template(scene_id: &str) -> Option { + let is_xianxia = matches!( + scene_id, + "xianxia-cloud-gate" + | "xianxia-floating-isle" + | "xianxia-celestial-corridor" + | "xianxia-star-vessel" + ); + if is_xianxia { + return Some(RuntimeScene { + id: scene_id.to_string(), + name: match scene_id { + "xianxia-floating-isle" => "浮空灵岛", + "xianxia-celestial-corridor" => "天门长廊", + "xianxia-star-vessel" => "星槎泊台", + _ => XIANXIA_FIRST_SCENE_NAME, + } + .to_string(), + description: match scene_id { + "xianxia-floating-isle" => "浮岛边缘灵雾翻涌,远处有阵纹一明一暗。", + "xianxia-celestial-corridor" => "长廊悬在云海上方,符光沿石柱缓慢游走。", + "xianxia-star-vessel" => "星槎泊在云海边缘,船身仍有星砂微光。", + _ => XIANXIA_FIRST_SCENE_DESCRIPTION, + } + .to_string(), + image_src: String::new(), + connected_scene_ids: vec![ + "xianxia-cloud-gate".to_string(), + "xianxia-floating-isle".to_string(), + "xianxia-celestial-corridor".to_string(), + ] + .into_iter() + .filter(|id| id != scene_id) + .collect(), + connections: vec![json!({ + "sceneId": if scene_id == "xianxia-cloud-gate" { "xianxia-celestial-corridor" } else { "xianxia-cloud-gate" }, + "relativePosition": if scene_id == "xianxia-cloud-gate" { "forward" } else { "back" }, + "summary": "沿主路继续移动" + })], + forward_scene_id: Some(if scene_id == "xianxia-cloud-gate" { + "xianxia-celestial-corridor".to_string() + } else { + "xianxia-cloud-gate".to_string() + }), + treasure_hints: vec!["云阶边缘的灵光残痕".to_string()], + npcs: Vec::new(), + }); + } + + Some(RuntimeScene { + id: scene_id.to_string(), + name: match scene_id { + "wuxia-mountain-gate" => "山门石阶", + "wuxia-mist-woods" => "迷雾竹林", + "wuxia-ferry-bridge" => "渡口断桥", + _ => WUXIA_FIRST_SCENE_NAME, + } + .to_string(), + description: match scene_id { + "wuxia-mountain-gate" => "山门石阶覆着苔痕,旧旗在风里压得很低。", + "wuxia-mist-woods" => "迷雾在竹林间翻卷,脚下泥印很快又被雾水抹平。", + "wuxia-ferry-bridge" => "渡口断桥横在冷水上,桥边灯笼只剩半截残光。", + _ => WUXIA_FIRST_SCENE_DESCRIPTION, + } + .to_string(), + image_src: String::new(), + connected_scene_ids: vec![ + "wuxia-bamboo-road".to_string(), + "wuxia-mountain-gate".to_string(), + "wuxia-mist-woods".to_string(), + ] + .into_iter() + .filter(|id| id != scene_id) + .collect(), + connections: vec![json!({ + "sceneId": if scene_id == "wuxia-bamboo-road" { "wuxia-mountain-gate" } else { "wuxia-bamboo-road" }, + "relativePosition": if scene_id == "wuxia-bamboo-road" { "forward" } else { "back" }, + "summary": "沿主路继续移动" + })], + forward_scene_id: Some(if scene_id == "wuxia-bamboo-road" { + "wuxia-mountain-gate".to_string() + } else { + "wuxia-bamboo-road".to_string() + }), + treasure_hints: vec!["路边半埋的旧物".to_string()], + npcs: Vec::new(), + }) +} + fn resolve_scene_name(game_state: &Value, scene_id: &str) -> Option { if read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")) @@ -553,7 +758,10 @@ fn direction_text(relative_position: &str) -> &'static str { } } -fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option { +fn resolve_next_scene_act_runtime_state( + game_state: &Value, + current_act_state_override: Option<&Value>, +) -> Option { let profile = read_object_field(game_state, "customWorldProfile")?; let scene_id = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")); @@ -563,7 +771,9 @@ fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option { if acts.is_empty() { return None; } - let runtime_state = build_initial_scene_act_runtime_state(game_state, scene_id_text)?; + let runtime_state = current_act_state_override + .cloned() + .or_else(|| build_initial_scene_act_runtime_state(game_state, scene_id_text))?; let current_act_id = read_optional_string_field(&runtime_state, "currentActId"); let current_index = acts .iter() @@ -762,9 +972,17 @@ fn resolve_scene_aliases(profile: &Value, scene_id: &str) -> Vec { fn resolve_active_scene_act_focus_npc_id( profile: &Value, scene_id: Option<&str>, + current_act_id: Option<&str>, ) -> Option { let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?; - let act_state = read_array_field(chapter, "acts").first().copied()?; + let acts = read_array_field(chapter, "acts"); + let act_state = current_act_id + .and_then(|act_id| { + acts.iter() + .copied() + .find(|act| read_optional_string_field(act, "id").as_deref() == Some(act_id)) + }) + .or_else(|| acts.first().copied())?; read_optional_string_field(act_state, "oppositeNpcId") .or_else(|| read_optional_string_field(act_state, "primaryNpcId")) .or_else(|| { diff --git a/server-rs/crates/module-runtime-story/src/session_action.rs b/server-rs/crates/module-runtime-story/src/session_action.rs index c7ad6421..93d9b25a 100644 --- a/server-rs/crates/module-runtime-story/src/session_action.rs +++ b/server-rs/crates/module-runtime-story/src/session_action.rs @@ -14,16 +14,18 @@ use crate::{ build_current_build_toast, build_npc_gift_result_text, build_runtime_story_option_from_story_option, build_runtime_story_view_model, build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option, - clear_encounter_state, clone_inventory_item_with_quantity, current_encounter_name, - ensure_json_object, find_player_inventory_entry, normalize_equipment_slot_id, - normalize_required_string, npc_buyback_price, npc_purchase_price, + clear_encounter_state, clear_post_battle_state, clone_inventory_item_with_quantity, + current_encounter_name, ensure_json_object, ensure_scene_act_state, + ensure_scene_encounter_preview, finalize_post_battle_resolution, find_player_inventory_entry, + normalize_equipment_slot_id, normalize_required_string, npc_buyback_price, npc_purchase_price, project_story_engine_after_action, read_array_field, read_bool_field, read_field, read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field, read_player_equipment_item, read_player_inventory_values, read_runtime_session_id, read_u32_field, recruit_companion_to_party, remove_inventory_item_from_list, resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item, resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action, - resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution, + resolve_forward_scene_id, resolve_npc_gift_affinity_gain, resolve_post_battle_story_options, + resolve_runtime_scene_preset, restore_player_resource, simple_story_resolution, write_bool_field, write_i32_field, write_null_field, write_player_equipment_item, write_player_inventory_values, write_runtime_npc_interaction_view, write_string_field, write_u32_field, @@ -97,23 +99,7 @@ pub fn resolve_story_runtime_action( requested_runtime_session_id.as_str(), ); - let mut options = resolution - .presentation_options - .take() - .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); - if options.is_empty() { - options = build_fallback_runtime_story_options(&game_state); - } - - let story_text = resolution - .story_text - .clone() - .unwrap_or_else(|| resolution.result_text.clone()); let history_result_text = resolution.result_text.clone(); - let saved_current_story = resolution - .saved_current_story - .take() - .unwrap_or_else(|| build_current_story(story_text.as_str(), &options)); append_story_history( &mut game_state, @@ -132,6 +118,37 @@ pub fn resolve_story_runtime_action( .and_then(|battle| battle.outcome.as_deref()), ); + if let Some(post_battle) = finalize_post_battle_resolution( + &mut game_state, + history_result_text.as_str(), + resolution + .battle + .as_ref() + .and_then(|battle| battle.outcome.as_deref()), + Vec::new(), + ) { + resolution.story_text = Some(post_battle.story_text); + resolution.presentation_options = Some(post_battle.presentation_options); + resolution.saved_current_story = Some(post_battle.saved_current_story); + } + + let mut options = resolution + .presentation_options + .take() + .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); + if options.is_empty() { + options = build_fallback_runtime_story_options(&game_state); + } + + let story_text = resolution + .story_text + .clone() + .unwrap_or_else(|| resolution.result_text.clone()); + let saved_current_story = resolution + .saved_current_story + .take() + .unwrap_or_else(|| build_current_story(story_text.as_str(), &options)); + let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend { action_text: resolution.action_text.clone(), result_text: history_result_text.clone(), @@ -212,11 +229,10 @@ fn resolve_runtime_story_choice_action( resolve_action_text("主动出声试探", request), "你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。", )), - "idle_explore_forward" => Ok(simple_story_resolution( - game_state, - resolve_action_text("继续向前探索", request), - "你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。", - )), + "idle_explore_forward" => resolve_idle_explore_forward_action(game_state, request), + "idle_travel_next_scene" | "camp_travel_home_scene" => { + resolve_idle_travel_next_scene_action(game_state, request) + } "idle_observe_signs" => Ok(simple_story_resolution( game_state, resolve_action_text("观察周围迹象", request), @@ -309,6 +325,62 @@ fn resolve_continue_adventure_action( }) } +fn resolve_idle_explore_forward_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + // 中文注释:探索前进是战后继续链路的一环,必须在后端清掉战斗态并生成下一段遭遇预览。 + // 前端只播放表现动画,不能只靠本地状态把同一组 idle 选项重新展示一遍。 + clear_post_battle_state(game_state); + ensure_scene_encounter_preview(game_state); + Ok(StoryResolution { + action_text: resolve_action_text("继续向前探索", request), + result_text: "你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。".to_string(), + story_text: None, + presentation_options: Some(resolve_post_battle_story_options(game_state)), + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: None, + toast: None, + }) +} + +fn resolve_idle_travel_next_scene_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + // 中文注释:切场景会改变 currentScenePreset、章节 act 状态和运行统计, + // 这些都是 runtime 快照真相,不能只在前端播放退场/进场动画。 + let payload = request.action.payload.as_ref(); + let target_scene_id = payload + .and_then(|payload| read_optional_string_field(payload, "targetSceneId")) + .or_else(|| resolve_forward_scene_id(game_state)) + .ok_or_else(|| "idle_travel_next_scene 缺少 targetSceneId".to_string())?; + let next_scene = resolve_runtime_scene_preset(game_state, target_scene_id.as_str()) + .ok_or_else(|| format!("未找到目标场景:{target_scene_id}"))?; + let next_scene_name = + read_optional_string_field(&next_scene, "name").unwrap_or_else(|| target_scene_id.clone()); + + clear_post_battle_state(game_state); + ensure_json_object(game_state).insert("currentScenePreset".to_string(), next_scene); + write_i32_field(game_state, "playerX", 0); + write_string_field(game_state, "playerFacing", "right"); + ensure_scene_act_state(game_state); + ensure_scene_encounter_preview(game_state); + increment_runtime_stat_local(game_state, "scenesTraveled", 1); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("前往{next_scene_name}"), request), + result_text: format!("你离开当前区域,抵达了{next_scene_name}。"), + story_text: None, + presentation_options: Some(resolve_post_battle_story_options(game_state)), + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: None, + toast: None, + }) +} + fn resolve_npc_preview_talk_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 5d6a6475..94d993dd 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -54,9 +54,9 @@ pub fn default_creation_entry_type_snapshots( "rpg", "文字冒险", "经典 RPG 体验", - "内测", + "可创建", "/creation-type-references/rpg.webp", - false, + true, true, 10, updated_at_micros, diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 892c9ade..896836fc 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -236,6 +236,23 @@ mod tests { ); } + #[test] + fn default_creation_entry_types_open_rpg_entry() { + let configs = default_creation_entry_type_snapshots(1); + let rpg = configs + .iter() + .find(|item| item.id == "rpg") + .expect("rpg creation entry should be seeded"); + + assert_eq!(rpg.title, "文字冒险"); + assert_eq!(rpg.subtitle, "经典 RPG 体验"); + assert!(rpg.visible); + assert!(rpg.open); + assert_eq!(rpg.badge, "可创建"); + assert_eq!(rpg.sort_order, 10); + assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp"); + } + #[test] fn default_creation_entry_types_include_bark_battle() { let configs = default_creation_entry_type_snapshots(1); diff --git a/server-rs/crates/shared-contracts/src/match3d_agent.rs b/server-rs/crates/shared-contracts/src/match3d_agent.rs index b64d140a..46365eec 100644 --- a/server-rs/crates/shared-contracts/src/match3d_agent.rs +++ b/server-rs/crates/shared-contracts/src/match3d_agent.rs @@ -110,10 +110,28 @@ pub struct Match3DResultDraftResponse { pub struct Match3DGeneratedBackgroundAssetResponse { pub prompt: String, #[serde(default)] + pub level_scene_prompt: Option, + #[serde(default)] + pub level_scene_image_src: Option, + #[serde(default)] + pub level_scene_image_object_key: Option, + #[serde(default)] pub image_src: Option, #[serde(default)] pub image_object_key: Option, #[serde(default)] + pub ui_spritesheet_prompt: Option, + #[serde(default)] + pub ui_spritesheet_image_src: Option, + #[serde(default)] + pub ui_spritesheet_image_object_key: Option, + #[serde(default)] + pub item_spritesheet_prompt: Option, + #[serde(default)] + pub item_spritesheet_image_src: Option, + #[serde(default)] + pub item_spritesheet_image_object_key: Option, + #[serde(default)] pub container_prompt: Option, #[serde(default)] pub container_image_src: Option, diff --git a/server-rs/crates/shared-contracts/src/match3d_works.rs b/server-rs/crates/shared-contracts/src/match3d_works.rs index bc68558b..1536d45f 100644 --- a/server-rs/crates/shared-contracts/src/match3d_works.rs +++ b/server-rs/crates/shared-contracts/src/match3d_works.rs @@ -170,10 +170,28 @@ pub struct Match3DWorkSummaryResponse { pub struct Match3DGeneratedBackgroundAssetResponse { pub prompt: String, #[serde(default)] + pub level_scene_prompt: Option, + #[serde(default)] + pub level_scene_image_src: Option, + #[serde(default)] + pub level_scene_image_object_key: Option, + #[serde(default)] pub image_src: Option, #[serde(default)] pub image_object_key: Option, #[serde(default)] + pub ui_spritesheet_prompt: Option, + #[serde(default)] + pub ui_spritesheet_image_src: Option, + #[serde(default)] + pub ui_spritesheet_image_object_key: Option, + #[serde(default)] + pub item_spritesheet_prompt: Option, + #[serde(default)] + pub item_spritesheet_image_src: Option, + #[serde(default)] + pub item_spritesheet_image_object_key: Option, + #[serde(default)] pub container_prompt: Option, #[serde(default)] pub container_image_src: Option, diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 7f416ca7..b417ca80 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -174,6 +174,18 @@ pub struct PuzzleDraftLevelResponse { #[serde(default)] pub ui_background_image_object_key: Option, #[serde(default)] + pub level_scene_image_src: Option, + #[serde(default)] + pub level_scene_image_object_key: Option, + #[serde(default)] + pub ui_spritesheet_image_src: Option, + #[serde(default)] + pub ui_spritesheet_image_object_key: Option, + #[serde(default)] + pub level_background_image_src: Option, + #[serde(default)] + pub level_background_image_object_key: Option, + #[serde(default)] pub background_music: Option, pub candidates: Vec, #[serde(default)] diff --git a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs index c5101734..c85e3e57 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs @@ -122,6 +122,14 @@ pub struct PuzzleRuntimeLevelSnapshotResponse { #[serde(default)] pub ui_background_image_object_key: Option, #[serde(default)] + pub level_background_image_src: Option, + #[serde(default)] + pub level_background_image_object_key: Option, + #[serde(default)] + pub ui_spritesheet_image_src: Option, + #[serde(default)] + pub ui_spritesheet_image_object_key: Option, + #[serde(default)] pub background_music: Option, pub board: PuzzleBoardSnapshotResponse, pub status: String, diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index ae67fd65..0b7d8ec6 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -145,6 +145,12 @@ pub(crate) fn map_puzzle_draft_level(snapshot: PuzzleDraftLevel) -> PuzzleDraftL ui_background_prompt: snapshot.ui_background_prompt, ui_background_image_src: snapshot.ui_background_image_src, ui_background_image_object_key: snapshot.ui_background_image_object_key, + level_scene_image_src: snapshot.level_scene_image_src, + level_scene_image_object_key: snapshot.level_scene_image_object_key, + ui_spritesheet_image_src: snapshot.ui_spritesheet_image_src, + ui_spritesheet_image_object_key: snapshot.ui_spritesheet_image_object_key, + level_background_image_src: snapshot.level_background_image_src, + level_background_image_object_key: snapshot.level_background_image_object_key, background_music: snapshot.background_music.map(map_puzzle_audio_asset), candidates: snapshot .candidates @@ -392,6 +398,10 @@ pub(crate) fn map_puzzle_runtime_level_snapshot( cover_image_src: snapshot.cover_image_src, ui_background_image_src: snapshot.ui_background_image_src, ui_background_image_object_key: snapshot.ui_background_image_object_key, + level_background_image_src: snapshot.level_background_image_src, + level_background_image_object_key: snapshot.level_background_image_object_key, + ui_spritesheet_image_src: snapshot.ui_spritesheet_image_src, + ui_spritesheet_image_object_key: snapshot.ui_spritesheet_image_object_key, background_music: snapshot.background_music.map(map_puzzle_audio_asset), board: map_puzzle_board_snapshot(snapshot.board), status: format_puzzle_runtime_level_status(snapshot.status).to_string(), @@ -835,6 +845,12 @@ pub struct PuzzleDraftLevelRecord { pub ui_background_prompt: Option, pub ui_background_image_src: Option, pub ui_background_image_object_key: Option, + pub level_scene_image_src: Option, + pub level_scene_image_object_key: Option, + pub ui_spritesheet_image_src: Option, + pub ui_spritesheet_image_object_key: Option, + pub level_background_image_src: Option, + pub level_background_image_object_key: Option, pub background_music: Option, pub candidates: Vec, pub selected_candidate_id: Option, @@ -1038,6 +1054,10 @@ pub struct PuzzleRuntimeLevelRecord { pub cover_image_src: Option, pub ui_background_image_src: Option, pub ui_background_image_object_key: Option, + pub level_background_image_src: Option, + pub level_background_image_object_key: Option, + pub ui_spritesheet_image_src: Option, + pub ui_spritesheet_image_object_key: Option, pub background_music: Option, pub board: PuzzleBoardRecord, pub status: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_level_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_level_type.rs index 36f12999..d2b44606 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_level_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_level_type.rs @@ -17,6 +17,12 @@ pub struct PuzzleDraftLevel { pub ui_background_prompt: Option, pub ui_background_image_src: Option, pub ui_background_image_object_key: Option, + pub level_scene_image_src: Option, + pub level_scene_image_object_key: Option, + pub ui_spritesheet_image_src: Option, + pub ui_spritesheet_image_object_key: Option, + pub level_background_image_src: Option, + pub level_background_image_object_key: Option, pub background_music: Option, pub candidates: Vec, pub selected_candidate_id: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_snapshot_type.rs index 3554ed20..bce6df25 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_snapshot_type.rs @@ -23,6 +23,10 @@ pub struct PuzzleRuntimeLevelSnapshot { pub cover_image_src: Option, pub ui_background_image_src: Option, pub ui_background_image_object_key: Option, + pub level_background_image_src: Option, + pub level_background_image_object_key: Option, + pub ui_spritesheet_image_src: Option, + pub ui_spritesheet_image_object_key: Option, pub background_music: Option, pub board: PuzzleBoardSnapshot, pub status: PuzzleRuntimeLevelStatus, diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index 36228bfe..a3249fa3 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -2562,6 +2562,18 @@ fn upsert_nested_result_profile_id( } } +fn resolve_publish_world_setting_text( + payload: &JsonMap, + draft_profile: &JsonMap, + session: &CustomWorldAgentSession, +) -> String { + module_custom_world::resolve_custom_world_publish_setting_text( + payload, + draft_profile, + &session.seed_text, + ) +} + fn is_same_agent_draft_profile_candidate( row: &CustomWorldProfile, owner_user_id: &str, @@ -2581,13 +2593,10 @@ fn execute_publish_world_action( ) -> Result { ensure_publishable_stage(session.stage, "publish_world")?; - let draft_profile = - if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { - explicit.clone() - } else { - parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? - }; + // 中文注释:发布动作不再信任前端携带的 draftProfile。 + // 点击发布前,结果页 profile 必须先通过 sync_result_profile 写回 + // custom_world_agent_session.draft_profile_json;正式发布只读取这份会话真相。 + let draft_profile = read_publish_world_draft_profile_from_session(session)?; let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, @@ -2601,24 +2610,9 @@ fn execute_publish_world_action( )); } - let profile_id = payload - .get("profileId") - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| gate.profile_id.clone()); - let setting_text = payload - .get("settingText") - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| session.seed_text.clone()); - let legacy_result_profile_json = payload - .get("legacyResultProfile") - .map(serialize_json_value) - .transpose()?; + let profile_id = gate.profile_id.clone(); + let setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session); + let legacy_result_profile_json = None; let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"]) .unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id)); let author_display_name = read_optional_text_field(payload, &["authorDisplayName"]) @@ -2663,6 +2657,13 @@ fn execute_publish_world_action( Ok(build_custom_world_agent_operation_snapshot(&operation)) } +fn read_publish_world_draft_profile_from_session( + session: &CustomWorldAgentSession, +) -> Result, String> { + parse_optional_session_object(session.draft_profile_json.as_deref()) + .ok_or_else(|| "publish_world requires draft_profile_json".to_string()) +} + fn execute_revert_checkpoint_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, @@ -5250,6 +5251,26 @@ mod tests { ); } + #[test] + fn publish_world_draft_profile_comes_from_session_not_payload() { + let session = build_test_custom_world_agent_session( + "seed", + RpgAgentStage::ReadyToPublish, + Some(r#"{"id":"saved-profile","name":"已保存草稿"}"#), + ); + let draft_profile = + read_publish_world_draft_profile_from_session(&session).expect("session draft exists"); + + assert_eq!( + draft_profile.get("id").and_then(JsonValue::as_str), + Some("saved-profile") + ); + assert_eq!( + draft_profile.get("name").and_then(JsonValue::as_str), + Some("已保存草稿") + ); + } + #[test] fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() { let empty_session = diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 2703a355..a22ed976 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1293,6 +1293,12 @@ fn select_puzzle_cover_image_tx( ui_background_prompt: target_level.ui_background_prompt, ui_background_image_src: target_level.ui_background_image_src, ui_background_image_object_key: target_level.ui_background_image_object_key, + level_scene_image_src: target_level.level_scene_image_src, + level_scene_image_object_key: target_level.level_scene_image_object_key, + ui_spritesheet_image_src: target_level.ui_spritesheet_image_src, + ui_spritesheet_image_object_key: target_level.ui_spritesheet_image_object_key, + level_background_image_src: target_level.level_background_image_src, + level_background_image_object_key: target_level.level_background_image_object_key, background_music: target_level.background_music, candidates: selected_level_draft.candidates, selected_candidate_id: selected_level_draft.selected_candidate_id, @@ -2636,6 +2642,12 @@ fn build_profile_levels_from_row( ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: Vec::new(), selected_candidate_id: None, diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index d754501d..a1437432 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -179,6 +179,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { } } + migrate_rpg_entry_from_old_hidden_default(ctx, now); migrate_visual_novel_entry_from_old_visible_default(ctx, now); migrate_coming_soon_entry_from_old_open_default( ctx, @@ -195,6 +196,36 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx, now); } +fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) { + let id = "rpg".to_string(); + let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { + return; + }; + + // 中文注释:只开放历史默认隐藏的 RPG 入口,不覆盖后台入口开关后续手动配置。 + let still_old_hidden_default = row.title == "文字冒险" + && row.subtitle == "经典 RPG 体验" + && row.badge == "内测" + && row.image_src == "/creation-type-references/rpg.webp" + && !row.visible + && row.open + && row.sort_order == 10; + if !still_old_hidden_default { + return; + } + + ctx.db + .creation_entry_type_config() + .id() + .update(CreationEntryTypeConfig { + badge: "可创建".to_string(), + visible: true, + open: true, + updated_at: now, + ..row + }); +} + fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) { let id = "visual-novel".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { diff --git a/src/App.tsx b/src/App.tsx index 5ede559e..e16ff02f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,16 @@ const RpgRuntimeApp = lazy(async () => { }; }); +function RuntimeLoadingFallback() { + return ( +
+
+ 正在启动 +
+
+ ); +} + function isRpgRuntimeRoute(pathname: string) { const normalizedPath = normalizeAppPath(pathname); return ( @@ -126,7 +136,7 @@ export default function App() { if (isRuntimeActive) { return ( - + }> { diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index 2142efe8..cf381a5f 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { expect, test, vi } from 'vitest'; +import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary'; import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types'; import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView'; @@ -286,6 +287,40 @@ function ResultViewHarness() { ); } +function ResultViewRehydratingHarness() { + const [profile, setProfile] = useState(baseProfile); + const [rehydrated, setRehydrated] = useState(false); + + return ( +
+
{rehydrated ? 'yes' : 'no'}
+ {}} + onProfileChange={(nextProfile) => { + setProfile(nextProfile); + if (!nextProfile.openingCg) { + return; + } + + window.setTimeout(() => { + const normalized = normalizeCustomWorldProfileRecord(nextProfile); + if (normalized) { + setProfile(normalized); + } + setRehydrated(true); + }, 0); + }} + /> +
+ ); +} + test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => { const user = userEvent.setup(); @@ -385,6 +420,40 @@ test('world tab generates opening cg only after manual click and writes it back }); }); +test('world tab keeps opening cg visible after parent rehydrates normalized profile', async () => { + const user = userEvent.setup(); + mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({ + id: 'opening-cg-1', + status: 'ready', + storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png', + storyboardAssetId: 'storyboard-1', + videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4', + videoAssetId: 'video-1', + imageModel: 'gpt-image-2', + videoModel: 'doubao-seedance-2-0-fast-260128', + aspectRatio: '16:9', + imageSize: '2k', + videoResolution: '480p', + durationSeconds: 15, + pointCost: 80, + estimatedWaitMinutes: 10, + updatedAt: '2026-05-03T00:00:00Z', + }); + + render(); + + await user.click(screen.getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(screen.getByTestId('rehydrated').textContent).toBe('yes'); + }); + expect( + document.querySelector( + 'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]', + ), + ).toBeTruthy(); +}); + test('playable tab prefers generated portrait over runtime preview placeholder', async () => { const user = userEvent.setup(); const profile = { diff --git a/src/components/common/CreativeImageInputPanel.test.tsx b/src/components/common/CreativeImageInputPanel.test.tsx index a3dbce18..22532783 100644 --- a/src/components/common/CreativeImageInputPanel.test.tsx +++ b/src/components/common/CreativeImageInputPanel.test.tsx @@ -275,3 +275,64 @@ test('creative image input panel can show an image without exposing AI redraw co expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull(); expect(screen.getByLabelText('画面描述')).toBeTruthy(); }); + +test('creative image input panel can upload prompt references while showing a main image', () => { + const onPromptReferenceFilesSelect = vi.fn(); + + render( + {}} + onMainImageRemove={() => {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onPromptReferenceFilesSelect={onPromptReferenceFilesSelect} + onSubmit={() => {}} + />, + ); + + const promptReferenceInput = screen.getByLabelText('上传参考图', { + selector: 'input', + }); + fireEvent.change(promptReferenceInput, { + target: { + files: [new File(['a'], 'prompt-reference.png', { type: 'image/png' })], + }, + }); + + expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([ + expect.any(File), + ]); + expect( + screen.getByRole('button', { name: '预览参考图 描述参考图 1' }), + ).toBeTruthy(); +}); diff --git a/src/components/common/CreativeImageInputPanel.tsx b/src/components/common/CreativeImageInputPanel.tsx index db299227..78448e9d 100644 --- a/src/components/common/CreativeImageInputPanel.tsx +++ b/src/components/common/CreativeImageInputPanel.tsx @@ -38,6 +38,7 @@ export type CreativeImageInputPanelProps = { mainImageMode?: 'edit' | 'preview'; canRemoveMainImage?: boolean; canToggleAiRedraw?: boolean; + canUploadPromptReferences?: boolean; uploadedImageSrc: string; uploadedImageAlt: string; uploadedImageRefreshKey?: string | number | null; @@ -79,6 +80,7 @@ export function CreativeImageInputPanel({ mainImageMode = 'edit', canRemoveMainImage = true, canToggleAiRedraw = true, + canUploadPromptReferences, uploadedImageSrc, uploadedImageAlt, uploadedImageRefreshKey = null, @@ -114,6 +116,8 @@ export function CreativeImageInputPanel({ const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] = useState(false); const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw; + const shouldShowPromptReferences = + canUploadPromptReferences ?? !uploadedImageSrc; const promptReferenceUploadDisabled = disabled || promptReferenceImages.length >= promptReferenceLimit; const canEditMainImage = mainImageMode === 'edit'; @@ -157,7 +161,7 @@ export function CreativeImageInputPanel({ {labels.imageField}
-
+
{canEditMainImage ? ( <> {imageModelPicker} - {!uploadedImageSrc && onPromptReferenceFilesSelect ? ( + {shouldShowPromptReferences && onPromptReferenceFilesSelect ? (
- {!uploadedImageSrc && promptReferenceImages.length > 0 ? ( + {shouldShowPromptReferences && promptReferenceImages.length > 0 ? (
{promptReferenceImages.map((reference) => (
{ expect(html).toContain('拼图关卡创作'); expect(html).toContain('抓大鹅'); expect(html).toContain('3D 消除关卡'); - expect(html).not.toContain('文字冒险'); + expect(html).toContain('文字冒险'); + expect(html).toContain('经典 RPG 体验'); expect(html).not.toContain('大鱼吃小鱼'); }); diff --git a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx index 6b26af15..86a1f313 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx @@ -7,7 +7,10 @@ import { type Encounter, type SceneHostileNpc, } from '../../types'; -import { GameCanvasEntityLayer } from './GameCanvasEntityLayer'; +import { + GameCanvasEntityLayer, + getCombatFloatingNumberPresentation, +} from './GameCanvasEntityLayer'; import { CHARACTER_COMBAT_HP_TOP_PX, ENTITY_CONTAINER_REM, @@ -125,6 +128,21 @@ function renderEntityLayer(effectNpcId: string | null) { } describe('GameCanvasEntityLayer', () => { + it('keeps combat floating numbers readable on dark noisy battle backgrounds', () => { + const damage = getCombatFloatingNumberPresentation(false); + const healing = getCombatFloatingNumberPresentation(true); + + expect(damage.toneClass).toContain('bg-rose-950/72'); + expect(damage.toneClass).toContain('text-rose-50'); + expect(damage.textStyle.WebkitTextStroke).toContain('rgba(127, 29, 29'); + expect(damage.textStyle.textShadow).toContain('rgba(0, 0, 0'); + + expect(healing.toneClass).toContain('bg-emerald-950/70'); + expect(healing.toneClass).toContain('text-emerald-50'); + expect(healing.textStyle.WebkitTextStroke).toContain('rgba(6, 78, 59'); + expect(healing.textStyle.textShadow).toContain('rgba(0, 0, 0'); + }); + it('uses mirrored stage anchors for player and opponent containers', () => { expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%'); expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`); diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx index f374e938..19928c93 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx @@ -1,5 +1,5 @@ import {motion} from 'motion/react'; -import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react'; +import {type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState} from 'react'; import {getCharacterById} from '../../data/characterPresets'; import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs'; @@ -130,6 +130,45 @@ function getSceneTransitionMotionConfig( }; } +export function getCombatFloatingNumberPresentation(isHealing: boolean): { + toneClass: string; + textStyle: CSSProperties; +} { + const textShadow = [ + '0 1px 0 rgba(0, 0, 0, 0.98)', + '0 0 8px rgba(0, 0, 0, 0.92)', + '0 0 16px rgba(0, 0, 0, 0.72)', + ].join(', '); + + if (isHealing) { + return { + toneClass: [ + 'border-emerald-100/70', + 'bg-emerald-950/70', + 'text-emerald-50', + 'shadow-[0_0_18px_rgba(52,211,153,0.55)]', + ].join(' '), + textStyle: { + WebkitTextStroke: '1.45px rgba(6, 78, 59, 0.95)', + textShadow, + }, + }; + } + + return { + toneClass: [ + 'border-rose-100/75', + 'bg-rose-950/72', + 'text-rose-50', + 'shadow-[0_0_20px_rgba(248,113,113,0.68)]', + ].join(' '), + textStyle: { + WebkitTextStroke: '1.55px rgba(127, 29, 29, 0.98)', + textShadow, + }, + }; +} + function CombatFloatingNumber({ event, onDone, @@ -139,23 +178,20 @@ function CombatFloatingNumber({ }) { const isHealing = event.delta > 0; const deltaText = `${isHealing ? '+' : ''}${event.delta}`; - const colorClass = isHealing ? 'text-emerald-200' : 'text-rose-200'; - const glowClass = isHealing - ? 'drop-shadow-[0_0_8px_rgba(52,211,153,0.9)]' - : 'drop-shadow-[0_0_8px_rgba(248,113,113,0.9)]'; + const presentation = getCombatFloatingNumberPresentation(isHealing); return ( onDone(event.id)} - className={`pointer-events-none absolute -top-16 left-1/2 z-[14] -translate-x-1/2 text-lg font-black leading-none ${colorClass} ${glowClass}`} + className={`pointer-events-none absolute -top-[4.65rem] left-1/2 z-[38] flex min-w-[2.4rem] -translate-x-1/2 select-none items-center justify-center rounded-full border px-1.5 py-0.5 text-[1.45rem] font-black leading-none tracking-[-0.04em] sm:text-[1.6rem] ${presentation.toneClass}`} data-testid={`combat-feedback-${event.targetKey}`} aria-label={`战斗数值 ${deltaText}`} > - + {deltaText} diff --git a/src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx b/src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx index f315fa4c..3acd53f5 100644 --- a/src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx +++ b/src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx @@ -80,9 +80,9 @@ test('match3d workspace submits derived entry form payload instead of agent chat expect(screen.getByText('想做个什么玩法?')).toBeTruthy(); expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy(); - expect(screen.getByText('2D素材风格')).toBeTruthy(); - expect(screen.getByRole('button', { name: '扁平图标' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy(); + expect(screen.queryByText('2D素材风格')).toBeNull(); + expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull(); + expect(screen.queryByRole('button', { name: '自定义' })).toBeNull(); expect(screen.getByText('消耗10泥点')).toBeTruthy(); expect(screen.queryByRole('button', { name: '生成音效' })).toBeNull(); expect(screen.queryByText('参考图')).toBeNull(); @@ -107,54 +107,12 @@ test('match3d workspace submits derived entry form payload instead of agent chat referenceImageSrc: null, clearCount: 16, difficulty: 6, - assetStyleId: 'flat-icon', - assetStyleLabel: '扁平图标', - assetStylePrompt: - '干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。', generateClickSound: false, }); expect(onExecuteAction).not.toHaveBeenCalled(); }); -test('match3d workspace supports custom 2d asset style prompt', () => { - const onCreateFromForm = vi.fn(); - - render( - {}} - onExecuteAction={() => {}} - onCreateFromForm={onCreateFromForm} - />, - ); - - fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), { - target: { value: '海底甜品店' }, - }); - fireEvent.click(screen.getByRole('button', { name: '自定义' })); - - expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy(); - fireEvent.change(screen.getByLabelText('自定义2D素材风格描述'), { - target: { value: '透明果冻材质,边缘有柔和蓝色荧光' }, - }); - fireEvent.click(screen.getByRole('button', { name: '应用' })); - fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u })); - confirmMatch3DPointCost(); - - expect(onCreateFromForm).toHaveBeenCalledWith( - expect.objectContaining({ - seedText: '海底甜品店题材,消除12次,难度4', - themeText: '海底甜品店', - clearCount: 12, - difficulty: 4, - assetStyleId: 'custom', - assetStyleLabel: '自定义风格', - assetStylePrompt: '透明果冻材质,边缘有柔和蓝色荧光', - }), - ); -}); - -test('match3d workspace submits strict pixel-retro style prompt', () => { +test('match3d workspace omits legacy asset style fields from entry payload', () => { const onCreateFromForm = vi.fn(); render( @@ -169,22 +127,13 @@ test('match3d workspace submits strict pixel-retro style prompt', () => { fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), { target: { value: '复古水果铺' }, }); - fireEvent.click(screen.getByRole('button', { name: '像素复古' })); fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u })); confirmMatch3DPointCost(); - expect(onCreateFromForm).toHaveBeenCalledWith( - expect.objectContaining({ - assetStyleId: 'pixel-retro', - assetStyleLabel: '像素复古', - assetStylePrompt: expect.stringContaining('64x64'), - }), - ); - expect(onCreateFromForm).toHaveBeenCalledWith( - expect.objectContaining({ - assetStylePrompt: expect.stringContaining('禁止抗锯齿'), - }), - ); + const payload = onCreateFromForm.mock.calls[0]?.[0] ?? {}; + expect('assetStyleId' in payload).toBe(false); + expect('assetStyleLabel' in payload).toBe(false); + expect('assetStylePrompt' in payload).toBe(false); }); test('match3d workspace keeps click sound generation disabled from entry form', () => { @@ -231,11 +180,8 @@ test('match3d workspace falls back to compile action when restored from the lega expect( screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'), ).toBe('true'); - expect( - screen - .getByRole('button', { name: '赛璐璐卡通' }) - .getAttribute('aria-pressed'), - ).toBe('true'); + expect(screen.queryByText('2D素材风格')).toBeNull(); + expect(screen.queryByRole('button', { name: '赛璐璐卡通' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u })); confirmMatch3DPointCost(); diff --git a/src/components/match3d-creation/Match3DAgentWorkspace.tsx b/src/components/match3d-creation/Match3DAgentWorkspace.tsx index 23ee7e57..0b1a0d61 100644 --- a/src/components/match3d-creation/Match3DAgentWorkspace.tsx +++ b/src/components/match3d-creation/Match3DAgentWorkspace.tsx @@ -1,4 +1,4 @@ -import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react'; +import { Loader2, Sparkles, WandSparkles } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { @@ -24,15 +24,11 @@ type Match3DAgentWorkspaceProps = { type Match3DFormState = { themeText: string; difficultyOptionId: Match3DDifficultyOptionId; - assetStyleId: Match3DAssetStyleOptionId; - customAssetStylePrompt: string; }; const EMPTY_FORM_STATE: Match3DFormState = { themeText: '', difficultyOptionId: 'standard', - assetStyleId: 'flat-icon', - customAssetStylePrompt: '', }; // 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。 @@ -46,60 +42,6 @@ const MATCH3D_DIFFICULTY_OPTIONS = [ type Match3DDifficultyOptionId = (typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id']; -const MATCH3D_ASSET_STYLE_OPTIONS = [ - { - id: 'flat-icon', - label: '扁平图标', - imageSrc: '/match3d-style-references/flat-icon.png', - prompt: - '干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。', - }, - { - id: 'cel-cartoon', - label: '赛璐璐卡通', - imageSrc: '/match3d-style-references/cel-cartoon.png', - prompt: - '明亮赛璐璐卡通2D游戏道具风格,清晰线稿,硬边阴影,饱和配色,轮廓醒目。', - }, - { - id: 'pixel-retro', - label: '像素复古', - imageSrc: '/match3d-style-references/pixel-retro.png', - prompt: - '64x64 复古像素 2D 游戏道具 sprite 风格,限制调色板,硬像素边缘,清晰正面剪影,禁止抗锯齿,禁止柔光渐变,透明背景。', - }, - { - id: 'watercolor', - label: '手绘水彩', - imageSrc: '/match3d-style-references/watercolor.png', - prompt: - '手绘水彩2D道具素材风格', - }, - { - id: 'sticker-outline', - label: '贴纸描边', - imageSrc: '/match3d-style-references/sticker-outline.png', - prompt: - '贴纸描边2D游戏道具素材风格,粗白边与深色外轮廓', - }, - { - id: 'painterly-icon', - label: '厚涂图标', - imageSrc: '/match3d-style-references/painterly-icon.png', - prompt: - '厚涂2D游戏道具图标风格,笔触细腻,体积光影明确,中心构图,保持图标级清晰剪影。', - }, - { - id: 'custom', - label: '自定义', - imageSrc: null, - prompt: '', - }, -] as const; - -type Match3DAssetStyleOptionId = - (typeof MATCH3D_ASSET_STYLE_OPTIONS)[number]['id']; - function normalizeDifficulty(value: number) { return Math.max(1, Math.min(10, Math.round(value))); } @@ -137,27 +79,6 @@ function getDifficultyOption(optionId: Match3DDifficultyOptionId) { ); } -function getAssetStyleOption(optionId: Match3DAssetStyleOptionId) { - return ( - MATCH3D_ASSET_STYLE_OPTIONS.find((option) => option.id === optionId) ?? - MATCH3D_ASSET_STYLE_OPTIONS[0] - ); -} - -function resolveAssetStyleOptionId( - assetStyleId: string | null | undefined, - assetStylePrompt: string | null | undefined, -): Match3DAssetStyleOptionId { - const matchedOption = MATCH3D_ASSET_STYLE_OPTIONS.find( - (option) => option.id === assetStyleId, - ); - if (matchedOption) { - return matchedOption.id; - } - - return assetStylePrompt?.trim() ? 'custom' : 'flat-icon'; -} - function resolveInitialFormState( session: Match3DAgentSessionSnapshot | null, initialFormPayload: CreateMatch3DSessionRequest | null = null, @@ -173,17 +94,11 @@ function resolveInitialFormState( initialFormPayload?.clearCount ?? config?.clearCount ?? null; const difficulty = initialFormPayload?.difficulty ?? config?.difficulty ?? null; - const assetStyleId = - initialFormPayload?.assetStyleId ?? config?.assetStyleId ?? null; - const assetStylePrompt = - initialFormPayload?.assetStylePrompt ?? config?.assetStylePrompt ?? ''; return { ...EMPTY_FORM_STATE, themeText, difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount), - assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt), - customAssetStylePrompt: assetStylePrompt, }; } @@ -205,9 +120,7 @@ export function Match3DAgentWorkspace({ const [formState, setFormState] = useState(() => resolveInitialFormState(session, initialFormPayload), ); - const [isCustomStylePanelOpen, setIsCustomStylePanelOpen] = useState(false); const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false); - const [draftCustomStylePrompt, setDraftCustomStylePrompt] = useState(''); const appliedInitialFormKeyRef = useRef(null); useEffect(() => { @@ -219,30 +132,14 @@ export function Match3DAgentWorkspace({ appliedInitialFormKeyRef.current = nextInitialFormKey; setFormState(resolveInitialFormState(session, initialFormPayload)); - setIsCustomStylePanelOpen(false); setIsPointCostConfirmOpen(false); - setDraftCustomStylePrompt(''); }, [initialFormPayload, session]); const themeText = formState.themeText.trim(); const selectedDifficultyOption = getDifficultyOption( formState.difficultyOptionId, ); - const selectedAssetStyleOption = getAssetStyleOption(formState.assetStyleId); - const assetStylePrompt = - formState.assetStyleId === 'custom' - ? formState.customAssetStylePrompt.trim() - : selectedAssetStyleOption.prompt; - const assetStyleLabel = - formState.assetStyleId === 'custom' - ? '自定义风格' - : selectedAssetStyleOption.label; - const canSubmit = Boolean( - themeText && - !isBusy && - (formState.assetStyleId !== 'custom' || - formState.customAssetStylePrompt.trim()), - ); + const canSubmit = Boolean(themeText && !isBusy); const formPayload = useMemo( () => ({ seedText: themeText @@ -252,34 +149,11 @@ export function Match3DAgentWorkspace({ referenceImageSrc: null, clearCount: selectedDifficultyOption.clearCount, difficulty: selectedDifficultyOption.difficulty, - assetStyleId: formState.assetStyleId, - assetStyleLabel, - assetStylePrompt, generateClickSound: false, }), - [ - assetStyleLabel, - assetStylePrompt, - formState.assetStyleId, - selectedDifficultyOption, - themeText, - ], + [selectedDifficultyOption, themeText], ); - const openCustomStylePanel = () => { - setDraftCustomStylePrompt(formState.customAssetStylePrompt); - setIsCustomStylePanelOpen(true); - }; - - const applyCustomStylePrompt = () => { - setFormState((current) => ({ - ...current, - assetStyleId: 'custom', - customAssetStylePrompt: draftCustomStylePrompt.trim(), - })); - setIsCustomStylePanelOpen(false); - }; - const submitForm = () => { if (!canSubmit) { return; @@ -362,76 +236,6 @@ export function Match3DAgentWorkspace({
-
-
- 2D素材风格 -
-
- {MATCH3D_ASSET_STYLE_OPTIONS.map((option) => { - const selected = formState.assetStyleId === option.id; - const isCustom = option.id === 'custom'; - return ( - - ); - })} -
-
-
难度 @@ -498,60 +302,6 @@ export function Match3DAgentWorkspace({
- {isCustomStylePanelOpen ? ( -
-
-
-
- 自定义风格 -
- -
-