diff --git a/.hermes/plans/架构优化计划书.md b/.hermes/plans/架构优化计划书.md new file mode 100644 index 00000000..604dc11b --- /dev/null +++ b/.hermes/plans/架构优化计划书.md @@ -0,0 +1,399 @@ +# Genarrative(陶泥儿)项目架构优化计划书 + +**文档版本**:v1.0 +**编制日期**:2026-05-26 +**项目名称**:Genarrative(陶泥儿) +**文档密级**:内部 + +--- + +## 一、项目概况 + +| 维度 | 详情 | +|---|---| +| **项目名** | Genarrative(陶泥儿) | +| **项目定位** | AI Native 互动视觉 RPG 平台——支持多种玩法模板的 AI 创作、运行与分享("玩法类型平台") | +| **核心玩法** | 拼图、视觉小说、Match3D、Bark Battle、Big Fish、Jump-Hop、Square Hole、木鱼、教娱等 10+ 种玩法模板 | +| **代码规模** | 前端 ~823 个 TS/TSX 文件,后端 ~1532 个 Rust 文件,属于大型项目 | + +### 1.1 计划目的 + +本计划书基于对 Genarrative 项目当前架构的全面分析,识别架构层面的关键问题,并提出分阶段、可落地的优化方案。旨在: + +- 统一前端架构模式,降低团队认知成本和新人上手门槛 +- 提升模块内聚性,减少不必要的耦合与依赖 +- 建立自动化契约保障机制,降低跨语言同步出错风险 +- 优化工程基础设施,提高开发效率和运维可观测性 + +--- + +## 二、技术栈总览 + +| 层级 | 技术选型 | 版本 | +|---|---|---| +| **前端框架** | React + TypeScript + Vite | React 19 / TS 5.8 / Vite 6 | +| **样式** | TailwindCSS | v4 | +| **3D / 动画** | Three.js、Motion、cannon-es | - | +| **后端 HTTP** | Rust + Axum(BFF 门面) | Axum 0.8 | +| **游戏状态 DB** | SpacetimeDB(实时反应式数据库) | v2.2 | +| **AI / LLM** | LangChain-Rust + LLM Proxy | - | +| **小程序** | 微信小程序(含微信支付) | - | +| **容器化** | Docker Compose(Nginx + API Server + OTel Collector) | - | +| **运维** | systemd、Nginx、Jenkins CI/CD、k6 压测 | - | +| **可观测性** | OpenTelemetry(OTLP → Grafana) | - | + +--- + +## 三、当前架构分层图 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 入口层 │ +│ index.html → main.tsx → resolveAppRoute() → RouteComponent │ +│ (多入口路由:平台主页 / 拼图 / BigFish / Match3D / ...) │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ 前端应用层 (src/) │ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐│ +│ │ components/ │ │ services/ │ │ games/ ││ +│ │ *-creation │ │ *-creation │ │ bark-battle/ ││ +│ │ *-result │ │ *-runtime │ │ domain/ ││ +│ │ *-runtime │ │ *-works │ │ application/ ││ +│ │ common/ │ │ storyEngine │ │ infrastructure/ ││ +│ │ auth/ │ │ payment │ │ ui/ ││ +│ └─────────────┘ └──────────────┘ └──────────────────────┘│ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐│ +│ │ hooks/ │ │ data/ │ │ routing/ ││ +│ │ persistence/│ │ functionCat. │ │ config/ editor/ ││ +│ └─────────────┘ └──────────────┘ └──────────────────────┘│ +└──────────────────────────────────────────────────────────────┘ + │ Vite Proxy (/api/*) + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Rust 后端 (server-rs/) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ api-server (Axum HTTP / SSE / BFF 门面) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────────────┐ ┌──────────────┐ │ +│ │ platform │ │ module-* │ │ shared │ │ +│ │ -auth │ │ -puzzle │ │ -contracts │ │ +│ │ -llm │ │ -visual-novel │ │ -kernel │ │ +│ │ -image │ │ -match3d │ │ -logging │ │ +│ │ -oss │ │ -bark-battle │ └──────────────┘ │ +│ │ -speech │ │ -big-fish ... │ │ +│ │ -agent │ │ -runtime │ │ +│ └──────────┘ │ -combat/npc │ │ +│ └──────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ spacetime-module + spacetime-client → SpacetimeDB │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ 辅助子系统 │ +│ apps/admin-web (React 后台管理) │ +│ miniprogram/ (微信小程序) │ +│ packages/shared (前后端共享契约/LLM工具) │ +│ deploy/ (Docker/Nginx/systemd/OTel) │ +│ scripts/ (40+ 构建/部署/检查脚本) │ +│ jenkins/ (CI/CD Pipeline) │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、架构亮点 + +1. **清晰的"玩法模板"平台模式** + 每种玩法遵循统一的 `creation → result → runtime` 三段式生命周期,前端 `components/` 和 `services/` 均按此模式组织。新增玩法可快速套用模板,极大降低了横向扩展成本。 + +2. **后端严格的分层约束** + `api-server`(门面)→ `module-*`(领域)→ `spacetime-module`(持久化),`module-*` 不直接依赖 Axum、HTTP、SpacetimeDB table、LLM、文件系统,保证了领域纯净性和可测试性。 + +3. **SpacetimeDB 作为游戏状态核心** + 采用反应式实时数据库替代传统 Redis + PostgreSQL 组合,天然适合多人实时游戏状态同步,减少了中间层复杂度和延迟。 + +4. **前端 DDD 探索** + `src/games/bark-battle/` 采用 `domain / application / infrastructure / ui` 四层 DDD 结构,为复杂玩法的前端架构提供了良好范本。 + +5. **完善的多入口路由体系** + 通过 `resolveAppRoute()` 按 URL path 分发到不同的 lazy-loaded 组件,实现了按需加载和良好的首屏性能。 + +6. **运维体系完备** + Docker Compose + systemd + Nginx + OpenTelemetry + k6 压测 + Jenkins CI/CD,覆盖了构建、部署、监控、压测全链路。 + +--- + +## 五、问题诊断与改进方案 + +### 问题 1:前端 services/ 与 components/ 的耦合不统一 + +**现状** +大部分玩法遵循 `services/` + `components/` 分离模式,但部分玩法运行时直接放在 `components/` 下,services 层职责模糊——有的承担了业务逻辑编排,有的仅作简单 API 调用。 + +**影响** +- 新人阅读代码时无法预判某个逻辑应位于哪个目录 +- 单元测试困难:services 与组件耦合的业务逻辑无法独立测试 +- 跨玩法复用时需要额外的迁移成本 + +**改进方案** +1. 制定规范:`services/` 仅负责纯 API 调用和数据转换,不包含业务判断逻辑 +2. 业务逻辑统一归到 `hooks/` 或 `games//application/` +3. 以 puzzle 玩法为样板,先行重构并形成迁移指南,再推广至其余玩法 +4. 在 CI 中增加 ESLint 规则,禁止 `components/` 直接 import `services/` 之外的外部模块 + +**预期收益** +- 职责边界清晰,降低认知成本约 30% +- services 层可独立单测覆盖率达到 80%+ +- 新玩法开发上手时间从 2 天缩短至 0.5 天 + +--- + +### 问题 2:前端 DDD 与模板模式并存,架构不统一 + +**现状** +`bark-battle` 采用 DDD 四层结构(`domain/application/infrastructure/ui`),其余玩法散落在 `components/` + `services/` 下,存在两种截然不同的组织范式。 + +**影响** +- 团队内部对"正确"的代码组织方式缺乏共识 +- 代码审查时需要切换判断标准 +- DDD 玩法的优势无法在全局范围内发挥 + +**改进方案** +1. 所有玩法统一迁移到 `src/games//` 下,采用 `domain / application / ui` 三层结构(infrastructure 按需保留) +2. 以 puzzle 为样板完成首例迁移,产出迁移 Checklist 和模板生成脚本 +3. 新增玩法脚手架直接生成 DDD 结构目录 +4. 旧玩法分批次迁移,每批次 2-3 个玩法,在 4 个迭代内完成 + +**预期收益** +- 架构一致性提升至 100% +- 跨玩法逻辑复用变得可能(domain 层可共享) +- 为后续 monorepo 改造打下基础 + +--- + +### 问题 3:后端 module-* 粒度偏细,存在潜在循环依赖风险 + +**现状** +后端共 35 个 crate。`module-runtime` 被拆分为 3 个独立 crate,`module-combat / npc / inventory` 各自独立。部分紧密协作的模块之间可能存在隐式耦合。 + +**影响** +- 编译时间增长(35 个 crate 独立编译) +- 跨 crate 重构时需要同时修改多处 +- 循环依赖风险增加,可能在特定组合下触发编译失败 + +**改进方案** +1. 评估合并方案: + - `module-runtime-*` 系列合并为单 crate `module-runtime`,内部用 `mod` 做逻辑隔离 + - `module-combat / npc / inventory` 评估合并为 `module-combat`,npc 和 inventory 作为子模块 +2. 保留 trait/interface 抽象层,确保 module 之间不直接依赖具体实现 +3. 在 CI 中引入 `cargo-deny` 或自定义脚本,自动检测 module 间的依赖方向是否违反分层约束 +4. 目标:将 35 个 crate 精简至 25 个以内 + +**预期收益** +- 全量编译时间预计缩短 15%-20% +- 循环依赖风险归零 +- module 内部重构成本降低 + +--- + +### 问题 4:根目录 env 文件过多且混乱 + +**现状** +根目录存在 4 个 env 文件(`.env`、`.env.example`、`.env.production` 等),且 `deploy/` 下另有多个 env 文件。配置分散在多处,部分文件之间字段不一致。 + +**影响** +- 排查配置问题时需要翻阅多个文件 +- 新人无法快速确定本地开发需要哪些环境变量 +- 部署时可能遗漏或错误覆盖某项配置 + +**改进方案** +1. 收敛为三层配置体系: + - `.env.example`:包含所有可配置项的说明和默认值(唯一提交到仓库的 env 文件) + - `.env.local`:本地开发覆盖(加入 .gitignore) + - `deploy/env/.env`:各部署环境专用配置 +2. 引入 config crate,支持层次覆盖(default → local → env-specific),启动时自动校验必填字段 +3. 在 CI 中加入 env 校验步骤:对比 `.env.example` 与部署环境的 env 文件,标记缺失或多余字段 + +**预期收益** +- 配置查找时间从分钟级降至秒级 +- 部署配置遗漏导致的线上事故减少 90%+ +- 新成员本地环境搭建时间从 30 分钟缩短至 10 分钟 + +--- + +### 问题 5:scripts/ 目录膨胀为"万能工具箱" + +**现状** +`scripts/` 目录共 42 个文件,涵盖构建、部署、检查、迁移、生成、压测等,全部平铺在同一层级,缺乏分类。 + +**影响** +- 难以快速定位所需脚本 +- 同类脚本缺乏命名规范 +- 新增脚本时不知道放在何处 + +**改进方案** +1. 按功能域分类重组: + +``` +scripts/ +├── build/ # 构建相关(vite、cargo、wasm 等) +├── deploy/ # 部署相关(docker、systemd、rsync) +├── check/ # 检查/校验(lint、format、type-check) +├── spacetime/ # SpacetimeDB 相关(migration、seed) +├── generate/ # 代码生成(scaffold、proto、types) +└── loadtest/ # 压测脚本(k6 配置及辅助) +``` + +2. 为每个子目录添加 README.md,说明各脚本用途和调用方式 +3. 将重复逻辑抽取为共享函数库 + +**预期收益** +- 脚本查找效率提升 60%+ +- 降低脚本重复概率 +- 便于 CI Pipeline 直接引用标准化路径 + +--- + +### 问题 6:前端路由系统缺乏统一的"玩法注册"机制 + +**现状** +`appRoutes.tsx` 中硬编码 `switch-case` 逻辑,每新增一种玩法需手动修改路由文件、入口组件、资源加载等多个位置。 + +**影响** +- 新增玩法的接入点分散,容易遗漏 +- 路由文件随玩法增多持续膨胀 +- 无法实现"按需注册"——即使某环境不包含某玩法,路由代码仍然存在 + +**改进方案** +1. 建立 `PlayTypeRegistry` 模式:每个玩法导出一个注册项对象,包含 `path`、`lazyComponent`、`preload` 等字段 +2. `resolveAppRoute()` 改为动态聚合所有注册项,替代硬编码 switch-case +3. 支持环境级玩法开关:通过配置控制某环境启用哪些玩法,路由系统自动忽略未启用的 + +**预期收益** +- 新增玩法零侵入路由系统,只需在玩法目录内添加注册文件 +- 路由文件体积与玩法数量解耦 +- 灰度发布和 A/B 测试变得可能 + +--- + +### 问题 7:前端缺少统一的状态管理层 + +**现状** +前端状态管理依赖 React hooks + props drilling + services 层手动管理。未使用任何状态管理库(如 Zustand、Jotai、Redux)。 + +**影响** +- 全局状态(用户认证、会话、通知)通过多层 props 传递,组件耦合度高 +- 跨页面状态无法优雅共享 +- 状态变更难以追踪和调试 + +**改进方案** +1. 引入 Zustand(轻量、无 boilerplate、TS 友好)管理全局状态: + - `useAuthStore`:认证状态 + - `useSessionStore`:当前会话/游戏状态 + - `useNotificationStore`:全局通知 +2. 玩法内状态继续使用 React hooks + `useReducer`,保持局部自治 +3. 全局 store 与玩法内 state 通过事件总线松耦合通信 + +**预期收益** +- props drilling 层级从 5+ 层降至 1-2 层 +- 全局状态可追溯,支持 Redux DevTools 调试 +- 跨玩法状态共享(如用户余额、道具)变得自然 + +--- + +### 问题 8:shared-contracts 的实际复用程度待验证 + +**现状** +前后端通过 `packages/shared` 共享 DTO 类型定义,但依赖手动同步 TypeScript 类型。可能存在前后端契约不一致但编译期无法检出的情况。 + +**影响** +- 后端修改 DTO 字段后,前端可能遗漏更新导致运行时错误 +- 手动同步耗时且易出错 +- Code Review 时难以判断契约一致性 + +**改进方案** +1. 引入 `ts-rs`:从 Rust 结构体自动生成 TypeScript 类型定义 +2. 将生成步骤集成到 CI Pipeline: + - 每次 Rust PR 触发 `ts-rs` 重新生成 TS 类型 + - 对比生成的类型与仓库中的类型是否一致,不一致则 CI 失败 +3. 长期考虑引入 Protobuf / OpenAPI 作为跨语言契约的单一事实来源 + +**预期收益** +- 前后端契约不一致导致的线上 bug 减少 95%+ +- 手动同步工作量归零 +- PR Review 时契约一致性问题自动拦截 + +--- + +## 六、改进优先级路线图 + +| 优先级 | 改进项 | 涉及层 | 建议时间 | 预期收益 | +|---|---|---|---|---| +| **P0** | 统一前端 services/hooks/components 职责边界 | 前端 | 第 1-2 周 | 降低认知成本,提升可测试性 | +| **P1** | 建立 PlayType 注册机制 | 前端 | 第 2-3 周 | 新增玩法零侵入路由 | +| **P1** | 评估 module-* 合并方案并执行 | 后端 | 第 3-4 周 | 减少编译时间 15%-20% | +| **P1** | 引入 Zustand 全局状态管理 | 前端 | 第 4-5 周 | 改善状态追踪与跨组件共享 | +| **P2** | 清理 scripts/ 目录结构 | 工程 | 第 5-6 周 | 提高可发现性 | +| **P2** | 前端玩法统一迁移至 DDD 结构 | 前端 | 第 5-8 周 | 架构一致性 100% | +| **P2** | env 配置收敛 | 工程 | 第 6-8 周 | 减少部署配置事故 | +| **P3** | 前后端共享 DTO 自动化(ts-rs) | 全栈 | 第 6-8 周 | 消除契约不一致风险 | +| **P3** | CI 分层约束检查(cargo-deny) | 后端 | 第 8-10 周 | 循环依赖归零 | + +> **说明**:P0 为阻塞项,必须最先完成。P1 项可部分并行推进(PlayType 注册与 module 合并互不依赖)。P2/P3 为优化项,可在日常迭代中穿插推进。 + +--- + +## 七、架构健康度评分卡 + +| 维度 | 评分 | 当前状态 | 目标状态 | +|---|---|---|---| +| **分层清晰度** | ★★★★☆ | 后端分层严格,前端分层存在不一致 | ★★★★★ 前后端均严格分层 | +| **模块化程度** | ★★★★☆ | 后端 35 crate 粒度偏细,前端结构化较好 | ★★★★☆ 后端精简至 25 crate | +| **可扩展性** | ★★★★★ | 玩法模板模式使新增玩法成本低 | ★★★★★ 维持 | +| **代码复用** | ★★★☆☆ | shared 层作用有限,services 层有重复 | ★★★★☆ DDD 统一后 domain 可复用 | +| **DevOps 成熟度** | ★★★★★ | Docker + k6 + OTel + Jenkins 覆盖完整 | ★★★★★ 维持 | +| **文档完备性** | ★★★★★ | docs/ 分类清晰,基线文档齐全 | ★★★★★ 维持 | +| **技术债务管控** | ★★★★☆ | 有明确的"历史残留"标记和废弃策略 | ★★★★★ 增加自动化检测 | + +**综合评级:A-(优秀,存在可优化空间)** + +--- + +## 八、附录:代码规模统计 + +| 维度 | 数量 | +|---|---| +| **前端 TypeScript/TSX 文件** | ~823 个 | +| **后端 Rust 源文件** | ~1532 个 | +| **后端 Crate 数量** | 35 个 | +| **核心玩法类型** | 10+ 种 | +| **scripts/ 脚本数量** | 42 个 | +| **根目录 env 文件** | 4 个 + deploy 下多个 | + +### 模块规模明细(后端) + +| Crate | 职责 | 建议 | +|---|---|---| +| `api-server` | Axum HTTP 门面,路由聚合 | 保持 | +| `platform-*` (auth/llm/image/oss/speech/agent) | 平台级跨玩法能力 | 保持 | +| `module-puzzle` | 拼图玩法 | 作为 DDD 迁移样板 | +| `module-visual-novel` | 视觉小说 | 后续迁移 | +| `module-match3d` | Match3D 三消 | 后续迁移 | +| `module-bark-battle` | 犬吠对战 | 已对接前端 DDD | +| `module-big-fish` | Big Fish | 后续迁移 | +| `module-runtime*` (3 crates) | 通用运行时 | **建议合并为单 crate** | +| `module-combat / npc / inventory` | 战斗系统 | **建议合并** | +| `spacetime-module` + `spacetime-client` | SpacetimeDB 接入 | 保持 | +| `shared-contracts / kernel / logging` | 共享基础设施 | 保持 | + +--- + +> **文档结束** +> 本计划书由 Genarrative 架构分析报告衍生,所有改进项均基于对当前项目代码库的实际分析。执行过程中如遇阻力或新发现,应及时更新本计划书并同步相关方。 \ No newline at end of file diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 51c774ff..eb5da0c9 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -40,6 +40,22 @@ - 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应断言任务卡显示 `1 / 1`、领取后显示已完成,且新用户账号也没有 `次级入口` / `填邀请码` 常驻按钮;`npm run typecheck`、`npm run check:encoding` 通过。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态 + +- 背景:本轮抓大鹅资源管线已经生成完整 `level-scene`、背景、UI spritesheet、物品 spritesheet 和切片资源,需要放入发现页作为可试玩验证入口,但不应把一次性本地资源包装成后端正式作品。 +- 决策:发现页官方抓大鹅 demo 固定 profileId 为 `match3d-demo-20260525`、公开作品号为 `M3-20260525`,资源读取 `public/match3d-demo/undersea-candy-market/` 下的静态文件。公开卡片、作品号搜索和详情页沿用平台公开作品详情链路;启动运行态时用 `createLocalMatch3DRuntimeAdapter`,不调用正式 Match3D runtime 后端、不新增 SpacetimeDB schema、不写正式作品统计。 +- 影响范围:`src/data/match3dDemoGalleryCard.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、发现页公开卡片、作品号搜索、Match3D 本地 runtime adapter、玩法链路文档。 +- 验证方式:搜索 `M3-20260525` 能打开“海底糖果集市”并启动本地抓大鹅运行态;正式 Match3D 公开作品仍走 server runtime adapter。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式 + +- 背景:抓大鹅游玩阶段 UI 需要继续对齐拼图运行态的观感,同时移除右上角设置入口、灰白半透底板和显眼锅壳,让棋盘区域更专注。 +- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo.png` 产品 logo;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。 +- 影响范围:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`src/index.css`、抓大鹅玩法链路文档。 +- 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-25 平台首页推荐按桌面与移动断点分流 - 背景:平台首页的推荐页在桌面与移动端之间原先共用同一套推荐运行态逻辑,容易让桌面和移动两套内容同时启动,也让首页的推荐卡与桌面发现壳互相抢状态。 @@ -56,6 +72,22 @@ - 验证方式:玩法接入 PRD 和实现验收必须列出作品架链路;若一个玩法具备发布或试玩能力,但缺少 `/api/creation//works`、前端 client `listWorks`、`CustomWorldCreationHub` props、`creationWorkShelf` adapter 或草稿 / 已发布作品架测试,则接入不算完成。 - 关联文档:`AGENTS.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-05-26 统一公开作品主读模型收口 + +- 背景:各玩法原有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 已经足够承载各自 source 投影,但公开列表 / 详情在 `api-server` 侧分散拼装会继续放大重复逻辑和契约漂移。 +- 决策:新增跨玩法统一公开主读模型 `public_work_gallery_entry` 与 `public_work_detail_entry`。各玩法旧公开 view 不删除,退为 source / 兼容路径;`api-server` 公开列表与详情主路径统一读 public view cache,再映射回现有 HTTP DTO。前端首期仍不直接订阅 SpacetimeDB,只走 BFF HTTP。 +- 影响范围:`server-rs/crates/spacetime-module`、`server-rs/crates/spacetime-client`、`server-rs/crates/api-server`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。 +- 验证方式:`SELECT * FROM public_work_gallery_entry` 与 `SELECT * FROM public_work_detail_entry` 可作为 `api-server` 长期订阅目标;`/api/public-works` 与 `/api/public-works/{publicWorkCode}` 走统一 cache;旧 `/api/runtime//gallery` 响应 shape 保持兼容。 +- 关联文档:`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态 + +- 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。 +- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品。 +- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。 +- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-24 创作 Tab banner 轮播只展示主题赛 - 背景:创作 Tab banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致首屏出现 58000 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。 @@ -1000,3 +1032,11 @@ - 影响范围:`WoodenFishWorkspace`、`WoodenFishResultView`、`PlatformEntryFlowShellImpl`、敲木鱼 PRD 和平台入口链路文档。 - 验证方式:工作台首屏不再出现标题 / 简介 / 标签输入;结果页修改后点试玩或发布会先写回当前作品信息。 - 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-26 前端不外露图片模型名 + +- 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2`、`gemini-3.1-flash-image-preview`、`image-2` 等名称,会把内部模型路由暴露给普通用户。 +- 决策:前端展示层统一改用产品化名称,如“标准模式”“创意模式”,以及“素材”“图片生成模式”等中性文案;内部 `imageModel`、`generationProvider` 和后端契约值保留不变,只改 UI 文案与错误提示。 +- 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。 +- 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f086ebc2..538880eb 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -55,6 +55,14 @@ - 验证:`npm run test -- src/services/creationUrlState.test.ts src/routing/appPageRoutes.test.ts src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx`;手测生成页 / 结果页刷新仍恢复同一草稿,打开公开作品详情 URL 不带私有恢复参数。 - 关联:`src/services/creationUrlState.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 草稿作品架打开结果页返回必须回草稿 Tab + +- 现象:从草稿 Tab 作品架点击已有草稿进入结果页后,点结果页返回会跳回创作 Tab 模板入口,用户需要重新切回草稿页才能继续找原草稿。 +- 原因:平台壳层只按结果页类型硬编码返回创作入口,没有记录本次创作流是从草稿作品架打开;如果来源标记没有在新建入口时重置,还可能污染下一条创作链路。 +- 处理:从作品架打开任一玩法草稿时标记返回目标为 `draft-shelf`;从创作 Tab 新建、打开模板或退出非草稿来源工作区时重置为 `create`;结果页返回和工作区退出统一消费这个返回目标,并在消费后复位。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft result back button returns to draft hub when opened from shelf|agent draft result back button returns to draft hub without syncing result profile"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 拼图生成页轮询不要绑展示 phase 或不稳定 setter - 现象:拼图创作进入生成中页后,`/api/runtime/puzzle/agent/sessions/{sessionId}` 会在 0.3 到 0.5 秒内被反复 GET,看起来像轮询风暴,而不是 3 秒一次的正常刷新。 @@ -1560,6 +1568,14 @@ - 验证:`curl.exe -i http://127.0.0.1:8082/api/creation-entry/config` 返回 `200` 且包含 `baby-object-match`;前端草稿页作品架重新渲染。 - 关联:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/creation_entry_config.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 抓大鹅物品 spritesheet 偏移先查 alpha 连通域切片是否启用 + +- 现象:抓大鹅物品图集里大多数素材显示不全、被裁碎、位置整体偏移,甚至切出来像拼贴块。 +- 原因:旧链路只按 `10x10` 固定格线裁切,遇到模型输出的透明图集稍有偏移、跨格或留白不均时就会把主体切坏。现在后端优先按透明 alpha 连通域识别真实素材矩形,再按原图从上到下、从左到右排序;只有识别数量不足时才回退旧网格切法。 +- 处理:优先检查 `generated_asset_sheets.rs` 的 alpha 连通域切片是否生效,再查 `item_assets.rs` 是否还在透传旧的固定格线语义。不要只改前端显示比例。 +- 验证:定向测试 `cargo test -p api-server generated_asset_sheet_two_items_per_row --manifest-path server-rs/Cargo.toml -- --nocapture` 应通过,且错位透明样本应按连通域切出完整视图。 +- 关联:`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`server-rs/crates/api-server/src/match3d/item_assets.rs`。 + ## 个人中心不再保留直达“存档”按钮入口 - 现象:2026-05-25 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。 diff --git a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md new file mode 100644 index 00000000..faa08e20 --- /dev/null +++ b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md @@ -0,0 +1,92 @@ +# 统一公开作品 ReadModel 设计 + +更新时间:`2026-05-26` + +## 背景 + +各玩法原本各自维护 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 等公开投影。它们继续保留为 source view 和兼容路径,但公开列表与公开详情的主读模型需要跨玩法统一,避免 `api-server` 在 HTTP 热路径里为每个玩法各写一套拼装逻辑。 + +## 统一契约 + +公开列表主读模型: + +- `public_work_gallery_entry` + +公开详情摘要主读模型: + +- `public_work_detail_entry` + +统一字段只保留公开层契约所需内容: + +- `source_type` +- `work_id` +- `profile_id` +- `source_session_id` +- `public_work_code` +- `owner_user_id` +- `author_display_name` +- `world_name` +- `subtitle` +- `summary_text` +- `cover_image_src` +- `cover_asset_id` +- `theme_tags` +- `play_count` +- `remix_count` +- `like_count` +- `published_at_micros` +- `updated_at_micros` +- `sort_time_micros` +- `detail_payload_json` + +其中 `detail_payload_json` 只承载平台详情页展示扩展,不承载正式 runtime 配置、玩法规则或草稿真相。 + +## 来源与兼容 + +统一 public view 由现有玩法 source view 组装: + +- `puzzle_gallery_card_view` +- `puzzle_gallery_view` +- `custom_world_gallery_entry` +- `jump_hop_gallery_card_view` +- `jump_hop_gallery_view` +- `wooden_fish_gallery_card_view` +- `wooden_fish_gallery_view` +- `match_3_d_gallery_view` +- `square_hole_gallery_view` +- `visual_novel_gallery_view` +- `big_fish_gallery_view` +- `bark_battle_gallery_view` + +规则是: + +- 旧 view 保留,不删除。 +- 旧 view 退到底层 source / 兼容职责。 +- 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。 +- 旧 `/api/runtime//gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。 +- 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。 + +## 订阅与路由 + +`spacetime-client` 当前长期订阅: + +- `SELECT * FROM public_work_gallery_entry` +- `SELECT * FROM public_work_detail_entry` +- `SELECT * FROM public_work_play_daily_stat` +- 各玩法 source view 作为兼容缓存和旧路径支撑 + +`api-server` 当前新增统一公开路由: + +- `GET /api/public-works` +- `GET /api/public-works/{publicWorkCode}` + +旧 route 继续保留,由 BFF 从统一 cache 映射回旧 DTO 形状。 + +## 验证 + +- `npm run spacetime:generate` +- `npm run check:spacetime-schema` +- `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml` +- `cargo check -p api-server --manifest-path server-rs/Cargo.toml` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 24a75120..206c98b5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -131,7 +131,7 @@ npm run check:server-rs-ddd 3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。 4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option>` 加 `#[default(None::>)]`,业务层归一为空数组。 5. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。 -6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅公开统计表后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍可走 procedure / reducer。中期如要让前端可选直连订阅,只能新增或统一稳定的专用 public read model,例如 `public_work_gallery_entry`,并保持字段、排序键、公开权限和降级语义由后端投影定义;前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 仍由 `api-server` BFF 维持。 +6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。 7. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。 8. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。 9. 修改后运行: @@ -160,7 +160,7 @@ npm run check:server-rs-ddd - LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。 -- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端固定从该 sheet 解析并持久化 20 个物品、每个 5 个形态;通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 +- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 @@ -304,7 +304,7 @@ npm run check:server-rs-ddd - Rust view:`big_fish_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/big_fish/session.rs` -- 说明:大鱼吃小鱼公开广场列表投影,只从 `Published` creation session 组装公开卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM big_fish_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'` 后,从本地 cache 构造 `/api/runtime/big-fish/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_big_fish_works` procedure;个人作品列表、详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 +- 说明:大鱼吃小鱼公开 source 投影,只从 `Published` creation session 组装公开卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。玩法旧 gallery 路径保留兼容 shape;个人作品列表、详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 ### `chapter_progression` @@ -350,7 +350,7 @@ npm run check:server-rs-ddd - Rust 结构体:`CustomWorldGalleryEntry` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` -- 作用:自定义世界公开作品列表读模型。`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM custom_world_gallery_entry` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'`,`/api/runtime/custom-world-gallery` 从本地 cache 排序并聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_custom_world_gallery_entries` procedure。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。 +- 作用:自定义世界公开 source 读模型。统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该投影并映射成跨玩法契约;`/api/runtime/custom-world-gallery` 保留旧 HTTP shape,并从统一 public cache 映射回旧 DTO。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。 ### `custom_world_profile` @@ -402,14 +402,14 @@ npm run check:server-rs-ddd - Rust view:`jump_hop_gallery_card_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs` -- 说明:跳一跳公开广场列表卡片投影,只暴露 `publication_status = Published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM jump_hop_gallery_card_view` 后,从本地 cache 构造跳一跳公开列表响应。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。 +- 说明:跳一跳公开列表 source 投影,只暴露 `publication_status = Published` 的作品卡片字段;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。 ### SpacetimeDB view:`jump_hop_gallery_view` - Rust view:`jump_hop_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs` -- 说明:跳一跳公开详情兼容投影,包含作品、路径和素材字段;公开列表主路径优先使用 `jump_hop_gallery_card_view`。 +- 说明:跳一跳公开详情兼容投影,包含作品、路径和素材字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。 ### `wooden_fish_agent_session` @@ -437,14 +437,14 @@ npm run check:server-rs-ddd - Rust view:`wooden_fish_gallery_card_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/wooden_fish.rs` -- 说明:敲木鱼公开广场列表卡片投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM wooden_fish_gallery_card_view` 后,从本地 cache 构造敲木鱼公开列表响应。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。 +- 说明:敲木鱼公开列表 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。 ### SpacetimeDB view:`wooden_fish_gallery_view` - Rust view:`wooden_fish_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/wooden_fish.rs` -- 说明:敲木鱼公开详情兼容投影,包含敲击物图案、背景环境图、主题返回按钮图、敲击音效和飘字配置;公开列表主路径优先使用 `wooden_fish_gallery_card_view`。 +- 说明:敲木鱼公开详情兼容投影,包含敲击物图案、背景环境图、主题返回按钮图、敲击音效和飘字配置;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。 ### `match3d_agent_message` @@ -471,7 +471,7 @@ npm run check:server-rs-ddd - Rust view:`match3d_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/match3d.rs` -- 说明:抓大鹅公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM match_3_d_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'` 后,从本地 cache 构造 `/api/runtime/match3d/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_match3d_works` procedure;个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 +- 说明:抓大鹅公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 ### `npc_state` @@ -604,14 +604,14 @@ npm run check:server-rs-ddd - Rust view:`puzzle_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` -- 说明:拼图广场公开详情兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;公开列表主路径不再订阅该 view。 +- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。 ### SpacetimeDB view:`puzzle_gallery_card_view` - Rust view:`puzzle_gallery_card_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` -- 说明:拼图广场公开列表卡片投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM puzzle_gallery_card_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 后,从本地 cache 构造 `/api/runtime/puzzle/gallery` 响应,并在本地按当前请求时间聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 +- 说明:拼图公开列表 source 投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view,`/api/runtime/puzzle/gallery` 保留旧 HTTP shape,并从统一 public cache 映射回 `PuzzleGalleryResponse`。 ### 拼图公开列表 HTTP 窗口缓存 @@ -624,26 +624,31 @@ npm run check:server-rs-ddd `spacetime-client` 建立每个池连接时会等待下列订阅初始同步: +- `SELECT * FROM public_work_gallery_entry` +- `SELECT * FROM public_work_detail_entry` +- `SELECT * FROM bark_battle_gallery_view` - `SELECT * FROM puzzle_gallery_card_view` +- `SELECT * FROM jump_hop_gallery_card_view` +- `SELECT * FROM wooden_fish_gallery_card_view` - `SELECT * FROM custom_world_gallery_entry` - `SELECT * FROM match_3_d_gallery_view` - `SELECT * FROM square_hole_gallery_view` - `SELECT * FROM visual_novel_gallery_view` - `SELECT * FROM big_fish_gallery_view` -- `SELECT * FROM jump_hop_gallery_card_view` - -下列订阅用于统计或配置缓存,订阅失败不会让公开列表连接整体不可用,调用方保留兼容兜底: - - `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'` +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'` - `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'` - `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'` - `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'` - `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'` - `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'` +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'` - `SELECT * FROM creation_entry_config` - `SELECT * FROM creation_entry_type_config` +- `SELECT * FROM asset_object` -拼图、自定义世界、抓大鹅、方洞挑战、视觉小说和大鱼吃小鱼的公开列表 HTTP 路由都从订阅 cache 读取公开 read model / view。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。 +跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry` 与 `public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape,由 BFF mapper 从统一 public cache 映射回当前 DTO;旧 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。 `GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。 入口配置快照包含 start card、类型弹窗、活动横幅和入口类型列表;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。 @@ -652,7 +657,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; 结构化创作和 RPG 的 LLM JSON 链路默认不启用 Responses `web_search`;只有在明确需要联网增强时,才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` 显式打开。否则未开通工具的上游会先吐自然语言再返回 `ToolNotOpen`,这类失败要按上游工具不可用处理,不要误判成模型返回结果解析失败。 -未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表;BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback。 +统一公开作品 BFF 路由是 `GET /api/public-works` 与 `GET /api/public-works/{publicWorkCode}`,响应契约由 `shared-contracts::public_work` 和 `packages/shared/src/contracts/publicWork.ts` 共同维护。前端首期仍走 BFF HTTP,不直接订阅 SpacetimeDB;后续若允许浏览器直连订阅,也只能订阅 `public_work_gallery_entry` / `public_work_detail_entry` 这类稳定公开 read model,不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表。设计细节见 `docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。 ### `quest_log` @@ -704,7 +709,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - Rust view:`square_hole_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/square_hole.rs` -- 说明:方洞挑战公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM square_hole_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'` 后,从本地 cache 构造 `/api/runtime/square-hole/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_square_hole_works` procedure;个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 +- 说明:方洞挑战公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 ### `story_event` @@ -779,4 +784,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - Rust view:`visual_novel_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs` -- 说明:视觉小说公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM visual_novel_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'` 后,从本地 cache 构造 `/api/runtime/visual-novel/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_visual_novel_works` procedure;个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。 +- 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 9899a199..d427a240 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -46,7 +46,8 @@ 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。 6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。 -7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 +7. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。 +8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 @@ -93,7 +94,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。 - 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进,总进度按 `0-88`、`88-94`、`94-96`、`96-98` 的真实里程碑区间平滑推进。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。 - 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。 -- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用进入生成页的当前时间作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。 +- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用进入生成页的当前时间作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。 +- 前端创作、结果页、生成页和错误提示不展示 GPT / Gemini 等具体模型名称;如需在内部保留模型路由,UI 只使用“标准模式”“创意模式”等产品化名称。 - 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。 - 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。 - 每次新建关卡生成或重新生成关卡图都必须由 `api-server` 串起当前关卡资产包:AI 重绘开启时第一段沿用草稿生成第一关的拼图主图提示词配置和模型 / 尺寸 / 参考图规则生成 `coverImageSrc/coverAssetId` 作为关卡拼图画面和结果页预览图,提示词来源同样按显式画面描述、关卡画面描述、草稿摘要顺序回退,且固定要求输出画面比例为 `1:1`;上传图且关闭 AI 重绘时跳过这一段,把上传图或历史图持久化为 `sourceType=uploaded` 的正式候选。随后用正式候选图作为参考,`9:16` 生成完整拼图游戏关卡画面并写入 `levelSceneImageSrc/levelSceneImageObjectKey`,提示词必须要求道具按钮上不要显示次数标注,且返回按钮和设置按钮旁禁止标注文字;UI spritesheet 与关卡纯背景在关卡画面完成后并发生成,spritesheet 用 `1:1`、`1k` 先生成纯绿色绿幕背景图,后端上传 OSS 前必须把绿幕扣成透明 PNG,再写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,按钮顺序固定为返回、设置、下一关、提示、原图、冻结,按钮素材自身保留对应中文文字,返回和设置按钮不得额外生成白色外圈、白底圆环或浮雕外框;纯背景用 `9:16`、`1k` 写入 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,提示词必须包含“禁止在背景中出现人像或和拼图画面中主体一致的内容”。运行态不直接使用第二段完整关卡画面,但必须持久化它用于追踪和后续再生成。结果页局部关卡生成进度按 AI 重绘开启约 270 秒、关闭 AI 重绘约 180 秒展示。 @@ -111,6 +113,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。 - 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。 - 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。 +- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。 - 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 @@ -194,8 +197,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮。 4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K`、`1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`;`1K`、`9:16` 的背景图写入 `imageSrc/imageObjectKey`;`2K`、`1:1` 的物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。 5. UI spritesheet 提示词固定要求按从上到下、从左到右整理纯绿色绿幕背景素材:返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮;后端上传 OSS 前必须把绿幕扣成透明 PNG。背景图提示词固定要求移除全部 UI 组件和容器内含物,完整保留容器和背景,并补全被 UI 覆盖的背景内容。 -6. 物品 spritesheet 固定 `10行*10列`、统一纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次固定解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;持久化单格映射必须按 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1`。`generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退。 -7. 前端和运行态统一使用 alpha 连通域矩形检测解析 spritesheet:UI 图按返回、设置、方格、移出、凑齐、打乱顺序映射回原 UI 位置;物品图按检测顺序每 `5` 个区域组成一个物品的五个形态,最多 `20` 个物品。透明背景是解析前提,不能在前端按固定像素坐标写死切片。 +6. 物品 spritesheet 固定 `10行*10列`、统一纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;后端切 `generatedItemAssets[].imageViews[]` 时优先按透明 alpha 连通域识别真实素材矩形,再按原图从上到下、从左到右排序,每 `5` 个区域组成一个物品的五个形态;只有识别出的区域数量不足时才回退 `10*10` 固定网格。持久化单格映射元数据仍按 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1`。`generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退。 +7. 前端和运行态统一使用 alpha 连通域矩形检测解析 spritesheet:UI 图先把识别出的透明素材矩形按行聚类,再在每一行内按横向 `x` 坐标排序,最后按返回、设置、方格、移出、凑齐、打乱顺序映射回原 UI 位置;不能只按全局 `y` 坐标排序,否则同一行素材上下略有错位时会把方格和底部道具按钮顺序打乱。物品图按检测顺序每 `5` 个区域组成一个物品的五个形态,最多 `20` 个物品。透明背景是解析前提,不能在前端按固定像素坐标写死切片。 8. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 10. 背景、UI spritesheet、物品 spritesheet 和历史容器兼容字段的持久化真相仍在 `generatedItemAssets[].backgroundAsset` 与提升后的 `generatedBackgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 图集。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景。 @@ -218,12 +221,15 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。 - 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。 - 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。 +- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo.png` 产品 logo;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。 - generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。 - `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。 - 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。 -- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。 +- 抓大鹅运行态不渲染右上角设置入口,也不在局内直接暴露重新开始按钮;结算弹层仍保留结果态的再来一局动作。 - 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。 +发现页可挂载官方抓大鹅静态 demo,用于验证生图、切图和运行态资源闭环。当前 demo profile 固定为 `match3d-demo-20260525`,公开作品号为 `M3-20260525`,静态资源位于 `public/match3d-demo/undersea-candy-market/`;公开卡片、作品号搜索和详情启动都走平台现有公开作品详情,不新建页面。demo 运行态使用前端本地 `createLocalMatch3DRuntimeAdapter`,不调用正式 Match3D runtime 后端、不新增 SpacetimeDB schema,也不写正式作品统计;后续若要把 demo 资源转成正式公开作品,必须改为后端 profile / gallery 投影真相后再接正式 runtime。 + ## 视觉小说 当前视觉小说只吸收外部 TXT 玩法的创作与运行经验,不迁入外部平台社区、支付、榜单、私有存档或回放。 diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml index 5d830465..b9469f31 100644 --- a/miniprogram/pages/web-view/index.wxml +++ b/miniprogram/pages/web-view/index.wxml @@ -15,7 +15,7 @@ - 绑定手机号 + 登录 {{errorMessage}} @@ -26,7 +26,7 @@ loading="{{bindingPhone}}" disabled="{{bindingPhone}}" > - {{bindingPhone ? '正在绑定' : '微信授权手机号'}} + {{bindingPhone ? '正在绑定' : '手机号快捷登录'}} )} -
-
- 第 1 关 - +
+
+ + + 第 1 关 + + {displayLevelName}
-
- +
+ {formatTimer(timeLeftMs)}
- + - ) : null} ); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 8b43f4c4..dc7ce515 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -108,6 +108,12 @@ import type { VisualNovelWorkSummary, } from '../../../packages/shared/src/contracts/visualNovel'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import { + MATCH3D_DEMO_GALLERY_CARD, + MATCH3D_DEMO_PROFILE_ID, + MATCH3D_DEMO_WORK_PROFILE, + isMatch3DDemoProfileId, +} from '../../data/match3dDemoGalleryCard'; import { buildPublicWorkStagePath, pushAppHistoryPath, @@ -192,7 +198,10 @@ import { JumpHopWorkspaceCreateRequest, } from '../../services/jump-hop/jumpHopClient'; import { match3dCreationClient } from '../../services/match3d-creation'; -import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; +import { + createLocalMatch3DRuntimeAdapter, + createServerMatch3DRuntimeAdapter, +} from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, @@ -463,6 +472,7 @@ type PendingDraftShelfMap = Partial< Record > >; +type CreationFlowReturnTarget = 'create' | 'draft-shelf'; type Match3DBackgroundCompileTask = { session: Match3DAgentSessionSnapshot; payload: CreateMatch3DSessionRequest; @@ -705,7 +715,10 @@ function isRecommendRuntimeReadyForEntry( return Boolean(state.match3dRun); } if (expectedKind === 'puzzle') { - return Boolean(state.puzzleRun); + return ( + state.puzzleRun?.entryProfileId === entry.profileId || + state.puzzleRun?.currentLevel?.profileId === entry.profileId + ); } if (expectedKind === 'square-hole') { return Boolean(state.squareHoleRun); @@ -3227,6 +3240,7 @@ export function PlatformEntryFlowShellImpl({ useState('default'); const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false); const submittedPuzzleLeaderboardKeysRef = useRef(new Set()); + const puzzleStartInFlightKeyRef = useRef(null); const [puzzleRun, setPuzzleRun] = useState(null); const puzzleRunRef = useRef(null); const errorSetterRefNoop = useMemo( @@ -3388,6 +3402,8 @@ export function PlatformEntryFlowShellImpl({ ); const handledInitialPublicWorkCodeRef = useRef(null); const selectionStageRef = useRef(selectionStage); + const creationFlowReturnTargetRef = + useRef('create'); const activeMatch3DGenerationSessionIdRef = useRef(null); const activePuzzleGenerationSessionIdRef = useRef(null); const [draftGenerationNotices, setDraftGenerationNotices] = @@ -3633,6 +3649,13 @@ export function PlatformEntryFlowShellImpl({ setSelectedDetailEntry, }); const { setPlatformTab } = platformBootstrap; + const returnPlatformHomeAfterMissingWork = useCallback(() => { + setPlatformTab('home'); + setSelectionStage('platform'); + if (!maybeAlertWorkNotFoundAndReturnHome()) { + pushAppHistoryPath('/'); + } + }, [setPlatformTab, setSelectionStage]); useEffect(() => { if (selectionStage === 'profile-feedback') { @@ -3658,11 +3681,34 @@ export function PlatformEntryFlowShellImpl({ const enterDraftTab = useCallback(() => { setPlatformTab('saves'); }, [setPlatformTab]); + const markCreationFlowReturnToCreate = useCallback(() => { + creationFlowReturnTargetRef.current = 'create'; + }, []); + const markCreationFlowReturnToDraftShelf = useCallback(() => { + creationFlowReturnTargetRef.current = 'draft-shelf'; + }, []); + const returnToCreationFlowSource = useCallback(() => { + const returnTarget = creationFlowReturnTargetRef.current; + creationFlowReturnTargetRef.current = 'create'; + clearCreationUrlState(); + if (returnTarget === 'draft-shelf') { + enterDraftTab(); + } else { + enterCreateTab(); + } + selectionStageRef.current = 'platform'; + setSelectionStage('platform'); + }, [enterCreateTab, enterDraftTab, setSelectionStage]); + const shouldReturnToDraftShelf = useCallback( + () => creationFlowReturnTargetRef.current === 'draft-shelf', + [], + ); const returnToCreationCenterFromGeneration = useCallback(() => { + markCreationFlowReturnToCreate(); enterCreateTab(); selectionStageRef.current = 'platform'; setSelectionStage('platform'); - }, [enterCreateTab, setSelectionStage]); + }, [enterCreateTab, markCreationFlowReturnToCreate, setSelectionStage]); const isViewingMatch3DGeneration = useCallback((sessionId: string) => { return ( selectionStageRef.current === 'match3d-generating' && @@ -4286,6 +4332,8 @@ export function PlatformEntryFlowShellImpl({ } return '服务端预览'; }, [agentResultPreview]); + const match3dDemoProfile = MATCH3D_DEMO_WORK_PROFILE; + const match3dDemoGalleryCard = MATCH3D_DEMO_GALLERY_CARD; const featuredGalleryEntries = useMemo(() => { const bigFishPublicEntries = isBigFishCreationVisible @@ -4325,6 +4373,7 @@ export function PlatformEntryFlowShellImpl({ [ ...bigFishPublicEntries, ...match3dPublicEntries, + match3dDemoGalleryCard, ...puzzlePublicEntries, ...barkBattlePublicEntries, ...squareHolePublicEntries, @@ -4349,6 +4398,7 @@ export function PlatformEntryFlowShellImpl({ squareHoleGalleryEntries, visualNovelGalleryEntries, woodenFishGalleryEntries, + match3dDemoGalleryCard, ]); const latestGalleryEntries = useMemo( () => @@ -4359,6 +4409,7 @@ export function PlatformEntryFlowShellImpl({ ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) : []), ...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail), + match3dDemoGalleryCard, ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), ...barkBattleGalleryEntries.map(mapBarkBattleWorkToPlatformGalleryCard), ...jumpHopGalleryEntries.map(mapJumpHopWorkToPlatformGalleryCard), @@ -4400,6 +4451,7 @@ export function PlatformEntryFlowShellImpl({ barkBattleGalleryEntries, barkBattleWorks, woodenFishGalleryEntries, + match3dDemoGalleryCard, ], ); const recommendRuntimeEntries = useMemo(() => { @@ -4407,9 +4459,11 @@ export function PlatformEntryFlowShellImpl({ filterGeneralPublicWorks([ ...featuredGalleryEntries, ...latestGalleryEntries, - ]).forEach((entry) => { - entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); - }); + ]) + .filter((entry) => !isMatch3DDemoProfileId(entry.profileId)) + .forEach((entry) => { + entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); + }); return Array.from(entryMap.values()); }, [featuredGalleryEntries, latestGalleryEntries]); @@ -4830,9 +4884,15 @@ export function PlatformEntryFlowShellImpl({ handleStartNewGame(); } + markCreationFlowReturnToCreate(); sessionController.setCreationTypeError(null); return true; - }, [handleStartNewGame, hasSavedGame, sessionController]); + }, [ + handleStartNewGame, + hasSavedGame, + markCreationFlowReturnToCreate, + sessionController, + ]); const openCreationTypePicker = useCallback(() => { if (!prepareCreationLaunch()) { @@ -4945,6 +5005,21 @@ export function PlatformEntryFlowShellImpl({ () => createServerMatch3DRuntimeAdapter(), [], ); + const match3dDemoRuntimeAdapter = useMemo( + () => + createLocalMatch3DRuntimeAdapter({ + clearCount: 21, + profileId: MATCH3D_DEMO_PROFILE_ID, + }), + [], + ); + const resolveMatch3DRuntimeAdapter = useCallback( + (profileId: string | null | undefined) => + isMatch3DDemoProfileId(profileId) + ? match3dDemoRuntimeAdapter + : match3dRuntimeAdapter, + [match3dDemoRuntimeAdapter, match3dRuntimeAdapter], + ); const match3dFlow = usePlatformCreationAgentFlowController< Match3DAgentSessionSnapshot, CreateMatch3DSessionRequest, @@ -6384,11 +6459,13 @@ export function PlatformEntryFlowShellImpl({ ]); const openBigFishAgentWorkspace = useCallback(async () => { + markCreationFlowReturnToCreate(); setBigFishRun(null); await bigFishFlow.openWorkspace(); - }, [bigFishFlow]); + }, [bigFishFlow, markCreationFlowReturnToCreate]); const openSquareHoleAgentWorkspace = useCallback(async () => { + markCreationFlowReturnToCreate(); setSquareHoleSession(null); setSquareHoleProfile(null); setSquareHoleRun(null); @@ -6406,9 +6483,11 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleSession, setStreamingSquareHoleReplyText, squareHoleFlow, + markCreationFlowReturnToCreate, ]); const openMatch3DWorkspace = useCallback(() => { + markCreationFlowReturnToCreate(); setMatch3DRun(null); setMatch3DProfile(null); setMatch3DRuntimeProfile(null); @@ -6425,9 +6504,11 @@ export function PlatformEntryFlowShellImpl({ setMatch3DError, setSelectionStage, setStreamingMatch3DReplyText, + markCreationFlowReturnToCreate, ]); const openJumpHopWorkspace = useCallback(() => { + markCreationFlowReturnToCreate(); setJumpHopError(null); setJumpHopSession(null); setJumpHopWork(null); @@ -6436,9 +6517,10 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab(); setShowCreationTypeModal(false); setSelectionStage('jump-hop-workspace'); - }, [enterCreateTab, setSelectionStage]); + }, [enterCreateTab, markCreationFlowReturnToCreate, setSelectionStage]); const openWoodenFishWorkspace = useCallback(() => { + markCreationFlowReturnToCreate(); setWoodenFishError(null); setWoodenFishSession(null); setWoodenFishWork(null); @@ -6447,9 +6529,10 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab(); setShowCreationTypeModal(false); setSelectionStage('wooden-fish-workspace'); - }, [enterCreateTab, setSelectionStage]); + }, [enterCreateTab, markCreationFlowReturnToCreate, setSelectionStage]); const openPuzzleWorkspace = useCallback(() => { + markCreationFlowReturnToCreate(); enterCreateTab(); setShowCreationTypeModal(false); setPuzzleCreationError(null); @@ -6460,9 +6543,11 @@ export function PlatformEntryFlowShellImpl({ setPuzzleCreationError, setPuzzleError, setSelectionStage, + markCreationFlowReturnToCreate, ]); const openBarkBattleWorkspace = useCallback(() => { + markCreationFlowReturnToCreate(); setBarkBattleDraftConfig(null); setBarkBattlePublishedConfig(null); setBarkBattleRuntimeMode('draft'); @@ -6474,15 +6559,17 @@ export function PlatformEntryFlowShellImpl({ setShowCreationTypeModal(false); selectionStageRef.current = 'bark-battle-workspace'; setSelectionStage('bark-battle-workspace'); - }, [enterCreateTab, setSelectionStage]); + }, [enterCreateTab, markCreationFlowReturnToCreate, setSelectionStage]); const openVisualNovelWorkspace = useCallback(() => { + markCreationFlowReturnToCreate(); enterCreateTab(); setShowCreationTypeModal(false); setVisualNovelError(null); setSelectionStage('visual-novel-agent-workspace'); }, [ enterCreateTab, + markCreationFlowReturnToCreate, setSelectionStage, setVisualNovelError, ]); @@ -6493,6 +6580,7 @@ export function PlatformEntryFlowShellImpl({ return; } + markCreationFlowReturnToCreate(); enterCreateTab(); setShowCreationTypeModal(false); setBabyObjectMatchError(null); @@ -6500,6 +6588,7 @@ export function PlatformEntryFlowShellImpl({ }, [ enterCreateTab, isBabyObjectMatchVisible, + markCreationFlowReturnToCreate, sessionController, setBabyObjectMatchError, setSelectionStage, @@ -6518,9 +6607,15 @@ export function PlatformEntryFlowShellImpl({ setIsCreativeAgentStreaming(false); setCreativeDraftEditError(null); setIsCreativeDraftEditBusy(false); + markCreationFlowReturnToCreate(); enterCreateTab(); setSelectionStage('platform'); - }, [creativeAgentSession, enterCreateTab, setSelectionStage]); + }, [ + creativeAgentSession, + enterCreateTab, + markCreationFlowReturnToCreate, + setSelectionStage, + ]); const openCreativeAgentWorkspace = useCallback(async () => { if (isCreativeAgentBusy || isCreativeAgentStreaming) { @@ -6528,6 +6623,7 @@ export function PlatformEntryFlowShellImpl({ } setPuzzleRun(null); + markCreationFlowReturnToCreate(); setPuzzleRuntimeAuthMode('default'); setPuzzleOperation(null); setPuzzleGenerationState(null); @@ -6556,6 +6652,7 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, isCreativeAgentBusy, isCreativeAgentStreaming, + markCreationFlowReturnToCreate, resolvePuzzleErrorMessage, setSelectionStage, ]); @@ -7402,9 +7499,9 @@ export function PlatformEntryFlowShellImpl({ setBigFishRuntimeStartedAt(null); setBigFishRuntimeReturnStage('platform'); setBigFishGenerationState(null); - clearCreationUrlState(); bigFishFlow.leaveFlow(); - }, [bigFishFlow]); + returnToCreationFlowSource(); + }, [bigFishFlow, returnToCreationFlowSource]); const leaveMatch3DFlow = useCallback(() => { setMatch3DRun(null); @@ -7412,17 +7509,17 @@ export function PlatformEntryFlowShellImpl({ setMatch3DFormDraftPayload(null); setMatch3DGenerationState(null); setMatch3DRuntimeReturnStage('match3d-result'); - clearCreationUrlState(); match3dFlow.leaveFlow(); - }, [match3dFlow, setMatch3DFormDraftPayload]); + returnToCreationFlowSource(); + }, [match3dFlow, returnToCreationFlowSource, setMatch3DFormDraftPayload]); const leaveSquareHoleFlow = useCallback(() => { setSquareHoleRun(null); setSquareHoleRuntimeReturnStage('square-hole-result'); setSquareHoleGenerationState(null); - clearCreationUrlState(); squareHoleFlow.leaveFlow(); - }, [squareHoleFlow]); + returnToCreationFlowSource(); + }, [returnToCreationFlowSource, squareHoleFlow]); const leaveJumpHopFlow = useCallback(() => { setJumpHopRun(null); @@ -7431,9 +7528,8 @@ export function PlatformEntryFlowShellImpl({ setJumpHopGenerationState(null); setJumpHopSession(null); setJumpHopError(null); - clearCreationUrlState(); - setSelectionStage('platform'); - }, [setSelectionStage]); + returnToCreationFlowSource(); + }, [returnToCreationFlowSource]); const leaveWoodenFishFlow = useCallback(() => { setWoodenFishRun(null); @@ -7442,9 +7538,8 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishGenerationState(null); setWoodenFishSession(null); setWoodenFishError(null); - clearCreationUrlState(); - setSelectionStage('platform'); - }, [setSelectionStage]); + returnToCreationFlowSource(); + }, [returnToCreationFlowSource]); const createReadyJumpHopGenerationState = useCallback( (state: MiniGameDraftGenerationState) => @@ -7472,10 +7567,8 @@ export function PlatformEntryFlowShellImpl({ setBarkBattleError(null); setBarkBattleGenerationPartialFailed(false); setIsBarkBattleBusy(false); - clearCreationUrlState(); - selectionStageRef.current = 'platform'; - setSelectionStage('platform'); - }, [setSelectionStage]); + returnToCreationFlowSource(); + }, [returnToCreationFlowSource]); const createBarkBattleGeneratingDraft = useCallback( async (payload: BarkBattleConfigEditorPayload) => { @@ -7756,10 +7849,10 @@ export function PlatformEntryFlowShellImpl({ setActiveCreativeAgentSessionId(null); setCreativeDraftEditError(null); resetRecommendRuntimeSelection(); - clearCreationUrlState(); clearPuzzleRuntimeUrlState(); puzzleFlow.leaveFlow(); - }, [puzzleFlow, resetRecommendRuntimeSelection]); + returnToCreationFlowSource(); + }, [puzzleFlow, resetRecommendRuntimeSelection, returnToCreationFlowSource]); const leaveVisualNovelFlow = useCallback(() => { setVisualNovelWork(null); @@ -7768,9 +7861,9 @@ export function PlatformEntryFlowShellImpl({ setVisualNovelFormDraftPayload(null); setVisualNovelGenerationStartedAtMs(null); setVisualNovelGenerationPhase('generating'); - clearCreationUrlState(); visualNovelFlow.leaveFlow(); - }, [visualNovelFlow]); + returnToCreationFlowSource(); + }, [returnToCreationFlowSource, visualNovelFlow]); const leaveBabyObjectMatchFlow = useCallback(() => { setBabyObjectMatchDraft(null); @@ -7778,11 +7871,8 @@ export function PlatformEntryFlowShellImpl({ setBabyObjectMatchGenerationState(null); setBabyObjectMatchGenerationPhase('generating'); setBabyObjectMatchError(null); - clearCreationUrlState(); - enterCreateTab(); - selectionStageRef.current = 'platform'; - setSelectionStage('platform'); - }, [enterCreateTab, setSelectionStage]); + returnToCreationFlowSource(); + }, [returnToCreationFlowSource]); const saveBabyObjectMatchResultDraft = useCallback( async (draft: BabyObjectMatchDraft) => { @@ -7831,7 +7921,7 @@ export function PlatformEntryFlowShellImpl({ setBabyObjectMatchError( resolvePuzzleErrorMessage( error, - '重新生成宝贝识物 image-2 资源失败。', + '重新生成宝贝识物素材失败。', ), ); } finally { @@ -7864,7 +7954,7 @@ export function PlatformEntryFlowShellImpl({ setBabyObjectMatchError( resolvePuzzleErrorMessage( error, - '生成宝贝识物 image-2 资源失败,请重试后再发布。', + '生成宝贝识物素材失败,请重试后再发布。', ), ); } finally { @@ -7915,7 +8005,7 @@ export function PlatformEntryFlowShellImpl({ } catch (error) { const message = resolvePuzzleErrorMessage( error, - '生成宝贝识物 image-2 资源失败,请重试后再试玩。', + '生成宝贝识物素材失败,请重试后再试玩。', ); setBabyObjectMatchError(message); if (options.embedded) { @@ -9499,10 +9589,13 @@ export function PlatformEntryFlowShellImpl({ levelId?: string | null, options: { embedded?: boolean; authMode?: PuzzleRuntimeAuthMode } = {}, ) => { - if (isPuzzleBusy) { + const normalizedLevelId = levelId?.trim() ?? ''; + const startKey = `${profileId}:${normalizedLevelId}`; + if (isPuzzleBusy || puzzleStartInFlightKeyRef.current === startKey) { return false; } + puzzleStartInFlightKeyRef.current = startKey; setIsPuzzleBusy(true); setPuzzleError(null); @@ -9511,7 +9604,7 @@ export function PlatformEntryFlowShellImpl({ detailItem ?? (await getPuzzleGalleryDetail(profileId)).item; const startRunPayload = { profileId: item.profileId, - levelId: levelId ?? null, + levelId: normalizedLevelId || null, }; const canUseRuntimeGuestAuth = options.embedded || options.authMode === 'isolated'; @@ -9549,13 +9642,12 @@ export function PlatformEntryFlowShellImpl({ setPuzzleDetailReturnTarget(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); + setPuzzleGalleryEntries((current) => + current.filter((entry) => entry.profileId !== profileId), + ); setPuzzleError(null); setPublicWorkDetailError(null); - setPlatformTab('home'); - setSelectionStage('platform'); - if (!maybeAlertWorkNotFoundAndReturnHome()) { - pushAppHistoryPath('/'); - } + returnPlatformHomeAfterMissingWork(); return false; } @@ -9566,6 +9658,9 @@ export function PlatformEntryFlowShellImpl({ } return false; } finally { + if (puzzleStartInFlightKeyRef.current === startKey) { + puzzleStartInFlightKeyRef.current = null; + } setIsPuzzleBusy(false); } }, @@ -9573,9 +9668,9 @@ export function PlatformEntryFlowShellImpl({ isPuzzleBusy, authUi, resolvePuzzleErrorMessage, + returnPlatformHomeAfterMissingWork, setIsPuzzleBusy, setPuzzleError, - setPlatformTab, setSelectionStage, ], ); @@ -9595,10 +9690,13 @@ export function PlatformEntryFlowShellImpl({ setMatch3DError(null); try { - let runtimeProfile = profile; + const isDemoProfile = isMatch3DDemoProfileId(profile.profileId); + let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary = + isDemoProfile ? match3dDemoProfile : profile; if ( - !hasMatch3DRuntimeAsset(profile.generatedItemAssets) || - !hasMatch3DRuntimeBackgroundAsset(profile) + !isDemoProfile && + (!hasMatch3DRuntimeAsset(profile.generatedItemAssets) || + !hasMatch3DRuntimeBackgroundAsset(profile)) ) { try { const { item } = await getMatch3DWorkDetail(profile.profileId); @@ -9634,7 +9732,10 @@ export function PlatformEntryFlowShellImpl({ ? { itemTypeCountOverride: options.itemTypeCountOverride } : {}), }; - const { run } = await match3dRuntimeAdapter.startRun( + const activeRuntimeAdapter = resolveMatch3DRuntimeAdapter( + runtimeProfile.profileId, + ); + const { run } = await activeRuntimeAdapter.startRun( runtimeProfile.profileId, runtimeOptions, ); @@ -9674,10 +9775,11 @@ export function PlatformEntryFlowShellImpl({ }, [ isMatch3DBusy, + match3dDemoProfile, authUi, match3dFlow, - match3dRuntimeAdapter, resolveMatch3DErrorMessage, + resolveMatch3DRuntimeAdapter, setMatch3DError, setSelectionStage, ], @@ -10337,7 +10439,13 @@ export function PlatformEntryFlowShellImpl({ return; } - const submitKey = `${puzzleRun.runId}:${currentLevel.profileId}:${currentLevel.gridSize}:${currentLevel.elapsedMs}`; + const submitKey = [ + puzzleRun.runId, + currentLevel.profileId, + currentLevel.levelId ?? currentLevel.levelIndex, + currentLevel.gridSize, + currentLevel.elapsedMs, + ].join(':'); if (submittedPuzzleLeaderboardKeysRef.current.has(submitKey)) { return; } @@ -10375,7 +10483,6 @@ export function PlatformEntryFlowShellImpl({ void platformBootstrap.refreshSaveArchives(); }) .catch((error) => { - submittedPuzzleLeaderboardKeysRef.current.delete(submitKey); setPuzzleError( resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'), ); @@ -10422,6 +10529,10 @@ export function PlatformEntryFlowShellImpl({ ? await buildRecommendRuntimeGuestOptions() : {}; const targetProfileId = _target?.profileId?.trim() ?? ''; + const preferSimilarWork = + activeRecommendRuntimeKind === 'puzzle' && + puzzleRuntimeReturnStage === 'platform' && + puzzleRun.nextLevelMode === 'sameWork'; if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) { const itemPromise = selectedPuzzleDetail?.profileId === targetProfileId @@ -10461,10 +10572,35 @@ export function PlatformEntryFlowShellImpl({ puzzleRuntimeAuthMode === 'isolated' ? await advancePuzzleNextLevel( puzzleRun.runId, - {}, + preferSimilarWork ? { preferSimilarWork: true } : {}, runtimeGuestOptions, ) - : await advancePuzzleNextLevel(puzzleRun.runId); + : await advancePuzzleNextLevel( + puzzleRun.runId, + preferSimilarWork ? { preferSimilarWork: true } : {}, + ); + const nextProfileId = run.currentLevel?.profileId?.trim() ?? ''; + if ( + nextProfileId && + selectedPuzzleDetail?.profileId !== nextProfileId + ) { + const item = await getPuzzleGalleryDetail(nextProfileId).then( + (response) => response.item, + ); + const nextRecommendEntry = + mapPuzzleWorkToPlatformGalleryCard(item); + setPuzzleGalleryEntries((current) => { + const nextEntries = current.filter( + (entry) => entry.profileId !== item.profileId, + ); + nextEntries.push(item); + return nextEntries; + }); + setSelectedPuzzleDetail(item); + setActiveRecommendEntryKey( + getPlatformPublicGalleryEntryKey(nextRecommendEntry), + ); + } setPuzzleRun(run); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); @@ -10476,8 +10612,12 @@ export function PlatformEntryFlowShellImpl({ [ isPuzzleBusy, isPuzzleLeaderboardBusy, + activeRecommendRuntimeKind, puzzleRun, + puzzleRuntimeReturnStage, puzzleRuntimeAuthMode, + setActiveRecommendEntryKey, + setPuzzleGalleryEntries, resolvePuzzleErrorMessage, selectedPuzzleDetail, setIsPuzzleBusy, @@ -10559,7 +10699,6 @@ export function PlatformEntryFlowShellImpl({ }, [activeRecommendRuntimeKind, setPuzzleError]); const leaveAgentWorkspace = useCallback(() => { - enterCreateTab(); sessionController.resetSessionViewState(); sessionController.setGeneratedCustomWorldProfile(null); autosaveCoordinator.resetAutoSaveTrackingToIdle(); @@ -10567,12 +10706,11 @@ export function PlatformEntryFlowShellImpl({ sessionController.activeAgentSessionId, null, ); - setSelectionStage('platform'); + returnToCreationFlowSource(); }, [ autosaveCoordinator, - enterCreateTab, + returnToCreationFlowSource, sessionController, - setSelectionStage, ]); const leaveAgentDraftGeneration = useCallback(() => { @@ -10592,13 +10730,11 @@ export function PlatformEntryFlowShellImpl({ autosaveCoordinator.resetAutoSaveTrackingToIdle(); sessionController.setCustomWorldGenerationViewSource(null); sessionController.setCustomWorldResultViewSource(null); - enterCreateTab(); - setSelectionStage('platform'); + returnToCreationFlowSource(); }, [ autosaveCoordinator, - enterCreateTab, + returnToCreationFlowSource, sessionController, - setSelectionStage, ]); const leaveCustomWorldResult = useCallback(() => { @@ -10607,12 +10743,18 @@ export function PlatformEntryFlowShellImpl({ autosaveCoordinator.resetAutoSaveTrackingToIdle(); sessionController.setCustomWorldGenerationViewSource(null); sessionController.setCustomWorldResultViewSource(null); + if (shouldReturnToDraftShelf()) { + returnToCreationFlowSource(); + return; + } setSelectionStage(selectedDetailEntry ? 'detail' : 'platform'); }, [ autosaveCoordinator, + returnToCreationFlowSource, selectedDetailEntry, sessionController, setSelectionStage, + shouldReturnToDraftShelf, ]); const handleStartSelectedWorld = useCallback(() => { @@ -11377,13 +11519,12 @@ export function PlatformEntryFlowShellImpl({ setPuzzleDetailReturnTarget(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); + setPuzzleGalleryEntries((current) => + current.filter((entry) => entry.profileId !== profileId), + ); setPuzzleError(null); setPublicWorkDetailError(null); - setPlatformTab('home'); - setSelectionStage('platform'); - if (!maybeAlertWorkNotFoundAndReturnHome()) { - pushAppHistoryPath('/'); - } + returnPlatformHomeAfterMissingWork(); return; } @@ -11401,7 +11542,6 @@ export function PlatformEntryFlowShellImpl({ resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, - setPlatformTab, setSelectionStage, ], ); @@ -11416,8 +11556,11 @@ export function PlatformEntryFlowShellImpl({ try { const entries = match3dGalleryEntries.length > 0 - ? match3dGalleryEntries - : await refreshMatch3DGallery(); + ? [...match3dGalleryEntries, match3dDemoProfile] + : await refreshMatch3DGallery().then((items) => [ + ...items, + match3dDemoProfile, + ]); const matchedEntry = entries.find( (entry) => entry.profileId === profileId, ); @@ -11437,6 +11580,7 @@ export function PlatformEntryFlowShellImpl({ }, [ match3dGalleryEntries, + match3dDemoProfile, openPublicWorkDetail, refreshMatch3DGallery, resolveMatch3DErrorMessage, @@ -11723,8 +11867,7 @@ export function PlatformEntryFlowShellImpl({ resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, - setPlatformTab, - setSelectionStage, + returnPlatformHomeAfterMissingWork, ], ); @@ -12874,6 +13017,10 @@ export function PlatformEntryFlowShellImpl({ recommendRuntimeStartRequestRef.current = startRequestId; const isCurrentStartRequest = () => recommendRuntimeStartRequestRef.current === startRequestId; + setActiveRecommendEntryKey(entryKey); + setActiveRecommendRuntimeKind(runtimeKind); + setActiveRecommendRuntimeError(null); + setIsStartingRecommendEntry(true); if (entryKey !== activeRecommendEntryKey) { await saveAndExitRecommendPuzzleRuntime(); if (!isCurrentStartRequest()) { @@ -13026,14 +13173,16 @@ export function PlatformEntryFlowShellImpl({ ], ); const selectAdjacentRecommendRuntimeEntry = useCallback( - (direction: 1 | -1) => { + (direction: 1 | -1, baseEntryKey?: string | null) => { if (recommendRuntimeEntries.length === 0) { return; } + const normalizedBaseEntryKey = + baseEntryKey?.trim() || activeRecommendEntryKey; const activeIndex = recommendRuntimeEntries.findIndex( (entry) => - getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey, + getPlatformPublicGalleryEntryKey(entry) === normalizedBaseEntryKey, ); const baseIndex = activeIndex >= 0 ? activeIndex : 0; const nextIndex = @@ -13044,7 +13193,7 @@ export function PlatformEntryFlowShellImpl({ return; } if ( - getPlatformPublicGalleryEntryKey(nextEntry) === activeRecommendEntryKey + getPlatformPublicGalleryEntryKey(nextEntry) === normalizedBaseEntryKey ) { return; } @@ -13142,7 +13291,9 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .restartRun(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -13162,14 +13313,18 @@ export function PlatformEntryFlowShellImpl({ if (!runId) { return Promise.reject(new Error('抓大鹅运行态缺少 runId。')); } - return match3dRuntimeAdapter.clickItem(runId, payload); + return resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ).clickItem(runId, payload); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .finishTimeUp(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -13983,8 +14138,11 @@ export function PlatformEntryFlowShellImpl({ const tryOpenMatch3DGalleryEntry = async () => { const entries = match3dGalleryEntries.length > 0 - ? match3dGalleryEntries - : await refreshMatch3DGallery(); + ? [...match3dGalleryEntries, match3dDemoProfile] + : await refreshMatch3DGallery().then((items) => [ + ...items, + match3dDemoProfile, + ]); const matchedEntry = entries.find((entry) => { const detailEntry = mapMatch3DWorkToPublicWorkDetail(entry); return ( @@ -14190,11 +14348,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRuntimeAuthMode('default'); setPuzzleError(null); setPublicWorkDetailError(null); - setPlatformTab('home'); - setSelectionStage('platform'); - if (!maybeAlertWorkNotFoundAndReturnHome()) { - pushAppHistoryPath('/'); - } + returnPlatformHomeAfterMissingWork(); return; } @@ -14223,6 +14377,7 @@ export function PlatformEntryFlowShellImpl({ refreshSquareHoleGallery, refreshVisualNovelGallery, squareHoleGalleryEntries, + returnPlatformHomeAfterMissingWork, selectionStage, setPlatformTab, setPuzzleError, @@ -14404,6 +14559,7 @@ export function PlatformEntryFlowShellImpl({ refreshBigFishGallery, resolveBigFishErrorMessage, setBigFishError, + match3dDemoProfile, ], ); @@ -14602,6 +14758,7 @@ export function PlatformEntryFlowShellImpl({ }} onOpenDraft={(item) => { runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); void detailNavigation.handleOpenCreationWork(item); }); }} @@ -14613,6 +14770,7 @@ export function PlatformEntryFlowShellImpl({ if (!matchedWork) { return; } + markCreationFlowReturnToDraftShelf(); void detailNavigation.handleOpenCreationWork(matchedWork); }); }} @@ -14627,6 +14785,7 @@ export function PlatformEntryFlowShellImpl({ isBigFishCreationVisible ? (item) => { runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); void openBigFishDraft(item); }); } @@ -14636,6 +14795,7 @@ export function PlatformEntryFlowShellImpl({ isJumpHopCreationVisible ? (item) => { runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); void openJumpHopDraft(item); }); } @@ -14652,6 +14812,7 @@ export function PlatformEntryFlowShellImpl({ match3dItems={match3dShelfItems} onOpenMatch3DDetail={(item) => { runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); void openMatch3DDraft(item); }); }} @@ -14665,6 +14826,7 @@ export function PlatformEntryFlowShellImpl({ isSquareHoleCreationVisible ? (item) => { runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); void openSquareHoleDraft(item); }); } @@ -14680,6 +14842,7 @@ export function PlatformEntryFlowShellImpl({ puzzleItems={puzzleShelfItems} onOpenPuzzleDetail={(item) => { runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); void openPuzzleDraft(item); }); }} @@ -14695,6 +14858,7 @@ export function PlatformEntryFlowShellImpl({ } onOpenBabyObjectMatchDetail={(item) => { runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); openBabyObjectMatchDraft(item); }); }} @@ -14704,12 +14868,14 @@ export function PlatformEntryFlowShellImpl({ barkBattleItems={barkBattleShelfItems} onOpenBarkBattleDetail={(item) => { runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); openBarkBattleDraft(item); }); }} visualNovelItems={visualNovelShelfItems} onOpenVisualNovelDetail={(item) => { runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); void openVisualNovelDraft(item); }); }} @@ -14793,18 +14959,19 @@ export function PlatformEntryFlowShellImpl({ isStartingRecommendEntry={ isStartingRecommendEntry || isBigFishBusy || - isPuzzleBusy || + (isPuzzleBusy && + !(activeRecommendRuntimeKind === 'puzzle' && puzzleRun)) || isMatch3DBusy || isSquareHoleBusy || isVisualNovelBusy || isWoodenFishBusy } recommendRuntimeError={activeRecommendRuntimeError} - onSelectNextRecommendEntry={() => - selectAdjacentRecommendRuntimeEntry(1) + onSelectNextRecommendEntry={(activeEntryKey) => + selectAdjacentRecommendRuntimeEntry(1, activeEntryKey) } - onSelectPreviousRecommendEntry={() => - selectAdjacentRecommendRuntimeEntry(-1) + onSelectPreviousRecommendEntry={(activeEntryKey) => + selectAdjacentRecommendRuntimeEntry(-1, activeEntryKey) } onLikeRecommendEntry={(entry) => { likePublicWork(entry); @@ -15159,6 +15326,10 @@ export function PlatformEntryFlowShellImpl({ isBusy={isBigFishBusy} error={bigFishError} onBack={() => { + if (shouldReturnToDraftShelf()) { + leaveBigFishFlow(); + return; + } setSelectionStage('big-fish-agent-workspace'); }} onDismissError={() => { @@ -15308,6 +15479,10 @@ export function PlatformEntryFlowShellImpl({ isBusy={isMatch3DBusy} error={match3dError} onBack={() => { + if (shouldReturnToDraftShelf()) { + leaveMatch3DFlow(); + return; + } returnToCreationCenterFromGeneration(); }} onSaved={(profile) => { @@ -15391,7 +15566,9 @@ export function PlatformEntryFlowShellImpl({ match3dRun?.runId && match3dRun.status === 'running' ) { - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .stopRun(match3dRun.runId) .catch(() => undefined); } @@ -15404,7 +15581,9 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .restartRun(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -15429,14 +15608,18 @@ export function PlatformEntryFlowShellImpl({ new Error('抓大鹅运行态缺少 runId。'), ); } - return match3dRuntimeAdapter.clickItem(runId, payload); + return resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ).clickItem(runId, payload); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .finishTimeUp(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -15549,6 +15732,10 @@ export function PlatformEntryFlowShellImpl({ isBusy={isBabyObjectMatchBusy} error={babyObjectMatchError} onBack={() => { + if (shouldReturnToDraftShelf()) { + leaveBabyObjectMatchFlow(); + return; + } setSelectionStage('baby-object-match-workspace'); }} onSaveDraft={(draft) => { @@ -15734,6 +15921,10 @@ export function PlatformEntryFlowShellImpl({ isBusy={isSquareHoleBusy} error={squareHoleError} onBack={() => { + if (shouldReturnToDraftShelf()) { + leaveSquareHoleFlow(); + return; + } setSelectionStage('square-hole-agent-workspace'); }} onSaved={(profile) => { @@ -16370,6 +16561,10 @@ export function PlatformEntryFlowShellImpl({ isBusy={isVisualNovelBusy} error={visualNovelError} onBack={() => { + if (shouldReturnToDraftShelf()) { + leaveVisualNovelFlow(); + return; + } setSelectionStage('visual-novel-agent-workspace'); }} onSaveDraft={(draft) => { @@ -16648,6 +16843,10 @@ export function PlatformEntryFlowShellImpl({ : barkBattleError } onBack={() => { + if (shouldReturnToDraftShelf()) { + leaveBarkBattleFlow(); + return; + } enterCreateTab(); selectionStageRef.current = 'platform'; setSelectionStage('platform'); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index 3785425c..cd58b666 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -411,7 +411,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () = }); }); -test('puzzle workspace switches the image model from the description box', () => { +test('puzzle workspace switches image mode without exposing model names', () => { const onCreateFromForm = vi.fn(); render( @@ -427,9 +427,9 @@ test('puzzle workspace switches the image model from the description box', () => fireEvent.change(screen.getByLabelText('画面描述'), { target: { value: '一只猫在雨夜灯牌下回头。' }, }); - fireEvent.click(screen.getByRole('button', { name: '图片模型' })); - expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull(); - fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' })); + fireEvent.click(screen.getByRole('button', { name: '图片生成模式' })); + expect(screen.queryByText(/gpt|nanobanana|gemini/u)).toBeNull(); + fireEvent.click(screen.getByRole('menuitemradio', { name: '创意模式' })); fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u })); confirmPuzzlePointCost(); diff --git a/src/components/puzzle-agent/PuzzleImageModelPicker.tsx b/src/components/puzzle-agent/PuzzleImageModelPicker.tsx index c8cb4e31..3cefe328 100644 --- a/src/components/puzzle-agent/PuzzleImageModelPicker.tsx +++ b/src/components/puzzle-agent/PuzzleImageModelPicker.tsx @@ -45,8 +45,8 @@ export function PuzzleImageModelPicker({ className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`} aria-haspopup="menu" aria-expanded={isOpen} - aria-label="图片模型" - title="图片模型" + aria-label="图片生成模式" + title="图片生成模式" > {getPuzzleImageModelLabel(normalizedValue)} diff --git a/src/components/puzzle-agent/puzzleImageModelOptions.ts b/src/components/puzzle-agent/puzzleImageModelOptions.ts index 12498083..147685f1 100644 --- a/src/components/puzzle-agent/puzzleImageModelOptions.ts +++ b/src/components/puzzle-agent/puzzleImageModelOptions.ts @@ -9,8 +9,8 @@ export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{ id: PuzzleImageModelId; label: string; }> = [ - { id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' }, - { id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' }, + { id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: '标准模式' }, + { id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: '创意模式' }, ]; export function normalizePuzzleImageModel( @@ -25,6 +25,6 @@ export function normalizePuzzleImageModel( export function getPuzzleImageModelLabel(model: PuzzleImageModelId) { return ( PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ?? - 'gpt-image-2' + '标准模式' ); } diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index 8bd79844..018ce784 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -1305,7 +1305,7 @@ describe('PuzzleResultView', () => { expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull(); }); - test('passes the selected image model when regenerating a level image', () => { + test('passes the selected image mode without exposing model names', () => { const onExecuteAction = vi.fn(); render( @@ -1319,9 +1319,12 @@ describe('PuzzleResultView', () => { openPuzzleLevelsTab(); fireEvent.click(screen.getByText('雨夜猫街')); const dialog = screen.getByRole('dialog', { name: '关卡详情' }); - fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' })); fireEvent.click( - within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }), + within(dialog).getByRole('button', { name: '图片生成模式' }), + ); + expect(within(dialog).queryByText(/gpt|nanobanana|gemini/u)).toBeNull(); + fireEvent.click( + within(dialog).getByRole('menuitemradio', { name: '标准模式' }), ); fireEvent.click( within(dialog).getByRole('button', { name: /重新生成画面/u }), diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx index fda6527a..e2604ace 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx @@ -882,6 +882,7 @@ test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关 expect(nextSprite).toBeTruthy(); expect(nextSprite?.style.backgroundSize).toBe('320% 480%'); expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%'); + expect(screen.getByRole('button', { name: '下一关' }).textContent).toBe(''); expect( screen .getByRole('button', { name: '提示' }) @@ -971,6 +972,11 @@ test('关闭通关弹窗后保留底部下一关入口', () => { nextLevelProfileId: 'profile-1', nextLevelId: 'puzzle-level-2', recommendedNextWorks: [], + currentLevel: { + ...clearedRun.currentLevel!, + uiSpritesheetImageSrc: + '/generated-puzzle-assets/session/ui-spritesheet/sheet.png', + }, }; renderPuzzleRuntime( @@ -986,7 +992,9 @@ test('关闭通关弹窗后保留底部下一关入口', () => { act(() => { vi.advanceTimersByTime(1_400); }); - fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); + act(() => { + fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); + }); expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); const nextButton = screen.getByRole('button', { name: /下一关/u }); @@ -1002,6 +1010,53 @@ test('关闭通关弹窗后保留底部下一关入口', () => { vi.useRealTimers(); }); +test('推荐页关闭通关弹窗后保留底部下一关入口且不叠加下一关素材图', async () => { + vi.useFakeTimers(); + const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = { + ...clearedRun, + recommendedNextProfileId: null, + nextLevelMode: 'sameWork', + nextLevelProfileId: 'profile-1', + nextLevelId: 'puzzle-level-2', + recommendedNextWorks: [], + currentLevel: { + ...clearedRun.currentLevel!, + uiSpritesheetImageSrc: + '/generated-puzzle-assets/session/ui-spritesheet/sheet.png', + }, + }; + + renderPuzzleRuntime( + , + ); + + act(() => { + vi.advanceTimersByTime(1_400); + }); + act(() => { + fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); + }); + await act(async () => {}); + + expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); + const nextButton = screen.getByRole('button', { name: /下一关/u }); + expect(nextButton).toBeTruthy(); + expect( + nextButton.querySelector('[data-puzzle-ui-sprite="next"]'), + ).toBeTruthy(); + expect(nextButton.textContent?.trim()).toBe(''); + vi.useRealTimers(); +}); + test('当前作品没有下一关时展示三个相似作品并可选择进入', () => { vi.useFakeTimers(); const onAdvanceNextLevel = vi.fn(); diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index a2cd504e..92f395de 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -1933,6 +1933,7 @@ export function PuzzleRuntimeShell({