master #14
@@ -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,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 前端相关默认验证
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 共享记忆更新准则
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),快速建立这个项目的开发共识。
|
||||
|
||||
@@ -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 后清理本次启动进程。
|
||||
|
||||
@@ -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,但未阻止服务启动。
|
||||
|
||||
仍需单独处理的非本轮阻塞:
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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` 重启验证。
|
||||
|
||||
@@ -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/*` 请求路径。
|
||||
|
||||
@@ -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` 重启后端。
|
||||
|
||||
@@ -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 数据库或令牌失败,必须记录具体错误;不能改用旧后端重启命令。
|
||||
|
||||
@@ -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 接线
|
||||
|
||||
|
||||
@@ -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`,后端只重生成该洞口选项图;未传槽位的自动补齐需要同时补缺失的形状贴图和洞口选项图。
|
||||
|
||||
@@ -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. 游戏模式中洞口、当前选项和拖拽影子不再显示几何形状,只显示对应图片或中性图片占位。
|
||||
@@ -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. 运行态箭头和高亮不会默认指向当前正确洞口。
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,7 @@ export * from './contracts/squareHoleAgent';
|
||||
export * from './contracts/squareHoleRuntime';
|
||||
export type {
|
||||
PutSquareHoleWorkRequest,
|
||||
RegenerateSquareHoleWorkImageRequest,
|
||||
SquareHoleWorkDetailResponse,
|
||||
SquareHoleHoleOption as SquareHoleWorkHoleOption,
|
||||
SquareHoleWorkMutationResponse,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<AppState>,
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<RegenerateSquareHoleWorkImageRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
@@ -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<String>,
|
||||
visual_asset_option_id: Option<String>,
|
||||
) -> Result<SquareHoleAgentSessionRecord, Response> {
|
||||
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<String>,
|
||||
) -> Result<SquareHoleWorkProfileRecord, Response> {
|
||||
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<String, AppError> {
|
||||
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<String, String> {
|
||||
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::<String>()
|
||||
.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<SquareHoleWorkShapeOptionResponse>,
|
||||
hole_options: &[SquareHoleHoleOptionRecord],
|
||||
) -> Vec<SquareHoleShapeOptionRecord> {
|
||||
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<SquareHoleVisualAssetSlotRequest> {
|
||||
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<module_square_hole::SquareHoleShapeOption> 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<SquareHoleShapeOptionRecord> 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<module_square_hole::SquareHoleHoleOption> 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<SquareHoleHoleOptionRecord> 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<F>(
|
||||
@@ -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<SquareHoleShapeOption> {
|
||||
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<SquareHoleHoleOption> {
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
@@ -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<String> {
|
||||
@@ -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<SquareHoleShapeOption> 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<SquareHoleHoleOption> 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,
|
||||
"办公室桌面纸箱玩具背景"
|
||||
|
||||
@@ -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<SquareHoleHoleSnapshot> {
|
||||
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<SquareHoleShapeOption> {
|
||||
pub fn default_shape_options(theme_text: &str, hole_ids: &[String]) -> Vec<SquareHoleShapeOption> {
|
||||
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::<Vec<_>>()
|
||||
} else {
|
||||
hole_ids.to_vec()
|
||||
};
|
||||
[
|
||||
("square", "方块"),
|
||||
("circle", "圆块"),
|
||||
@@ -334,35 +343,41 @@ pub fn default_shape_options(theme_text: &str) -> Vec<SquareHoleShapeOption> {
|
||||
("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<SquareHoleHoleOption> {
|
||||
pub fn default_hole_options(theme_text: &str) -> Vec<SquareHoleHoleOption> {
|
||||
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<SquareHoleHoleOption> {
|
||||
pub fn normalize_shape_options(
|
||||
options: Vec<SquareHoleShapeOption>,
|
||||
theme_text: &str,
|
||||
hole_options: &[SquareHoleHoleOption],
|
||||
) -> Vec<SquareHoleShapeOption> {
|
||||
let hole_ids = if hole_options.is_empty() {
|
||||
default_hole_options(theme_text)
|
||||
.into_iter()
|
||||
.map(|option| option.hole_id)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
hole_options
|
||||
.iter()
|
||||
.map(|option| option.hole_id.clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
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<SquareHoleHoleOption>) -> Vec<SquareHoleHoleOption> {
|
||||
pub fn normalize_hole_options(
|
||||
options: Vec<SquareHoleHoleOption>,
|
||||
theme_text: &str,
|
||||
) -> Vec<SquareHoleHoleOption> {
|
||||
let mut normalized = Vec::new();
|
||||
for (index, option) in options
|
||||
.into_iter()
|
||||
@@ -419,20 +453,27 @@ pub fn normalize_hole_options(options: Vec<SquareHoleHoleOption>) -> Vec<SquareH
|
||||
.enumerate()
|
||||
{
|
||||
let hole_kind = normalize_required_string(&option.hole_kind)
|
||||
.unwrap_or_else(|| fallback_shape_kind(index));
|
||||
.unwrap_or_else(|| format!("hole-{}", index + 1));
|
||||
let label = normalize_required_string(&option.label)
|
||||
.unwrap_or_else(|| fallback_hole_label(&hole_kind).to_string());
|
||||
.unwrap_or_else(|| fallback_hole_label(index).to_string());
|
||||
let hole_id = normalize_required_string(&option.hole_id)
|
||||
.unwrap_or_else(|| format!("{hole_kind}-hole-{index}"));
|
||||
.unwrap_or_else(|| format!("hole-{}", index + 1));
|
||||
normalized.push(SquareHoleHoleOption {
|
||||
hole_id,
|
||||
hole_kind,
|
||||
label,
|
||||
bonus: option.bonus,
|
||||
image_prompt: normalize_required_string(&option.image_prompt).unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}主题的{}贴纸图,透明背景,明亮可爱,游戏资产",
|
||||
normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()),
|
||||
fallback_hole_label(index)
|
||||
)
|
||||
}),
|
||||
image_src: option.image_src.and_then(normalize_required_string),
|
||||
});
|
||||
}
|
||||
|
||||
for fallback in default_hole_options() {
|
||||
for fallback in default_hole_options(theme_text) {
|
||||
if normalized.len() >= SQUARE_HOLE_MIN_HOLE_OPTION_COUNT {
|
||||
break;
|
||||
}
|
||||
@@ -444,11 +485,6 @@ pub fn normalize_hole_options(options: Vec<SquareHoleHoleOption>) -> Vec<SquareH
|
||||
}
|
||||
}
|
||||
|
||||
if normalized.iter().all(|option| !option.bonus)
|
||||
&& let Some(first) = normalized.first_mut()
|
||||
{
|
||||
first.bonus = true;
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
@@ -460,7 +496,7 @@ pub fn default_background_prompt(theme_text: &str) -> String {
|
||||
}
|
||||
|
||||
fn build_holes(options: &[SquareHoleHoleOption]) -> Vec<SquareHoleHoleSnapshot> {
|
||||
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<SquareHoleHoleSnapshot>
|
||||
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<SquareHoleShapeOption> {
|
||||
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,
|
||||
|
||||
@@ -32,13 +32,14 @@ pub fn build_creator_config(
|
||||
shape_count: u32,
|
||||
difficulty: u32,
|
||||
) -> Result<SquareHoleCreatorConfig, SquareHoleError> {
|
||||
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<Strin
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
#[deprecated(note = "请使用 compile_result_draft(profile_id, &config)")]
|
||||
pub fn build_result_draft(
|
||||
profile_id: String,
|
||||
theme_text: String,
|
||||
twist_rule: String,
|
||||
shape_count: u32,
|
||||
difficulty: u32,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
@@ -161,7 +167,7 @@ pub struct SquareHoleHoleSnapshot {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
#[serde(default)]
|
||||
pub bonus: bool,
|
||||
pub image_src: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -36,6 +36,12 @@ pub struct ExecuteSquareHoleActionRequest {
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub regenerate_visual_assets: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub visual_asset_slot: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visual_asset_option_id: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SquareHoleWorkSummaryResponse {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
@@ -6233,7 +6239,7 @@ pub struct SquareHoleHoleSnapshotRecord {
|
||||
pub label: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub bonus: bool,
|
||||
pub image_src: Option<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
当前首版只放无业务规则的 smoke/HTTP 通用断言:
|
||||
|
||||
1. Maincloud healthz 默认地址常量
|
||||
1. api-server healthz 默认地址常量
|
||||
2. smoke URL 空值与尾斜杠归一化
|
||||
3. HTTP 2xx 状态码断言
|
||||
4. healthz 非空响应体断言
|
||||
|
||||
@@ -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<str>) -> 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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载方洞挑战生成面板..." />}
|
||||
fallback={
|
||||
<LazyPanelFallback label="正在加载方洞挑战生成面板..." />
|
||||
}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={
|
||||
@@ -5736,59 +5755,60 @@ export function PlatformEntryFlowShellImpl({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'square-hole-result' && squareHoleSession?.draft && (
|
||||
<motion.div
|
||||
key="square-hole-result"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载方洞挑战结果..." />}
|
||||
{selectionStage === 'square-hole-result' &&
|
||||
squareHoleSession?.draft && (
|
||||
<motion.div
|
||||
key="square-hole-result"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<SquareHoleResultView
|
||||
profile={
|
||||
squareHoleProfile ??
|
||||
buildSquareHoleProfileFromSession(squareHoleSession)!
|
||||
}
|
||||
draft={squareHoleSession.draft}
|
||||
isBusy={isSquareHoleBusy}
|
||||
error={squareHoleError}
|
||||
onBack={() => {
|
||||
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',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载方洞挑战结果..." />}
|
||||
>
|
||||
<SquareHoleResultView
|
||||
profile={
|
||||
squareHoleProfile ??
|
||||
buildSquareHoleProfileFromSession(squareHoleSession)!
|
||||
}
|
||||
draft={squareHoleSession.draft}
|
||||
isBusy={isSquareHoleBusy}
|
||||
error={squareHoleError}
|
||||
onBack={() => {
|
||||
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',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'square-hole-runtime' && (
|
||||
<motion.div
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
DropSquareHoleShapeRequest,
|
||||
SquareHoleRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
|
||||
import { SquareHoleRuntimeShell } from './SquareHoleRuntimeShell';
|
||||
|
||||
function buildRun(): SquareHoleRunSnapshot {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'running',
|
||||
snapshotVersion: 3,
|
||||
startedAtMs: Date.now(),
|
||||
durationLimitMs: 60_000,
|
||||
remainingMs: 60_000,
|
||||
totalShapeCount: 8,
|
||||
completedShapeCount: 0,
|
||||
combo: 0,
|
||||
bestCombo: 0,
|
||||
score: 0,
|
||||
ruleLabel: '把当前选项投入指定洞口',
|
||||
backgroundImageSrc: null,
|
||||
currentShape: {
|
||||
shapeId: 'shape-1',
|
||||
shapeKind: 'square',
|
||||
label: '当前选项',
|
||||
targetHoleId: 'hole-b',
|
||||
color: '#38bdf8',
|
||||
imageSrc: null,
|
||||
},
|
||||
holes: [
|
||||
{
|
||||
holeId: 'hole-a',
|
||||
holeKind: 'hole-a',
|
||||
label: '洞口 A',
|
||||
x: 0.28,
|
||||
y: 0.32,
|
||||
imageSrc: null,
|
||||
},
|
||||
{
|
||||
holeId: 'hole-b',
|
||||
holeKind: 'hole-b',
|
||||
label: '洞口 B',
|
||||
x: 0.66,
|
||||
y: 0.52,
|
||||
imageSrc: null,
|
||||
},
|
||||
],
|
||||
lastFeedback: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRuntime() {
|
||||
const run = buildRun();
|
||||
const onDropShape = vi.fn(async (_payload: DropSquareHoleShapeRequest) => ({
|
||||
feedback: {
|
||||
accepted: true,
|
||||
rejectReason: null,
|
||||
message: '已投入',
|
||||
},
|
||||
run,
|
||||
}));
|
||||
|
||||
render(
|
||||
<SquareHoleRuntimeShell
|
||||
run={run}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onDropShape={onDropShape}
|
||||
/>,
|
||||
);
|
||||
|
||||
return { onDropShape };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('点击洞口会提交该洞口选择', async () => {
|
||||
const { onDropShape } = renderRuntime();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '投入 洞口 B' }));
|
||||
|
||||
await waitFor(() => expect(onDropShape).toHaveBeenCalledTimes(1));
|
||||
expect(onDropShape.mock.calls[0]?.[0]).toMatchObject({
|
||||
runId: 'run-1',
|
||||
holeId: 'hole-b',
|
||||
clientSnapshotVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('引导高亮不会默认指向当前正确洞口', () => {
|
||||
renderRuntime();
|
||||
|
||||
const correctHole = screen.getByRole('button', { name: '投入 洞口 B' });
|
||||
const hintedHole = screen.getByRole('button', { name: '投入 洞口 A' });
|
||||
|
||||
expect(correctHole.className).not.toContain('ring-2');
|
||||
expect(hintedHole.className).toContain('ring-2');
|
||||
});
|
||||
|
||||
test('拖拽当前选项到洞口上松开会提交该洞口选择', async () => {
|
||||
const { onDropShape } = renderRuntime();
|
||||
const shape = screen.getByRole('button', { name: '拖拽当前选项' });
|
||||
const hole = screen.getByRole('button', { name: '投入 洞口 A' });
|
||||
const elementsFromPoint = vi.fn(() => [hole]);
|
||||
Object.defineProperty(document, 'elementsFromPoint', {
|
||||
configurable: true,
|
||||
value: elementsFromPoint,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(shape, { pointerId: 1, clientX: 20, clientY: 20 });
|
||||
fireEvent.pointerMove(shape, { pointerId: 1, clientX: 140, clientY: 160 });
|
||||
fireEvent.pointerUp(shape, { pointerId: 1, clientX: 140, clientY: 160 });
|
||||
|
||||
await waitFor(() => expect(onDropShape).toHaveBeenCalledTimes(1));
|
||||
expect(onDropShape.mock.calls[0]?.[0]).toMatchObject({
|
||||
runId: 'run-1',
|
||||
holeId: 'hole-a',
|
||||
clientSnapshotVersion: 3,
|
||||
});
|
||||
expect(elementsFromPoint).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Image,
|
||||
RotateCcw,
|
||||
Shapes,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
DropSquareHoleShapeRequest,
|
||||
SquareHoleDropResponse,
|
||||
SquareHoleHoleSnapshot,
|
||||
SquareHoleRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
@@ -35,6 +43,16 @@ type PendingDrop = {
|
||||
holeId: string;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
function isRunning(run: SquareHoleRunSnapshot) {
|
||||
return run.status.toLowerCase() === 'running';
|
||||
}
|
||||
@@ -52,44 +70,17 @@ function buildClientEventId(runId: string, holeId: string) {
|
||||
)}`;
|
||||
}
|
||||
|
||||
function getHoleShapeClass(hole: SquareHoleHoleSnapshot) {
|
||||
const kind = hole.holeKind.toLowerCase();
|
||||
if (kind.includes('circle')) {
|
||||
return 'rounded-full';
|
||||
}
|
||||
if (kind.includes('triangle')) {
|
||||
return 'square-hole-runtime__hole-cut--triangle';
|
||||
}
|
||||
if (kind.includes('diamond')) {
|
||||
return 'rotate-45 rounded-[0.75rem]';
|
||||
}
|
||||
if (kind.includes('star')) {
|
||||
return 'square-hole-runtime__hole-cut--star';
|
||||
}
|
||||
if (kind.includes('arch')) {
|
||||
return 'rounded-t-full rounded-b-[0.85rem]';
|
||||
}
|
||||
return 'rounded-[0.85rem]';
|
||||
function clampPercent(value: number) {
|
||||
return Math.min(92, Math.max(8, value * 100));
|
||||
}
|
||||
|
||||
function getShapePreviewClass(shapeKind: string) {
|
||||
const kind = shapeKind.toLowerCase();
|
||||
if (kind.includes('circle')) {
|
||||
return 'rounded-full';
|
||||
function hashText(value: string) {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
if (kind.includes('triangle')) {
|
||||
return 'square-hole-runtime__shape--triangle';
|
||||
}
|
||||
if (kind.includes('diamond')) {
|
||||
return 'rotate-45 rounded-[0.8rem]';
|
||||
}
|
||||
if (kind.includes('star')) {
|
||||
return 'square-hole-runtime__shape--star';
|
||||
}
|
||||
if (kind.includes('arch')) {
|
||||
return 'rounded-t-full rounded-b-[0.9rem]';
|
||||
}
|
||||
return 'rounded-[0.9rem]';
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function SquareHoleSettlement({
|
||||
@@ -166,9 +157,14 @@ export function SquareHoleRuntimeShell({
|
||||
const [pendingDrop, setPendingDrop] = useState<PendingDrop | null>(null);
|
||||
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
||||
const [feedbackPulseId, setFeedbackPulseId] = useState<string | null>(null);
|
||||
const [dragState, setDragState] = useState<DragState | null>(null);
|
||||
const [isShapeArmed, setIsShapeArmed] = useState(false);
|
||||
const [dropError, setDropError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeftMs(run?.remainingMs ?? 0);
|
||||
setIsShapeArmed(false);
|
||||
setDropError(null);
|
||||
}, [run?.remainingMs, run?.snapshotVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,6 +201,100 @@ export function SquareHoleRuntimeShell({
|
||||
return `${run.completedShapeCount}/${run.totalShapeCount}`;
|
||||
}, [run]);
|
||||
|
||||
const currentShape = run?.currentShape ?? null;
|
||||
const hintHole = useMemo(() => {
|
||||
if (!run || !currentShape) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hintCandidates =
|
||||
run.holes.length > 1
|
||||
? run.holes.filter(
|
||||
(hole) => hole.holeId !== currentShape.targetHoleId,
|
||||
)
|
||||
: run.holes;
|
||||
if (hintCandidates.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seed = `${run.runId}:${run.snapshotVersion}:${run.completedShapeCount}:${currentShape.shapeId}`;
|
||||
return hintCandidates[hashText(seed) % hintCandidates.length] ?? null;
|
||||
}, [currentShape, run]);
|
||||
|
||||
const arrowStyle = useMemo<CSSProperties>(() => {
|
||||
if (!hintHole) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
left: `${clampPercent(hintHole.x)}%`,
|
||||
top: `${clampPercent(hintHole.y)}%`,
|
||||
};
|
||||
}, [hintHole]);
|
||||
|
||||
const resolveHoleAtPoint = useCallback((clientX: number, clientY: number) => {
|
||||
const elements = document.elementsFromPoint(clientX, clientY);
|
||||
const holeElement = elements.find((element) =>
|
||||
element instanceof HTMLElement ? element.dataset.squareHoleId : false,
|
||||
) as HTMLElement | undefined;
|
||||
return holeElement?.dataset.squareHoleId ?? null;
|
||||
}, []);
|
||||
|
||||
const handleShapePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!run || !currentShape || !isRunning(run) || pendingDrop || isBusy) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragState({
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
size: event.currentTarget.getBoundingClientRect().width,
|
||||
moved: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleShapePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragState || dragState.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
setDragState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
moved:
|
||||
current.moved ||
|
||||
Math.hypot(
|
||||
event.clientX - current.startX,
|
||||
event.clientY - current.startY,
|
||||
) >= 6,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
};
|
||||
|
||||
const handleShapePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const currentDragState = dragState;
|
||||
if (!currentDragState || currentDragState.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
const holeId = resolveHoleAtPoint(event.clientX, event.clientY);
|
||||
setDragState(null);
|
||||
if (holeId) {
|
||||
void dropToHole(holeId);
|
||||
return;
|
||||
}
|
||||
if (!currentDragState.moved) {
|
||||
setIsShapeArmed(true);
|
||||
return;
|
||||
}
|
||||
setIsShapeArmed(false);
|
||||
};
|
||||
|
||||
const dropToHole = async (holeId: string) => {
|
||||
if (!run || !isRunning(run) || pendingDrop || isBusy) {
|
||||
return;
|
||||
@@ -213,6 +303,8 @@ export function SquareHoleRuntimeShell({
|
||||
const clientEventId = buildClientEventId(run.runId, holeId);
|
||||
setPendingDrop({ clientEventId, holeId });
|
||||
setFeedbackPulseId(null);
|
||||
setIsShapeArmed(false);
|
||||
setDropError(null);
|
||||
|
||||
try {
|
||||
const response = await onDropShape({
|
||||
@@ -224,6 +316,10 @@ export function SquareHoleRuntimeShell({
|
||||
});
|
||||
setFeedbackPulseId(clientEventId);
|
||||
onOptimisticRunChange?.(response.run);
|
||||
} catch (caughtError) {
|
||||
setDropError(
|
||||
caughtError instanceof Error ? caughtError.message : '本次投入失败',
|
||||
);
|
||||
} finally {
|
||||
setPendingDrop(null);
|
||||
}
|
||||
@@ -237,7 +333,6 @@ export function SquareHoleRuntimeShell({
|
||||
);
|
||||
}
|
||||
|
||||
const currentShape = run.currentShape;
|
||||
const feedback = run.lastFeedback;
|
||||
|
||||
return (
|
||||
@@ -253,6 +348,32 @@ export function SquareHoleRuntimeShell({
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(125,211,252,0.32),transparent_27%),radial-gradient(circle_at_80%_80%,rgba(248,113,113,0.24),transparent_34%),linear-gradient(180deg,#1f3a5f_0%,#152238_48%,#111827_100%)]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-slate-950/42" />
|
||||
{dragState && currentShape ? (
|
||||
<div
|
||||
className="pointer-events-none fixed z-[95] grid overflow-hidden rounded-[1.15rem] border border-white/18 bg-white/92 shadow-[0_24px_56px_rgba(15,23,42,0.46)]"
|
||||
style={{
|
||||
left: dragState.x,
|
||||
top: dragState.y,
|
||||
width: dragState.size,
|
||||
height: dragState.size,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{currentShape.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={currentShape.imageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-full w-full place-items-center bg-slate-100 text-slate-500">
|
||||
<Image size={30} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
|
||||
style={{
|
||||
@@ -296,66 +417,53 @@ export function SquareHoleRuntimeShell({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-3 rounded-[1.5rem] border border-white/14 bg-black/18 p-3 shadow-[0_18px_42px_rgba(15,23,42,0.28)] backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-2 text-xs font-bold text-white/68">
|
||||
<span>当前形状</span>
|
||||
<span>{progressText}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex min-h-[12rem] items-center justify-center rounded-[1.35rem] border border-white/10 bg-white/10">
|
||||
{currentShape ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className={`relative h-24 w-24 overflow-hidden shadow-[0_18px_38px_rgba(15,23,42,0.34)] ${getShapePreviewClass(
|
||||
currentShape.shapeKind,
|
||||
)}`}
|
||||
style={{
|
||||
background:
|
||||
currentShape.color ||
|
||||
'linear-gradient(135deg,#f8fafc,#38bdf8)',
|
||||
}}
|
||||
>
|
||||
{currentShape.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={currentShape.imageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-base font-black">
|
||||
<Shapes size={18} />
|
||||
<span>{currentShape.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm font-bold text-white/70">等待下一块</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-3 grid grid-cols-2 gap-2">
|
||||
<section className="relative mt-3 min-h-[22rem] overflow-hidden rounded-[1.5rem] border border-white/14 bg-black/18 p-3 shadow-[0_18px_42px_rgba(15,23,42,0.28)] backdrop-blur">
|
||||
<div className="absolute inset-3 rounded-[1.25rem] border border-white/10 bg-white/8" />
|
||||
{hintHole ? (
|
||||
<div
|
||||
className="square-hole-runtime__target-arrow pointer-events-none absolute z-20 -translate-x-1/2 -translate-y-[138%] text-cyan-100 drop-shadow-[0_4px_16px_rgba(103,232,249,0.65)]"
|
||||
style={arrowStyle}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ArrowDown size={34} strokeWidth={3.2} />
|
||||
</div>
|
||||
) : null}
|
||||
{run.holes.map((hole) => {
|
||||
const isPending = pendingDrop?.holeId === hole.holeId;
|
||||
const isHint = hintHole?.holeId === hole.holeId;
|
||||
return (
|
||||
<button
|
||||
key={hole.holeId}
|
||||
type="button"
|
||||
data-square-hole-id={hole.holeId}
|
||||
disabled={!isRunning(run) || Boolean(pendingDrop) || isBusy}
|
||||
className={`min-h-[6.25rem] rounded-[1.35rem] border border-white/14 bg-black/22 p-2 text-white shadow-[0_12px_28px_rgba(15,23,42,0.24)] backdrop-blur transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-58 ${
|
||||
isPending ? 'ring-2 ring-cyan-200/70' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
void dropToHole(hole.holeId);
|
||||
}}
|
||||
className={`absolute flex h-24 w-24 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-[1.35rem] border border-white/14 bg-black/34 p-2 text-white shadow-[0_12px_28px_rgba(15,23,42,0.24)] backdrop-blur transition disabled:cursor-not-allowed disabled:opacity-58 ${
|
||||
isPending || isHint || isShapeArmed
|
||||
? 'ring-2 ring-cyan-200/70'
|
||||
: ''
|
||||
}`}
|
||||
style={{
|
||||
left: `${clampPercent(hole.x)}%`,
|
||||
top: `${clampPercent(hole.y)}%`,
|
||||
}}
|
||||
aria-label={`投入 ${hole.label}`}
|
||||
>
|
||||
<span className="mx-auto grid h-12 w-12 place-items-center rounded-2xl bg-white/88 p-2">
|
||||
<span
|
||||
className={`block h-full w-full bg-slate-950 ${getHoleShapeClass(
|
||||
hole,
|
||||
)}`}
|
||||
/>
|
||||
<span className="grid h-14 w-14 place-items-center overflow-hidden rounded-2xl bg-white/88">
|
||||
{hole.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={hole.imageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-full w-full place-items-center bg-slate-100 text-slate-500">
|
||||
<Image size={22} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-2 block truncate text-sm font-black">
|
||||
{hole.label}
|
||||
@@ -365,6 +473,56 @@ export function SquareHoleRuntimeShell({
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="relative mt-3 min-h-[9.4rem] rounded-[1.45rem] border border-white/14 bg-black/24 p-3 backdrop-blur">
|
||||
{currentShape ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<div
|
||||
className={`relative h-24 w-24 touch-none select-none overflow-hidden shadow-[0_18px_38px_rgba(15,23,42,0.34)] transition ${
|
||||
dragState
|
||||
? 'opacity-35'
|
||||
: isShapeArmed
|
||||
? 'ring-4 ring-cyan-200/70 active:scale-[0.98]'
|
||||
: 'active:scale-[0.98]'
|
||||
} rounded-[1.15rem] border border-white/18 bg-white/92`}
|
||||
style={{
|
||||
cursor:
|
||||
isRunning(run) && !pendingDrop && !isBusy
|
||||
? 'grab'
|
||||
: 'default',
|
||||
}}
|
||||
onPointerDown={handleShapePointerDown}
|
||||
onPointerMove={handleShapePointerMove}
|
||||
onPointerUp={handleShapePointerEnd}
|
||||
onPointerCancel={handleShapePointerEnd}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`拖拽${currentShape.label}`}
|
||||
>
|
||||
{currentShape.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={currentShape.imageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-full w-full place-items-center bg-slate-100 text-slate-500">
|
||||
<Image size={32} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 text-base font-black">
|
||||
<Shapes size={18} />
|
||||
<span className="truncate">{currentShape.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm font-bold text-white/70">
|
||||
等待下一块
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-auto min-h-[3.5rem] pt-3">
|
||||
{feedback ? (
|
||||
<div
|
||||
@@ -376,9 +534,9 @@ export function SquareHoleRuntimeShell({
|
||||
>
|
||||
{feedback.message}
|
||||
</div>
|
||||
) : error ? (
|
||||
) : dropError || error ? (
|
||||
<div className="rounded-[1.2rem] border border-rose-200/35 bg-rose-400/18 px-3 py-2 text-center text-sm font-black text-rose-50">
|
||||
{error}
|
||||
{dropError ?? error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -1528,25 +1528,19 @@ body {
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.square-hole-runtime__shape--triangle,
|
||||
.square-hole-runtime__hole-cut--triangle {
|
||||
clip-path: polygon(50% 5%, 94% 92%, 6% 92%);
|
||||
.square-hole-runtime__target-arrow {
|
||||
animation: square-hole-runtime-target-arrow 0.86s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.square-hole-runtime__shape--star,
|
||||
.square-hole-runtime__hole-cut--star {
|
||||
clip-path: polygon(
|
||||
50% 4%,
|
||||
61% 36%,
|
||||
95% 36%,
|
||||
67% 55%,
|
||||
79% 88%,
|
||||
50% 68%,
|
||||
21% 88%,
|
||||
33% 55%,
|
||||
5% 36%,
|
||||
39% 36%
|
||||
);
|
||||
@keyframes square-hole-runtime-target-arrow {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-50%, -138%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-50%, -112%);
|
||||
}
|
||||
}
|
||||
|
||||
.platform-tab {
|
||||
|
||||
@@ -95,7 +95,7 @@ const SQUARE_HOLE_STEPS = [
|
||||
{
|
||||
id: 'square-hole-draft',
|
||||
label: '整理玩法草稿',
|
||||
detail: '收拢题材、形状、洞口与加分选项。',
|
||||
detail: '收拢题材、展示选项与洞口选项。',
|
||||
weight: 28,
|
||||
},
|
||||
{
|
||||
@@ -106,8 +106,8 @@ const SQUARE_HOLE_STEPS = [
|
||||
},
|
||||
{
|
||||
id: 'square-hole-shapes',
|
||||
label: '生成形状贴图',
|
||||
detail: '为每个可投放形状生成贴图。',
|
||||
label: '生成选项贴图',
|
||||
detail: '为展示选项与洞口选项生成贴图。',
|
||||
weight: 40,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
@@ -225,7 +225,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
...state,
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
: state;
|
||||
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
@@ -248,7 +248,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 0.55
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? 0.42
|
||||
: 0;
|
||||
: 0;
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
@@ -283,7 +283,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: null,
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(steps, activeStepIndex, normalizedState),
|
||||
};
|
||||
@@ -396,7 +396,8 @@ export function buildSquareHoleGenerationAnchorEntries(
|
||||
{
|
||||
key: 'square-hole-options',
|
||||
label: '选项资产',
|
||||
value: totalShapeCount > 0 ? `形状贴图 ${shapeCount}/${totalShapeCount}` : '',
|
||||
value:
|
||||
totalShapeCount > 0 ? `形状贴图 ${shapeCount}/${totalShapeCount}` : '',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
export {
|
||||
listSquareHoleHistoryAssets,
|
||||
squareHoleAssetClient,
|
||||
type SquareHoleHistoryAsset,
|
||||
type SquareHoleImageAssetKind,
|
||||
} from './squareHoleAssetClient';
|
||||
export {
|
||||
deleteSquareHoleWork,
|
||||
getSquareHoleWorkDetail,
|
||||
listSquareHoleGallery,
|
||||
listSquareHoleWorks,
|
||||
publishSquareHoleWork,
|
||||
regenerateSquareHoleWorkImage,
|
||||
squareHoleWorksClient,
|
||||
updateSquareHoleWork,
|
||||
} from './squareHoleWorksClient';
|
||||
|
||||
46
src/services/square-hole-works/squareHoleAssetClient.ts
Normal file
46
src/services/square-hole-works/squareHoleAssetClient.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
export type SquareHoleImageAssetKind =
|
||||
| 'square_hole_cover_image'
|
||||
| 'square_hole_background_image'
|
||||
| 'square_hole_shape_image'
|
||||
| 'square_hole_hole_image';
|
||||
|
||||
export type SquareHoleHistoryAsset = {
|
||||
assetObjectId: string;
|
||||
assetKind: SquareHoleImageAssetKind;
|
||||
imageSrc: string;
|
||||
ownerUserId?: string | null;
|
||||
ownerLabel: string;
|
||||
profileId?: string | null;
|
||||
entityId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取当前账号的方洞图片历史素材。
|
||||
* 素材由后端图片生成链路写入正式资产索引,前端只负责按槽位展示和套用。
|
||||
*/
|
||||
export async function listSquareHoleHistoryAssets(payload: {
|
||||
kind: SquareHoleImageAssetKind;
|
||||
limit?: number;
|
||||
}) {
|
||||
const params = new URLSearchParams({ kind: payload.kind });
|
||||
if (payload.limit) {
|
||||
params.set('limit', String(payload.limit));
|
||||
}
|
||||
|
||||
const response = await requestJson<{ assets: SquareHoleHistoryAsset[] }>(
|
||||
`${ASSET_API_PATHS.assetHistory}?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
'读取方洞历史图片失败',
|
||||
);
|
||||
|
||||
return response.assets;
|
||||
}
|
||||
|
||||
export const squareHoleAssetClient = {
|
||||
listHistoryAssets: listSquareHoleHistoryAssets,
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
PutSquareHoleWorkRequest,
|
||||
RegenerateSquareHoleWorkImageRequest,
|
||||
SquareHoleWorkDetailResponse,
|
||||
SquareHoleWorkMutationResponse,
|
||||
SquareHoleWorksResponse,
|
||||
@@ -91,6 +92,25 @@ export function publishSquareHoleWork(profileId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只重生成某一个方洞挑战图片槽位,不触发 Agent 草稿编译或整稿图片生成进度页。
|
||||
*/
|
||||
export function regenerateSquareHoleWorkImage(
|
||||
profileId: string,
|
||||
payload: RegenerateSquareHoleWorkImageRequest,
|
||||
) {
|
||||
return requestJson<SquareHoleWorkMutationResponse>(
|
||||
`${SQUARE_HOLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}/images/regenerate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成方洞挑战图片失败',
|
||||
{ retry: SQUARE_HOLE_WORKS_WRITE_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前用户的方洞挑战作品,并返回删除后的列表。
|
||||
*/
|
||||
@@ -109,5 +129,6 @@ export const squareHoleWorksClient = {
|
||||
listGallery: listSquareHoleGallery,
|
||||
list: listSquareHoleWorks,
|
||||
publish: publishSquareHoleWork,
|
||||
regenerateImage: regenerateSquareHoleWorkImage,
|
||||
update: updateSquareHoleWork,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user