From d06107f2c6b453be27c7834e21a9d6a05e819599 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 6 May 2026 12:51:28 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E6=96=B9=E6=B4=9E=E6=8C=91?= =?UTF-8?q?=E6=88=98=E5=9B=BE=E7=89=87=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=80=81?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/development-workflow.md | 2 + .hermes/shared-memory/team-conventions.md | 2 + AGENTS.md | 1 + docs/README.md | 2 + ...ATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md | 13 +- ...API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md | 4 +- ...OUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md | 41 + ..._APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md | 2 +- docs/technical/README.md | 4 +- ...ON_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md | 2 +- .../SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md | 4 +- ...VER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md | 2 +- ...HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md | 54 +- ..._DRAG_DROP_GAMEPLAY_REDESIGN_2026-05-05.md | 49 + ..._AND_RUNTIME_INTERACTION_FIX_2026-05-06.md | 31 + package.json | 1 - .../shared/src/contracts/squareHoleAgent.ts | 7 +- .../shared/src/contracts/squareHoleRuntime.ts | 3 +- .../shared/src/contracts/squareHoleWorks.ts | 9 +- packages/shared/src/index.ts | 1 + scripts/api-server-maincloud.mjs | 220 ---- server-rs/crates/api-server/src/app.rs | 12 +- server-rs/crates/api-server/src/assets.rs | 25 +- .../api-server/src/prompt/square_hole.rs | 20 +- .../crates/api-server/src/runtime_profile.rs | 13 +- .../crates/api-server/src/square_hole.rs | 739 ++++++++++++-- .../api-server/src/square_hole_agent_turn.rs | 84 +- .../module-square-hole/src/application.rs | 289 ++++-- .../crates/module-square-hole/src/commands.rs | 38 +- .../crates/module-square-hole/src/domain.rs | 10 +- server-rs/crates/platform-oss/src/lib.rs | 6 +- .../shared-contracts/src/square_hole_agent.rs | 11 +- .../src/square_hole_runtime.rs | 4 +- .../shared-contracts/src/square_hole_works.rs | 13 +- .../crates/spacetime-client/src/mapper.rs | 24 +- .../src/asset_metadata/objects.rs | 10 +- .../spacetime-module/src/runtime/profile.rs | 2 - .../spacetime-module/src/square_hole/mod.rs | 21 +- .../spacetime-module/src/square_hole/types.rs | 10 +- server-rs/crates/tests-support/README.md | 2 +- server-rs/crates/tests-support/src/lib.rs | 8 +- .../PlatformEntryFlowShellImpl.tsx | 248 ++--- .../SquareHoleResultView.tsx | 940 +++++++++++++----- .../SquareHoleRuntimeShell.test.tsx | 137 +++ .../SquareHoleRuntimeShell.tsx | 334 +++++-- src/index.css | 28 +- .../miniGameDraftGenerationProgress.ts | 15 +- src/services/square-hole-works/index.ts | 7 + .../squareHoleAssetClient.ts | 46 + .../squareHoleWorksClient.ts | 21 + 51 files changed, 2590 insertions(+), 989 deletions(-) create mode 100644 docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md create mode 100644 docs/technical/SQUARE_HOLE_DRAG_DROP_GAMEPLAY_REDESIGN_2026-05-05.md create mode 100644 docs/technical/SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md delete mode 100644 scripts/api-server-maincloud.mjs create mode 100644 src/components/square-hole-runtime/SquareHoleRuntimeShell.test.tsx create mode 100644 src/services/square-hole-works/squareHoleAssetClient.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 3eea40a8..38a9590a 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-06 Maincloud 历史残留引用禁止再使用 + +- 背景:项目已经全面移除 Maincloud 运行口径,但历史脚本、测试名和文档仍可能让后续开发误用 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`。 +- 决策:`maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求全部视为历史残留,后续禁止新增、运行或引用;后端 API smoke 统一使用 `npm run api-server` 并检查 `/healthz`。 +- 影响范围:`AGENTS.md`、`docs/technical/`、`.hermes/shared-memory/`、后端启动脚本、测试支撑和所有后续工程文档。 +- 验证方式:新增或修改后端相关文档时,检查不得要求 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`;触碰历史残留时同步删除或改名。 +- 关联文档:`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`、`docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md`。 + ## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆 - 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 36de9840..48edd942 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -153,6 +153,7 @@ npm run check:server-rs-ddd 后端修改后,按 DDD 文档中的验收命令执行。涉及 API smoke 时: - 使用 `npm run api-server` 重新拉起后端。 +- 禁止使用 `npm run api-server:maincloud`、`npm.cmd run api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径;这些只属于历史残留。 - 检查 `/healthz`。 - 执行对应自动测试。 - 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。 @@ -165,6 +166,7 @@ npm run check:server-rs-ddd - `docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md` - `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` - `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` +- `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md` ## 前端相关默认验证 diff --git a/.hermes/shared-memory/team-conventions.md b/.hermes/shared-memory/team-conventions.md index 5815cb93..3c3cc18f 100644 --- a/.hermes/shared-memory/team-conventions.md +++ b/.hermes/shared-memory/team-conventions.md @@ -43,6 +43,7 @@ - 复用、修改、扩展现有系统优先,避免新建重复系统或页面。 - 涉及中文文本时注意 UTF-8 编码和乱码排查。 - 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。 +- `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用;API smoke 统一使用 `npm run api-server` 与 `/healthz`。 - 涉及 SpacetimeDB 表结构、发布或迁移时,先看 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 和 `SPACETIMEDB_TABLE_CATALOG.md`。 - 涉及生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 @@ -74,6 +75,7 @@ 4. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md` 5. `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 6. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` +7. `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md` ## 共享记忆更新准则 diff --git a/AGENTS.md b/AGENTS.md index f6bf6e0c..6231885b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ - DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。 - 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。 - 修改后端代码后,按对应 DDD 文档中的验收命令执行测试;涉及 API smoke 时使用 `npm run api-server` 重新拉起后端并执行相应自动测试,同时确认 `/healthz`。 +- `maincloud` / `Maincloud` / `MAINCLOUD` 相关脚本、环境变量、测试、文档要求和命名全部视为历史残留,禁止新增、运行或引用;若旧文档仍要求 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`,以 [`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`](docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md) 和本文件为准,并先修正文档口径。 - 凡是涉及 SpacetimeDB 的设计、实现、脚本、调试、前端绑定接入,统一显式使用以下 skill 作为执行依据: - [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md) - [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md) diff --git a/docs/README.md b/docs/README.md index e0c3eb78..df25e2cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段 创作 Agent 问答流式失败时保留已显示回复、并透出更具体上游错误的契约见 [CREATION_AGENT_STREAM_FAILURE_RETENTION_FIX_2026-05-05.md](./technical/CREATION_AGENT_STREAM_FAILURE_RETENTION_FIX_2026-05-05.md)。 +`maincloud` 相关脚本、环境变量、测试名和文档要求已统一判定为历史残留,后续禁止新增、运行或引用;当前后端 smoke 使用 `npm run api-server` 与 `/healthz`,详细规则见 [MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md](./technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md b/docs/prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md index 5b835995..30d348d3 100644 --- a/docs/prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md +++ b/docs/prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md @@ -187,9 +187,12 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问 2. 创作者上传入口保留;上传图片可以覆盖生成图片。 3. 图片生成失败时保留草稿和可编辑配置,结果页展示缺失槽位,允许创作者重试生成或上传替代图。 4. 结果页必须展示每个形状选项及其图片、背景图、封面图和洞口选项配置。 -5. 运行态当前形状优先显示 `imageSrc`,没有图片时才回退到 CSS 形状。 -6. 运行态背景优先显示 `backgroundImageSrc`,没有图片时才回退到默认渐变。 -7. 运行态顶部不显示“方洞是唯一解”或等价真实规则提示;只保留时间、进度、分数和连击。 +5. 每个图片槽位点击后进入查看模式;查看模式内同时提供历史图片、上传图片和 AI 生成图片入口。 +6. 查看模式里的 AI 生成只能重生成当前槽位,不触发整份草稿重新编译,也不切换到草稿生成进度页。 +7. 洞口图使用独立历史素材类型 `square_hole_hole_image`,需要和封面、背景、形状图一样支持历史图片选择。 +8. 运行态当前形状优先显示 `imageSrc`,没有图片时才回退到 CSS 形状。 +9. 运行态背景优先显示 `backgroundImageSrc`,没有图片时才回退到默认渐变。 +10. 运行态顶部不显示“方洞是唯一解”或等价真实规则提示;只保留时间、进度、分数和连击。 ## 6.5 前端表现 @@ -199,6 +202,8 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问 4. 不默认展示长篇规则说明。 5. 错误反馈用短促动画、颜色闪烁和轻量文字状态,不堆解释。 6. 点击按钮弹出的配置或结算必须使用独立面板,不在当前面板下方展开。 +7. 运行态必须同时支持拖拽和点击投放:拖动当前选项到洞口松开即提交该洞口,点击当前选项后点击洞口也提交该洞口;直接点击洞口时若当前局可操作,也提交该洞口。 +8. `difficulty` 当前不参与运行态裁决、计时、计分或队列生成;前端不应把它作为显性玩法调参展示,后端仅保留兼容字段。 --- @@ -325,4 +330,4 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问 8. 后端裁决命中规则、连击、失败和胜利。 9. 刷新后可恢复作品与运行态快照。 10. `docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md` 记录该入口开放状态。 -11. 后端改动完成后必须执行 `npm run api-server:maincloud`,以 `GET /healthz` 返回 `200` 作为主云配置启动 smoke 通过标准,并在 smoke 后清理本次启动进程。 +11. 后端改动完成后必须执行 `npm run api-server`,以 `GET /healthz` 返回 `200` 作为主云配置启动 smoke 通过标准,并在 smoke 后清理本次启动进程。 diff --git a/docs/technical/API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md b/docs/technical/API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md index d567b2af..c3807426 100644 --- a/docs/technical/API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md +++ b/docs/technical/API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md @@ -49,7 +49,7 @@ npm run build npm run check:content ``` -后端代码变更后,按项目约束还需要用 `npm run api-server:maincloud` 做一次启动验证。 +后端代码变更后,按项目约束还需要用 `npm run api-server` 做一次启动验证。 本轮最终结果: @@ -58,7 +58,7 @@ npm run check:content - `cargo test --manifest-path server-rs\Cargo.toml` 已通过,结果同 `api-server` 默认测试。 - `npm test` 已通过,结果为 `160 passed` 个测试文件、`704 passed` 个用例。 - `npm run typecheck`、`npm run build`、`npm run check:content`、`npm run check:encoding`、`git diff --check` 已通过。 -- `npm run api-server:maincloud` 已完成启动烟测,`/healthz` 返回 `200`;期间 Maincloud 订阅恢复出现 `503` warning,但未阻止服务启动。 +- `npm run api-server` 已完成启动烟测,`/healthz` 返回 `200`;期间 Maincloud 订阅恢复出现 `503` warning,但未阻止服务启动。 仍需单独处理的非本轮阻塞: diff --git a/docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md b/docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md new file mode 100644 index 00000000..3cb20a02 --- /dev/null +++ b/docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md @@ -0,0 +1,41 @@ +# Maincloud 残留引用移除策略 + +## 背景 + +项目后端、发布和本地联调已经切到 `server-rs + Axum + SpacetimeDB` 当前基线,并以本地或显式配置的 SpacetimeDB 服务为运行目标。历史上用于连接 SpacetimeDB Maincloud 的脚本、环境变量、测试名和文档口径已经不再代表当前工程约束。 + +## 决策 + +- `maincloud` / `Maincloud` / `MAINCLOUD` 相关命名、脚本、测试、环境变量、文档要求和启动命令全部视为历史残留。 +- 后续禁止新增、运行或引用 `maincloud` 相关代码、测试、脚本、文档要求、环境变量和命令。 +- 旧文档若要求执行 `npm run api-server:maincloud`、`npm.cmd run api-server:maincloud` 或读取 `GENARRATIVE_SPACETIME_MAINCLOUD_*`,一律以本策略和 `AGENTS.md` 最新约束为准,并在触碰该文档或代码时同步修正。 +- 后端 API smoke 统一使用当前非 Maincloud 启动入口:`npm run api-server`。服务就绪以 `GET /healthz` 返回成功为准。 +- SpacetimeDB 运行目标必须来自本地开发服务、生产自托管服务,或显式 `SERVER_URL` 配置;不得再回退到 Maincloud 默认值。 + +## 落地规则 + +1. 修改后端代码后,按对应 DDD 文档执行定向测试;涉及 API smoke 时执行 `npm run api-server` 并探测 `/healthz`。 +2. 触碰历史脚本、测试支撑或文档时,优先删除或改名其中的 `maincloud` 口径,改为当前本地或显式服务配置口径。 +3. 新增文档不得把 `api-server:maincloud` 写成验收命令,也不得要求配置 `GENARRATIVE_SPACETIME_MAINCLOUD_*`。 +4. 新增测试不得使用 `DEFAULT_MAINCLOUD_*` 这类历史命名;测试辅助应使用通用 `api-server`、`healthz` 或明确的本地 SpacetimeDB 命名。 +5. 如需保留历史文档中的旧执行记录,只能作为归档事实存在,不得作为当前执行清单、验收命令或开发约束继续引用。 + +## 验证方式 + +常规检查: + +```bash +rg -n "maincloud|Maincloud|MAINCLOUD|api-server:maincloud|GENARRATIVE_SPACETIME_MAINCLOUD" AGENTS.md docs .hermes package.json scripts server-rs -S +``` + +验收口径: + +- `AGENTS.md`、`.hermes/shared-memory/` 和当前任务相关文档不得要求使用 Maincloud。 +- 活跃脚本、测试和配置不得依赖 `GENARRATIVE_SPACETIME_MAINCLOUD_*`。 +- 后端启动和 smoke 以 `npm run api-server` 与 `/healthz` 为准。 + +## 关联文档 + +- [SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md](./SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md) +- [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md) +- [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md) diff --git a/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md index 48c7877a..e297bc97 100644 --- a/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md +++ b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md @@ -72,4 +72,4 @@ APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 5. 选择 APIMart 模型时,请求 `POST {APIMART_BASE_URL}/images/generations`,使用 `Authorization: Bearer {APIMART_API_KEY}`,`model` 等于请求值,`size = 1:1`。 6. “生成草稿”和关卡详情生图按钮展示 `消耗2光点`;关卡详情确认后展示 30 秒预计剩余进度条。 7. 不改 SpacetimeDB 表结构,因此无需更新 `migration.rs` 或重新生成 bindings。 -8. 后端改动后运行对应 Rust 测试,并按项目约束用 `npm run api-server:maincloud` 重启验证。 +8. 后端改动后运行对应 Rust 测试,并按项目约束用 `npm run api-server` 重启验证。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 1520af4f..bfc5118f 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,8 @@ ## 文档列表 - [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](./PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md):冻结个人任务与埋点系统首版方案,明确 `tracking_event`、`tracking_daily_stat`、`profile_task_config`、任务进度、领奖记录和光点钱包流水的边界。 +- [SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md](./SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md):记录方洞挑战结果页图片槽位局部生成、洞口图历史素材、运行态拖拽与点击投放交互的修正口径。 +- [MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md](./MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md):冻结 Maincloud 历史残留引用禁用策略,明确后续不得新增、运行或引用 `api-server:maincloud`、`GENARRATIVE_SPACETIME_MAINCLOUD_*` 和相关测试/文档口径。 - [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md):冻结单机生产部署目标,从旧一体化启动脚本切到 Nginx、systemd 托管 SpacetimeDB 与 Rust `api-server`,并记录生产 Jenkins 流水线拆分计划和首批部署骨架。 - [PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md](./PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。 - [RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md](./RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md):记录 `agent-foundation-*-dossier-batch-*` 无搜索 Responses 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。 @@ -21,7 +23,7 @@ - [SERVER_RS_DDD_WP_RS_RUNTIME_STORY_CLOSURE_2026-05-01.md](./SERVER_RS_DDD_WP_RS_RUNTIME_STORY_CLOSURE_2026-05-01.md):记录 `WP-RS Runtime Story` 写链路收尾,补齐 `/api/story/sessions/runtime` 与 `/api/story/sessions/{storySessionId}/actions/resolve`,统一返回 `StoryRuntimeMutationResponse.projection`,并保持旧 `/api/runtime/story/*` 未挂载。 - [SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md](./SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md):记录 `WP-CW Custom World` 的领域拆分与 Agent action 收口,将 `module-custom-world` 大 `lib.rs` 拆入 DDD 骨架,并移除 Custom World 运行代码中的最小兼容占位动作。 - [SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md](./SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md):记录 `WP-BF Big Fish` 物理拆分漂移和 G2 迁移期口径清理,将 Big Fish 创作域类型、命令、应用规则和错误层拆入 DDD 文件,并清理剩余 `过渡落位` 注释。 -- [SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md](./SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md):记录 `tests-support` 从目录占位收口为 `server-rs` workspace 共享测试支撑 crate,首版提供 Maincloud healthz 与 HTTP smoke 通用断言。 +- [SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md](./SERVER_RS_DDD_TESTS_SUPPORT_CRATE_CLOSURE_2026-04-30.md):记录 `tests-support` 从目录占位收口为 `server-rs` workspace 共享测试支撑 crate;该历史文档中的旧 Maincloud 口径不再作为当前执行依据,当前 smoke 以通用 `/healthz` 为准。 - [SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md](./SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md):记录 `WP-BF Big Fish` 运行态从前端本地规则切到 Rust 领域真相源、SpacetimeDB run 表、API facade 和前端新接口接入的关闭口径。 - [SERVER_RS_DDD_WP_PF_PLATFORM_ERROR_CLASSIFICATION_2026-04-29.md](./SERVER_RS_DDD_WP_PF_PLATFORM_ERROR_CLASSIFICATION_2026-04-29.md):记录 `WP-PF platform side effects` 平台副作用收口,统一 LLM、OSS、SMS、微信平台错误分类与 API 映射,并将微信 OAuth provider 下沉到 `platform-auth`。 - [SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md](./SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md):记录 `WP-RT Runtime/Profile/Save` Adapter/API 收口,将 checkpoint、profile/save archive meta、充值/邀请/兑换/钱包等剩余纯规则迁入 `module-runtime`,移除 `/api/runtime/profile/*` 旧兼容挂载并对齐前端 `/api/profile/*` 请求路径。 diff --git a/docs/technical/RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md b/docs/technical/RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md index 7935e9c6..2124e769 100644 --- a/docs/technical/RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md +++ b/docs/technical/RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md @@ -42,4 +42,4 @@ Your account has not activated web search. 2. 降级重试请求体不再包含 `tools` / `web_search`。 3. `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false` 时,foundation draft 全流程直接不带搜索工具。 4. `cargo test -p api-server custom_world_foundation_draft --manifest-path server-rs/Cargo.toml` 通过。 -5. 修改后按项目约束使用 `npm run api-server:maincloud` 重启后端。 +5. 修改后按项目约束使用 `npm run api-server` 重启后端。 diff --git a/docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md b/docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md index bb01be01..ac95a2d0 100644 --- a/docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md +++ b/docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md @@ -386,8 +386,8 @@ node scripts/check-server-rs-ddd-boundaries.mjs cargo fmt --all --check --manifest-path server-rs/Cargo.toml cargo test --workspace --manifest-path server-rs/Cargo.toml cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml -npm run api-server:maincloud +npm run api-server npm run check:encoding ``` -若 `npm run api-server:maincloud` 因本机未配置 Maincloud 数据库或令牌失败,必须记录具体错误;不能改用旧后端重启命令。 +若 `npm run api-server` 因本机未配置 Maincloud 数据库或令牌失败,必须记录具体错误;不能改用旧后端重启命令。 diff --git a/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md b/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md index 404f534a..0bde0633 100644 --- a/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md +++ b/docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md @@ -1818,7 +1818,7 @@ npm.cmd run api-server:maincloud npm.cmd run api-server:maincloud ``` -结果:命令在 60 秒观察窗口内超时。随后探测 `http://127.0.0.1:3100/healthz` 未连通;进程检查发现存在两组遗留的 `npm run api-server:maincloud -> scripts/api-server-maincloud.mjs -> cargo run -p api-server` 链路,同时还有并行 `module-assets` 测试和 `spacetime-module` 检查在运行。该结果不是本次 WP-API route 编译错误,需清理本次遗留 api-server 启动链后再做一次干净启动。 +结果:命令在 60 秒观察窗口内超时。随后探测 `http://127.0.0.1:3100/healthz` 未连通;进程检查发现存在两组遗留的 `npm run api-server -> scripts/api-server-maincloud.mjs -> cargo run -p api-server` 链路,同时还有并行 `module-assets` 测试和 `spacetime-module` 检查在运行。该结果不是本次 WP-API route 编译错误,需清理本次遗留 api-server 启动链后再做一次干净启动。 ### 2026-04-29 WP-API runtime projection 接线 diff --git a/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md b/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md index a73a2c1d..e17bedbd 100644 --- a/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md +++ b/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md @@ -29,4 +29,56 @@ 1. `platform-llm` 测试覆盖请求级 timeout 会让慢响应提前超时。 2. `creation_agent_llm_turn` 测试覆盖流式 JSON 请求带创作 Agent timeout。 3. `cargo test -p platform-llm -p api-server creation_agent --manifest-path server-rs/Cargo.toml` 通过。 -4. 后端代码变更后按项目约束运行 `npm run api-server:maincloud` 并确认 `/healthz`。 +4. 后端代码变更后按项目约束运行 `npm run api-server` 并确认 `/healthz`。 + +## 5. 追加:视觉资产动作 503 降级 + +现场后续日志: + +```text +status=503 method=POST uri=/api/creation/square-hole/sessions/{sessionId}/actions +``` + +该请求落在方洞 `/actions`,草稿编译成功后前端会自动追加执行 `square_hole_generate_visual_assets`。视觉资产生成依赖 APIMart OpenAI 兼容图片入口;当本地或部署环境缺少 `APIMART_API_KEY` 时,后端会在 `require_openai_image_settings()` 阶段快速返回 `503 SERVICE_UNAVAILABLE`,因此日志 latency 只有个位毫秒。 + +这类错误只表示“图片自动生成服务不可用”,不代表方洞草稿编译失败。前端处理规则: + +1. `square_hole_compile_draft` 成功后,如果自动图片生成失败,保留错误横幅。 +2. 立即进入方洞结果页,展示已生成的形状/洞口配置。 +3. 保留封面图、背景图、形状贴图和洞口选项图上传入口。 +4. 进度页仍可通过“重新生成图片”在配置补齐后重试。 + +## 6. 追加:结果页图片重生成入口 + +方洞结果页需要保留用户上传入口,同时提供 `AI重生成图片` 操作。该按钮先保存当前编辑内容,再复用 `/api/creation/square-hole/sessions/{sessionId}/actions` 的 `square_hole_generate_visual_assets` 动作,并额外传入 `regenerateVisualAssets=true`。 + +后端收到该标记后,不再按已有 `coverImageSrc`、`backgroundImageSrc` 或形状 `imageSrc` 跳过图片生成,而是重新生成封面图、背景图和所有形状贴图。自动编译后的图片补齐流程仍保持默认 `false`,只补缺失图片,避免覆盖创作者刚上传的素材。 + +## 7. 追加:图片槽位查看模式与历史素材 + +方洞结果页的封面图、背景图、每个形状贴图和每个洞口选项图不再把上传入口直接散落在卡片上。点击任一图片槽位后打开独立查看面板,面板内负责: + +1. 展示当前槽位图片。 +2. 展示当前账号历史生成的方洞图片素材。 +3. 提供本地上传入口,上传后只替换当前槽位。 +4. 提供 `AI生成图片` 入口,触发 `square_hole_generate_visual_assets` 并带 `regenerateVisualAssets=true`、`visualAssetSlot` 与可选 `visualAssetOptionId`。 +5. 后端按槽位定向生成:封面面板只替换封面图,背景面板只替换背景图,形状贴图面板只替换当前形状选项的贴图,洞口选项图面板只替换当前洞口选项图。草稿编译后的自动图片补齐仍不传槽位,保持“只补缺失图片”的原逻辑。 + +历史素材来源必须是真实资产索引,不允许只从前端当前草稿拼假列表。方洞图片生成成功后,API 层需要像拼图封面一样写入 OSS 与 `asset_object`: + +1. 封面图使用 `asset_kind = square_hole_cover_image`。 +2. 背景图使用 `asset_kind = square_hole_background_image`。 +3. 形状贴图使用 `asset_kind = square_hole_shape_image`。 +4. 洞口选项图使用 `asset_kind = square_hole_hole_image`。 +5. 资产历史接口 `/api/assets/history` 与 SpacetimeDB 侧历史素材白名单同步放行这四类。 +6. 如果 OSS 或 SpacetimeDB 资产索引不可用,本次生成仍允许以 Data URL 回写作品,历史素材列表只降级为空或缺少本次记录。 + +## 8. 追加:选项图片不再局限于形状 + +方洞挑战的可展示图片不再只挂在 `shapeOptions`。创作者配置中的 `holeOptions` 也需要具备: + +1. `imagePrompt`:洞口选项图的生成提示词,默认由题材主题和洞口标签补齐。 +2. `imageSrc`:洞口选项图地址,可来自 AI 生成、历史素材套用或本地上传 Data URL。 +3. 结果页需要把洞口选项图做成与形状贴图相同的图片槽位,打开查看面板后支持历史、上传和 AI 生成。 +4. 运行态洞口按钮优先展示 `hole.imageSrc`,没有图片时再回落到几何剪影。 +5. `visualAssetSlot = hole` 时必须携带 `visualAssetOptionId = holeId`,后端只重生成该洞口选项图;未传槽位的自动补齐需要同时补缺失的形状贴图和洞口选项图。 diff --git a/docs/technical/SQUARE_HOLE_DRAG_DROP_GAMEPLAY_REDESIGN_2026-05-05.md b/docs/technical/SQUARE_HOLE_DRAG_DROP_GAMEPLAY_REDESIGN_2026-05-05.md new file mode 100644 index 00000000..ed566e39 --- /dev/null +++ b/docs/technical/SQUARE_HOLE_DRAG_DROP_GAMEPLAY_REDESIGN_2026-05-05.md @@ -0,0 +1,49 @@ +# 方洞挑战拖拽玩法重构 2026-05-05 + +## 1. 目标 + +把方洞挑战从“按洞口按钮点选”改成“拖拽形状到目标洞口”的玩法,同时把洞口选项与形状选项的编辑关系改成稳定 ID + 名称联动。 + +## 2. 编辑态规则 + +1. 洞口选项不再暴露 `square / circle / triangle` 之类的下拉框。 +2. 每个洞口选项只保留稳定 `holeId`、可编辑名称 `label`,以及可选图片字段。 +3. 洞口名称改动后,所有引用该洞口的形状选项下拉框必须同步显示新名称。 +4. 形状选项不再直接绑定洞口“形状类型”,而是绑定一个目标洞口 `targetHoleId`。 +5. 形状选项下拉框展示的是洞口名称,底层值是 `targetHoleId`。 +6. 形状选项仍保留自己的视觉配置字段,如 `shapeKind`、`imagePrompt`、`imageSrc`。 +7. 去掉加分选项,所有洞口选项一律平权。 + +## 3. 运行态规则 + +1. 运行态上半区展示全部洞口,布局成一块平面。 +2. 下半区展示当前轮次的形状选项。 +3. 每轮随机选择一个形状选项作为当前轮显示项,并以它绑定的 `targetHoleId` 作为目标洞口。 +4. 箭头动画只负责给出随机引导洞口,不绑定正确答案;多洞口时优先避开当前正确洞口,避免直接剧透。 +5. 用户把当前形状拖到任意洞口上松开后,前端把该洞口的 `holeId` 提交给后端。 +6. 后端以 `holeId === currentShape.targetHoleId` 作为唯一判定标准。 +7. 命中后进入下一轮;未命中时后端返回错误反馈、清空连击并保留当前运行态。 +8. 计分不再包含任何加分洞口分支。 +9. 游戏模式下洞口和当前选项只展示图片卡片,不再按 `shapeKind` / `holeKind` 裁剪成圆形、三角形、星形等几何形状。 + +## 4. 图片槽位 + +1. 封面图、背景图、形状图、洞口图都使用独立图片槽位。 +2. 洞口图对应新的历史素材类型 `square_hole_hole_image`。 +3. 图片槽位查看面板内保留历史图片、上传入口和 AI 生成入口。 + +## 5. 后端契约要求 + +1. 共享契约里补充形状到洞口的目标引用字段。 +2. 共享契约里补充洞口图片字段。 +3. 运行态快照里必须包含当前形状对应的目标洞口信息,便于前端画箭头。 +4. 作品和草稿返回的洞口数据必须和运行态保持同一组 ID 与名称。 + +## 6. 验收标准 + +1. 编辑洞口名称后,形状下拉框立即显示新名称。 +2. 洞口编辑不再出现 `square / circle / triangle` 下拉框。 +3. 运行态不再是按钮点选洞口,而是拖拽命中。 +4. 每轮都能看到箭头或高亮引导洞口,但该引导不等同于正确答案。 +5. 计分中不再出现 `bonus` 相关加分。 +6. 游戏模式中洞口、当前选项和拖拽影子不再显示几何形状,只显示对应图片或中性图片占位。 diff --git a/docs/technical/SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md b/docs/technical/SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md new file mode 100644 index 00000000..1cf38c9b --- /dev/null +++ b/docs/technical/SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md @@ -0,0 +1,31 @@ +# 方洞挑战图片槽位与运行态交互修正 + +## 背景 + +方洞挑战结果页把封面图、背景图、形状图和洞口图统一放进查看模式后,图片面板里的“AI生成图片”仍复用了 Agent action:`square_hole_generate_visual_assets`。这条链路会进入生成进度页,并按草稿视觉资产流程更新会话,用户点击任意图片槽位时看起来像触发了整份草稿重编译。 + +同时,洞口图已经有独立资产类型 `square_hole_hole_image`,但 HTTP 历史素材白名单未放通,导致洞口图片面板无法读取历史图片。运行态方面,预期是拖拽当前选项到洞口松开,移动端同时支持点击当前选项后点洞口;现有洞口按钮没有点击提交,拖拽也缺少稳定的释放兜底。 + +## 修正决策 + +1. 结果页图片查看模式里的“AI生成图片”只允许生成当前图片槽位,不切换到草稿生成进度页。 +2. 新增作品级槽位重生成接口,前端传 `slot.kind` 和对应 option id,后端只更新当前作品的目标图片字段。 +3. `square_hole_hole_image` 必须加入资产历史 HTTP 白名单,与 SpacetimeDB 侧历史素材白名单保持一致。 +4. 洞口图片查看模式必须和封面、背景、形状一样支持历史图片、上传图片和 AI 生成图片。 +5. 运行态交互统一为: + - 拖动下方当前选项到上方任一洞口松开,提交该洞口 `holeId`。 + - 点击下方当前选项进入待投放状态,再点击任一洞口,也提交该洞口 `holeId`。 + - 直接点击洞口时,如果当前局正在运行且没有待处理操作,也提交该洞口 `holeId`。 +6. 箭头和高亮只表示随机引导洞口,不表示正确答案;多洞口时优先避开当前正确洞口。 +7. 游戏模式只展示图片卡片,不再按形状字段裁剪出几何图形;没有图片时使用中性图片占位。 +8. `difficulty` 当前不参与运行态裁决、计时、计分或队列生成,不应作为结果页显性玩法调参继续误导创作者;后端字段保留兼容,前端不再突出展示和编辑。 + +## 验收 + +1. 在结果页点击任一图片槽位,只打开图片查看面板,不触发生成进度页。 +2. 图片面板点击“AI生成图片”后,只刷新当前槽位图片,结果页仍停留在当前面板。 +3. 洞口图片面板可以读取 `square_hole_hole_image` 历史图片。 +4. 运行态拖拽当前选项到洞口松开会调用 drop API。 +5. 运行态点击当前选项后点击洞口、或直接点击洞口,都会调用 drop API。 +6. 运行态洞口、当前选项和拖拽影子只显示图片或图片占位,不显示圆形、三角形、星形等几何形状。 +7. 运行态箭头和高亮不会默认指向当前正确洞口。 diff --git a/package.json b/package.json index f4491d63..75c82caf 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "admin-web:preview": "npm --prefix apps/admin-web run preview --", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", "api-server": "node scripts/api-server-dev.mjs", - "api-server:maincloud": "node scripts/api-server-maincloud.mjs", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh", "build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", diff --git a/packages/shared/src/contracts/squareHoleAgent.ts b/packages/shared/src/contracts/squareHoleAgent.ts index 0befbd27..4897f7e3 100644 --- a/packages/shared/src/contracts/squareHoleAgent.ts +++ b/packages/shared/src/contracts/squareHoleAgent.ts @@ -33,12 +33,16 @@ export interface ExecuteSquareHoleActionRequest { summary?: string; tags?: string[]; coverImageSrc?: string | null; + regenerateVisualAssets?: boolean; + visualAssetSlot?: 'cover' | 'background' | 'shape' | string | null; + visualAssetOptionId?: string | null; } export interface SquareHoleShapeOption { optionId: string; shapeKind: string; label: string; + targetHoleId: string; imagePrompt: string; imageSrc?: string | null; } @@ -47,7 +51,8 @@ export interface SquareHoleHoleOption { holeId: string; holeKind: string; label: string; - bonus: boolean; + imagePrompt: string; + imageSrc?: string | null; } export interface SquareHoleAnchorItemResponse { diff --git a/packages/shared/src/contracts/squareHoleRuntime.ts b/packages/shared/src/contracts/squareHoleRuntime.ts index 09c8612e..5d2bb468 100644 --- a/packages/shared/src/contracts/squareHoleRuntime.ts +++ b/packages/shared/src/contracts/squareHoleRuntime.ts @@ -36,6 +36,7 @@ export interface SquareHoleShapeSnapshot { shapeId: string; shapeKind: SquareHoleShapeKind; label: string; + targetHoleId: string; color: string; imageSrc?: string | null; } @@ -46,7 +47,7 @@ export interface SquareHoleHoleSnapshot { label: string; x: number; y: number; - bonus: boolean; + imageSrc?: string | null; } export interface SquareHoleRunSnapshot { diff --git a/packages/shared/src/contracts/squareHoleWorks.ts b/packages/shared/src/contracts/squareHoleWorks.ts index 4284f24d..7a956a66 100644 --- a/packages/shared/src/contracts/squareHoleWorks.ts +++ b/packages/shared/src/contracts/squareHoleWorks.ts @@ -8,6 +8,7 @@ export interface SquareHoleShapeOption { optionId: string; shapeKind: string; label: string; + targetHoleId: string; imagePrompt: string; imageSrc?: string | null; } @@ -16,7 +17,8 @@ export interface SquareHoleHoleOption { holeId: string; holeKind: string; label: string; - bonus: boolean; + imagePrompt: string; + imageSrc?: string | null; } export interface PutSquareHoleWorkRequest { @@ -34,6 +36,11 @@ export interface PutSquareHoleWorkRequest { difficulty: number; } +export interface RegenerateSquareHoleWorkImageRequest { + visualAssetSlot: 'cover' | 'background' | 'shape' | 'hole' | string; + visualAssetOptionId?: string | null; +} + export interface SquareHoleWorkSummary { workId: string; profileId: string; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 99378c6d..4ffecc24 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,6 +30,7 @@ export * from './contracts/squareHoleAgent'; export * from './contracts/squareHoleRuntime'; export type { PutSquareHoleWorkRequest, + RegenerateSquareHoleWorkImageRequest, SquareHoleWorkDetailResponse, SquareHoleHoleOption as SquareHoleWorkHoleOption, SquareHoleWorkMutationResponse, diff --git a/scripts/api-server-maincloud.mjs b/scripts/api-server-maincloud.mjs deleted file mode 100644 index 6d7d1989..00000000 --- a/scripts/api-server-maincloud.mjs +++ /dev/null @@ -1,220 +0,0 @@ -import { execFileSync, spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; - -const repoRoot = process.cwd(); -const apiServerExePath = resolve( - repoRoot, - 'server-rs/target/debug/api-server.exe', -); -const defaultHealthHost = '127.0.0.1'; -const defaultHealthPort = '3100'; -const healthTimeoutMs = - Number(process.env.GENARRATIVE_API_SERVER_MAINCLOUD_SMOKE_TIMEOUT_SECONDS) * - 1000 || 180_000; - -function loadEnvFile(path, target) { - if (!existsSync(path)) { - return; - } - - const rawText = readFileSync(path, 'utf8'); - for (const rawLine of rawText.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - continue; - } - - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); - if (!match) { - continue; - } - - const [, key, rawValue] = match; - if (target[key] !== undefined) { - continue; - } - - target[key] = rawValue.replace(/^['"]|['"]$/gu, ''); - } -} - -function stopExistingWindowsApiServer() { - if (process.platform !== 'win32') { - return; - } - - // Windows 下 cargo 重编译不能覆盖正在运行的 exe,只清理本仓库 target 内的 api-server。 - const command = [ - '$ErrorActionPreference = "Continue"', - '$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)', - '$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {', - ' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)', - '}', - 'foreach ($process in $processes) {', - ' try {', - ' Stop-Process -Id $process.Id -Force -ErrorAction Stop', - ' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue', - ' Write-Output $process.Id', - ' } catch {', - ' Write-Error "[api-server:maincloud] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"', - ' }', - '}', - 'exit 0', - ].join('\n'); - - const output = execFileSync( - 'powershell.exe', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], - { - encoding: 'utf8', - env: { - ...process.env, - GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath, - }, - }, - ).trim(); - - if (output) { - console.log(`[api-server:maincloud] 已停止旧 api-server 进程: ${output}`); - } -} - -function stopProcessTree(child) { - if (!child || child.exitCode !== null || child.signalCode) { - return; - } - - if (process.platform === 'win32') { - try { - execFileSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { - stdio: 'ignore', - }); - return; - } catch { - // taskkill 可能已经被进程自然退出抢先;继续走兜底清理。 - } - } - - child.kill('SIGTERM'); -} - -async function waitForHealthz({ child, healthUrl }) { - const deadline = Date.now() + healthTimeoutMs; - let childExit = null; - child.once('exit', (code, signal) => { - childExit = { code, signal }; - }); - - while (Date.now() < deadline) { - if (childExit) { - throw new Error( - `api-server 在 healthz 就绪前退出:code=${childExit.code ?? ''} signal=${ - childExit.signal ?? '' - }`, - ); - } - - try { - const response = await fetch(healthUrl, { - signal: AbortSignal.timeout(1_000), - }); - const body = await response.text(); - if (response.status === 200) { - return body; - } - } catch { - // 服务启动期间连接失败是预期状态,继续轮询。 - } - - await delay(500); - } - - throw new Error(`等待 /healthz 超时:${healthUrl}`); -} - -const mergedEnv = { ...process.env }; -loadEnvFile(resolve(repoRoot, '.env'), mergedEnv); -loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv); -loadEnvFile(resolve(repoRoot, '.env.secrets.local'), mergedEnv); - -mergedEnv.GENARRATIVE_API_HOST = - mergedEnv.GENARRATIVE_API_HOST || defaultHealthHost; -mergedEnv.GENARRATIVE_API_PORT = - mergedEnv.GENARRATIVE_API_PORT || defaultHealthPort; -mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL = - mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL || - mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || - 'https://maincloud.spacetimedb.com'; -mergedEnv.GENARRATIVE_SPACETIME_DATABASE = - mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE || - mergedEnv.GENARRATIVE_SPACETIME_DATABASE || - ''; -mergedEnv.GENARRATIVE_SPACETIME_TOKEN = - mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN || - mergedEnv.GENARRATIVE_SPACETIME_TOKEN || - ''; - -if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) { - console.error( - '[api-server:maincloud] 缺少 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE 或 GENARRATIVE_SPACETIME_DATABASE。', - ); - process.exit(1); -} - -try { - stopExistingWindowsApiServer(); -} catch (error) { - console.error( - `[api-server:maincloud] 清理旧 api-server 进程失败: ${error.message}`, - ); - process.exit(1); -} - -console.log( - `[api-server:maincloud] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`, -); - -const child = spawn( - 'cargo', - ['run', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'], - { - cwd: repoRoot, - env: mergedEnv, - stdio: 'inherit', - }, -); - -const cleanup = () => { - stopProcessTree(child); - try { - stopExistingWindowsApiServer(); - } catch { - // 退出阶段只做 best-effort 清理,不能覆盖真实 smoke 结果。 - } -}; - -process.once('SIGINT', () => { - cleanup(); - process.exit(130); -}); -process.once('SIGTERM', () => { - cleanup(); - process.exit(143); -}); - -try { - const healthHost = - mergedEnv.GENARRATIVE_API_HOST === '0.0.0.0' - ? defaultHealthHost - : mergedEnv.GENARRATIVE_API_HOST; - const healthUrl = `http://${healthHost}:${mergedEnv.GENARRATIVE_API_PORT}/healthz`; - const body = await waitForHealthz({ child, healthUrl }); - console.log(`[api-server:maincloud] /healthz 通过:${body}`); - cleanup(); -} catch (error) { - console.error(`[api-server:maincloud] smoke 失败:${error.message}`); - cleanup(); - process.exit(1); -} diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 9842d452..6691a4d9 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -124,8 +124,9 @@ use crate::{ drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up, get_square_hole_agent_session, get_square_hole_run, get_square_hole_work_detail, get_square_hole_works, list_square_hole_gallery, publish_square_hole_work, - put_square_hole_work, restart_square_hole_run, start_square_hole_run, stop_square_hole_run, - stream_square_hole_agent_message, submit_square_hole_agent_message, + put_square_hole_work, regenerate_square_hole_work_image, restart_square_hole_run, + start_square_hole_run, stop_square_hole_run, stream_square_hole_agent_message, + submit_square_hole_agent_message, }, state::AppState, story_battles::{ @@ -904,6 +905,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/creation/square-hole/works/{profile_id}/images/regenerate", + post(regenerate_square_hole_work_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/square-hole/gallery", get(list_square_hole_gallery), diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 1d07ae66..d0605df2 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -28,8 +28,15 @@ use crate::{ }; // 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 -const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = - ["character_visual", "scene_image", "puzzle_cover_image"]; +const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [ + "character_visual", + "scene_image", + "puzzle_cover_image", + "square_hole_cover_image", + "square_hole_background_image", + "square_hole_shape_image", + "square_hole_hole_image", +]; pub async fn create_direct_upload_ticket( State(state): State, @@ -492,6 +499,18 @@ mod tests { assert!(super::is_supported_asset_history_kind("character_visual")); assert!(super::is_supported_asset_history_kind("scene_image")); assert!(super::is_supported_asset_history_kind("puzzle_cover_image")); + assert!(super::is_supported_asset_history_kind( + "square_hole_cover_image" + )); + assert!(super::is_supported_asset_history_kind( + "square_hole_background_image" + )); + assert!(super::is_supported_asset_history_kind( + "square_hole_shape_image" + )); + assert!(super::is_supported_asset_history_kind( + "square_hole_hole_image" + )); assert!(!super::is_supported_asset_history_kind( "puzzle_preview_image" )); @@ -501,7 +520,7 @@ mod tests { fn asset_history_kind_message_lists_all_supported_kinds() { assert_eq!( super::supported_asset_history_kind_message(), - "历史素材类型只支持 character_visual、scene_image、puzzle_cover_image" + "历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image" ); } diff --git a/server-rs/crates/api-server/src/prompt/square_hole.rs b/server-rs/crates/api-server/src/prompt/square_hole.rs index b533eb8a..e646fea8 100644 --- a/server-rs/crates/api-server/src/prompt/square_hole.rs +++ b/server-rs/crates/api-server/src/prompt/square_hole.rs @@ -24,8 +24,9 @@ pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责 6. 默认核心反差优先使用“方洞万能”或“方洞优先”,但可以根据用户题材包装成更有记忆点的规则 7. progressPercent 范围只能是 0 到 100 8. shapeCount 只能是 6 到 24 的整数,difficulty 只能是 1 到 10 的整数 -9. shapeOptions 至少给 6 个,holeOptions 给 3 到 6 个,且至少一个 holeOptions.bonus 为 true -10. imagePrompt 和 backgroundPrompt 必须适合直接生成图片,不要包含 UI、文字、水印或解释 +9. shapeOptions 至少给 6 个,每个 shapeOptions.targetHoleId 必须指向某个 holeOptions.holeId +10. holeOptions 给 3 到 6 个,每个洞口都要有 imagePrompt +11. imagePrompt 和 backgroundPrompt 必须适合直接生成图片,不要包含 UI、文字、水印或解释 "#; const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字: @@ -42,15 +43,16 @@ const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输 "optionId": "square-block", "shapeKind": "square", "label": "方块", + "targetHoleId": "hole-1", "imagePrompt": "玩具纸箱主题的方块贴纸图,透明背景,明亮可爱,游戏资产" } ], "holeOptions": [ { - "holeId": "square-hole", - "holeKind": "square", - "label": "方洞", - "bonus": true + "holeId": "hole-1", + "holeKind": "hole-1", + "label": "洞口 1", + "imagePrompt": "玩具纸箱主题的洞口 1 贴纸图,透明背景,明亮可爱,游戏资产" } ], "backgroundPrompt": "玩具桌面上的纸箱洞板背景,中央留出操作空间" @@ -80,7 +82,7 @@ pub(crate) fn build_square_hole_agent_prompt( String::new() }; format!( - "模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,优先体现方洞优先或类似反直觉逻辑。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. shapeOptions 必须给出至少 6 个可生成贴图的形状候选,每个 imagePrompt 都围绕主题生成。\n6. holeOptions 必须给出 3 到 6 个洞口,创作者可在结果页继续改;至少一个 bonus=true。\n7. backgroundPrompt 用于生成运行态背景,必须描述画面,不要写规则说明。\n8. 用户给出明确方向时优先吸收并推进,不要机械问完所有字段。\n\n{contract}", + "模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,强调每轮当前选项需要拖进指定洞口形成反直觉效果。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. shapeOptions 必须给出至少 6 个可生成贴图的候选,每个 imagePrompt 都围绕主题生成,每个 targetHoleId 指向一个洞口 holeId。\n6. holeOptions 必须给出 3 到 6 个洞口,holeId 使用 hole-1、hole-2 这类稳定 ID,holeKind 保持同 ID;每个洞口都要有 imagePrompt。\n7. backgroundPrompt 用于生成运行态背景,必须描述画面,不要写规则说明。\n8. 用户给出明确方向时优先吸收并推进,不要机械问完所有字段。\n\n{contract}", quick_fill_rules = quick_fill_rules, turn = session.current_turn.saturating_add(1), progress = session.progress_percent, @@ -103,6 +105,7 @@ fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord) "optionId": option.option_id, "shapeKind": option.shape_kind, "label": option.label, + "targetHoleId": option.target_hole_id, "imagePrompt": option.image_prompt, "imageSrc": option.image_src, }) @@ -117,7 +120,8 @@ fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord) "holeId": option.hole_id, "holeKind": option.hole_kind, "label": option.label, - "bonus": option.bonus, + "imagePrompt": option.image_prompt, + "imageSrc": option.image_src, }) }) .collect(); diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 110e009f..2e5ab5e2 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -14,13 +14,14 @@ use module_runtime::{ RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; -use serde_json::{Value, json}; use serde::Deserialize; +use serde_json::{Value, json}; use shared_contracts::runtime::{ - AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest, - AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest, - AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse, - AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, + ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, + ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest, + AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, + AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, + AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE, @@ -33,8 +34,6 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, - ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, - ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index b8fbaeb5..ce6a9512 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, convert::Infallible, time::{SystemTime, UNIX_EPOCH}, }; @@ -14,11 +15,16 @@ use axum::{ }, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use module_assets::{ + AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, + build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, +}; use module_square_hole::{ SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX, SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options, normalize_shape_options, }; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::{ @@ -36,10 +42,11 @@ use shared_contracts::{ SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest, }, square_hole_works::{ - PutSquareHoleWorkRequest, SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse, + PutSquareHoleWorkRequest, RegenerateSquareHoleWorkImageRequest, + SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse, SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse, - SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse, - SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse, + SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse, SquareHoleWorkProfileResponse, + SquareHoleWorkSummaryResponse, SquareHoleWorksResponse, }, }; use shared_kernel::build_prefixed_uuid_id; @@ -60,9 +67,10 @@ use crate::{ auth::AuthenticatedAccessToken, http_error::AppError, openai_image_generation::{ - build_openai_image_http_client, create_openai_image_generation, + DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation, require_openai_image_settings, }, + platform_errors::map_oss_error, request_context::RequestContext, square_hole_agent_turn::{ SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn, @@ -78,6 +86,11 @@ const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能"; const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12; const SQUARE_HOLE_DEFAULT_DIFFICULTY: u32 = 4; const SQUARE_HOLE_QUESTION_THEME: &str = "你想做什么题材"; +const SQUARE_HOLE_ENTITY_KIND: &str = "square_hole_work"; +const SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image"; +const SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image"; +const SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image"; +const SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -104,6 +117,8 @@ struct SquareHoleConfigShapeOptionJson { option_id: String, shape_kind: String, label: String, + #[serde(default)] + target_hole_id: String, image_prompt: String, #[serde(default, deserialize_with = "deserialize_optional_string_as_default")] image_src: String, @@ -116,7 +131,9 @@ struct SquareHoleConfigHoleOptionJson { hole_kind: String, label: String, #[serde(default)] - bonus: bool, + image_prompt: String, + #[serde(default, deserialize_with = "deserialize_optional_string_as_default")] + image_src: String, } #[derive(Clone, Debug, Deserialize)] @@ -359,6 +376,9 @@ pub async fn execute_square_hole_agent_action( &request_context, &authenticated, session_id, + payload.regenerate_visual_assets.unwrap_or(false), + payload.visual_asset_slot, + payload.visual_asset_option_id, ) .await? } @@ -543,18 +563,18 @@ pub async fn put_square_hole_work( .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or(existing.theme_text); - let shape_options_json = payload - .shape_options - .clone() - .map(square_hole_work_shape_options_to_records) - .unwrap_or_else(|| existing.shape_options.clone()); - let shape_options_json = serialize_square_hole_shape_option_records(&shape_options_json); - let hole_options_json = payload + let hole_options = payload .hole_options .clone() .map(square_hole_work_hole_options_to_records) .unwrap_or_else(|| existing.hole_options.clone()); - let hole_options_json = serialize_square_hole_hole_option_records(&hole_options_json); + let hole_options_json = serialize_square_hole_hole_option_records(&hole_options); + let shape_options = payload + .shape_options + .clone() + .map(|options| square_hole_work_shape_options_to_records(options, hole_options.as_slice())) + .unwrap_or_else(|| existing.shape_options.clone()); + let shape_options_json = serialize_square_hole_shape_option_records(&shape_options); let item = state .spacetime_client() .update_square_hole_work(SquareHoleWorkUpdateRecordInput { @@ -633,6 +653,40 @@ pub async fn publish_square_hole_work( )) } +pub async fn regenerate_square_hole_work_image( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + SQUARE_HOLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let item = regenerate_square_hole_visual_asset_for_work( + &state, + &request_context, + owner_user_id, + profile_id, + payload.visual_asset_slot, + payload.visual_asset_option_id, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + SquareHoleWorkMutationResponse { + item: map_square_hole_work_profile_response(item), + }, + )) +} + pub async fn delete_square_hole_work( State(state): State, Path(profile_id): Path, @@ -1064,6 +1118,9 @@ async fn generate_square_hole_visual_assets_for_session( request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, session_id: String, + regenerate_visual_assets: bool, + visual_asset_slot: Option, + visual_asset_option_id: Option, ) -> Result { let owner_user_id = authenticated.claims().user_id().to_string(); let session = state @@ -1100,11 +1157,29 @@ async fn generate_square_hole_visual_assets_for_session( ) })?; + let requested_slot = normalize_square_hole_visual_asset_slot( + visual_asset_slot.as_deref(), + visual_asset_option_id.as_deref(), + ); + let cover_image_src = match work.cover_image_src.clone() { - Some(value) if !value.trim().is_empty() => Some(value), + Some(value) + if !should_generate_square_hole_cover_image( + requested_slot.as_ref(), + regenerate_visual_assets, + value.as_str(), + ) => + { + Some(value) + } _ => Some( generate_square_hole_image_data_url( state, + &owner_user_id, + &session_id, + profile_id.as_str(), + "cover", + SQUARE_HOLE_COVER_IMAGE_KIND, build_square_hole_cover_prompt(&work).as_str(), "16:9", "生成方洞挑战封面图失败", @@ -1116,10 +1191,23 @@ async fn generate_square_hole_visual_assets_for_session( ), }; let background_image_src = match work.background_image_src.clone() { - Some(value) if !value.trim().is_empty() => Some(value), + Some(value) + if !should_generate_square_hole_background_image( + requested_slot.as_ref(), + regenerate_visual_assets, + value.as_str(), + ) => + { + Some(value) + } _ => Some( generate_square_hole_image_data_url( state, + &owner_user_id, + &session_id, + profile_id.as_str(), + "background", + SQUARE_HOLE_BACKGROUND_IMAGE_KIND, build_square_hole_background_prompt(&work).as_str(), "16:9", "生成方洞挑战背景图失败", @@ -1133,18 +1221,21 @@ async fn generate_square_hole_visual_assets_for_session( let mut shape_options = work.shape_options.clone(); let prompt_work = work.clone(); for option in shape_options.iter_mut() { - if option - .image_src - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some() - { + if !should_generate_square_hole_shape_image( + requested_slot.as_ref(), + regenerate_visual_assets, + option, + ) { continue; } option.image_src = Some( generate_square_hole_image_data_url( state, + &owner_user_id, + &session_id, + profile_id.as_str(), + option.option_id.as_str(), + SQUARE_HOLE_SHAPE_IMAGE_KIND, build_square_hole_shape_prompt(&prompt_work, option).as_str(), "1:1", "生成方洞挑战形状贴图失败", @@ -1155,6 +1246,33 @@ async fn generate_square_hole_visual_assets_for_session( })?, ); } + let mut hole_options = work.hole_options.clone(); + for option in hole_options.iter_mut() { + if !should_generate_square_hole_hole_image( + requested_slot.as_ref(), + regenerate_visual_assets, + option, + ) { + continue; + } + option.image_src = Some( + generate_square_hole_image_data_url( + state, + &owner_user_id, + &session_id, + profile_id.as_str(), + option.hole_id.as_str(), + SQUARE_HOLE_HOLE_IMAGE_KIND, + build_square_hole_hole_prompt(&prompt_work, option).as_str(), + "1:1", + "生成方洞挑战洞口贴图失败", + ) + .await + .map_err(|error| { + square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error) + })?, + ); + } work = state .spacetime_client() @@ -1171,7 +1289,7 @@ async fn generate_square_hole_visual_assets_for_session( background_prompt: work.background_prompt.clone(), background_image_src: background_image_src.clone().unwrap_or_default(), shape_options_json: serialize_square_hole_shape_option_records(&shape_options), - hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options), + hole_options_json: serialize_square_hole_hole_option_records(&hole_options), shape_count: work.shape_count, difficulty: work.difficulty, updated_at_micros: current_utc_micros(), @@ -1206,8 +1324,180 @@ async fn generate_square_hole_visual_assets_for_session( Ok(next_session) } +async fn regenerate_square_hole_visual_asset_for_work( + state: &AppState, + request_context: &RequestContext, + owner_user_id: String, + profile_id: String, + visual_asset_slot: String, + visual_asset_option_id: Option, +) -> Result { + let mut work = state + .spacetime_client() + .get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + square_hole_error_response( + request_context, + SQUARE_HOLE_WORKS_PROVIDER, + map_square_hole_client_error(error), + ) + })?; + let requested_slot = normalize_square_hole_visual_asset_slot( + Some(visual_asset_slot.as_str()), + visual_asset_option_id.as_deref(), + ) + .ok_or_else(|| { + square_hole_bad_request( + request_context, + SQUARE_HOLE_WORKS_PROVIDER, + "图片槽位不存在", + ) + })?; + let synthetic_session_id = work + .source_session_id + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| profile_id.clone()); + let prompt_work = work.clone(); + match &requested_slot { + SquareHoleVisualAssetSlotRequest::Cover => { + work.cover_image_src = Some( + generate_square_hole_image_data_url( + state, + owner_user_id.as_str(), + synthetic_session_id.as_str(), + profile_id.as_str(), + "cover", + SQUARE_HOLE_COVER_IMAGE_KIND, + build_square_hole_cover_prompt(&prompt_work).as_str(), + "16:9", + "生成方洞挑战封面图失败", + ) + .await + .map_err(|error| { + square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error) + })?, + ); + } + SquareHoleVisualAssetSlotRequest::Background => { + work.background_image_src = Some( + generate_square_hole_image_data_url( + state, + owner_user_id.as_str(), + synthetic_session_id.as_str(), + profile_id.as_str(), + "background", + SQUARE_HOLE_BACKGROUND_IMAGE_KIND, + build_square_hole_background_prompt(&prompt_work).as_str(), + "16:9", + "生成方洞挑战背景图失败", + ) + .await + .map_err(|error| { + square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error) + })?, + ); + } + SquareHoleVisualAssetSlotRequest::Shape(option_id) => { + let Some(option) = work + .shape_options + .iter_mut() + .find(|option| option.option_id == *option_id) + else { + return Err(square_hole_bad_request( + request_context, + SQUARE_HOLE_WORKS_PROVIDER, + "形状图片槽位不存在", + )); + }; + option.image_src = Some( + generate_square_hole_image_data_url( + state, + owner_user_id.as_str(), + synthetic_session_id.as_str(), + profile_id.as_str(), + option.option_id.as_str(), + SQUARE_HOLE_SHAPE_IMAGE_KIND, + build_square_hole_shape_prompt(&prompt_work, option).as_str(), + "1:1", + "生成方洞挑战形状贴图失败", + ) + .await + .map_err(|error| { + square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error) + })?, + ); + } + SquareHoleVisualAssetSlotRequest::Hole(hole_id) => { + let Some(option) = work + .hole_options + .iter_mut() + .find(|option| option.hole_id == *hole_id) + else { + return Err(square_hole_bad_request( + request_context, + SQUARE_HOLE_WORKS_PROVIDER, + "洞口图片槽位不存在", + )); + }; + option.image_src = Some( + generate_square_hole_image_data_url( + state, + owner_user_id.as_str(), + synthetic_session_id.as_str(), + profile_id.as_str(), + option.hole_id.as_str(), + SQUARE_HOLE_HOLE_IMAGE_KIND, + build_square_hole_hole_prompt(&prompt_work, option).as_str(), + "1:1", + "生成方洞挑战洞口贴图失败", + ) + .await + .map_err(|error| { + square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error) + })?, + ); + } + } + + state + .spacetime_client() + .update_square_hole_work(SquareHoleWorkUpdateRecordInput { + profile_id, + owner_user_id, + game_name: work.game_name.clone(), + theme_text: work.theme_text.clone(), + twist_rule: work.twist_rule.clone(), + summary_text: work.summary.clone(), + tags_json: serde_json::to_string(&normalize_tags(work.tags.clone())) + .unwrap_or_default(), + cover_image_src: work.cover_image_src.clone().unwrap_or_default(), + background_prompt: work.background_prompt.clone(), + background_image_src: work.background_image_src.clone().unwrap_or_default(), + shape_options_json: serialize_square_hole_shape_option_records(&work.shape_options), + hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options), + shape_count: work.shape_count, + difficulty: work.difficulty, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + square_hole_error_response( + request_context, + SQUARE_HOLE_WORKS_PROVIDER, + map_square_hole_client_error(error), + ) + }) +} + async fn generate_square_hole_image_data_url( state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + slot: &str, + asset_kind: &str, prompt: &str, size: &str, failure_context: &str, @@ -1232,11 +1522,220 @@ async fn generate_square_hole_image_data_url( })) })?; - Ok(format!( + let fallback_data_url = format_square_hole_data_url(&image); + match persist_square_hole_generated_asset( + state, + owner_user_id, + session_id, + profile_id, + slot, + asset_kind, + generated.task_id.as_str(), + image, + current_utc_micros(), + ) + .await + { + Ok(image_src) => Ok(image_src), + Err(error) => { + tracing::warn!( + provider = "square-hole-assets", + owner_user_id, + session_id, + profile_id, + slot, + asset_kind, + message = %error.body_text(), + "方洞图片已生成但资产持久化失败,降级回写 Data URL" + ); + Ok(fallback_data_url) + } + } +} + +fn format_square_hole_data_url(image: &DownloadedOpenAiImage) -> String { + format!( "data:{};base64,{}", image.mime_type, - BASE64_STANDARD.encode(image.bytes) - )) + BASE64_STANDARD.encode(&image.bytes) + ) +} + +#[allow(clippy::too_many_arguments)] +async fn persist_square_hole_generated_asset( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + slot: &str, + asset_kind: &str, + task_id: &str, + image: DownloadedOpenAiImage, + generated_at_micros: i64, +) -> 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 storage_slot = sanitize_square_hole_asset_segment(slot, "slot"); + let put_result = oss_client + .put_object( + &http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::SquareHoleAssets, + path_segments: vec![ + sanitize_square_hole_asset_segment(session_id, "session"), + sanitize_square_hole_asset_segment(profile_id, "profile"), + sanitize_square_hole_asset_segment(asset_kind, "asset"), + storage_slot.clone(), + format!("asset-{generated_at_micros}"), + ], + file_name: format!("image.{}", image.extension), + content_type: Some(image.mime_type.clone()), + access: OssObjectAccess::Private, + metadata: build_square_hole_asset_metadata( + asset_kind, + owner_user_id, + profile_id, + slot, + ), + body: image.bytes, + }, + ) + .await + .map_err(map_square_hole_asset_oss_error)?; + let head = oss_client + .head_object( + &http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(map_square_hole_asset_oss_error)?; + + match state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(generated_at_micros), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(image.mime_type)), + head.content_length, + head.etag, + asset_kind.to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + Some(profile_id.to_string()), + generated_at_micros, + ) + .map_err(map_square_hole_asset_field_error)?, + ) + .await + { + Ok(asset_object) => { + if let Err(error) = state + .spacetime_client() + .bind_asset_object_to_entity( + build_asset_entity_binding_input( + generate_asset_binding_id(generated_at_micros), + asset_object.asset_object_id, + SQUARE_HOLE_ENTITY_KIND.to_string(), + profile_id.to_string(), + slot.to_string(), + asset_kind.to_string(), + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + generated_at_micros, + ) + .map_err(map_square_hole_asset_field_error)?, + ) + .await + { + tracing::warn!( + provider = "spacetimedb", + owner_user_id, + session_id, + profile_id, + slot, + asset_kind, + error = %error, + "方洞图片资产绑定失败,历史素材索引可能缺少绑定记录" + ); + } + } + Err(error) => { + tracing::warn!( + provider = "spacetimedb", + owner_user_id, + session_id, + profile_id, + slot, + asset_kind, + error = %error, + "方洞图片资产对象确认失败,历史素材索引可能缺少本次记录" + ); + } + } + + Ok(put_result.legacy_public_path) +} + +fn build_square_hole_asset_metadata( + asset_kind: &str, + owner_user_id: &str, + profile_id: &str, + slot: &str, +) -> BTreeMap { + BTreeMap::from([ + ("asset_kind".to_string(), asset_kind.to_string()), + ("owner_user_id".to_string(), owner_user_id.to_string()), + ("profile_id".to_string(), profile_id.to_string()), + ( + "entity_kind".to_string(), + SQUARE_HOLE_ENTITY_KIND.to_string(), + ), + ("entity_id".to_string(), profile_id.to_string()), + ("slot".to_string(), slot.to_string()), + ]) +} + +fn map_square_hole_asset_oss_error(error: platform_oss::OssError) -> AppError { + map_oss_error(error, "aliyun-oss") +} + +fn map_square_hole_asset_field_error(error: AssetObjectFieldError) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "square-hole-assets", + "message": error.to_string(), + })) +} + +fn sanitize_square_hole_asset_segment(value: &str, fallback: &str) -> String { + let sanitized = value + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) { + ch + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string(); + if sanitized.is_empty() { + fallback.to_string() + } else { + sanitized + } } fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String { @@ -1280,6 +1779,24 @@ fn build_square_hole_shape_prompt( ) } +fn build_square_hole_hole_prompt( + work: &SquareHoleWorkProfileRecord, + option: &SquareHoleHoleOptionRecord, +) -> String { + let image_prompt = option.image_prompt.trim(); + let option_prompt = if image_prompt.is_empty() { + format!("{} 主题的 {}", work.theme_text, option.label) + } else { + image_prompt.to_string() + }; + + format!( + "单个游戏洞口贴图,透明或干净浅色背景。洞口名称:{}。主题贴图:{}。要求主体居中、边缘清晰、适合放在可接收拖拽形状的洞口平面上,不要文字、不要 UI、不要水印。", + clean_prompt_text(&option.label, "洞口"), + clean_prompt_text(&option_prompt, "主题洞口") + ) +} + fn build_square_hole_negative_prompt() -> String { "文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string() } @@ -1518,6 +2035,7 @@ fn map_square_hole_shape_response( shape_id: item.shape_id, shape_kind: item.shape_kind, label: item.label, + target_hole_id: item.target_hole_id, color: item.color, image_src: item.image_src, } @@ -1532,7 +2050,7 @@ fn map_square_hole_hole_response( label: slot.label, x: slot.x, y: slot.y, - bonus: slot.bonus, + image_src: slot.image_src, } } @@ -1543,6 +2061,7 @@ fn map_square_hole_shape_option_response( option_id: item.option_id, shape_kind: item.shape_kind, label: item.label, + target_hole_id: item.target_hole_id, image_prompt: item.image_prompt, image_src: item.image_src, } @@ -1555,7 +2074,8 @@ fn map_square_hole_hole_option_response( hole_id: item.hole_id, hole_kind: item.hole_kind, label: item.label, - bonus: item.bonus, + image_prompt: item.image_prompt, + image_src: item.image_src, } } @@ -1566,6 +2086,7 @@ fn map_square_hole_work_shape_option_response( option_id: item.option_id, shape_kind: item.shape_kind, label: item.label, + target_hole_id: item.target_hole_id, image_prompt: item.image_prompt, image_src: item.image_src, } @@ -1578,7 +2099,8 @@ fn map_square_hole_work_hole_option_response( hole_id: item.hole_id, hole_kind: item.hole_kind, label: item.label, - bonus: item.bonus, + image_prompt: item.image_prompt, + image_src: item.image_src, } } @@ -1595,15 +2117,16 @@ fn map_square_hole_feedback_response( fn build_config_from_create_request( payload: &CreateSquareHoleSessionRequest, ) -> SquareHoleConfigJson { + let theme_text = payload + .theme_text + .as_deref() + .or(payload.seed_text.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(SQUARE_HOLE_DEFAULT_THEME); + let hole_options = normalize_hole_options(Vec::new(), theme_text); SquareHoleConfigJson { - theme_text: payload - .theme_text - .as_deref() - .or(payload.seed_text.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(SQUARE_HOLE_DEFAULT_THEME) - .to_string(), + theme_text: theme_text.to_string(), twist_rule: payload .twist_rule .as_deref() @@ -1621,20 +2144,11 @@ fn build_config_from_create_request( .clamp(1, 10), shape_options: square_hole_shape_records_to_config_json(normalize_shape_options( Vec::new(), - payload - .theme_text - .as_deref() - .or(payload.seed_text.as_deref()) - .unwrap_or(SQUARE_HOLE_DEFAULT_THEME), + theme_text, + hole_options.as_slice(), )), - hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(Vec::new())), - background_prompt: default_background_prompt( - payload - .theme_text - .as_deref() - .or(payload.seed_text.as_deref()) - .unwrap_or(SQUARE_HOLE_DEFAULT_THEME), - ), + hole_options: square_hole_hole_records_to_config_json(hole_options), + background_prompt: default_background_prompt(theme_text), cover_image_src: String::new(), background_image_src: String::new(), } @@ -1660,12 +2174,17 @@ fn resolve_config_or_default( twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(), shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT, difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY, - shape_options: square_hole_shape_records_to_config_json(normalize_shape_options( - Vec::new(), - SQUARE_HOLE_DEFAULT_THEME, - )), + shape_options: { + let hole_options = normalize_hole_options(Vec::new(), SQUARE_HOLE_DEFAULT_THEME); + square_hole_shape_records_to_config_json(normalize_shape_options( + Vec::new(), + SQUARE_HOLE_DEFAULT_THEME, + hole_options.as_slice(), + )) + }, hole_options: square_hole_hole_records_to_config_json(normalize_hole_options( Vec::new(), + SQUARE_HOLE_DEFAULT_THEME, )), background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME), cover_image_src: String::new(), @@ -1730,13 +2249,23 @@ fn square_hole_hole_records_to_config_json( fn square_hole_work_shape_options_to_records( options: Vec, + hole_options: &[SquareHoleHoleOptionRecord], ) -> Vec { + let fallback_hole_id = hole_options + .first() + .map(|option| option.hole_id.clone()) + .unwrap_or_else(|| "hole-1".to_string()); options .into_iter() .map(|option| SquareHoleShapeOptionRecord { option_id: option.option_id, shape_kind: option.shape_kind, label: option.label, + target_hole_id: hole_options + .iter() + .find(|hole| hole.hole_id == option.target_hole_id) + .map(|hole| hole.hole_id.clone()) + .unwrap_or_else(|| fallback_hole_id.clone()), image_prompt: option.image_prompt, image_src: option.image_src.filter(|value| !value.trim().is_empty()), }) @@ -1752,7 +2281,8 @@ fn square_hole_work_hole_options_to_records( hole_id: option.hole_id, hole_kind: option.hole_kind, label: option.label, - bonus: option.bonus, + image_prompt: option.image_prompt, + image_src: option.image_src.filter(|value| !value.trim().is_empty()), }) .collect() } @@ -1782,12 +2312,104 @@ fn clean_prompt_text(value: &str, fallback: &str) -> String { } } +#[derive(Clone, Debug, PartialEq, Eq)] +enum SquareHoleVisualAssetSlotRequest { + Cover, + Background, + Shape(String), + Hole(String), +} + +fn normalize_square_hole_visual_asset_slot( + slot: Option<&str>, + option_id: Option<&str>, +) -> Option { + match slot.map(str::trim).unwrap_or_default() { + "cover" => Some(SquareHoleVisualAssetSlotRequest::Cover), + "background" => Some(SquareHoleVisualAssetSlotRequest::Background), + "shape" => option_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| SquareHoleVisualAssetSlotRequest::Shape(value.to_string())), + "hole" => option_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| SquareHoleVisualAssetSlotRequest::Hole(value.to_string())), + _ => None, + } +} + +fn should_generate_square_hole_cover_image( + requested_slot: Option<&SquareHoleVisualAssetSlotRequest>, + regenerate_visual_assets: bool, + current_image_src: &str, +) -> bool { + matches!( + requested_slot, + Some(SquareHoleVisualAssetSlotRequest::Cover) + ) || (requested_slot.is_none() + && (regenerate_visual_assets || current_image_src.trim().is_empty())) +} + +fn should_generate_square_hole_background_image( + requested_slot: Option<&SquareHoleVisualAssetSlotRequest>, + regenerate_visual_assets: bool, + current_image_src: &str, +) -> bool { + matches!( + requested_slot, + Some(SquareHoleVisualAssetSlotRequest::Background) + ) || (requested_slot.is_none() + && (regenerate_visual_assets || current_image_src.trim().is_empty())) +} + +fn should_generate_square_hole_shape_image( + requested_slot: Option<&SquareHoleVisualAssetSlotRequest>, + regenerate_visual_assets: bool, + option: &SquareHoleShapeOptionRecord, +) -> bool { + match requested_slot { + Some(SquareHoleVisualAssetSlotRequest::Shape(option_id)) => option.option_id == *option_id, + Some(_) => false, + None => { + regenerate_visual_assets + || option + .image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + } + } +} + +fn should_generate_square_hole_hole_image( + requested_slot: Option<&SquareHoleVisualAssetSlotRequest>, + regenerate_visual_assets: bool, + option: &SquareHoleHoleOptionRecord, +) -> bool { + match requested_slot { + Some(SquareHoleVisualAssetSlotRequest::Hole(hole_id)) => option.hole_id == *hole_id, + Some(_) => false, + None => { + regenerate_visual_assets + || option + .image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + } + } +} + impl From for SquareHoleConfigShapeOptionJson { fn from(option: module_square_hole::SquareHoleShapeOption) -> Self { Self { option_id: option.option_id, shape_kind: option.shape_kind, label: option.label, + target_hole_id: option.target_hole_id, image_prompt: option.image_prompt, image_src: option.image_src.unwrap_or_default(), } @@ -1800,6 +2422,7 @@ impl From for SquareHoleConfigShapeOptionJson { option_id: option.option_id, shape_kind: option.shape_kind, label: option.label, + target_hole_id: option.target_hole_id, image_prompt: option.image_prompt, image_src: option.image_src.unwrap_or_default(), } @@ -1812,7 +2435,8 @@ impl From for SquareHoleConfigHoleOpti hole_id: option.hole_id, hole_kind: option.hole_kind, label: option.label, - bonus: option.bonus, + image_prompt: option.image_prompt, + image_src: option.image_src.unwrap_or_default(), } } } @@ -1823,7 +2447,8 @@ impl From for SquareHoleConfigHoleOptionJson { hole_id: option.hole_id, hole_kind: option.hole_kind, label: option.label, - bonus: option.bonus, + image_prompt: option.image_prompt, + image_src: option.image_src.unwrap_or_default(), } } } diff --git a/server-rs/crates/api-server/src/square_hole_agent_turn.rs b/server-rs/crates/api-server/src/square_hole_agent_turn.rs index 8bc9aaa3..020386e2 100644 --- a/server-rs/crates/api-server/src/square_hole_agent_turn.rs +++ b/server-rs/crates/api-server/src/square_hole_agent_turn.rs @@ -84,6 +84,7 @@ struct SquareHoleAgentShapeOptionOutput { option_id: String, shape_kind: String, label: String, + target_hole_id: String, image_prompt: String, #[serde(default)] image_src: String, @@ -95,8 +96,9 @@ struct SquareHoleAgentHoleOptionOutput { hole_id: String, hole_kind: String, label: String, + image_prompt: String, #[serde(default)] - bonus: bool, + image_src: String, } pub(crate) async fn run_square_hole_agent_turn( @@ -195,12 +197,12 @@ fn parse_model_config( )); } - let theme_text = read_text_field(value, "themeText") - .unwrap_or_else(|| session.config.theme_text.clone()); - let twist_rule = read_text_field(value, "twistRule") - .unwrap_or_else(|| session.config.twist_rule.clone()); - let shape_options = parse_shape_options(value, session, &theme_text); - let hole_options = parse_hole_options(value, session); + let theme_text = + read_text_field(value, "themeText").unwrap_or_else(|| session.config.theme_text.clone()); + let twist_rule = + read_text_field(value, "twistRule").unwrap_or_else(|| session.config.twist_rule.clone()); + let hole_options = parse_hole_options(value, session, &theme_text); + let shape_options = parse_shape_options(value, session, &theme_text, hole_options.as_slice()); let background_prompt = read_text_field(value, "backgroundPrompt") .or_else(|| { session @@ -243,6 +245,7 @@ fn parse_shape_options( value: &JsonValue, session: &SquareHoleAgentSessionRecord, theme_text: &str, + hole_options: &[SquareHoleHoleOption], ) -> Vec { let parsed = value .get("shapeOptions") @@ -258,8 +261,19 @@ fn parse_shape_options( .unwrap_or_else(|| fallback_shape_kind(index).to_string()), label: read_text_field(item, "label") .unwrap_or_else(|| fallback_shape_label(index).to_string()), + target_hole_id: read_text_field(item, "targetHoleId") + .filter(|value| hole_options.iter().any(|option| option.hole_id == *value)) + .unwrap_or_else(|| { + hole_options + .get(index % hole_options.len().max(1)) + .map(|option| option.hole_id.clone()) + .unwrap_or_else(|| fallback_target_hole_id(index).to_string()) + }), image_prompt: read_text_field(item, "imagePrompt").unwrap_or_else(|| { - format!("{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产", fallback_shape_label(index)) + format!( + "{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产", + fallback_shape_label(index) + ) }), image_src: read_text_field(item, "imageSrc"), }) @@ -274,18 +288,20 @@ fn parse_shape_options( option_id: option.option_id.clone(), shape_kind: option.shape_kind.clone(), label: option.label.clone(), + target_hole_id: option.target_hole_id.clone(), image_prompt: option.image_prompt.clone(), image_src: option.image_src.clone(), }) .collect() }); - normalize_shape_options(parsed, theme_text) + normalize_shape_options(parsed, theme_text, hole_options) } fn parse_hole_options( value: &JsonValue, session: &SquareHoleAgentSessionRecord, + theme_text: &str, ) -> Vec { let parsed = value .get("holeOptions") @@ -298,13 +314,16 @@ fn parse_hole_options( hole_id: read_text_field(item, "holeId") .unwrap_or_else(|| format!("hole-option-{index}")), hole_kind: read_text_field(item, "holeKind") - .unwrap_or_else(|| fallback_shape_kind(index).to_string()), + .unwrap_or_else(|| format!("hole-{}", index + 1)), label: read_text_field(item, "label") .unwrap_or_else(|| fallback_hole_label(index).to_string()), - bonus: item - .get("bonus") - .and_then(JsonValue::as_bool) - .unwrap_or(index == 0), + image_prompt: read_text_field(item, "imagePrompt").unwrap_or_else(|| { + format!( + "{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产", + fallback_hole_label(index) + ) + }), + image_src: read_text_field(item, "imageSrc"), }) .collect::>() }) @@ -317,12 +336,13 @@ fn parse_hole_options( hole_id: option.hole_id.clone(), hole_kind: option.hole_kind.clone(), label: option.label.clone(), - bonus: option.bonus, + image_prompt: option.image_prompt.clone(), + image_src: option.image_src.clone(), }) .collect() }); - normalize_hole_options(parsed) + normalize_hole_options(parsed, theme_text) } fn read_text_field(value: &JsonValue, field_name: &str) -> Option { @@ -363,14 +383,15 @@ fn fallback_shape_label(index: usize) -> &'static str { } } -fn fallback_hole_label(index: usize) -> &'static str { - match fallback_shape_kind(index) { - "square" => "方洞", - "circle" => "圆洞", - "triangle" => "三角洞", - "diamond" => "菱形洞", - "star" => "星形洞", - _ => "拱形洞", +fn fallback_hole_label(index: usize) -> String { + format!("洞口 {}", index + 1) +} + +fn fallback_target_hole_id(index: usize) -> &'static str { + match index % 3 { + 0 => "hole-1", + 1 => "hole-2", + _ => "hole-3", } } @@ -380,6 +401,7 @@ impl From for SquareHoleAgentShapeOptionOutput { option_id: option.option_id, shape_kind: option.shape_kind, label: option.label, + target_hole_id: option.target_hole_id, image_prompt: option.image_prompt, image_src: option.image_src.unwrap_or_default(), } @@ -392,7 +414,8 @@ impl From for SquareHoleAgentHoleOptionOutput { hole_id: option.hole_id, hole_kind: option.hole_kind, label: option.label, - bonus: option.bonus, + image_prompt: option.image_prompt, + image_src: option.image_src.unwrap_or_default(), } } } @@ -472,15 +495,16 @@ mod tests { "optionId": "stamp", "shapeKind": "circle", "label": "圆形印章", + "targetHoleId": "folder", "imagePrompt": "办公室圆形印章贴纸" } ], "holeOptions": [ { "holeId": "folder", - "holeKind": "square", + "holeKind": "folder", "label": "档案盒方洞", - "bonus": true + "imagePrompt": "办公室档案盒洞口贴纸" } ], "backgroundPrompt": "办公室桌面纸箱玩具背景" @@ -501,8 +525,12 @@ mod tests { assert_eq!(output.next_config.difficulty, 6); assert!(output.next_config.shape_options.len() >= 6); assert_eq!(output.next_config.shape_options[0].label, "圆形印章"); + assert_eq!(output.next_config.shape_options[0].target_hole_id, "folder"); assert_eq!(output.next_config.hole_options[0].label, "档案盒方洞"); - assert!(output.next_config.hole_options[0].bonus); + assert_eq!( + output.next_config.hole_options[0].image_prompt, + "办公室档案盒洞口贴纸" + ); assert_eq!( output.next_config.background_prompt, "办公室桌面纸箱玩具背景" diff --git a/server-rs/crates/module-square-hole/src/application.rs b/server-rs/crates/module-square-hole/src/application.rs index ca7e1caf..6aed7a89 100644 --- a/server-rs/crates/module-square-hole/src/application.rs +++ b/server-rs/crates/module-square-hole/src/application.rs @@ -16,8 +16,12 @@ pub fn compile_result_draft( config: &SquareHoleCreatorConfig, ) -> SquareHoleResultDraft { let game_name = format!("{}方洞挑战", config.theme_text); - let shape_options = normalize_shape_options(config.shape_options.clone(), &config.theme_text); - let hole_options = normalize_hole_options(config.hole_options.clone()); + let hole_options = normalize_hole_options(config.hole_options.clone(), &config.theme_text); + let shape_options = normalize_shape_options( + config.shape_options.clone(), + &config.theme_text, + hole_options.as_slice(), + ); let background_prompt = normalize_required_string(&config.background_prompt) .unwrap_or_else(|| default_background_prompt(&config.theme_text)); let summary = format!( @@ -73,8 +77,12 @@ pub fn create_work_profile( cover_image_src: draft.cover_image_src.clone(), background_prompt: draft.background_prompt.clone(), background_image_src: draft.background_image_src.clone(), - shape_options: normalize_shape_options(draft.shape_options.clone(), &draft.theme_text), - hole_options: normalize_hole_options(draft.hole_options.clone()), + hole_options: { normalize_hole_options(draft.hole_options.clone(), &draft.theme_text) }, + shape_options: normalize_shape_options( + draft.shape_options.clone(), + &draft.theme_text, + normalize_hole_options(draft.hole_options.clone(), &draft.theme_text).as_slice(), + ), shape_count: draft.shape_count, difficulty: draft.difficulty, publication_status: SquareHolePublicationStatus::Draft, @@ -114,7 +122,13 @@ pub fn start_run_at( normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?; let profile_id = normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?; - let shape_options = normalize_shape_options(config.shape_options.clone(), &config.theme_text); + let hole_options = normalize_hole_options(config.hole_options.clone(), &config.theme_text); + let shape_options = normalize_shape_options( + config.shape_options.clone(), + &config.theme_text, + hole_options.as_slice(), + ); + let current_shape = build_shape_at(0, config.shape_count, shape_options.as_slice(), &run_id); Ok(SquareHoleRunSnapshot { run_id, @@ -132,13 +146,9 @@ pub fn start_run_at( score: 0, rule_label: config.twist_rule.clone(), background_image_src: config.background_image_src.clone(), - current_shape: Some(build_shape_at( - 0, - config.shape_count, - shape_options.as_slice(), - )), + current_shape: Some(current_shape), shape_options, - holes: build_holes(config.hole_options.as_slice()), + holes: build_holes(hole_options.as_slice()), last_feedback: None, }) } @@ -182,10 +192,7 @@ pub fn confirm_drop_at( next.completed_shape_count = next.completed_shape_count.saturating_add(1); next.combo = next.combo.saturating_add(1); next.best_combo = next.best_combo.max(next.combo); - let bonus_score = if hole.bonus { 50 } else { 0 }; - next.score = next - .score - .saturating_add(100 + next.combo * 10 + bonus_score); + next.score = next.score.saturating_add(100 + next.combo * 10); next.current_shape = if next.completed_shape_count >= next.total_shape_count { next.status = SquareHoleRunStatus::Won; None @@ -194,6 +201,7 @@ pub fn confirm_drop_at( next.completed_shape_count, next.total_shape_count, next.shape_options.as_slice(), + next.run_id.as_str(), )) }; next.snapshot_version = next.snapshot_version.saturating_add(1); @@ -246,8 +254,9 @@ pub fn build_shape_at( index: u32, total: u32, options: &[SquareHoleShapeOption], + run_seed: &str, ) -> SquareHoleShapeSnapshot { - if let Some(option) = pick_shape_option(index, options) { + if let Some(option) = pick_shape_option(index, options, run_seed) { let shape_kind = option.shape_kind; let label = option.label; return SquareHoleShapeSnapshot { @@ -255,6 +264,7 @@ pub fn build_shape_at( color: fallback_shape_color(&shape_kind).to_string(), shape_kind, label, + target_hole_id: option.target_hole_id, image_src: option.image_src, }; } @@ -282,6 +292,7 @@ pub fn build_shape_at( _ => "星形块", } .to_string(), + target_hole_id: fallback_target_hole_id(index).to_string(), color: match kind { "square" => "#facc15", "circle" => "#22c55e", @@ -295,36 +306,34 @@ pub fn build_shape_at( } pub fn default_holes() -> Vec { - vec![ - SquareHoleHoleSnapshot { - hole_id: "square-hole".to_string(), - hole_kind: "square".to_string(), - label: "方洞".to_string(), - x: 0.5, - y: 0.28, - bonus: true, - }, - SquareHoleHoleSnapshot { - hole_id: "circle-hole".to_string(), - hole_kind: "circle".to_string(), - label: "圆洞".to_string(), - x: 0.24, - y: 0.54, - bonus: false, - }, - SquareHoleHoleSnapshot { - hole_id: "triangle-hole".to_string(), - hole_kind: "triangle".to_string(), - label: "三角洞".to_string(), - x: 0.76, - y: 0.54, - bonus: false, - }, - ] + default_hole_options("玩具") + .into_iter() + .enumerate() + .map(|(index, option)| { + let positions = [(0.5, 0.28), (0.24, 0.54), (0.76, 0.54)]; + let (x, y) = positions[index.min(positions.len() - 1)]; + SquareHoleHoleSnapshot { + hole_id: option.hole_id, + hole_kind: option.hole_kind, + label: option.label, + x, + y, + image_src: option.image_src, + } + }) + .collect() } -pub fn default_shape_options(theme_text: &str) -> Vec { +pub fn default_shape_options(theme_text: &str, hole_ids: &[String]) -> Vec { let theme = normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()); + let default_hole_ids = if hole_ids.is_empty() { + default_hole_options(theme_text) + .into_iter() + .map(|option| option.hole_id) + .collect::>() + } else { + hole_ids.to_vec() + }; [ ("square", "方块"), ("circle", "圆块"), @@ -334,35 +343,41 @@ pub fn default_shape_options(theme_text: &str) -> Vec { ("arch", "拱形块"), ] .into_iter() - .map(|(kind, label)| SquareHoleShapeOption { + .enumerate() + .map(|(index, (kind, label))| SquareHoleShapeOption { option_id: format!("{kind}-option"), shape_kind: kind.to_string(), label: label.to_string(), + target_hole_id: default_hole_ids[index % default_hole_ids.len()].clone(), image_prompt: format!("{theme}主题的{label}贴纸图,透明背景,明亮可爱,游戏资产"), image_src: None, }) .collect() } -pub fn default_hole_options() -> Vec { +pub fn default_hole_options(theme_text: &str) -> Vec { + let theme = normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()); vec![ SquareHoleHoleOption { - hole_id: "square-hole".to_string(), - hole_kind: "square".to_string(), - label: "方洞".to_string(), - bonus: true, + hole_id: "hole-1".to_string(), + hole_kind: "hole-1".to_string(), + label: "洞口 1".to_string(), + image_prompt: format!("{theme}主题的第一个洞口贴纸图,透明背景,明亮可爱,游戏资产"), + image_src: None, }, SquareHoleHoleOption { - hole_id: "circle-hole".to_string(), - hole_kind: "circle".to_string(), - label: "圆洞".to_string(), - bonus: false, + hole_id: "hole-2".to_string(), + hole_kind: "hole-2".to_string(), + label: "洞口 2".to_string(), + image_prompt: format!("{theme}主题的第二个洞口贴纸图,透明背景,明亮可爱,游戏资产"), + image_src: None, }, SquareHoleHoleOption { - hole_id: "triangle-hole".to_string(), - hole_kind: "triangle".to_string(), - label: "三角洞".to_string(), - bonus: false, + hole_id: "hole-3".to_string(), + hole_kind: "hole-3".to_string(), + label: "洞口 3".to_string(), + image_prompt: format!("{theme}主题的第三个洞口贴纸图,透明背景,明亮可爱,游戏资产"), + image_src: None, }, ] } @@ -370,7 +385,19 @@ pub fn default_hole_options() -> Vec { pub fn normalize_shape_options( options: Vec, theme_text: &str, + hole_options: &[SquareHoleHoleOption], ) -> Vec { + let hole_ids = if hole_options.is_empty() { + default_hole_options(theme_text) + .into_iter() + .map(|option| option.hole_id) + .collect::>() + } else { + hole_options + .iter() + .map(|option| option.hole_id.clone()) + .collect::>() + }; let mut normalized = Vec::new(); for (index, option) in options.into_iter().enumerate() { let shape_kind = normalize_required_string(&option.shape_kind) @@ -379,6 +406,9 @@ pub fn normalize_shape_options( .unwrap_or_else(|| fallback_shape_label(&shape_kind).to_string()); let option_id = normalize_required_string(&option.option_id) .unwrap_or_else(|| format!("{shape_kind}-option-{index}")); + let target_hole_id = normalize_required_string(&option.target_hole_id) + .filter(|value| hole_ids.iter().any(|hole_id| hole_id == value)) + .unwrap_or_else(|| hole_ids[index % hole_ids.len()].clone()); let image_prompt = normalize_required_string(&option.image_prompt).unwrap_or_else(|| { format!( "{}主题的{}贴纸图,透明背景,明亮可爱,游戏资产", @@ -390,12 +420,13 @@ pub fn normalize_shape_options( option_id, shape_kind, label, + target_hole_id, image_prompt, image_src: option.image_src.and_then(normalize_required_string), }); } - let defaults = default_shape_options(theme_text); + let defaults = default_shape_options(theme_text, hole_ids.as_slice()); let mut default_index = 0; while normalized.len() < SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT { let mut fallback = defaults[default_index % defaults.len()].clone(); @@ -411,7 +442,10 @@ pub fn normalize_shape_options( normalized } -pub fn normalize_hole_options(options: Vec) -> Vec { +pub fn normalize_hole_options( + options: Vec, + theme_text: &str, +) -> Vec { let mut normalized = Vec::new(); for (index, option) in options .into_iter() @@ -419,20 +453,27 @@ pub fn normalize_hole_options(options: Vec) -> Vec= SQUARE_HOLE_MIN_HOLE_OPTION_COUNT { break; } @@ -444,11 +485,6 @@ pub fn normalize_hole_options(options: Vec) -> Vec String { } fn build_holes(options: &[SquareHoleHoleOption]) -> Vec { - let normalized = normalize_hole_options(options.to_vec()); + let normalized = normalize_hole_options(options.to_vec(), "玩具"); let positions = [ (0.5, 0.28), (0.24, 0.54), @@ -480,7 +516,7 @@ fn build_holes(options: &[SquareHoleHoleOption]) -> Vec label: option.label, x, y, - bonus: option.bonus, + image_src: option.image_src, } }) .collect() @@ -490,18 +526,34 @@ fn build_shape_from_previous_options( index: u32, total: u32, options: &[SquareHoleShapeOption], + run_seed: &str, ) -> SquareHoleShapeSnapshot { - build_shape_at(index, total, options) + build_shape_at(index, total, options, run_seed) } fn pick_shape_option( index: u32, options: &[SquareHoleShapeOption], + run_seed: &str, ) -> Option { if options.is_empty() { return None; } - options.get(index as usize % options.len()).cloned() + let base_seed = run_seed.as_bytes().iter().fold(index, |current, byte| { + current.wrapping_mul(31).wrapping_add(u32::from(*byte)) + }); + let seed = options + .iter() + .enumerate() + .map(|(option_index, option)| { + let mut hash = base_seed.wrapping_add(option_index as u32).wrapping_mul(97); + for byte in option.option_id.as_bytes() { + hash = hash.wrapping_mul(33).wrapping_add(u32::from(*byte)); + } + hash + }) + .fold(0u32, u32::wrapping_add); + options.get((seed as usize) % options.len()).cloned() } fn fallback_shape_kind(index: usize) -> String { @@ -528,16 +580,8 @@ fn fallback_shape_label(kind: &str) -> &'static str { } } -fn fallback_hole_label(kind: &str) -> &'static str { - match kind { - "square" => "方洞", - "circle" => "圆洞", - "triangle" => "三角洞", - "diamond" => "菱形洞", - "star" => "星形洞", - "arch" => "拱形洞", - _ => "洞口", - } +fn fallback_hole_label(index: usize) -> String { + format!("洞口 {}", index + 1) } fn fallback_shape_color(kind: &str) -> &'static str { @@ -556,8 +600,15 @@ fn is_shape_accepted_by_hole( shape: &SquareHoleShapeSnapshot, hole: &SquareHoleHoleSnapshot, ) -> bool { - // 中文注释:首版核心反差固定为“方洞万能”,保留同形状洞口兼容便于后续扩展规则。 - hole.hole_kind == "square" || hole.hole_kind == shape.shape_kind + shape.target_hole_id == hole.hole_id +} + +fn fallback_target_hole_id(index: u32) -> &'static str { + match index % 3 { + 0 => "hole-1", + 1 => "hole-2", + _ => "hole-3", + } } fn rejected( @@ -590,28 +641,39 @@ mod tests { build_creator_config("玩具", "方洞万能", shape_count, 4).expect("config should be valid") } - fn test_config_with_bonus_hole(shape_count: u32) -> SquareHoleCreatorConfig { + fn test_config_with_custom_targets(shape_count: u32) -> SquareHoleCreatorConfig { SquareHoleCreatorConfig { hole_options: vec![ SquareHoleHoleOption { - hole_id: "square-hole".to_string(), - hole_kind: "square".to_string(), - label: "方洞".to_string(), - bonus: true, + hole_id: "hole-alpha".to_string(), + hole_kind: "hole-alpha".to_string(), + label: "洞口 Alpha".to_string(), + image_prompt: "玩具主题的 Alpha 洞口贴纸图".to_string(), + image_src: None, }, SquareHoleHoleOption { - hole_id: "circle-hole".to_string(), - hole_kind: "circle".to_string(), - label: "圆洞".to_string(), - bonus: false, + hole_id: "hole-beta".to_string(), + hole_kind: "hole-beta".to_string(), + label: "洞口 Beta".to_string(), + image_prompt: "玩具主题的 Beta 洞口贴纸图".to_string(), + image_src: None, }, SquareHoleHoleOption { - hole_id: "triangle-hole".to_string(), - hole_kind: "triangle".to_string(), - label: "三角洞".to_string(), - bonus: false, + hole_id: "hole-gamma".to_string(), + hole_kind: "hole-gamma".to_string(), + label: "洞口 Gamma".to_string(), + image_prompt: "玩具主题的 Gamma 洞口贴纸图".to_string(), + image_src: None, }, ], + shape_options: vec![SquareHoleShapeOption { + option_id: "shape-alpha".to_string(), + shape_kind: "square".to_string(), + label: "Alpha 形状".to_string(), + target_hole_id: "hole-alpha".to_string(), + image_prompt: "玩具主题的 Alpha 形状贴纸图".to_string(), + image_src: None, + }], ..test_config(shape_count) } } @@ -642,7 +704,7 @@ mod tests { } #[test] - fn square_hole_accepts_non_square_shape() { + fn target_hole_accepts_current_shape() { let run = start_run_at( "run-1".to_string(), "user-1".to_string(), @@ -656,7 +718,7 @@ mod tests { &SquareHoleDropInput { run_id: run.run_id.clone(), owner_user_id: run.owner_user_id.clone(), - hole_id: "square-hole".to_string(), + hole_id: run.current_shape.as_ref().unwrap().target_hole_id.clone(), client_snapshot_version: run.snapshot_version, client_event_id: "event-1".to_string(), dropped_at_ms: 1_100, @@ -670,12 +732,12 @@ mod tests { } #[test] - fn bonus_hole_adds_extra_score_when_accepted() { + fn accepted_drop_uses_base_combo_score() { let run = start_run_at( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), - &test_config_with_bonus_hole(8), + &test_config_with_custom_targets(8), 1_000, ) .expect("run should start"); @@ -684,7 +746,7 @@ mod tests { &SquareHoleDropInput { run_id: run.run_id.clone(), owner_user_id: run.owner_user_id.clone(), - hole_id: "square-hole".to_string(), + hole_id: run.current_shape.as_ref().unwrap().target_hole_id.clone(), client_snapshot_version: run.snapshot_version, client_event_id: "event-1".to_string(), dropped_at_ms: 1_100, @@ -693,28 +755,35 @@ mod tests { .expect("drop should resolve"); assert!(result.feedback.accepted); - assert_eq!(result.run.score, 160); + assert_eq!(result.run.score, 110); } #[test] - fn wrong_non_square_hole_rejects_and_resets_combo() { + fn wrong_target_hole_rejects_and_resets_combo() { let mut run = start_run_at( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), - &test_config(8), + &test_config_with_custom_targets(8), 1_000, ) .expect("run should start"); - run.current_shape = Some(build_shape_at(1, 8, &[])); run.combo = 2; + let target_hole_id = run.current_shape.as_ref().unwrap().target_hole_id.clone(); + let wrong_hole_id = run + .holes + .iter() + .find(|hole| hole.hole_id != target_hole_id) + .expect("test run should have a non-target hole") + .hole_id + .clone(); let result = confirm_drop_at( &run, &SquareHoleDropInput { run_id: run.run_id.clone(), owner_user_id: run.owner_user_id.clone(), - hole_id: "circle-hole".to_string(), + hole_id: wrong_hole_id, client_snapshot_version: run.snapshot_version, client_event_id: "event-1".to_string(), dropped_at_ms: 1_100, @@ -741,14 +810,14 @@ mod tests { ) .expect("run should start"); run.completed_shape_count = 5; - run.current_shape = Some(build_shape_at(5, 6, &[])); + run.current_shape = Some(build_shape_at(5, 6, &[], run.run_id.as_str())); let result = confirm_drop_at( &run, &SquareHoleDropInput { run_id: run.run_id.clone(), owner_user_id: run.owner_user_id.clone(), - hole_id: "square-hole".to_string(), + hole_id: run.current_shape.as_ref().unwrap().target_hole_id.clone(), client_snapshot_version: run.snapshot_version, client_event_id: "event-1".to_string(), dropped_at_ms: 1_100, diff --git a/server-rs/crates/module-square-hole/src/commands.rs b/server-rs/crates/module-square-hole/src/commands.rs index a010bfb1..704857a6 100644 --- a/server-rs/crates/module-square-hole/src/commands.rs +++ b/server-rs/crates/module-square-hole/src/commands.rs @@ -32,13 +32,14 @@ pub fn build_creator_config( shape_count: u32, difficulty: u32, ) -> Result { + let hole_options = normalize_hole_options(Vec::new(), theme_text); Ok(SquareHoleCreatorConfig { theme_text: normalize_theme_text(theme_text)?, twist_rule: normalize_required_string(twist_rule).ok_or(SquareHoleError::MissingText)?, shape_count: validate_shape_count(shape_count)?, difficulty: validate_difficulty(difficulty)?, - shape_options: normalize_shape_options(Vec::new(), theme_text), - hole_options: normalize_hole_options(Vec::new()), + shape_options: normalize_shape_options(Vec::new(), theme_text, hole_options.as_slice()), + hole_options, background_prompt: format!("{theme_text}主题的竖屏游戏背景,舞台中央有多个形状洞口"), cover_image_src: None, background_image_src: None, @@ -98,36 +99,3 @@ pub fn validate_publish_requirements(draft: &SquareHoleResultDraft) -> Vec SquareHoleResultDraft { - let game_name = format!("{theme_text}方洞挑战"); - let summary = format!( - "{theme_text}主题,{} 个形状,难度 {},规则:{twist_rule}", - shape_count, difficulty - ); - let blockers = Vec::new(); - SquareHoleResultDraft { - profile_id, - game_name, - theme_text: theme_text.clone(), - twist_rule, - summary, - tags: build_default_tags("方洞挑战"), - cover_image_src: None, - background_prompt: format!("{theme_text}主题的竖屏游戏背景,舞台中央有多个形状洞口"), - background_image_src: None, - shape_options: normalize_shape_options(Vec::new(), &theme_text), - hole_options: normalize_hole_options(Vec::new()), - shape_count, - difficulty, - publish_ready: true, - blockers, - } -} diff --git a/server-rs/crates/module-square-hole/src/domain.rs b/server-rs/crates/module-square-hole/src/domain.rs index ac68505b..f6bc97a6 100644 --- a/server-rs/crates/module-square-hole/src/domain.rs +++ b/server-rs/crates/module-square-hole/src/domain.rs @@ -76,6 +76,8 @@ pub struct SquareHoleShapeOption { pub option_id: String, pub shape_kind: String, pub label: String, + #[serde(default)] + pub target_hole_id: String, pub image_prompt: String, #[serde(default)] pub image_src: Option, @@ -88,7 +90,9 @@ pub struct SquareHoleHoleOption { pub hole_kind: String, pub label: String, #[serde(default)] - pub bonus: bool, + pub image_prompt: String, + #[serde(default)] + pub image_src: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -147,6 +151,8 @@ pub struct SquareHoleShapeSnapshot { pub shape_id: String, pub shape_kind: String, pub label: String, + #[serde(default)] + pub target_hole_id: String, pub color: String, #[serde(default)] pub image_src: Option, @@ -161,7 +167,7 @@ pub struct SquareHoleHoleSnapshot { pub x: f32, pub y: f32, #[serde(default)] - pub bonus: bool, + pub image_src: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index c116e19b..9d441d27 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -17,11 +17,12 @@ pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024; pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200; pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024; -pub const LEGACY_PUBLIC_PREFIXES: [&str; 7] = [ +pub const LEGACY_PUBLIC_PREFIXES: [&str; 8] = [ "generated-character-drafts", "generated-characters", "generated-animations", "generated-big-fish-assets", + "generated-square-hole-assets", "generated-custom-world-scenes", "generated-custom-world-covers", "generated-qwen-sprites", @@ -40,6 +41,7 @@ pub enum LegacyAssetPrefix { Characters, Animations, BigFishAssets, + SquareHoleAssets, PuzzleAssets, CustomWorldScenes, CustomWorldCovers, @@ -221,6 +223,7 @@ impl LegacyAssetPrefix { "generated-characters" => Some(Self::Characters), "generated-animations" => Some(Self::Animations), "generated-big-fish-assets" => Some(Self::BigFishAssets), + "generated-square-hole-assets" => Some(Self::SquareHoleAssets), "generated-puzzle-assets" => Some(Self::PuzzleAssets), "generated-custom-world-scenes" => Some(Self::CustomWorldScenes), "generated-custom-world-covers" => Some(Self::CustomWorldCovers), @@ -235,6 +238,7 @@ impl LegacyAssetPrefix { Self::Characters => "generated-characters", Self::Animations => "generated-animations", Self::BigFishAssets => "generated-big-fish-assets", + Self::SquareHoleAssets => "generated-square-hole-assets", Self::PuzzleAssets => "generated-puzzle-assets", Self::CustomWorldScenes => "generated-custom-world-scenes", Self::CustomWorldCovers => "generated-custom-world-covers", diff --git a/server-rs/crates/shared-contracts/src/square_hole_agent.rs b/server-rs/crates/shared-contracts/src/square_hole_agent.rs index 905c074b..8c1dc8f7 100644 --- a/server-rs/crates/shared-contracts/src/square_hole_agent.rs +++ b/server-rs/crates/shared-contracts/src/square_hole_agent.rs @@ -36,6 +36,12 @@ pub struct ExecuteSquareHoleActionRequest { pub tags: Option>, #[serde(default)] pub cover_image_src: Option, + #[serde(default)] + pub regenerate_visual_assets: Option, + #[serde(default)] + pub visual_asset_slot: Option, + #[serde(default)] + pub visual_asset_option_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -44,6 +50,7 @@ pub struct SquareHoleShapeOptionResponse { pub option_id: String, pub shape_kind: String, pub label: String, + pub target_hole_id: String, pub image_prompt: String, #[serde(default)] pub image_src: Option, @@ -55,7 +62,9 @@ pub struct SquareHoleHoleOptionResponse { pub hole_id: String, pub hole_kind: String, pub label: String, - pub bonus: bool, + pub image_prompt: String, + #[serde(default)] + pub image_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/server-rs/crates/shared-contracts/src/square_hole_runtime.rs b/server-rs/crates/shared-contracts/src/square_hole_runtime.rs index 33549203..c9c42acf 100644 --- a/server-rs/crates/shared-contracts/src/square_hole_runtime.rs +++ b/server-rs/crates/shared-contracts/src/square_hole_runtime.rs @@ -29,6 +29,7 @@ pub struct SquareHoleShapeSnapshotResponse { pub shape_id: String, pub shape_kind: String, pub label: String, + pub target_hole_id: String, pub color: String, #[serde(default)] pub image_src: Option, @@ -42,7 +43,8 @@ pub struct SquareHoleHoleSnapshotResponse { pub label: String, pub x: f32, pub y: f32, - pub bonus: bool, + #[serde(default)] + pub image_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/square_hole_works.rs b/server-rs/crates/shared-contracts/src/square_hole_works.rs index 5eab4743..7e5dd502 100644 --- a/server-rs/crates/shared-contracts/src/square_hole_works.rs +++ b/server-rs/crates/shared-contracts/src/square_hole_works.rs @@ -6,6 +6,7 @@ pub struct SquareHoleShapeOptionResponse { pub option_id: String, pub shape_kind: String, pub label: String, + pub target_hole_id: String, pub image_prompt: String, #[serde(default)] pub image_src: Option, @@ -17,7 +18,9 @@ pub struct SquareHoleHoleOptionResponse { pub hole_id: String, pub hole_kind: String, pub label: String, - pub bonus: bool, + pub image_prompt: String, + #[serde(default)] + pub image_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -43,6 +46,14 @@ pub struct PutSquareHoleWorkRequest { pub difficulty: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RegenerateSquareHoleWorkImageRequest { + pub visual_asset_slot: String, + #[serde(default)] + pub visual_asset_option_id: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SquareHoleWorkSummaryResponse { diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index b3109a43..2359bf4c 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -3084,6 +3084,7 @@ fn map_square_hole_shape_snapshot( shape_id: snapshot.shape_id, shape_kind: snapshot.shape_kind, label: snapshot.label, + target_hole_id: snapshot.target_hole_id, color: snapshot.color, image_src: empty_string_to_none(snapshot.image_src), } @@ -3098,7 +3099,7 @@ fn map_square_hole_hole_snapshot( label: snapshot.label, x: snapshot.x, y: snapshot.y, - bonus: snapshot.bonus, + image_src: empty_string_to_none(snapshot.image_src), } } @@ -3109,6 +3110,7 @@ fn map_square_hole_shape_option( option_id: snapshot.option_id, shape_kind: snapshot.shape_kind, label: snapshot.label, + target_hole_id: snapshot.target_hole_id, image_prompt: snapshot.image_prompt, image_src: empty_string_to_none(snapshot.image_src), } @@ -3121,7 +3123,8 @@ fn map_square_hole_hole_option( hole_id: snapshot.hole_id, hole_kind: snapshot.hole_kind, label: snapshot.label, - bonus: snapshot.bonus, + image_prompt: snapshot.image_prompt, + image_src: empty_string_to_none(snapshot.image_src), } } @@ -6136,6 +6139,7 @@ pub struct SquareHoleShapeOptionRecord { pub option_id: String, pub shape_kind: String, pub label: String, + pub target_hole_id: String, pub image_prompt: String, pub image_src: Option, } @@ -6145,7 +6149,8 @@ pub struct SquareHoleHoleOptionRecord { pub hole_id: String, pub hole_kind: String, pub label: String, - pub bonus: bool, + pub image_prompt: String, + pub image_src: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -6222,6 +6227,7 @@ pub struct SquareHoleShapeSnapshotRecord { pub shape_id: String, pub shape_kind: String, pub label: String, + pub target_hole_id: String, pub color: String, pub image_src: Option, } @@ -6233,7 +6239,7 @@ pub struct SquareHoleHoleSnapshotRecord { pub label: String, pub x: f32, pub y: f32, - pub bonus: bool, + pub image_src: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -6302,6 +6308,8 @@ struct SquareHoleShapeOptionJsonRecord { option_id: String, shape_kind: String, label: String, + #[serde(default)] + target_hole_id: String, image_prompt: String, #[serde(default)] image_src: String, @@ -6314,6 +6322,10 @@ struct SquareHoleHoleOptionJsonRecord { hole_kind: String, label: String, #[serde(default)] + image_prompt: String, + #[serde(default)] + image_src: String, + #[serde(default)] bonus: bool, } @@ -6412,6 +6424,8 @@ struct SquareHoleShapeJsonRecord { shape_id: String, shape_kind: String, label: String, + #[serde(default)] + target_hole_id: String, color: String, #[serde(default)] image_src: String, @@ -6426,6 +6440,8 @@ struct SquareHoleHoleJsonRecord { x: f32, y: f32, #[serde(default)] + image_src: String, + #[serde(default)] bonus: bool, } diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index 4532bf9a..e6650242 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -4,6 +4,10 @@ const ASSET_HISTORY_MAX_LIMIT: usize = 120; const ASSET_HISTORY_CHARACTER_VISUAL_KIND: &str = "character_visual"; const ASSET_HISTORY_SCENE_IMAGE_KIND: &str = "scene_image"; const ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND: &str = "puzzle_cover_image"; +const ASSET_HISTORY_SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image"; +const ASSET_HISTORY_SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image"; +const ASSET_HISTORY_SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image"; +const ASSET_HISTORY_SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image"; /// 资产事件类型。 /// @@ -204,9 +208,13 @@ fn list_asset_history( if asset_kind != ASSET_HISTORY_CHARACTER_VISUAL_KIND && asset_kind != ASSET_HISTORY_SCENE_IMAGE_KIND && asset_kind != ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND + && asset_kind != ASSET_HISTORY_SQUARE_HOLE_COVER_IMAGE_KIND + && asset_kind != ASSET_HISTORY_SQUARE_HOLE_BACKGROUND_IMAGE_KIND + && asset_kind != ASSET_HISTORY_SQUARE_HOLE_SHAPE_IMAGE_KIND + && asset_kind != ASSET_HISTORY_SQUARE_HOLE_HOLE_IMAGE_KIND { return Err( - "历史素材类型只支持 character_visual、scene_image 或 puzzle_cover_image".to_string(), + "历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image 或 square_hole_hole_image".to_string(), ); } diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 89a24541..ef9f030b 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -471,7 +471,6 @@ pub fn list_profile_wallet_ledger( } } - // analytics metric 查询直接聚合 tracking_daily_stat,避免 API 层订阅全量表后自行汇总。 #[spacetimedb::procedure] pub fn query_analytics_metric( @@ -2747,7 +2746,6 @@ fn build_profile_task_center_snapshot( }) } - fn query_analytics_metric_buckets( ctx: &ReducerContext, input: AnalyticsMetricQueryInput, diff --git a/server-rs/crates/spacetime-module/src/square_hole/mod.rs b/server-rs/crates/spacetime-module/src/square_hole/mod.rs index 03ee9b1d..522d2a66 100644 --- a/server-rs/crates/spacetime-module/src/square_hole/mod.rs +++ b/server-rs/crates/spacetime-module/src/square_hole/mod.rs @@ -1116,12 +1116,15 @@ fn normalize_config( config.cover_image_src = config.cover_image_src.trim().to_string(); config.background_image_src = config.background_image_src.trim().to_string(); + let hole_options = normalize_domain_hole_options( + domain_hole_options_from_snapshot(&config.hole_options), + &config.theme_text, + ); let shape_options = normalize_domain_shape_options( domain_shape_options_from_snapshot(&config.shape_options), &config.theme_text, + hole_options.as_slice(), ); - let hole_options = - normalize_domain_hole_options(domain_hole_options_from_snapshot(&config.hole_options)); config.shape_options = shape_options_to_snapshot(&shape_options); config.hole_options = hole_options_to_snapshot(&hole_options); config @@ -1249,6 +1252,7 @@ fn shape_from_domain(shape: &DomainSquareHoleShapeSnapshot) -> SquareHoleShapeSn shape_id: shape.shape_id.clone(), shape_kind: shape.shape_kind.clone(), label: shape.label.clone(), + target_hole_id: shape.target_hole_id.clone(), color: shape.color.clone(), image_src: shape.image_src.clone().unwrap_or_default(), } @@ -1259,6 +1263,7 @@ fn domain_shape_from_snapshot(shape: &SquareHoleShapeSnapshot) -> DomainSquareHo shape_id: shape.shape_id.clone(), shape_kind: shape.shape_kind.clone(), label: shape.label.clone(), + target_hole_id: shape.target_hole_id.clone(), color: shape.color.clone(), image_src: empty_to_none(&shape.image_src), } @@ -1271,7 +1276,7 @@ fn hole_from_domain(hole: &DomainSquareHoleHoleSnapshot) -> SquareHoleHoleSnapsh label: hole.label.clone(), x: hole.x, y: hole.y, - bonus: hole.bonus, + image_src: hole.image_src.clone().unwrap_or_default(), } } @@ -1282,7 +1287,7 @@ fn domain_hole_from_snapshot(hole: &SquareHoleHoleSnapshot) -> DomainSquareHoleH label: hole.label.clone(), x: hole.x, y: hole.y, - bonus: hole.bonus, + image_src: empty_to_none(&hole.image_src), } } @@ -1295,6 +1300,7 @@ fn shape_options_to_snapshot( option_id: option.option_id.clone(), shape_kind: option.shape_kind.clone(), label: option.label.clone(), + target_hole_id: option.target_hole_id.clone(), image_prompt: option.image_prompt.clone(), image_src: option.image_src.clone().unwrap_or_default(), }) @@ -1310,6 +1316,7 @@ fn domain_shape_options_from_snapshot( option_id: option.option_id.clone(), shape_kind: option.shape_kind.clone(), label: option.label.clone(), + target_hole_id: option.target_hole_id.clone(), image_prompt: option.image_prompt.clone(), image_src: empty_to_none(&option.image_src), }) @@ -1325,7 +1332,8 @@ fn hole_options_to_snapshot( hole_id: option.hole_id.clone(), hole_kind: option.hole_kind.clone(), label: option.label.clone(), - bonus: option.bonus, + image_prompt: option.image_prompt.clone(), + image_src: option.image_src.clone().unwrap_or_default(), }) .collect() } @@ -1339,7 +1347,8 @@ fn domain_hole_options_from_snapshot( hole_id: option.hole_id.clone(), hole_kind: option.hole_kind.clone(), label: option.label.clone(), - bonus: option.bonus, + image_prompt: option.image_prompt.clone(), + image_src: empty_to_none(&option.image_src), }) .collect() } diff --git a/server-rs/crates/spacetime-module/src/square_hole/types.rs b/server-rs/crates/spacetime-module/src/square_hole/types.rs index 52e06e04..232002e1 100644 --- a/server-rs/crates/spacetime-module/src/square_hole/types.rs +++ b/server-rs/crates/spacetime-module/src/square_hole/types.rs @@ -228,6 +228,8 @@ pub struct SquareHoleShapeOptionSnapshot { pub option_id: String, pub shape_kind: String, pub label: String, + #[serde(default)] + pub target_hole_id: String, pub image_prompt: String, #[serde(default)] pub image_src: String, @@ -240,7 +242,9 @@ pub struct SquareHoleHoleOptionSnapshot { pub hole_kind: String, pub label: String, #[serde(default)] - pub bonus: bool, + pub image_prompt: String, + #[serde(default)] + pub image_src: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -333,6 +337,8 @@ pub struct SquareHoleShapeSnapshot { pub shape_id: String, pub shape_kind: String, pub label: String, + #[serde(default)] + pub target_hole_id: String, pub color: String, #[serde(default)] pub image_src: String, @@ -347,7 +353,7 @@ pub struct SquareHoleHoleSnapshot { pub x: f32, pub y: f32, #[serde(default)] - pub bonus: bool, + pub image_src: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/server-rs/crates/tests-support/README.md b/server-rs/crates/tests-support/README.md index e09d9505..8e74f8be 100644 --- a/server-rs/crates/tests-support/README.md +++ b/server-rs/crates/tests-support/README.md @@ -8,7 +8,7 @@ 当前首版只放无业务规则的 smoke/HTTP 通用断言: -1. Maincloud healthz 默认地址常量 +1. api-server healthz 默认地址常量 2. smoke URL 空值与尾斜杠归一化 3. HTTP 2xx 状态码断言 4. healthz 非空响应体断言 diff --git a/server-rs/crates/tests-support/src/lib.rs b/server-rs/crates/tests-support/src/lib.rs index 29ae4390..5745a755 100644 --- a/server-rs/crates/tests-support/src/lib.rs +++ b/server-rs/crates/tests-support/src/lib.rs @@ -1,6 +1,6 @@ use std::fmt; -pub const DEFAULT_MAINCLOUD_HEALTHZ_URL: &str = "http://127.0.0.1:3100/healthz"; +pub const DEFAULT_API_SERVER_HEALTHZ_URL: &str = "http://127.0.0.1:3100/healthz"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SmokeAssertionError { @@ -32,7 +32,7 @@ impl std::error::Error for SmokeAssertionError {} pub fn normalize_smoke_url(input: impl AsRef) -> String { let trimmed = input.as_ref().trim(); if trimmed.is_empty() { - return DEFAULT_MAINCLOUD_HEALTHZ_URL.to_string(); + return DEFAULT_API_SERVER_HEALTHZ_URL.to_string(); } trimmed.trim_end_matches('/').to_string() @@ -63,10 +63,10 @@ mod tests { use super::*; #[test] - fn normalize_smoke_url_uses_maincloud_healthz_when_empty() { + fn normalize_smoke_url_uses_api_server_healthz_when_empty() { assert_eq!( normalize_smoke_url(" "), - DEFAULT_MAINCLOUD_HEALTHZ_URL.to_string() + DEFAULT_API_SERVER_HEALTHZ_URL.to_string() ); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 2bd4cb36..de3f31db 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -64,9 +64,7 @@ import type { SquareHoleSessionResponse, SquareHoleSessionSnapshot, } from '../../../packages/shared/src/contracts/squareHoleAgent'; -import type { - SquareHoleRunSnapshot, -} from '../../../packages/shared/src/contracts/squareHoleRuntime'; +import type { SquareHoleRunSnapshot } from '../../../packages/shared/src/contracts/squareHoleRuntime'; import type { SquareHoleWorkProfile, SquareHoleWorkSummary, @@ -1703,7 +1701,9 @@ export function PlatformEntryFlowShellImpl({ void refreshBigFishGallery(); openPublishShareModal({ title: response.session.draft?.title ?? '大鱼吃小鱼', - publicWorkCode: buildBigFishPublicWorkCode(response.session.sessionId), + publicWorkCode: buildBigFishPublicWorkCode( + response.session.sessionId, + ), stage: 'big-fish-runtime', }); } @@ -1889,8 +1889,10 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleGenerationState((current) => ({ ...(current ?? createMiniGameDraftGenerationState('square-hole')), phase: 'ready', - completedAssetCount: item.shapeOptions.length + 2, - totalAssetCount: item.shapeOptions.length + 2, + completedAssetCount: + item.shapeOptions.length + item.holeOptions.length + 2, + totalAssetCount: + item.shapeOptions.length + item.holeOptions.length + 2, error: null, })); await refreshSquareHoleShelf().catch(() => undefined); @@ -1906,8 +1908,10 @@ export function PlatformEntryFlowShellImpl({ phase: 'failed', error: errorMessage, })); - setSquareHoleProfile(buildSquareHoleProfileFromSession(response.session)); - setSelectionStage('square-hole-generating'); + setSquareHoleProfile( + buildSquareHoleProfileFromSession(response.session), + ); + setSelectionStage('square-hole-result'); } return; } @@ -1918,14 +1922,18 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleGenerationState((current) => ({ ...(current ?? createMiniGameDraftGenerationState('square-hole')), phase: 'ready', - completedAssetCount: item.shapeOptions.length + 2, - totalAssetCount: item.shapeOptions.length + 2, + completedAssetCount: + item.shapeOptions.length + item.holeOptions.length + 2, + totalAssetCount: + item.shapeOptions.length + item.holeOptions.length + 2, error: null, })); await refreshSquareHoleShelf().catch(() => undefined); setSelectionStage('square-hole-result'); } catch { - setSquareHoleProfile(buildSquareHoleProfileFromSession(response.session)); + setSquareHoleProfile( + buildSquareHoleProfileFromSession(response.session), + ); setSelectionStage('square-hole-result'); } }, @@ -2031,7 +2039,9 @@ export function PlatformEntryFlowShellImpl({ ); openPublishShareModal({ title: galleryDetail.item.workTitle || galleryDetail.item.levelName, - publicWorkCode: buildPuzzlePublicWorkCode(galleryDetail.item.profileId), + publicWorkCode: buildPuzzlePublicWorkCode( + galleryDetail.item.profileId, + ), stage: 'puzzle-gallery-detail', }); } @@ -2087,8 +2097,7 @@ export function PlatformEntryFlowShellImpl({ const setSquareHoleError = squareHoleFlow.setError; const isSquareHoleBusy = squareHoleFlow.isBusy; const streamingSquareHoleReplyText = squareHoleFlow.streamingReplyText; - const setStreamingSquareHoleReplyText = - squareHoleFlow.setStreamingReplyText; + const setStreamingSquareHoleReplyText = squareHoleFlow.setStreamingReplyText; const isStreamingSquareHoleReply = squareHoleFlow.isStreamingReply; const setIsStreamingSquareHoleReply = squareHoleFlow.setIsStreamingReply; @@ -2310,10 +2319,7 @@ export function PlatformEntryFlowShellImpl({ const handleCreationHubCreateType = useCallback( (type: PlatformCreationTypeId) => { - if ( - type === 'airp' || - type === 'visual-novel' - ) { + if (type === 'airp' || type === 'visual-novel') { return; } @@ -2796,9 +2802,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRuntimeReturnStage('puzzle-result'); setSelectionStage('puzzle-runtime'); } catch (error) { - setPuzzleError( - resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'), - ); + setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图试玩失败。')); } finally { setIsPuzzleBusy(false); } @@ -2885,7 +2889,11 @@ export function PlatformEntryFlowShellImpl({ ); const dragPuzzlePiece = useCallback( - async (payload: { pieceId: string; targetRow: number; targetCol: number }) => { + async (payload: { + pieceId: string; + targetRow: number; + targetCol: number; + }) => { if (!puzzleRun || isPuzzleBusy) { return; } @@ -3014,7 +3022,9 @@ export function PlatformEntryFlowShellImpl({ puzzleRunRef.current = run; setPuzzleRun(run); } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '重新开始拼图关卡失败。')); + setPuzzleError( + resolvePuzzleErrorMessage(error, '重新开始拼图关卡失败。'), + ); } finally { setIsPuzzleBusy(false); } @@ -3220,45 +3230,50 @@ export function PlatformEntryFlowShellImpl({ ], ); - const remodelCurrentPuzzleRuntimeWork = useCallback((profileId: string) => { - const targetProfileId = profileId.trim(); - if (!targetProfileId || isPublicWorkDetailBusy || isPuzzleBusy) { - return; - } + const remodelCurrentPuzzleRuntimeWork = useCallback( + (profileId: string) => { + const targetProfileId = profileId.trim(); + if (!targetProfileId || isPublicWorkDetailBusy || isPuzzleBusy) { + return; + } - runProtectedAction(() => { - setIsPublicWorkDetailBusy(true); - setIsPuzzleBusy(true); - setPuzzleError(null); - setPublicWorkDetailError(null); + runProtectedAction(() => { + setIsPublicWorkDetailBusy(true); + setIsPuzzleBusy(true); + setPuzzleError(null); + setPublicWorkDetailError(null); - void remixPuzzleGalleryWork(targetProfileId) - .then((response) => { - puzzleFlow.setSession(response.session); - setPuzzleOperation(null); - setPuzzleRun(null); - enterCreateTab(); - setSelectionStage('puzzle-result'); - }) - .catch((error) => { - setPuzzleError(resolvePuzzleErrorMessage(error, '改造拼图作品失败。')); - }) - .finally(() => { - setIsPublicWorkDetailBusy(false); - setIsPuzzleBusy(false); - }); - }); - }, [ - enterCreateTab, - isPublicWorkDetailBusy, - isPuzzleBusy, - puzzleFlow, - resolvePuzzleErrorMessage, - runProtectedAction, - setIsPuzzleBusy, - setPuzzleError, - setSelectionStage, - ]); + void remixPuzzleGalleryWork(targetProfileId) + .then((response) => { + puzzleFlow.setSession(response.session); + setPuzzleOperation(null); + setPuzzleRun(null); + enterCreateTab(); + setSelectionStage('puzzle-result'); + }) + .catch((error) => { + setPuzzleError( + resolvePuzzleErrorMessage(error, '改造拼图作品失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + setIsPuzzleBusy(false); + }); + }); + }, + [ + enterCreateTab, + isPublicWorkDetailBusy, + isPuzzleBusy, + puzzleFlow, + resolvePuzzleErrorMessage, + runProtectedAction, + setIsPuzzleBusy, + setPuzzleError, + setSelectionStage, + ], + ); const leaveAgentWorkspace = useCallback(() => { enterCreateTab(); @@ -4134,7 +4149,9 @@ export function PlatformEntryFlowShellImpl({ const { item: profile } = await getSquareHoleWorkDetail(item.profileId); setSquareHoleProfile(profile); } catch (error) { - setSquareHoleProfile(buildSquareHoleProfileFromSession(restoredSession)); + setSquareHoleProfile( + buildSquareHoleProfileFromSession(restoredSession), + ); setSquareHoleError( resolveSquareHoleErrorMessage(error, '读取方洞挑战作品详情失败。'), ); @@ -5701,7 +5718,9 @@ export function PlatformEntryFlowShellImpl({ className="flex h-full min-h-0 flex-col" > } + fallback={ + + } > )} - {selectionStage === 'square-hole-result' && squareHoleSession?.draft && ( - - } + {selectionStage === 'square-hole-result' && + squareHoleSession?.draft && ( + - { - setSelectionStage('square-hole-agent-workspace'); - }} - onSaved={(profile) => { - setSquareHoleProfile(profile); - }} - onPublished={(profile) => { - setSquareHoleProfile(profile); - void Promise.allSettled([ - refreshSquareHoleShelf(), - refreshSquareHoleGallery(), - ]); - openPublicWorkDetail( - mapSquareHoleWorkToPublicWorkDetail(profile), - ); - openPublishShareModal({ - title: profile.gameName, - publicWorkCode: buildSquareHolePublicWorkCode( - profile.profileId, - ), - stage: 'work-detail', - }); - }} - onStartTestRun={(profile) => { - setSquareHoleProfile(profile); - void startSquareHoleRunFromProfile( - profile, - 'square-hole-result', - ); - }} - /> - - - )} + } + > + { + setSelectionStage('square-hole-agent-workspace'); + }} + onSaved={(profile) => { + setSquareHoleProfile(profile); + }} + onPublished={(profile) => { + setSquareHoleProfile(profile); + void Promise.allSettled([ + refreshSquareHoleShelf(), + refreshSquareHoleGallery(), + ]); + openPublicWorkDetail( + mapSquareHoleWorkToPublicWorkDetail(profile), + ); + openPublishShareModal({ + title: profile.gameName, + publicWorkCode: buildSquareHolePublicWorkCode( + profile.profileId, + ), + stage: 'work-detail', + }); + }} + onStartTestRun={(profile) => { + setSquareHoleProfile(profile); + void startSquareHoleRunFromProfile( + profile, + 'square-hole-result', + ); + }} + /> + + + )} {selectionStage === 'square-hole-runtime' && ( option.holeId.trim())?.holeId.trim() ?? '' + ); +} + +function resolveShapeTargetHoleId( + option: SquareHoleShapeOption, + index: number, + holeOptions: SquareHoleHoleOption[], +) { + const holeIds = holeOptions + .map((holeOption) => holeOption.holeId.trim()) + .filter(Boolean); + const currentTarget = option.targetHoleId?.trim() ?? ''; + if (holeIds.includes(currentTarget)) { + return currentTarget; + } + return holeIds[index % Math.max(1, holeIds.length)] ?? ''; +} + +function remapShapeTargetsToHoles( + shapeOptions: SquareHoleShapeOption[], + holeOptions: SquareHoleHoleOption[], +) { + return shapeOptions.map((option, index) => ({ + ...option, + targetHoleId: resolveShapeTargetHoleId(option, index, holeOptions), + })); +} function normalizeTags(value: string) { return [ @@ -75,21 +126,30 @@ function normalizeTags(value: string) { function normalizeShapeCount(value: string) { const parsed = Number.parseInt(value.trim(), 10); - return Number.isFinite(parsed) && parsed >= 6 && parsed <= 24 - ? parsed - : null; -} - -function normalizeDifficulty(value: string) { - const parsed = Number.parseInt(value.trim(), 10); - return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10 - ? parsed - : null; + return Number.isFinite(parsed) && parsed >= 6 && parsed <= 24 ? parsed : null; } function createEditState( profile: SquareHoleWorkProfile, ): SquareHoleResultEditState { + const holeOptions = profile.holeOptions.map((option, index) => { + const holeId = option.holeId.trim() || fallbackHoleOptionId(index); + return { + ...option, + holeId, + holeKind: option.holeKind.trim() || holeId, + imagePrompt: option.imagePrompt ?? '', + imageSrc: option.imageSrc ?? null, + }; + }); + const shapeOptions = remapShapeTargetsToHoles( + profile.shapeOptions.map((option) => ({ + ...option, + targetHoleId: option.targetHoleId ?? '', + })), + holeOptions, + ); + return { gameName: profile.gameName, summary: profile.summary, @@ -99,10 +159,10 @@ function createEditState( backgroundImageSrc: profile.backgroundImageSrc?.trim() || '', themeText: profile.themeText, twistRule: profile.twistRule, - shapeOptions: profile.shapeOptions.map((option) => ({ ...option })), - holeOptions: profile.holeOptions.map((option) => ({ ...option })), + shapeOptions, + holeOptions, shapeCountText: String(profile.shapeCount), - difficultyText: String(profile.difficulty), + difficulty: profile.difficulty, }; } @@ -110,7 +170,6 @@ function buildSavePayload( editState: SquareHoleResultEditState, ): PutSquareHoleWorkRequest | null { const shapeCount = normalizeShapeCount(editState.shapeCountText); - const difficulty = normalizeDifficulty(editState.difficultyText); const gameName = editState.gameName.trim(); const themeText = editState.themeText.trim(); const twistRule = editState.twistRule.trim(); @@ -118,26 +177,37 @@ function buildSavePayload( const tags = normalizeTags(editState.tagsText); const shapeOptions = editState.shapeOptions .map((option) => ({ - ...option, optionId: option.optionId.trim(), shapeKind: option.shapeKind.trim(), label: option.label.trim(), + targetHoleId: option.targetHoleId.trim(), imagePrompt: option.imagePrompt.trim(), imageSrc: option.imageSrc?.trim() || null, })) .filter( (option) => - option.optionId && option.shapeKind && option.label && option.imagePrompt, + option.optionId && + option.shapeKind && + option.label && + option.targetHoleId && + option.imagePrompt, ); const holeOptions = editState.holeOptions - .map((option) => ({ - ...option, - holeId: option.holeId.trim(), - holeKind: option.holeKind.trim(), - label: option.label.trim(), - bonus: Boolean(option.bonus), - })) - .filter((option) => option.holeId && option.holeKind && option.label); + .map((option, index) => { + const holeId = option.holeId.trim() || fallbackHoleOptionId(index); + return { + holeId, + holeKind: holeId, + label: option.label.trim(), + imagePrompt: option.imagePrompt.trim(), + imageSrc: option.imageSrc?.trim() || null, + }; + }) + .filter((option) => option.holeId && option.label && option.imagePrompt); + const holeIdSet = new Set(holeOptions.map((option) => option.holeId)); + const hasInvalidShapeTarget = shapeOptions.some( + (option) => !holeIdSet.has(option.targetHoleId), + ); if ( !gameName || @@ -147,8 +217,8 @@ function buildSavePayload( tags.length === 0 || shapeOptions.length === 0 || holeOptions.length === 0 || - !shapeCount || - !difficulty + hasInvalidShapeTarget || + !shapeCount ) { return null; } @@ -165,7 +235,7 @@ function buildSavePayload( shapeOptions, holeOptions, shapeCount, - difficulty, + difficulty: editState.difficulty, }; } @@ -183,22 +253,29 @@ function buildPublishBlockers(editState: SquareHoleResultEditState) { option.optionId.trim() && option.shapeKind.trim() && option.label.trim() && + option.targetHoleId.trim() && option.imagePrompt.trim(), ) ? [] : ['至少需要 1 个形状选项。']), ...(editState.holeOptions.some( (option) => - option.holeId.trim() && option.holeKind.trim() && option.label.trim(), + option.holeId.trim() && + option.label.trim() && + option.imagePrompt.trim(), ) ? [] : ['至少需要 1 个洞口选项。']), + ...(editState.shapeOptions.every((option) => + editState.holeOptions.some( + (holeOption) => holeOption.holeId.trim() === option.targetHoleId.trim(), + ), + ) + ? [] + : ['每个展示选项都需要绑定一个洞口选项。']), ...(normalizeShapeCount(editState.shapeCountText) ? [] : ['形状数量需要在 6 到 24 之间。']), - ...(normalizeDifficulty(editState.difficultyText) - ? [] - : ['难度必须为 1 到 10。']), ]; return [...new Set(blockers)]; @@ -218,7 +295,10 @@ function readImageAsDataUrl(file: File) { }); } -function createShapeOption(index: number): SquareHoleShapeOption { +function createShapeOption( + index: number, + holeOptions: SquareHoleHoleOption[], +): SquareHoleShapeOption { return { optionId: `shape-${Date.now().toString(36)}-${index}`, shapeKind: @@ -226,20 +306,100 @@ function createShapeOption(index: number): SquareHoleShapeOption { index % SQUARE_HOLE_SHAPE_KIND_OPTIONS.length ] ?? 'square', label: `形状 ${index + 1}`, + targetHoleId: resolveFirstHoleId(holeOptions), imagePrompt: '主题贴图', imageSrc: null, }; } function createHoleOption(index: number): SquareHoleHoleOption { + const holeId = `hole-${Date.now().toString(36)}-${index}`; return { - holeId: `hole-${Date.now().toString(36)}-${index}`, - holeKind: - SQUARE_HOLE_HOLE_KIND_OPTIONS[ - index % SQUARE_HOLE_HOLE_KIND_OPTIONS.length - ] ?? 'square', + holeId, + holeKind: holeId, label: `洞口 ${index + 1}`, - bonus: index === 0, + imagePrompt: `洞口 ${index + 1}贴图,透明背景,游戏资产`, + imageSrc: null, + }; +} + +function formatHistoryAssetDate(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value || ''; + } + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function resolveImageSlotSrc( + editState: SquareHoleResultEditState, + slot: SquareHoleImageSlot, +) { + if (slot.kind === 'cover') { + return editState.coverImageSrc; + } + if (slot.kind === 'background') { + return editState.backgroundImageSrc; + } + if (slot.kind === 'hole') { + return ( + editState.holeOptions + .find((option) => option.holeId === slot.holeOptionId) + ?.imageSrc?.trim() || '' + ); + } + return ( + editState.shapeOptions + .find((option) => option.optionId === slot.shapeOptionId) + ?.imageSrc?.trim() || '' + ); +} + +function applyImageSlotSrc( + current: SquareHoleResultEditState, + slot: SquareHoleImageSlot, + imageSrc: string, +): SquareHoleResultEditState { + if (slot.kind === 'cover') { + return { + ...current, + coverImageSrc: imageSrc, + }; + } + if (slot.kind === 'background') { + return { + ...current, + backgroundImageSrc: imageSrc, + }; + } + if (slot.kind === 'hole') { + return { + ...current, + holeOptions: current.holeOptions.map((option) => + option.holeId === slot.holeOptionId + ? { + ...option, + imageSrc, + } + : option, + ), + }; + } + return { + ...current, + shapeOptions: current.shapeOptions.map((option) => + option.optionId === slot.shapeOptionId + ? { + ...option, + imageSrc, + } + : option, + ), }; } @@ -265,7 +425,7 @@ function buildPlayableProfile( shapeOptions: payload.shapeOptions ?? profile.shapeOptions, holeOptions: payload.holeOptions ?? profile.holeOptions, shapeCount: payload.shapeCount, - difficulty: payload.difficulty, + difficulty: payload.difficulty ?? profile.difficulty, }; } @@ -311,6 +471,208 @@ function SquareHoleResultHeader({ ); } +function SquareHoleImageSlotDialog({ + currentImageSrc, + canRegenerateImages, + isBusy, + isRegeneratingImages, + slot, + onClose, + onRegenerateImages, + onSelectHistory, + onUpload, +}: { + currentImageSrc: string; + canRegenerateImages: boolean; + isBusy: boolean; + isRegeneratingImages: boolean; + slot: SquareHoleImageSlot; + onClose: () => void; + onRegenerateImages: () => void; + onSelectHistory: (asset: SquareHoleHistoryAsset) => void; + onUpload: ( + slot: SquareHoleImageSlot, + event: ChangeEvent, + ) => void; +}) { + const platformTheme = useAuthUi()?.platformTheme ?? 'light'; + const [assets, setAssets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setError(null); + squareHoleAssetClient + .listHistoryAssets({ kind: slot.assetKind, limit: 120 }) + .then((nextAssets) => { + if (!cancelled) { + setAssets(nextAssets); + } + }) + .catch((loadError) => { + if (!cancelled) { + setError( + loadError instanceof Error + ? loadError.message + : '历史图片读取失败。', + ); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [slot.assetKind]); + + if (typeof document === 'undefined') { + return null; + } + + return createPortal( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
event.stopPropagation()} + > +
+
+ {slot.title} +
+ +
+ +
+
+
+
+ {currentImageSrc ? ( + + ) : ( +
+ +
+ )} +
+
+ + +
+
+ +
+
+ + 历史生成 +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {isLoading ? ( +
+ 读取中... +
+ ) : null} + + {!isLoading && !error && assets.length <= 0 ? ( +
+ 暂无历史图片 +
+ ) : null} + + {!isLoading && assets.length > 0 ? ( +
+ {assets.map((asset) => ( + + ))} +
+ ) : null} +
+
+
+
+
, + document.body, + ); +} + export function SquareHoleResultView({ profile, draft = null, @@ -326,7 +688,11 @@ export function SquareHoleResultView({ useState('idle'); const [localError, setLocalError] = useState(null); const [isPublishing, setIsPublishing] = useState(false); + const [isRegeneratingImages, setIsRegeneratingImages] = useState(false); + const [isApplyingHistoryImage, setIsApplyingHistoryImage] = useState(false); const [isStartingTestRun, setIsStartingTestRun] = useState(false); + const [activeImageSlot, setActiveImageSlot] = + useState(null); const blockers = useMemo(() => buildPublishBlockers(editState), [editState]); const canSubmit = blockers.length === 0; @@ -337,29 +703,39 @@ export function SquareHoleResultView({ }, [profile]); useEffect(() => { + if ( + isApplyingHistoryImage || + isPublishing || + isRegeneratingImages || + isStartingTestRun + ) { + return undefined; + } + const payload = buildSavePayload(editState); if (!payload) { return undefined; } - const currentTags = normalizeTags(profile.tags.join(',')); - const currentShapeOptions = JSON.stringify(profile.shapeOptions); - const currentHoleOptions = JSON.stringify(profile.holeOptions); + const currentPayload = buildSavePayload(createEditState(profile)); const changed = - payload.gameName !== profile.gameName || - payload.themeText !== profile.themeText || - payload.twistRule !== profile.twistRule || - payload.summary !== profile.summary || - (payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') || - (payload.backgroundPrompt ?? '') !== (profile.backgroundPrompt ?? '') || + !currentPayload || + payload.gameName !== currentPayload.gameName || + payload.themeText !== currentPayload.themeText || + payload.twistRule !== currentPayload.twistRule || + payload.summary !== currentPayload.summary || + (payload.coverImageSrc ?? '') !== (currentPayload.coverImageSrc ?? '') || + (payload.backgroundPrompt ?? '') !== + (currentPayload.backgroundPrompt ?? '') || (payload.backgroundImageSrc ?? '') !== - (profile.backgroundImageSrc ?? '') || - payload.shapeCount !== profile.shapeCount || - payload.difficulty !== profile.difficulty || - JSON.stringify(payload.shapeOptions ?? []) !== currentShapeOptions || - JSON.stringify(payload.holeOptions ?? []) !== currentHoleOptions || - payload.tags.length !== currentTags.length || - payload.tags.some((tag, index) => tag !== currentTags[index]); + (currentPayload.backgroundImageSrc ?? '') || + payload.shapeCount !== currentPayload.shapeCount || + JSON.stringify(payload.shapeOptions ?? []) !== + JSON.stringify(currentPayload.shapeOptions ?? []) || + JSON.stringify(payload.holeOptions ?? []) !== + JSON.stringify(currentPayload.holeOptions ?? []) || + payload.tags.length !== currentPayload.tags.length || + payload.tags.some((tag, index) => tag !== currentPayload.tags[index]); if (!changed) { return undefined; @@ -392,10 +768,18 @@ export function SquareHoleResultView({ cancelled = true; window.clearTimeout(timer); }; - }, [editState, onSaved, profile]); + }, [ + editState, + isApplyingHistoryImage, + isPublishing, + isRegeneratingImages, + isStartingTestRun, + onSaved, + profile, + ]); - const saveNow = async () => { - const payload = buildSavePayload(editState); + const saveStateNow = async (state: SquareHoleResultEditState) => { + const payload = buildSavePayload(state); if (!payload) { setLocalError(blockers[0] ?? '请补全作品信息。'); return null; @@ -409,28 +793,12 @@ export function SquareHoleResultView({ return item; }; - const handleCoverImageChange = async (event: ChangeEvent) => { - const file = event.target.files?.[0] ?? null; - event.target.value = ''; - if (!file) { - return; - } - - try { - const dataUrl = await readImageAsDataUrl(file); - setEditState((current) => ({ - ...current, - coverImageSrc: dataUrl, - })); - setLocalError(null); - } catch (caughtError) { - setLocalError( - caughtError instanceof Error ? caughtError.message : '封面图读取失败。', - ); - } + const saveNow = async () => { + return saveStateNow(editState); }; - const handleBackgroundImageChange = async ( + const handleImageSlotUpload = async ( + slot: SquareHoleImageSlot, event: ChangeEvent, ) => { const file = event.target.files?.[0] ?? null; @@ -441,45 +809,11 @@ export function SquareHoleResultView({ try { const dataUrl = await readImageAsDataUrl(file); - setEditState((current) => ({ - ...current, - backgroundImageSrc: dataUrl, - })); + setEditState((current) => applyImageSlotSrc(current, slot, dataUrl)); setLocalError(null); } catch (caughtError) { setLocalError( - caughtError instanceof Error ? caughtError.message : '背景图读取失败。', - ); - } - }; - - const handleShapeImageChange = async ( - optionId: string, - event: ChangeEvent, - ) => { - const file = event.target.files?.[0] ?? null; - event.target.value = ''; - if (!file) { - return; - } - - try { - const dataUrl = await readImageAsDataUrl(file); - setEditState((current) => ({ - ...current, - shapeOptions: current.shapeOptions.map((option) => - option.optionId === optionId - ? { - ...option, - imageSrc: dataUrl, - } - : option, - ), - })); - setLocalError(null); - } catch (caughtError) { - setLocalError( - caughtError instanceof Error ? caughtError.message : '形状贴图读取失败。', + caughtError instanceof Error ? caughtError.message : '图片读取失败。', ); } }; @@ -496,13 +830,77 @@ export function SquareHoleResultView({ onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState)); } catch (caughtError) { setLocalError( - caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。', + caughtError instanceof Error + ? caughtError.message + : '启动试玩前保存失败。', ); } finally { setIsStartingTestRun(false); } }; + const handleRegenerateImages = async () => { + if (!activeImageSlot || !canSubmit || isRegeneratingImages) { + setLocalError(blockers[0] ?? null); + return; + } + + setIsRegeneratingImages(true); + try { + const savedProfile = await saveNow(); + const targetProfile = + savedProfile ?? buildPlayableProfile(profile, editState); + const { item } = await regenerateSquareHoleWorkImage( + targetProfile.profileId, + { + visualAssetSlot: activeImageSlot.kind, + visualAssetOptionId: + activeImageSlot.shapeOptionId ?? activeImageSlot.holeOptionId ?? null, + }, + ); + setEditState(createEditState(item)); + onSaved?.(item); + setLocalError(null); + } catch (caughtError) { + setLocalError( + caughtError instanceof Error + ? caughtError.message + : '重生成图片前保存失败。', + ); + } finally { + setIsRegeneratingImages(false); + } + }; + + const handleSelectHistoryImage = async ( + slot: SquareHoleImageSlot, + asset: SquareHoleHistoryAsset, + ) => { + if ( + isApplyingHistoryImage || + isBusy || + isPublishing || + isRegeneratingImages || + isStartingTestRun + ) { + return; + } + + const nextState = applyImageSlotSrc(editState, slot, asset.imageSrc); + setEditState(nextState); + setIsApplyingHistoryImage(true); + try { + await saveStateNow(nextState); + setLocalError(null); + } catch (caughtError) { + setLocalError( + caughtError instanceof Error ? caughtError.message : '套用历史图片失败。', + ); + } finally { + setIsApplyingHistoryImage(false); + } + }; + const handlePublish = async () => { if (!canSubmit || isPublishing) { setLocalError(blockers[0] ?? null); @@ -519,15 +917,25 @@ export function SquareHoleResultView({ setLocalError(null); } catch (caughtError) { setLocalError( - caughtError instanceof Error ? caughtError.message : '发布方洞挑战失败。', + caughtError instanceof Error + ? caughtError.message + : '发布方洞挑战失败。', ); } finally { setIsPublishing(false); } }; - const busy = isBusy || isPublishing || isStartingTestRun; + const busy = + isBusy || + isPublishing || + isRegeneratingImages || + isApplyingHistoryImage || + isStartingTestRun; const displayError = error ?? localError; + const activeImageSrc = activeImageSlot + ? resolveImageSlotSrc(editState, activeImageSlot) + : ''; return (
@@ -540,7 +948,19 @@ export function SquareHoleResultView({
-
+
)} -
- -
+ +
{editState.shapeCountText || '-'} 个
- 难度 {editState.difficultyText || '-'} -
-
- {draft?.publishReady ?? profile.publishReady ? '可发布' : '草稿'} + {(draft?.publishReady ?? profile.publishReady) + ? '可发布' + : '草稿'}
-
+
)} -
- +
@@ -656,7 +1065,10 @@ export function SquareHoleResultView({ value={editState.themeText} disabled={busy} onChange={(event) => - setEditState({ ...editState, themeText: event.target.value }) + setEditState({ + ...editState, + themeText: event.target.value, + }) } className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none" /> @@ -670,7 +1082,10 @@ export function SquareHoleResultView({ value={editState.twistRule} disabled={busy} onChange={(event) => - setEditState({ ...editState, twistRule: event.target.value }) + setEditState({ + ...editState, + twistRule: event.target.value, + }) } className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none" /> @@ -711,23 +1126,6 @@ export function SquareHoleResultView({ /> -
@@ -744,7 +1142,10 @@ export function SquareHoleResultView({ ...current, shapeOptions: [ ...current.shapeOptions, - createShapeOption(current.shapeOptions.length), + createShapeOption( + current.shapeOptions.length, + current.holeOptions, + ), ], })) } @@ -761,7 +1162,20 @@ export function SquareHoleResultView({ className="rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3" >
-