diff --git a/.codex-home-desktop-wait.png b/.codex-home-desktop-wait.png new file mode 100644 index 00000000..1cd523f7 Binary files /dev/null and b/.codex-home-desktop-wait.png differ diff --git a/.codex-home-desktop.png b/.codex-home-desktop.png new file mode 100644 index 00000000..68698ffa Binary files /dev/null and b/.codex-home-desktop.png differ diff --git a/.codex-home-mobile-wait.png b/.codex-home-mobile-wait.png new file mode 100644 index 00000000..d404bce4 Binary files /dev/null and b/.codex-home-mobile-wait.png differ diff --git a/.codex-home-mobile.png b/.codex-home-mobile.png new file mode 100644 index 00000000..1a06f613 Binary files /dev/null and b/.codex-home-mobile.png differ diff --git a/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md b/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md new file mode 100644 index 00000000..5b423dd4 --- /dev/null +++ b/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md @@ -0,0 +1,59 @@ +# 平台首页移动端信息流与作品卡设计 + +更新时间:`2026-04-28` + +## 1. 本次目标 + +1. 桌面端首页布局保持现有顶部栏、侧边导航、Hero、趋势区与下方网格结构,不调整桌面端区块顺序。 +2. 移动端首页改为参考图式信息流:顶部搜索框、横向频道 Tab、纵向作品列表、底部主导航。 +3. 双端公开作品卡统一结构:上方 `16:9` 封面图,下方作品名称、作品描述、标签、点赞数。 +4. 点赞数必须来自作品读模型字段,前端只负责展示,不把游玩数或评分临时改名成点赞。 + +## 2. 数据契约 + +### 2.1 统一字段 + +公开作品卡和创作中心复用的作品摘要都增加: + +```ts +likeCount: number +``` + +当前阶段只做只读展示,不新增点赞按钮和点击 reducer。后端对尚未接入真实点赞表的作品返回 `0`,保证接口 shape 稳定,后续可无 UI 结构迁移地接入真实互动计数。 + +### 2.2 各玩法映射 + +1. RPG 公开广场:`CustomWorldLibraryEntry` 与 `CustomWorldGalleryCard` 返回 `likeCount`,当前由 Rust facade 返回 `0`。 +2. 拼图公开广场:`PuzzleWorkSummary` 返回 `likeCount`,当前由 Rust facade 返回 `0`,`playCount` 继续仅表示游玩次数。 +3. 大鱼公开广场:`BigFishWorkSummary` 返回 `likeCount`,当前由 Rust facade 返回 `0`,`playCount` 继续仅表示游玩次数。 +4. 前端聚合类型 `PlatformPublicGalleryCard` 透传 `likeCount`,`WorldCard` 不再依赖 `badge/metaLabel` 决定主要信息结构。 + +## 3. 移动端布局 + +1. 移动端首页只在 `RpgEntryHomeView` 的 mobile content 内重排。 +2. 第一屏顺序: + - 搜索框 + - 频道横滑 Tab:推荐、今日游戏、游戏分类、PC游戏、即点即玩 + - 作品信息流 +3. 作品信息流使用单列纵向列表,卡片宽度填满容器,卡片之间保留短间距。 +4. 不新增功能说明类长文案;空态仍沿用现有短状态文案。 +5. 移动端卡片视觉允许接近参考图的深色信息流,但仍走平台主题 token,避免写死不可维护的大面积色块。 + +## 4. 作品卡结构 + +每张公开作品卡固定为: + +1. 封面区域:`aspect-ratio: 16 / 9`,图片 `object-cover`;无封面时使用轻量主题底。 +2. 信息区域: + - 第一行:作品名称,右侧点赞数。 + - 第二行:作品描述,两行截断。 + - 第三行:最多三个标签。 +3. 点赞数展示在参考图评分位置,使用心形图标 + 紧凑数字,例如 `128`、`1.2万`。 +4. 不展示作品号;作品号仍只在详情页或分享路径中使用。 + +## 5. 验收 + +1. 390px 移动端首页不横向溢出,能看到搜索、频道和纵向作品列表。 +2. 桌面端首页布局区块顺序不变,只替换公开作品卡内部结构。 +3. RPG、拼图、大鱼三类公开作品卡都有 `likeCount` 字段,前端聚合后能统一展示。 +4. 运行编码检查、前端定向测试和必要的 Rust 检查。 diff --git a/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md b/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md new file mode 100644 index 00000000..3f957856 --- /dev/null +++ b/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md @@ -0,0 +1,87 @@ +# 平台统一作品详情页与 Remix 数据链路设计 + +更新时间:`2026-04-28` + +## 1. 本次目标 + +1. 平台首页、公开广场、分类列表中的每个公开作品点击后,统一先进入作品详情页,不再直接启动玩法。 +2. 作品详情页结构参考 TapTap 详情页:顶部封面图、作品基础信息、右侧 Remix 按钮、四项统计、简介内容、底部启动按钮。 +3. 删除参考图顶部 Tab,不接入评价和论坛功能,不展示“开发者的话”模块。 +4. 统计数据必须从数据库读模型贯穿到前端展示,禁止在前端用假字段、游玩数冒充点赞数或固定文案代替真实字段。 +5. Remix 按钮必须由后端事务复制公开作品为当前用户草稿,并同步增加原作品改造次数,成功后前端进入新草稿详情/结果页。 + +## 2. 详情页 UI 结构 + +统一详情页只做作品展示与动作入口,不承担规则说明。 + +1. 顶部导航:返回按钮、标题“详情”、更多按钮占位;不展示“统计 / 详情 / 评价 / 论坛”Tab。 +2. 封面区:使用作品封面图作为主视觉,背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。 +3. 基础信息区: + - 左侧作品图标使用作品封面或首图。 + - 中间展示作品名、作者名、玩法类型。 + - 右侧原 TapTap 评分位置替换为 `Remix` 按钮。 +4. 统计区固定四项: + - 改造次数:`remixCount` + - 游玩次数:`playCount` + - 点赞次数:`likeCount` + - 上线日期:`publishedAt` +5. 简介区:展示玩法标签和作品简介;不追加说明类文案。 +6. 底部动作:主按钮为“启动”,点击后进入对应玩法运行态并记录游玩次数。 + +## 3. 数据真相源 + +### 3.1 RPG 作品 + +1. `custom_world_profile` 增加 `play_count`、`remix_count`、`like_count`。 +2. `custom_world_gallery_entry` 同步这三项统计,作为公开详情和首页卡片读模型。 +3. `record_custom_world_profile_play` 负责在公开作品启动前递增 `play_count`,只更新已发布且未删除作品。 +4. `remix_custom_world_profile` 在同一事务内: + - 校验源作品已发布、未删除。 + - 递增源作品 `remix_count` 并刷新源作品 gallery。 + - 复制源 profile payload 为当前用户草稿,清空公开编号、发布时间与统计。 + - 返回新草稿 profile,供前端进入草稿详情页。 + +### 3.2 拼图作品 + +1. `puzzle_work_profile` 保留既有 `play_count`,新增 `remix_count`、`like_count`。 +2. `start_puzzle_run` 继续作为游玩次数递增入口。 +3. `remix_puzzle_work` 在同一事务内: + - 校验源 profile 为已发布作品。 + - 递增源作品 `remix_count`。 + - 新建当前用户拼图 Agent session,并把源作品锚点、封面、简介复制为草稿。 + - 新建当前用户草稿 profile,统计归零,返回新草稿 session 与 profile。 +4. API facade 解析拼图 `item_json` / `items_json` 时必须兼容历史公开作品缺失 `play_count`、`remix_count`、`like_count` 的 JSON,缺失值统一按 `0` 处理;新写入数据仍必须写全统计字段。 + +### 3.3 大鱼吃小鱼作品 + +1. `big_fish_creation_session` 现有 `play_count` 继续作为游玩统计,新增 `remix_count`、`like_count`、`published_at`。 +2. `publish_big_fish_game` 写入 `published_at`,公开列表和详情用它展示上线日期。 +3. `record_big_fish_play` 继续作为游玩次数递增入口。 +4. `remix_big_fish_work` 在同一事务内: + - 校验源 session 为已发布作品。 + - 递增源作品 `remix_count`。 + - 新建当前用户创作 session,复制锚点、草稿和资源槽,阶段回到可编辑草稿态。 + - 新 session 的统计归零,返回新草稿 session。 + +## 4. API 与前端接入 + +1. 三类公开作品摘要统一返回:`playCount`、`remixCount`、`likeCount`、`publishedAt`。 +2. Remix API: + - RPG:`POST /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix` + - 拼图:`POST /api/runtime/puzzle/gallery/{profile_id}/remix` + - 大鱼:`POST /api/runtime/big-fish/gallery/{session_id}/remix` +3. 前端统一详情页只消费读模型字段,不自行派生统计。 +4. 首页卡片点击只设置统一详情状态;启动与 Remix 只能在详情页触发。 +5. Remix 成功后的跳转: + - RPG:进入复制出的草稿详情。 + - 拼图:进入复制出的拼图结果页草稿。 + - 大鱼:进入复制出的大鱼结果页草稿。 + +## 5. 验收点 + +1. 三类作品从首页点击均先进入统一作品详情页。 +2. 详情页无评价、论坛 Tab,无开发者的话模块。 +3. 四项统计在前端、共享契约、API facade、SpacetimeDB 表之间字段一致。 +4. Remix 后原作品改造次数增加,新草稿归当前用户所有,且不会继承源作品统计。 +5. 启动公开作品会走对应后端记录入口,刷新后仍能看到递增后的游玩次数。 +6. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。 diff --git a/docs/design/README.md b/docs/design/README.md index 2e39dc9e..ee18cac7 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -10,6 +10,7 @@ - [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。 - [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。 - [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。 +- [PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md](./PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md):平台首页移动端参考图式信息流、双端公开作品卡 16:9 封面结构与点赞数读模型设计。 - [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。 - [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。 - [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。 diff --git a/docs/technical/FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md b/docs/technical/FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md index 57535d24..86131bf0 100644 --- a/docs/technical/FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md +++ b/docs/technical/FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md @@ -16,22 +16,23 @@ ## 页面路径表 -| 页面阶段 | 路径 | 说明 | -| --- | --- | --- | -| `platform` | `/` | 平台首页、广场、我的、创作中心等主入口 | -| `detail` | `/worlds/detail` | RPG 世界详情页,依赖当前已选作品 | -| `agent-workspace` | `/creation/rpg/agent` | RPG Agent 共创工作区 | -| `custom-world-generating` | `/creation/rpg/generating` | RPG 世界草稿生成进度页 | -| `custom-world-result` | `/creation/rpg/result` | RPG 世界结果页与编辑页 | -| `big-fish-agent-workspace` | `/creation/big-fish/agent` | 大鱼吃小鱼 Agent 共创工作区 | -| `big-fish-result` | `/creation/big-fish/result` | 大鱼吃小鱼草稿结果页 | -| `big-fish-runtime` | `/runtime/big-fish` | 正式链路中的大鱼吃小鱼运行页 | -| `puzzle-agent-workspace` | `/creation/puzzle/agent` | 拼图 Agent 共创工作区 | -| `puzzle-result` | `/creation/puzzle/result` | 拼图草稿结果页 | -| `puzzle-gallery-detail` | `/gallery/puzzle/detail` | 拼图作品详情页,依赖当前已选作品 | -| `puzzle-runtime` | `/runtime/puzzle` | 正式链路中的拼图运行页 | -| RPG 选角页 | `/runtime/rpg/characters` | 进入世界后、确认角色前的选角阶段 | -| RPG 冒险页 | `/runtime/rpg/adventure` | 已确认角色后的 RPG 主运行态 | +| 页面阶段 | 路径 | 说明 | +| -------------------------- | --------------------------- | ------------------------------------------------------ | +| `platform` | `/` | 平台首页、广场、我的、创作中心等主入口 | +| `work-detail` | `/works/detail` | 统一公开作品详情页,承接 RPG、拼图、大鱼吃小鱼公开作品 | +| `detail` | `/worlds/detail` | RPG 世界详情页,依赖当前已选作品 | +| `agent-workspace` | `/creation/rpg/agent` | RPG Agent 共创工作区 | +| `custom-world-generating` | `/creation/rpg/generating` | RPG 世界草稿生成进度页 | +| `custom-world-result` | `/creation/rpg/result` | RPG 世界结果页与编辑页 | +| `big-fish-agent-workspace` | `/creation/big-fish/agent` | 大鱼吃小鱼 Agent 共创工作区 | +| `big-fish-result` | `/creation/big-fish/result` | 大鱼吃小鱼草稿结果页 | +| `big-fish-runtime` | `/runtime/big-fish` | 正式链路中的大鱼吃小鱼运行页 | +| `puzzle-agent-workspace` | `/creation/puzzle/agent` | 拼图 Agent 共创工作区 | +| `puzzle-result` | `/creation/puzzle/result` | 拼图草稿结果页 | +| `puzzle-gallery-detail` | `/gallery/puzzle/detail` | 拼图作品详情页,依赖当前已选作品 | +| `puzzle-runtime` | `/runtime/puzzle` | 正式链路中的拼图运行页 | +| RPG 选角页 | `/runtime/rpg/characters` | 进入世界后、确认角色前的选角阶段 | +| RPG 冒险页 | `/runtime/rpg/adventure` | 已确认角色后的 RPG 主运行态 | ## 落地边界 diff --git a/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md index 53d24314..e481a2de 100644 --- a/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md +++ b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md @@ -16,7 +16,7 @@ ## 作品分享路由补充 -1. 公开作品入口路由统一使用当前作品页面路径加 `work=作品号`:RPG 为 `/worlds/detail?work=CW-00000001`,拼图为 `/gallery/puzzle/detail?work=PZ-00000001`,大鱼玩法为 `/runtime/big-fish?work=BF-00000001`。 +1. 公开作品入口路由统一使用 `/works/detail?work=作品号`,三类作品在该页先展示统一公开详情,再由“启动”进入对应运行态;旧 `/worlds/detail` 与 `/gallery/puzzle/detail` 只保留给既有创作/编辑链路。 2. 从公开广场、最近浏览、创作中心打开已发布作品详情或玩法时,若当前作品有公开作品号,地址栏必须同步追加 `work=作品号`;没有作品号的草稿详情仍保持无查询参数路径。 3. 首次进入主应用时若 URL 带 `work` 查询参数,平台入口自动复用现有公开编号搜索逻辑打开对应作品详情,不新增独立详情系统。 4. 详情页必须保留“复制作品号”和“分享作品”两个独立动作: @@ -34,5 +34,5 @@ 5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。 6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。 7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。 -8. 打开 `/?work=CW-00000001`、`/worlds/detail?work=CW-00000001`、`/gallery/puzzle/detail?work=PZ-00000001` 或 `/runtime/big-fish?work=BF-00000001` 后能自动进入对应公开作品详情或玩法。 +8. 打开 `/?work=CW-00000001` 或 `/works/detail?work=CW-00000001` 后能自动进入对应公开作品详情;拼图、大鱼吃小鱼作品号同样进入统一公开详情后再启动玩法。 9. 点击详情页“分享作品”后,剪切板内容包含邀请文本、作品号和当前站点下带 `work=作品号` 的完整网址。 diff --git a/docs/technical/RPG_NPC_CHAT_TRANSIENT_SNAPSHOT_SESSION_FIX_2026-04-28.md b/docs/technical/RPG_NPC_CHAT_TRANSIENT_SNAPSHOT_SESSION_FIX_2026-04-28.md new file mode 100644 index 00000000..e8b4f233 --- /dev/null +++ b/docs/technical/RPG_NPC_CHAT_TRANSIENT_SNAPSHOT_SESSION_FIX_2026-04-28.md @@ -0,0 +1,49 @@ +# RPG NPC 聊天禁存快照会话修复(2026-04-28) + +## 背景 + +复测 NPC 对话时,前端请求: + +```text +POST /api/runtime/chat/npc/turn/stream +``` + +返回 `409 Conflict`,错误为: + +```text +请求的运行时会话与服务端快照不一致,请重新进入游戏 +``` + +当前 story action 主链已经支持 `runtimePersistenceDisabled = true` 的运行态:这类请求会携带临时 `snapshot`,后端只构造本次响应,不写入 `runtime_snapshot`。但 NPC 聊天接口在带 `sessionId` 时只从正式 `runtime_snapshot` 读取上下文。若当前运行态是作品测试、幕预览或其他禁存 run,服务端正式快照仍可能是上一局正式存档,导致 `sessionId` 与 `snapshotSessionId` 不一致。 + +## 修复原则 + +1. 正式 `play` 且允许持久化的运行态,聊天请求继续只提交 `sessionId` 与聊天输入,保持后端快照为唯一来源。 +2. `runtimePersistenceDisabled = true` 或历史 `runtimeMode = preview/test` 的运行态,聊天请求必须携带临时 `snapshot`。 +3. 后端聊天接口收到临时 `snapshot` 时,只用它投影本轮 prompt context、NPC state、history、encounter,不写入 `runtime_snapshot`。 +4. 临时 `snapshot.gameState.runtimeSessionId` 必须与请求 `sessionId` 一致;不一致仍返回 `409`。 +5. 前端不得把完整快照默认用于正式游玩聊天,避免重新扩大浏览器对正式真相链的写入权。 + +## 工程落点 + +1. `packages/shared/src/contracts/rpgRuntimeChat.ts` + - 给角色聊天、NPC 对话、NPC 单轮、招募对话请求补可选 `snapshot` 字段。 +2. `src/services/aiService.ts` + - 增加禁存运行态 snapshot 构造。 + - 仅在 `context.runtimeSnapshot` 存在时,随聊天请求提交 `snapshot`。 +3. `src/services/aiTypes.ts` + - `StoryGenerationContext` 增加可选 `runtimeSnapshot`。 +4. `src/hooks/rpg-runtime-story/storyContextBuilder.ts` + - 禁存运行态把当前 `GameState` 作为临时 snapshot 注入 context。 +5. `server-rs/crates/api-server/src/runtime_chat.rs` + - `NpcChatTurnRequest` 接收 `snapshot`。 + - 优先使用请求 snapshot 校验并投影上下文。 +6. `server-rs/crates/api-server/src/runtime_chat_plain.rs` + - 角色聊天、NPC 开场、招募同样支持临时 snapshot。 + +## 验收 + +1. 禁存运行态 NPC 聊天请求体携带 `snapshot`,正式运行态不携带。 +2. 后端 NPC 聊天在请求 snapshot 与正式存档 session 不一致时,仍按请求 snapshot 成功投影,不读取旧正式存档。 +3. snapshot 内部 `runtimeSessionId` 与请求 `sessionId` 不一致时返回 `409`。 +4. 相关前端单测、后端 `runtime_chat` 单测、编码检查通过。 diff --git a/packages/shared/src/contracts/bigFishWorkSummary.ts b/packages/shared/src/contracts/bigFishWorkSummary.ts index 21b7f2a3..e785ff77 100644 --- a/packages/shared/src/contracts/bigFishWorkSummary.ts +++ b/packages/shared/src/contracts/bigFishWorkSummary.ts @@ -10,12 +10,15 @@ export interface BigFishWorkSummary { coverImageSrc: string | null; status: BigFishWorkStatus; updatedAt: string; + publishedAt?: string | null; publishReady: boolean; levelCount: number; levelMainImageReadyCount: number; levelMotionReadyCount: number; backgroundReady: boolean; playCount?: number; + remixCount?: number; + likeCount?: number; } export interface BigFishWorksResponse { diff --git a/packages/shared/src/contracts/puzzleWorkSummary.ts b/packages/shared/src/contracts/puzzleWorkSummary.ts index 0dd02f57..1b1dfe6d 100644 --- a/packages/shared/src/contracts/puzzleWorkSummary.ts +++ b/packages/shared/src/contracts/puzzleWorkSummary.ts @@ -17,7 +17,9 @@ export interface PuzzleWorkSummary { publicationStatus: PuzzleWorkPublicationStatus; updatedAt: string; publishedAt: string | null; - playCount: number; + playCount?: number; + remixCount?: number; + likeCount?: number; publishReady: boolean; } diff --git a/packages/shared/src/contracts/rpgCreationFixtures.ts b/packages/shared/src/contracts/rpgCreationFixtures.ts index 913216a8..23943e30 100644 --- a/packages/shared/src/contracts/rpgCreationFixtures.ts +++ b/packages/shared/src/contracts/rpgCreationFixtures.ts @@ -1,7 +1,3 @@ -import type { - CustomWorldLibraryEntry, - CustomWorldProfileRecord, -} from './runtime'; import type { RpgAgentSupportedAction } from './rpgAgentActions'; import type { RpgCreationAnchorContent } from './rpgAgentAnchors'; import type { @@ -15,6 +11,10 @@ import type { ListRpgCreationWorksResponse, RpgCreationWorkSummary, } from './rpgCreationWorkSummary'; +import type { + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from './runtime'; const RPG_CREATION_FIXTURE_SESSION_ID = 'rpg-session-fixture'; const RPG_CREATION_FIXTURE_PROFILE_ID = 'rpg-profile-fixture'; @@ -661,6 +661,7 @@ export function createRpgWorldLibraryEntryFixture(): CustomWorldLibraryEntry); } diff --git a/packages/shared/src/contracts/rpgRuntimeChat.ts b/packages/shared/src/contracts/rpgRuntimeChat.ts index 6cc7b1ad..3a274967 100644 --- a/packages/shared/src/contracts/rpgRuntimeChat.ts +++ b/packages/shared/src/contracts/rpgRuntimeChat.ts @@ -3,6 +3,7 @@ * 将角色聊天、NPC 对话与轻量 story 请求载荷从旧 story.ts 中独立出来。 */ import type { JsonObject } from './common'; +import type { SavedGameSnapshotInput } from './runtime'; export type NpcChatTurnLimitReason = 'negative_affinity'; @@ -46,8 +47,15 @@ export type CharacterChatReplyRequest< TContext = unknown, TConversationTurn = unknown, TTargetStatus = unknown, + TSnapshotGameState = unknown, + TSnapshotCurrentStory = unknown, > = { sessionId?: string; + snapshot?: SavedGameSnapshotInput< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; worldType?: string; playerCharacter?: TCharacter; targetCharacter: TCharacter; @@ -65,8 +73,15 @@ export type CharacterChatSuggestionsRequest< TContext = unknown, TConversationTurn = unknown, TTargetStatus = unknown, + TSnapshotGameState = unknown, + TSnapshotCurrentStory = unknown, > = { sessionId?: string; + snapshot?: SavedGameSnapshotInput< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; worldType?: string; playerCharacter?: TCharacter; targetCharacter: TCharacter; @@ -83,8 +98,15 @@ export type CharacterChatSummaryRequest< TContext = unknown, TConversationTurn = unknown, TTargetStatus = unknown, + TSnapshotGameState = unknown, + TSnapshotCurrentStory = unknown, > = { sessionId?: string; + snapshot?: SavedGameSnapshotInput< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; worldType?: string; playerCharacter?: TCharacter; targetCharacter: TCharacter; @@ -101,8 +123,15 @@ export type NpcChatDialogueRequest< TMonster = unknown, TStoryMoment = unknown, TContext = unknown, + TSnapshotGameState = unknown, + TSnapshotCurrentStory = unknown, > = { sessionId?: string; + snapshot?: SavedGameSnapshotInput< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; worldType?: string; character?: TCharacter; encounter: TEncounter; @@ -126,8 +155,15 @@ export type NpcChatTurnRequest< TQuestOfferState = unknown, TQuestOfferEncounter = unknown, TChatDirective = NpcChatTurnDirective, + TSnapshotGameState = unknown, + TSnapshotCurrentStory = unknown, > = { sessionId?: string; + snapshot?: SavedGameSnapshotInput< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; worldType?: string; character?: TCharacter; player?: TCharacter; @@ -175,8 +211,15 @@ export type NpcRecruitDialogueRequest< TMonster = unknown, TStoryMoment = unknown, TContext = unknown, + TSnapshotGameState = unknown, + TSnapshotCurrentStory = unknown, > = { sessionId?: string; + snapshot?: SavedGameSnapshotInput< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; worldType?: string; character?: TCharacter; encounter: TEncounter; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e8d7f4c5..e0a3a5f0 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -249,6 +249,9 @@ export type CustomWorldLibraryEntry = { themeMode: CustomWorldThemeMode; playableNpcCount: number; landmarkCount: number; + playCount?: number; + remixCount?: number; + likeCount?: number; }; export type CustomWorldGalleryCard = Omit< diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d450f9bd..373506c5 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -35,7 +35,7 @@ use crate::{ big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play, - stream_big_fish_message, submit_big_fish_message, + remix_big_fish_gallery_work, stream_big_fish_message, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -56,6 +56,7 @@ use crate::{ get_custom_world_gallery_detail_by_code, get_custom_world_library, get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery, publish_custom_world_library_profile, put_custom_world_library_profile, + record_custom_world_gallery_play, remix_custom_world_gallery_profile, stream_custom_world_agent_message, submit_custom_world_agent_message, unpublish_custom_world_library_profile, }, @@ -84,8 +85,8 @@ use crate::{ delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action, get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, - start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, - submit_puzzle_leaderboard, swap_puzzle_pieces, + remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message, + submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -522,6 +523,20 @@ pub fn build_router(state: AppState) -> Router { "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}", get(get_custom_world_gallery_detail), ) + .route( + "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix", + post(remix_custom_world_gallery_profile).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play", + post(record_custom_world_gallery_play).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/custom-world-gallery/by-code/{code}", get(get_custom_world_gallery_detail_by_code), @@ -634,6 +649,13 @@ pub fn build_router(state: AppState) -> Router { )), ) .route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery)) + .route( + "/api/runtime/big-fish/gallery/{session_id}/remix", + post(remix_big_fish_gallery_work).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/big-fish/works/{session_id}", delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state( @@ -712,6 +734,13 @@ pub fn build_router(state: AppState) -> Router { "/api/runtime/puzzle/gallery/{profile_id}", get(get_puzzle_gallery_detail), ) + .route( + "/api/runtime/puzzle/gallery/{profile_id}/remix", + post(remix_puzzle_gallery_work).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/puzzle/runs", post(start_puzzle_run).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 66aea5b6..dbf5f79c 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -35,7 +35,7 @@ use spacetime_client::{ BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord, - BigFishWorkSummaryRecord, SpacetimeClientError, + BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, SpacetimeClientError, }; use tokio::time::sleep; @@ -251,6 +251,36 @@ pub async fn record_big_fish_play( )) } +pub async fn remix_big_fish_gallery_work( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + + let session = state + .spacetime_client() + .remix_big_fish_work(BigFishWorkRemixRecordInput { + source_session_id: session_id, + target_session_id: build_prefixed_uuid_id("big-fish-session-"), + target_owner_user_id: authenticated.claims().user_id().to_string(), + welcome_message_id: build_prefixed_uuid_id("big-fish-message-"), + remixed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishSessionResponse { + session: map_big_fish_session_response(session), + }, + )) +} + pub async fn submit_big_fish_message( State(state): State, Path(session_id): Path, @@ -906,12 +936,15 @@ fn map_big_fish_work_summary_response( cover_image_src: item.cover_image_src, status: item.status, updated_at: current_timestamp_micros_to_string(item.updated_at_micros), + published_at: item.published_at_micros.map(current_timestamp_micros_to_string), publish_ready: item.publish_ready, level_count: item.level_count, level_main_image_ready_count: item.level_main_image_ready_count, level_motion_ready_count: item.level_motion_ready_count, background_ready: item.background_ready, play_count: item.play_count, + remix_count: item.remix_count, + like_count: item.like_count, } } diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 43fe3f10..b1f789d3 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -38,6 +38,7 @@ use spacetime_client::{ CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, + CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError, @@ -758,6 +759,82 @@ pub async fn get_custom_world_gallery_detail_by_code( )) } +pub async fn remix_custom_world_gallery_profile( + State(state): State, + Path((owner_user_id, profile_id)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-gallery", + "message": "ownerUserId and profileId are required", + })), + )); + } + + let mutation = state + .spacetime_client() + .remix_custom_world_profile(CustomWorldProfileRemixRecordInput { + source_owner_user_id: owner_user_id, + source_profile_id: profile_id, + target_owner_user_id: authenticated.claims().user_id().to_string(), + target_profile_id: build_prefixed_uuid_id("custom-world-profile-"), + author_display_name: resolve_author_display_name(&state, &authenticated), + remixed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldLibraryMutationResponse { + entry: map_custom_world_library_entry_response(mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response(mutation.entry)], + }, + )) +} + +pub async fn record_custom_world_gallery_play( + State(state): State, + Path((owner_user_id, profile_id)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result, Response> { + if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-gallery", + "message": "ownerUserId and profileId are required", + })), + )); + } + + let mutation = state + .spacetime_client() + .record_custom_world_profile_play(CustomWorldProfilePlayReportRecordInput { + owner_user_id, + profile_id, + played_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldGalleryDetailResponse { + entry: map_custom_world_library_entry_response(mutation.entry), + }, + )) +} + pub async fn create_custom_world_agent_session( State(state): State, Extension(request_context): Extension, @@ -2632,6 +2709,9 @@ fn map_custom_world_library_entry_response( theme_mode: entry.theme_mode, playable_npc_count: entry.playable_npc_count, landmark_count: entry.landmark_count, + play_count: entry.play_count, + remix_count: entry.remix_count, + like_count: entry.like_count, } } @@ -2654,6 +2734,9 @@ fn map_custom_world_gallery_card_response( theme_mode: entry.theme_mode, playable_npc_count: entry.playable_npc_count, landmark_count: entry.landmark_count, + play_count: entry.play_count, + remix_count: entry.remix_count, + like_count: entry.like_count, } } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 63b69a89..66e33d10 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -59,7 +59,7 @@ use spacetime_client::{ PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, - PuzzleWorkUpsertRecordInput, SpacetimeClientError, + PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; @@ -882,6 +882,49 @@ pub async fn get_puzzle_gallery_detail( )) } +pub async fn remix_puzzle_gallery_work( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .remix_puzzle_work(PuzzleWorkRemixRecordInput { + source_profile_id: profile_id, + target_owner_user_id: owner_user_id, + target_session_id: build_prefixed_uuid_id("puzzle-session-"), + target_profile_id: build_prefixed_uuid_id("puzzle-profile-"), + target_work_id: build_prefixed_uuid_id("puzzle-work-"), + author_display_name: resolve_author_display_name(&state, &authenticated), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + remixed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), + }, + )) +} + pub async fn start_puzzle_run( State(state): State, Extension(request_context): Extension, @@ -1354,6 +1397,8 @@ fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWork updated_at: item.updated_at, published_at: item.published_at, play_count: item.play_count, + remix_count: item.remix_count, + like_count: item.like_count, publish_ready: item.publish_ready, } } diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index 8f713229..aa418b6d 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -10,6 +10,7 @@ use axum::{ use platform_llm::{LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::{Value, json}; +use shared_contracts::runtime_story::RuntimeStorySnapshotPayload; use std::convert::Infallible; use module_runtime_story_compat::{ @@ -38,6 +39,8 @@ pub struct NpcChatTurnRequest { #[serde(default)] session_id: Option, #[serde(default)] + snapshot: Option, + #[serde(default)] world_type: String, #[serde(default)] character: Option, @@ -292,6 +295,16 @@ async fn hydrate_npc_chat_turn_request_from_session( // 中文注释:旧调用没有 sessionId 时继续使用请求体字段;正式运行态由后端快照投影上下文。 return Ok(()); }; + + if let Some(game_state) = resolve_request_snapshot_game_state( + request_context, + session_id.as_str(), + payload.snapshot.as_ref(), + )? { + apply_npc_chat_turn_game_state(payload, game_state); + return Ok(()); + } + let record = state .get_runtime_snapshot_record(user_id) .await @@ -328,6 +341,49 @@ async fn hydrate_npc_chat_turn_request_from_session( )); } + apply_npc_chat_turn_game_state(payload, game_state); + + Ok(()) +} + +fn resolve_request_snapshot_game_state( + request_context: &RequestContext, + session_id: &str, + snapshot: Option<&RuntimeStorySnapshotPayload>, +) -> Result, Response> { + let Some(snapshot) = snapshot else { + return Ok(None); + }; + if !snapshot.game_state.is_object() { + return Err(runtime_chat_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-chat", + "field": "snapshot.gameState", + "message": "snapshot.gameState 必须是 JSON object", + })), + )); + } + + let snapshot_session_id = + read_runtime_session_id(&snapshot.game_state).unwrap_or_else(|| session_id.to_string()); + if snapshot_session_id != session_id { + return Err(runtime_chat_error_response( + request_context, + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "runtime-chat", + "message": "请求的运行时会话与服务端快照不一致,请重新进入游戏", + "sessionId": session_id, + "snapshotSessionId": snapshot_session_id, + })), + )); + } + + // 中文注释:预览/测试/禁存运行态只把请求 snapshot 用于本轮 prompt 投影,不写入正式存档。 + Ok(Some(snapshot.game_state.clone())) +} + +fn apply_npc_chat_turn_game_state(payload: &mut NpcChatTurnRequest, game_state: Value) { payload.world_type = current_world_type(&game_state).unwrap_or_default(); payload.character = read_field(&game_state, "playerCharacter").cloned(); payload.player = payload.character.clone(); @@ -361,8 +417,6 @@ async fn hydrate_npc_chat_turn_request_from_session( object.insert("state".to_string(), game_state); } } - - Ok(()) } fn resolve_current_request_npc_state(game_state: &Value) -> Option { @@ -709,6 +763,8 @@ fn runtime_chat_error_response(request_context: &RequestContext, error: AppError #[cfg(test)] mod tests { use super::*; + use crate::{config::AppConfig, request_context::RequestContext, state::AppState}; + use std::time::Duration; #[test] fn npc_chat_affinity_delta_keeps_node_keyword_rules() { @@ -752,4 +808,129 @@ mod tests { vec!["继续问线索", "表明立场", "拉近关系"] ); } + + #[tokio::test] + async fn npc_chat_turn_prefers_request_snapshot_over_persisted_session() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .put_runtime_snapshot_record( + "user_00000001".to_string(), + 1, + "adventure".to_string(), + json!({ + "worldType": "WUXIA", + "runtimeSessionId": "runtime-main", + "playerCharacter": { "id": "hero-main", "name": "旧存档" }, + "currentEncounter": { "id": "npc-main", "npcName": "旧 NPC" }, + "sceneHostileNpcs": [], + "storyHistory": [], + }), + None, + 1, + ) + .await + .expect("snapshot should seed"); + let request_context = test_request_context(); + let mut payload = test_npc_chat_turn_payload( + "runtime-preview", + Some(json!({ + "worldType": "CUSTOM", + "runtimeSessionId": "runtime-preview", + "runtimePersistenceDisabled": true, + "playerCharacter": { "id": "hero-preview", "name": "临时角色" }, + "currentEncounter": { "id": "npc-preview", "npcName": "临时 NPC" }, + "sceneHostileNpcs": [{ "id": "monster-preview", "name": "雾影" }], + "storyHistory": [{ "text": "临时故事" }], + "npcStates": { + "npc-preview": { + "affinity": 12, + "helpUsed": false, + "chattedCount": 2, + "giftsGiven": 0, + "recruited": false + } + } + })), + ); + + hydrate_npc_chat_turn_request_from_session( + &state, + &request_context, + "user_00000001".to_string(), + &mut payload, + ) + .await + .expect("request snapshot should hydrate"); + + assert_eq!(payload.world_type, "CUSTOM"); + assert_eq!( + read_optional_string_field(&payload.encounter, "npcName").as_deref(), + Some("临时 NPC") + ); + assert_eq!(payload.monsters.len(), 1); + assert_eq!(read_i32_field(&payload.npc_state, "affinity"), Some(12)); + } + + #[tokio::test] + async fn npc_chat_turn_rejects_request_snapshot_session_mismatch() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let request_context = test_request_context(); + let mut payload = test_npc_chat_turn_payload( + "runtime-preview", + Some(json!({ + "worldType": "WUXIA", + "runtimeSessionId": "runtime-other", + })), + ); + + let response = hydrate_npc_chat_turn_request_from_session( + &state, + &request_context, + "user_00000001".to_string(), + &mut payload, + ) + .await + .expect_err("snapshot session mismatch should fail"); + + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + fn test_request_context() -> RequestContext { + RequestContext::new( + "runtime-chat-test".to_string(), + "POST /api/runtime/chat/npc/turn/stream".to_string(), + Duration::ZERO, + false, + ) + } + + fn test_npc_chat_turn_payload( + session_id: &str, + game_state: Option, + ) -> NpcChatTurnRequest { + NpcChatTurnRequest { + session_id: Some(session_id.to_string()), + snapshot: game_state.map(|game_state| RuntimeStorySnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state, + current_story: None, + }), + world_type: String::new(), + character: None, + player: None, + encounter: json!({ "id": "npc-request", "npcName": "请求 NPC" }), + monsters: Vec::new(), + history: Vec::new(), + context: Value::Null, + conversation_history: Vec::new(), + dialogue: Vec::new(), + combat_context: None, + player_message: "你刚才看见了什么?".to_string(), + npc_state: Value::Null, + npc_initiates_conversation: false, + quest_offer_context: None, + chat_directive: None, + } + } } diff --git a/server-rs/crates/api-server/src/runtime_chat_plain.rs b/server-rs/crates/api-server/src/runtime_chat_plain.rs index 622418f3..cbd79513 100644 --- a/server-rs/crates/api-server/src/runtime_chat_plain.rs +++ b/server-rs/crates/api-server/src/runtime_chat_plain.rs @@ -10,6 +10,7 @@ use axum::{ use platform_llm::{LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::{Value, json}; +use shared_contracts::runtime_story::RuntimeStorySnapshotPayload; use std::convert::Infallible; use crate::{ @@ -27,6 +28,8 @@ pub struct RuntimeCharacterChatRequest { #[serde(default)] session_id: Option, #[serde(default)] + snapshot: Option, + #[serde(default)] world_type: String, #[serde(default)] player_character: Value, @@ -54,6 +57,8 @@ pub struct RuntimeNpcDialogueRequest { #[serde(default)] session_id: Option, #[serde(default)] + snapshot: Option, + #[serde(default)] world_type: String, #[serde(default)] character: Value, @@ -77,6 +82,8 @@ pub struct RuntimeNpcRecruitDialogueRequest { #[serde(default)] session_id: Option, #[serde(default)] + snapshot: Option, + #[serde(default)] world_type: String, #[serde(default)] character: Value, @@ -346,6 +353,7 @@ async fn hydrate_character_chat_request_from_session( request_context, user_id, payload.session_id.as_deref(), + payload.snapshot.as_ref(), ) .await? else { @@ -382,6 +390,7 @@ async fn hydrate_npc_dialogue_request_from_session( request_context, user_id, payload.session_id.as_deref(), + payload.snapshot.as_ref(), ) .await? else { @@ -430,6 +439,7 @@ async fn hydrate_npc_recruit_request_from_session( request_context, user_id, payload.session_id.as_deref(), + payload.snapshot.as_ref(), ) .await? else { @@ -472,11 +482,19 @@ async fn resolve_runtime_chat_game_state( request_context: &RequestContext, user_id: String, session_id: Option<&str>, + snapshot: Option<&RuntimeStorySnapshotPayload>, ) -> Result, Response> { let Some(session_id) = session_id.and_then(normalize_required_string) else { // 中文注释:未携带 sessionId 的旧调用仅保留兼容,后续正式运行态应全部走后端快照。 return Ok(None); }; + + if let Some(game_state) = + resolve_request_snapshot_game_state(request_context, session_id.as_str(), snapshot)? + { + return Ok(Some(game_state)); + } + let record = state .get_runtime_snapshot_record(user_id) .await @@ -516,6 +534,43 @@ async fn resolve_runtime_chat_game_state( Ok(Some(game_state)) } +fn resolve_request_snapshot_game_state( + request_context: &RequestContext, + session_id: &str, + snapshot: Option<&RuntimeStorySnapshotPayload>, +) -> Result, Response> { + let Some(snapshot) = snapshot else { + return Ok(None); + }; + if !snapshot.game_state.is_object() { + return Err(runtime_plain_chat_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-chat", + "field": "snapshot.gameState", + "message": "snapshot.gameState 必须是 JSON object", + })), + )); + } + + let snapshot_session_id = + read_runtime_session_id(&snapshot.game_state).unwrap_or_else(|| session_id.to_string()); + if snapshot_session_id != session_id { + return Err(runtime_plain_chat_error_response( + request_context, + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "runtime-chat", + "message": "请求的运行时会话与服务端快照不一致,请重新进入游戏", + "sessionId": session_id, + "snapshotSessionId": snapshot_session_id, + })), + )); + } + + // 中文注释:临时运行态聊天只读取请求 snapshot 构造上下文,不把它写回 runtime_snapshot。 + Ok(Some(snapshot.game_state.clone())) +} + async fn request_runtime_plain_text( state: &AppState, system_prompt: &'static str, diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index 74bd0ea7..29ad42b9 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -226,6 +226,9 @@ pub struct BigFishWorkSummarySnapshot { pub level_motion_ready_count: u32, pub background_ready: bool, pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub published_at_micros: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -242,6 +245,16 @@ pub struct BigFishWorkDeleteInput { pub owner_user_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishWorkRemixInput { + pub source_session_id: String, + pub target_session_id: String, + pub target_owner_user_id: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorksProcedureResult { diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index 2898856b..41cc8be9 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -185,6 +185,9 @@ pub struct CustomWorldProfileSnapshot { pub profile_payload_json: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub author_display_name: String, pub published_at_micros: Option, pub deleted_at_micros: Option, @@ -207,6 +210,9 @@ pub struct CustomWorldGalleryEntrySnapshot { pub theme_mode: CustomWorldThemeMode, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub published_at_micros: i64, pub updated_at_micros: i64, } @@ -484,6 +490,25 @@ pub struct CustomWorldGalleryDetailByCodeInput { pub public_work_code: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileRemixInput { + pub source_owner_user_id: String, + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_profile_id: String, + pub author_display_name: String, + pub remixed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfilePlayRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub played_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CustomWorldAgentSessionCreateInput { diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 28c7ddf7..264c8c78 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -208,7 +208,12 @@ pub struct PuzzleWorkProfile { pub publication_status: PuzzlePublicationStatus, pub updated_at_micros: i64, pub published_at_micros: Option, + #[serde(default)] pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, pub publish_ready: bool, pub anchor_pack: PuzzleAnchorPack, } @@ -407,6 +412,19 @@ pub struct PuzzleWorkUpsertInput { pub updated_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleWorkRemixInput { + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_session_id: String, + pub target_profile_id: String, + pub target_work_id: String, + pub author_display_name: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunStartInput { @@ -846,6 +864,8 @@ pub fn create_work_profile( updated_at_micros, published_at_micros: None, play_count: 0, + remix_count: 0, + like_count: 0, publish_ready: preview.publish_ready, anchor_pack: draft.anchor_pack.clone(), }) @@ -2015,6 +2035,8 @@ mod tests { updated_at_micros: 100, published_at_micros: Some(100), play_count: 0, + remix_count: 0, + like_count: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), } diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs index b44cd94a..2b17ebd7 100644 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -13,6 +13,8 @@ pub struct BigFishWorkSummaryResponse { pub cover_image_src: Option, pub status: String, pub updated_at: String, + #[serde(default)] + pub published_at: Option, pub publish_ready: bool, pub level_count: u32, pub level_main_image_ready_count: u32, @@ -20,6 +22,10 @@ pub struct BigFishWorkSummaryResponse { pub background_ready: bool, #[serde(default)] pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/puzzle_works.rs b/server-rs/crates/shared-contracts/src/puzzle_works.rs index 8c2e4bdb..c5e75595 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_works.rs @@ -35,6 +35,10 @@ pub struct PuzzleWorkSummaryResponse { #[serde(default)] pub published_at: Option, pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, pub publish_ready: bool, } diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 8710d78c..1ae0753e 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -437,6 +437,12 @@ pub struct CustomWorldLibraryEntryResponse { pub theme_mode: String, pub playable_npc_count: u32, pub landmark_count: u32, + #[serde(default)] + pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -457,6 +463,12 @@ pub struct CustomWorldGalleryCardResponse { pub theme_mode: String, pub playable_npc_count: u32, pub landmark_count: u32, + #[serde(default)] + pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 626a7d92..98151507 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -2,6 +2,7 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play; +use crate::module_bindings::remix_big_fish_work_procedure::remix_big_fish_work; impl SpacetimeClient { pub async fn create_big_fish_session( @@ -290,4 +291,29 @@ impl SpacetimeClient { }) .await } + + pub async fn remix_big_fish_work( + &self, + input: BigFishWorkRemixRecordInput, + ) -> Result { + let procedure_input = BigFishWorkRemixInput { + source_session_id: input.source_session_id, + target_session_id: input.target_session_id, + target_owner_user_id: input.target_owner_user_id, + welcome_message_id: input.welcome_message_id, + remixed_at_micros: input.remixed_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .remix_big_fish_work_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } } diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs index 49b5aea2..35642923 100644 --- a/server-rs/crates/spacetime-client/src/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -1,6 +1,8 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_custom_world_agent_session_procedure::delete_custom_world_agent_session; +use crate::module_bindings::record_custom_world_profile_play_procedure::record_custom_world_profile_play; +use crate::module_bindings::remix_custom_world_profile_procedure::remix_custom_world_profile; impl SpacetimeClient { pub async fn list_custom_world_profiles( @@ -209,6 +211,55 @@ impl SpacetimeClient { .await } + pub async fn remix_custom_world_profile( + &self, + input: CustomWorldProfileRemixRecordInput, + ) -> Result { + let procedure_input = CustomWorldProfileRemixInput { + source_owner_user_id: input.source_owner_user_id, + source_profile_id: input.source_profile_id, + target_owner_user_id: input.target_owner_user_id, + target_profile_id: input.target_profile_id, + author_display_name: input.author_display_name, + remixed_at_micros: input.remixed_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .remix_custom_world_profile_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn record_custom_world_profile_play( + &self, + input: CustomWorldProfilePlayReportRecordInput, + ) -> Result { + let procedure_input = CustomWorldProfilePlayRecordInput { + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + played_at_micros: input.played_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .record_custom_world_profile_play_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn publish_custom_world_world( &self, input: CustomWorldPublishWorldRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index b164406e..9ad8af8a 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -20,8 +20,9 @@ pub use mapper::{ CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, - CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, - CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput, + CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, + CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord, + CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord, PuzzleAgentMessageFinalizeRecordInput, @@ -33,10 +34,10 @@ pub use mapper::{ PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, PuzzleWorkRemixRecordInput, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, - ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, + ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, BigFishWorkRemixRecordInput, }; pub mod ai; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 96e09a5f..a7f7cd2e 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1885,6 +1885,9 @@ pub(crate) fn map_custom_world_library_entry_from_profile_snapshot( .to_string(), playable_npc_count: snapshot.playable_npc_count, landmark_count: snapshot.landmark_count, + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, }) } @@ -1910,6 +1913,9 @@ pub(crate) fn map_custom_world_gallery_entry_snapshot( .to_string(), playable_npc_count: snapshot.playable_npc_count, landmark_count: snapshot.landmark_count, + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, }) } @@ -2387,6 +2393,8 @@ pub(crate) fn map_puzzle_work_profile( updated_at: format_timestamp_micros(snapshot.updated_at_micros), published_at: snapshot.published_at_micros.map(format_timestamp_micros), play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, publish_ready: snapshot.publish_ready, anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), } @@ -3937,6 +3945,9 @@ pub struct CustomWorldLibraryEntryRecord { pub theme_mode: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -3956,6 +3967,9 @@ pub struct CustomWorldGalleryEntryRecord { pub theme_mode: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, } #[derive(Clone, Debug, PartialEq)] @@ -4172,6 +4186,23 @@ pub struct CustomWorldProfileUpsertRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfileRemixRecordInput { + pub source_owner_user_id: String, + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_profile_id: String, + pub author_display_name: String, + pub remixed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfilePlayReportRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub played_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CustomWorldPublishWorldRecordInput { pub session_id: String, @@ -4334,6 +4365,18 @@ pub struct PuzzleWorkUpsertRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkRemixRecordInput { + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_session_id: String, + pub target_profile_id: String, + pub target_work_id: String, + pub author_display_name: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleRunStartRecordInput { pub run_id: String, @@ -4376,6 +4419,15 @@ pub struct BigFishPlayReportRecordInput { pub reported_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishWorkRemixRecordInput { + pub source_session_id: String, + pub target_session_id: String, + pub target_owner_user_id: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAnchorItemRecord { pub key: String, @@ -4502,6 +4554,8 @@ pub struct PuzzleWorkProfileRecord { pub updated_at: String, pub published_at: Option, pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub publish_ready: bool, pub anchor_pack: PuzzleAnchorPackRecord, } @@ -4771,12 +4825,15 @@ pub struct BigFishWorkSummaryRecord { pub cover_image_src: Option, pub status: String, pub updated_at_micros: i64, + pub published_at_micros: Option, pub publish_ready: bool, pub level_count: u32, pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, } #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] @@ -4791,6 +4848,8 @@ struct CompatibleBigFishWorkSummaryRecord { cover_image_src: Option, status: String, updated_at_micros: i64, + #[serde(default)] + published_at_micros: Option, publish_ready: bool, level_count: u32, level_main_image_ready_count: u32, @@ -4798,6 +4857,10 @@ struct CompatibleBigFishWorkSummaryRecord { background_ready: bool, #[serde(default)] play_count: u32, + #[serde(default)] + remix_count: u32, + #[serde(default)] + like_count: u32, } impl CompatibleBigFishWorkSummaryRecord { @@ -4817,12 +4880,15 @@ impl CompatibleBigFishWorkSummaryRecord { cover_image_src: self.cover_image_src, status: self.status, updated_at_micros: self.updated_at_micros, + published_at_micros: self.published_at_micros, publish_ready: self.publish_ready, level_count: self.level_count, level_main_image_ready_count: self.level_main_image_ready_count, level_motion_ready_count: self.level_motion_ready_count, background_ready: self.background_ready, play_count: self.play_count, + remix_count: self.remix_count, + like_count: self.like_count, } } } @@ -4831,6 +4897,73 @@ impl CompatibleBigFishWorkSummaryRecord { mod tests { use super::*; + #[test] + fn puzzle_works_mapper_backfills_missing_public_stat_fields() { + let result = PuzzleWorksProcedureResult { + ok: true, + items_json: Some( + r#"[{ + "work_id":"puzzle-work-1", + "profile_id":"puzzle-profile-1", + "owner_user_id":"user-1", + "source_session_id":null, + "author_display_name":"测试作者", + "level_name":"雨夜拼图", + "summary":"旧公开作品摘要", + "theme_tags":["雨夜","猫咪","神庙"], + "cover_image_src":null, + "cover_asset_id":null, + "publication_status":"Published", + "updated_at_micros":123000000, + "published_at_micros":123000000, + "publish_ready":true, + "anchor_pack":{ + "theme_promise":{ + "key":"themePromise", + "label":"题材承诺", + "value":"雨夜冒险", + "status":"Inferred" + }, + "visual_subject":{ + "key":"visualSubject", + "label":"画面主体", + "value":"猫咪神庙", + "status":"Inferred" + }, + "visual_mood":{ + "key":"visualMood", + "label":"视觉气质", + "value":"温暖", + "status":"Inferred" + }, + "composition_hooks":{ + "key":"compositionHooks", + "label":"拼图记忆点", + "value":"灯光", + "status":"Inferred" + }, + "tags_and_forbidden":{ + "key":"tagsAndForbidden", + "label":"标签与禁忌", + "value":"雨夜, 猫咪, 神庙", + "status":"Inferred" + } + } + }]"# + .to_string(), + ), + error_message: None, + }; + + let items = map_puzzle_works_procedure_result(result) + .expect("旧 puzzle works JSON 缺统计字段时应按 0 兼容"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].play_count, 0); + assert_eq!(items[0].remix_count, 0); + assert_eq!(items[0].like_count, 0); + } + #[test] fn big_fish_works_mapper_backfills_missing_owner_user_id_for_private_lists() { let result = BigFishWorksProcedureResult { @@ -4861,6 +4994,10 @@ mod tests { assert_eq!(items.len(), 1); assert_eq!(items[0].owner_user_id, "user-1"); + assert_eq!(items[0].published_at_micros, None); + assert_eq!(items[0].play_count, 0); + assert_eq!(items[0].remix_count, 0); + assert_eq!(items[0].like_count, 0); } #[test] @@ -4893,6 +5030,10 @@ mod tests { assert_eq!(items.len(), 1); assert!(items[0].owner_user_id.is_empty()); + assert_eq!(items[0].published_at_micros, None); + assert_eq!(items[0].play_count, 0); + assert_eq!(items[0].remix_count, 0); + assert_eq!(items[0].like_count, 0); } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs index a760fea0..6caba8ab 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs @@ -21,6 +21,9 @@ pub struct BigFishCreationSession { pub last_assistant_reply: Option, pub publish_ready: bool, pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub published_at: Option<__sdk::Timestamp>, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, } @@ -45,6 +48,9 @@ pub struct BigFishCreationSessionCols { pub last_assistant_reply: __sdk::__query_builder::Col>, pub publish_ready: __sdk::__query_builder::Col, pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, + pub published_at: __sdk::__query_builder::Col>, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, } @@ -71,6 +77,9 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession { ), publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), + published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_remix_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_remix_input_type.rs new file mode 100644 index 00000000..dc05718c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_remix_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishWorkRemixInput { + pub source_session_id: String, + pub target_session_id: String, + pub target_owner_user_id: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + +impl __sdk::InModule for BigFishWorkRemixInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs index ac3cf555..e3a654d5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs @@ -21,6 +21,9 @@ pub struct CustomWorldGalleryEntrySnapshot { pub theme_mode: CustomWorldThemeMode, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub published_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs index a1fc0481..971fd3b2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs @@ -21,6 +21,9 @@ pub struct CustomWorldGalleryEntry { pub theme_mode: CustomWorldThemeMode, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub published_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, } @@ -45,6 +48,9 @@ pub struct CustomWorldGalleryEntryCols { pub theme_mode: __sdk::__query_builder::Col, pub playable_npc_count: __sdk::__query_builder::Col, pub landmark_count: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, } @@ -71,6 +77,9 @@ impl __sdk::__query_builder::HasCols for CustomWorldGalleryEntry { theme_mode: __sdk::__query_builder::Col::new(table_name, "theme_mode"), playable_npc_count: __sdk::__query_builder::Col::new(table_name, "playable_npc_count"), landmark_count: __sdk::__query_builder::Col::new(table_name, "landmark_count"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_play_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_play_record_input_type.rs new file mode 100644 index 00000000..6c71b8a3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_play_record_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfilePlayRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub played_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldProfilePlayRecordInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_remix_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_remix_input_type.rs new file mode 100644 index 00000000..f995a468 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_remix_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfileRemixInput { + pub source_owner_user_id: String, + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_profile_id: String, + pub author_display_name: String, + pub remixed_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldProfileRemixInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs index 3b9f8b5a..f4a433b3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs @@ -24,6 +24,9 @@ pub struct CustomWorldProfileSnapshot { pub profile_payload_json: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub author_display_name: String, pub published_at_micros: Option, pub deleted_at_micros: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs index 8923286f..4ad1e730 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs @@ -24,6 +24,9 @@ pub struct CustomWorldProfile { pub profile_payload_json: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub author_display_name: String, pub published_at: Option<__sdk::Timestamp>, pub deleted_at: Option<__sdk::Timestamp>, @@ -54,6 +57,9 @@ pub struct CustomWorldProfileCols { pub profile_payload_json: __sdk::__query_builder::Col, pub playable_npc_count: __sdk::__query_builder::Col, pub landmark_count: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, pub author_display_name: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col>, pub deleted_at: __sdk::__query_builder::Col>, @@ -88,6 +94,9 @@ impl __sdk::__query_builder::HasCols for CustomWorldProfile { ), playable_npc_count: __sdk::__query_builder::Col::new(table_name, "playable_npc_count"), landmark_count: __sdk::__query_builder::Col::new(table_name, "landmark_count"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), author_display_name: __sdk::__query_builder::Col::new( table_name, "author_display_name", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 6edd0e7c..17a1ec47 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -99,6 +99,7 @@ pub mod big_fish_session_get_input_type; pub mod big_fish_session_procedure_result_type; pub mod big_fish_session_snapshot_type; pub mod big_fish_work_delete_input_type; +pub mod big_fish_work_remix_input_type; pub mod big_fish_works_list_input_type; pub mod big_fish_works_procedure_result_type; pub mod bind_asset_object_to_entity_and_return_procedure; @@ -166,7 +167,9 @@ pub mod custom_world_library_mutation_result_type; pub mod custom_world_profile_delete_input_type; pub mod custom_world_profile_list_input_type; pub mod custom_world_profile_list_result_type; +pub mod custom_world_profile_play_record_input_type; pub mod custom_world_profile_publish_input_type; +pub mod custom_world_profile_remix_input_type; pub mod custom_world_profile_snapshot_type; pub mod custom_world_profile_type; pub mod custom_world_profile_unpublish_input_type; @@ -318,6 +321,7 @@ pub mod puzzle_work_delete_input_type; pub mod puzzle_work_get_input_type; pub mod puzzle_work_procedure_result_type; pub mod puzzle_work_profile_row_type; +pub mod puzzle_work_remix_input_type; pub mod puzzle_work_upsert_input_type; pub mod puzzle_works_list_input_type; pub mod puzzle_works_procedure_result_type; @@ -349,10 +353,14 @@ pub mod quest_step_snapshot_type; pub mod quest_treasure_inspected_signal_type; pub mod quest_turn_in_input_type; pub mod record_big_fish_play_procedure; +pub mod record_custom_world_profile_play_procedure; pub mod redeem_profile_referral_invite_code_procedure; pub mod redeem_profile_reward_code_procedure; pub mod refresh_session_type; pub mod refund_profile_wallet_points_and_return_procedure; +pub mod remix_big_fish_work_procedure; +pub mod remix_custom_world_profile_procedure; +pub mod remix_puzzle_work_procedure; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; pub mod resolve_combat_action_procedure_result_type; @@ -583,6 +591,7 @@ pub use big_fish_session_get_input_type::BigFishSessionGetInput; pub use big_fish_session_procedure_result_type::BigFishSessionProcedureResult; pub use big_fish_session_snapshot_type::BigFishSessionSnapshot; pub use big_fish_work_delete_input_type::BigFishWorkDeleteInput; +pub use big_fish_work_remix_input_type::BigFishWorkRemixInput; pub use big_fish_works_list_input_type::BigFishWorksListInput; pub use big_fish_works_procedure_result_type::BigFishWorksProcedureResult; pub use bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return; @@ -650,7 +659,9 @@ pub use custom_world_library_mutation_result_type::CustomWorldLibraryMutationRes pub use custom_world_profile_delete_input_type::CustomWorldProfileDeleteInput; pub use custom_world_profile_list_input_type::CustomWorldProfileListInput; pub use custom_world_profile_list_result_type::CustomWorldProfileListResult; +pub use custom_world_profile_play_record_input_type::CustomWorldProfilePlayRecordInput; pub use custom_world_profile_publish_input_type::CustomWorldProfilePublishInput; +pub use custom_world_profile_remix_input_type::CustomWorldProfileRemixInput; pub use custom_world_profile_snapshot_type::CustomWorldProfileSnapshot; pub use custom_world_profile_type::CustomWorldProfile; pub use custom_world_profile_unpublish_input_type::CustomWorldProfileUnpublishInput; @@ -802,6 +813,7 @@ pub use puzzle_work_delete_input_type::PuzzleWorkDeleteInput; pub use puzzle_work_get_input_type::PuzzleWorkGetInput; pub use puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; pub use puzzle_work_profile_row_type::PuzzleWorkProfileRow; +pub use puzzle_work_remix_input_type::PuzzleWorkRemixInput; pub use puzzle_work_upsert_input_type::PuzzleWorkUpsertInput; pub use puzzle_works_list_input_type::PuzzleWorksListInput; pub use puzzle_works_procedure_result_type::PuzzleWorksProcedureResult; @@ -833,10 +845,14 @@ pub use quest_step_snapshot_type::QuestStepSnapshot; pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal; pub use quest_turn_in_input_type::QuestTurnInInput; pub use record_big_fish_play_procedure::record_big_fish_play; +pub use record_custom_world_profile_play_procedure::record_custom_world_profile_play; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; pub use refresh_session_type::RefreshSession; pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; +pub use remix_big_fish_work_procedure::remix_big_fish_work; +pub use remix_custom_world_profile_procedure::remix_custom_world_profile; +pub use remix_puzzle_work_procedure::remix_puzzle_work; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs index be53fc17..d412947b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs @@ -21,6 +21,8 @@ pub struct PuzzleWorkProfileRow { pub cover_asset_id: Option, pub publication_status: PuzzlePublicationStatus, pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub anchor_pack_json: String, pub publish_ready: bool, pub created_at: __sdk::Timestamp, @@ -49,6 +51,8 @@ pub struct PuzzleWorkProfileRowCols { pub publication_status: __sdk::__query_builder::Col, pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, pub anchor_pack_json: __sdk::__query_builder::Col, pub publish_ready: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, @@ -75,6 +79,8 @@ impl __sdk::__query_builder::HasCols for PuzzleWorkProfileRow { cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"), publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), anchor_pack_json: __sdk::__query_builder::Col::new(table_name, "anchor_pack_json"), publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_remix_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_remix_input_type.rs new file mode 100644 index 00000000..c66a5fef --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_remix_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleWorkRemixInput { + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_session_id: String, + pub target_profile_id: String, + pub target_work_id: String, + pub author_display_name: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + +impl __sdk::InModule for PuzzleWorkRemixInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_play_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_play_procedure.rs new file mode 100644 index 00000000..f803e277 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_play_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_profile_play_record_input_type::CustomWorldProfilePlayRecordInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordCustomWorldProfilePlayArgs { + pub input: CustomWorldProfilePlayRecordInput, +} + +impl __sdk::InModule for RecordCustomWorldProfilePlayArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_custom_world_profile_play`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_custom_world_profile_play { + fn record_custom_world_profile_play(&self, input: CustomWorldProfilePlayRecordInput) { + self.record_custom_world_profile_play_then(input, |_, _| {}); + } + + fn record_custom_world_profile_play_then( + &self, + input: CustomWorldProfilePlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_custom_world_profile_play for super::RemoteProcedures { + fn record_custom_world_profile_play_then( + &self, + input: CustomWorldProfilePlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "record_custom_world_profile_play", + RecordCustomWorldProfilePlayArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/remix_big_fish_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/remix_big_fish_work_procedure.rs new file mode 100644 index 00000000..7f58adb3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/remix_big_fish_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::big_fish_session_procedure_result_type::BigFishSessionProcedureResult; +use super::big_fish_work_remix_input_type::BigFishWorkRemixInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RemixBigFishWorkArgs { + pub input: BigFishWorkRemixInput, +} + +impl __sdk::InModule for RemixBigFishWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `remix_big_fish_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait remix_big_fish_work { + fn remix_big_fish_work(&self, input: BigFishWorkRemixInput) { + self.remix_big_fish_work_then(input, |_, _| {}); + } + + fn remix_big_fish_work_then( + &self, + input: BigFishWorkRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl remix_big_fish_work for super::RemoteProcedures { + fn remix_big_fish_work_then( + &self, + input: BigFishWorkRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>( + "remix_big_fish_work", + RemixBigFishWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/remix_custom_world_profile_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/remix_custom_world_profile_procedure.rs new file mode 100644 index 00000000..93f74383 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/remix_custom_world_profile_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_profile_remix_input_type::CustomWorldProfileRemixInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RemixCustomWorldProfileArgs { + pub input: CustomWorldProfileRemixInput, +} + +impl __sdk::InModule for RemixCustomWorldProfileArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `remix_custom_world_profile`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait remix_custom_world_profile { + fn remix_custom_world_profile(&self, input: CustomWorldProfileRemixInput) { + self.remix_custom_world_profile_then(input, |_, _| {}); + } + + fn remix_custom_world_profile_then( + &self, + input: CustomWorldProfileRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl remix_custom_world_profile for super::RemoteProcedures { + fn remix_custom_world_profile_then( + &self, + input: CustomWorldProfileRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "remix_custom_world_profile", + RemixCustomWorldProfileArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/remix_puzzle_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/remix_puzzle_work_procedure.rs new file mode 100644 index 00000000..da91b334 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/remix_puzzle_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult; +use super::puzzle_work_remix_input_type::PuzzleWorkRemixInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RemixPuzzleWorkArgs { + pub input: PuzzleWorkRemixInput, +} + +impl __sdk::InModule for RemixPuzzleWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `remix_puzzle_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait remix_puzzle_work { + fn remix_puzzle_work(&self, input: PuzzleWorkRemixInput) { + self.remix_puzzle_work_then(input, |_, _| {}); + } + + fn remix_puzzle_work_then( + &self, + input: PuzzleWorkRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl remix_puzzle_work for super::RemoteProcedures { + fn remix_puzzle_work_then( + &self, + input: PuzzleWorkRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>( + "remix_puzzle_work", + RemixPuzzleWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 9636ed13..c51b5083 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -1,6 +1,7 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_puzzle_work_procedure::delete_puzzle_work; +use crate::module_bindings::remix_puzzle_work_procedure::remix_puzzle_work; impl SpacetimeClient { pub async fn create_puzzle_agent_session( @@ -340,6 +341,34 @@ impl SpacetimeClient { .await } + pub async fn remix_puzzle_work( + &self, + input: PuzzleWorkRemixRecordInput, + ) -> Result { + let procedure_input = PuzzleWorkRemixInput { + source_profile_id: input.source_profile_id, + target_owner_user_id: input.target_owner_user_id, + target_session_id: input.target_session_id, + target_profile_id: input.target_profile_id, + target_work_id: input.target_work_id, + author_display_name: input.author_display_name, + welcome_message_id: input.welcome_message_id, + remixed_at_micros: input.remixed_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .remix_puzzle_work_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn start_puzzle_run( &self, input: PuzzleRunStartRecordInput, diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index ed97fd62..8a717923 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -109,6 +109,9 @@ pub(crate) fn generate_big_fish_asset_tx( last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at, }; @@ -166,6 +169,9 @@ pub(crate) fn publish_big_fish_game_tx( last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), publish_ready: true, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: Some(published_at), created_at: session.created_at, updated_at: published_at, }; diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 00d5fb20..44f980b0 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -122,6 +122,25 @@ pub fn record_big_fish_play( } } +#[spacetimedb::procedure] +pub fn remix_big_fish_work( + ctx: &mut ProcedureContext, + input: BigFishWorkRemixInput, +) -> BigFishSessionProcedureResult { + match ctx.try_with_tx(|tx| remix_big_fish_work_tx(tx, input.clone())) { + Ok(session) => BigFishSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => BigFishSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_big_fish_message( ctx: &mut ProcedureContext, @@ -224,6 +243,9 @@ pub(crate) fn create_big_fish_session_tx( last_assistant_reply: Some(input.welcome_message_text.clone()), publish_ready: false, play_count: 0, + remix_count: 0, + like_count: 0, + published_at: None, created_at, updated_at: created_at, }); @@ -414,6 +436,9 @@ pub(crate) fn submit_big_fish_message_tx( last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at: submitted_at, }; @@ -461,6 +486,9 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at, }; @@ -516,6 +544,9 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( last_assistant_reply: Some(assistant_reply_text), publish_ready: session.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at, }; @@ -570,6 +601,9 @@ pub(crate) fn compile_big_fish_draft_tx( last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at: compiled_at, }; @@ -656,6 +690,9 @@ pub(crate) fn record_big_fish_play_tx( publish_ready: session.publish_ready, // 中文注释:正式进入已发布作品时同时累加作品播放数,用户侧去重由 profile_played_world 保证。 play_count: session.play_count.saturating_add(1), + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at: played_at, }; @@ -670,6 +707,123 @@ pub(crate) fn record_big_fish_play_tx( ) } +fn remix_big_fish_work_tx( + ctx: &ReducerContext, + input: BigFishWorkRemixInput, +) -> Result { + let source_session_id = input.source_session_id.trim(); + let target_session_id = input.target_session_id.trim(); + let target_owner_user_id = input.target_owner_user_id.trim(); + let welcome_message_id = input.welcome_message_id.trim(); + if source_session_id.is_empty() + || target_session_id.is_empty() + || target_owner_user_id.is_empty() + || welcome_message_id.is_empty() + { + return Err("big_fish remix 参数不能为空".to_string()); + } + if ctx + .db + .big_fish_creation_session() + .session_id() + .find(&target_session_id.to_string()) + .is_some() + { + return Err("big_fish remix 目标 session 已存在".to_string()); + } + if ctx + .db + .big_fish_agent_message() + .message_id() + .find(&welcome_message_id.to_string()) + .is_some() + { + return Err("big_fish remix 消息已存在".to_string()); + } + + let source = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&source_session_id.to_string()) + .filter(|row| row.stage == BigFishCreationStage::Published) + .ok_or_else(|| "big_fish 已发布源作品不存在".to_string())?; + let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); + let next_source = BigFishCreationSession { + session_id: source.session_id.clone(), + owner_user_id: source.owner_user_id.clone(), + seed_text: source.seed_text.clone(), + current_turn: source.current_turn, + progress_percent: source.progress_percent, + stage: source.stage, + anchor_pack_json: source.anchor_pack_json.clone(), + draft_json: source.draft_json.clone(), + asset_coverage_json: source.asset_coverage_json.clone(), + last_assistant_reply: source.last_assistant_reply.clone(), + publish_ready: source.publish_ready, + play_count: source.play_count, + remix_count: source.remix_count.saturating_add(1), + like_count: source.like_count, + published_at: source.published_at, + created_at: source.created_at, + updated_at: remixed_at, + }; + replace_big_fish_session(ctx, &source, next_source); + + let target_session = BigFishCreationSession { + session_id: target_session_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + seed_text: source.seed_text.clone(), + current_turn: 1, + progress_percent: 80, + stage: BigFishCreationStage::DraftReady, + anchor_pack_json: source.anchor_pack_json.clone(), + draft_json: source.draft_json.clone(), + asset_coverage_json: source.asset_coverage_json.clone(), + last_assistant_reply: Some("已从公开作品 Remix 出新的大鱼吃小鱼草稿。".to_string()), + publish_ready: source.publish_ready, + play_count: 0, + remix_count: 0, + like_count: 0, + published_at: None, + created_at: remixed_at, + updated_at: remixed_at, + }; + ctx.db.big_fish_creation_session().insert(target_session); + ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { + message_id: welcome_message_id.to_string(), + session_id: target_session_id.to_string(), + role: BigFishAgentMessageRole::Assistant, + kind: BigFishAgentMessageKind::Summary, + text: "已复制公开作品为你的草稿。".to_string(), + created_at: remixed_at, + }); + for slot in list_big_fish_asset_slots(ctx, &source.session_id) { + upsert_big_fish_asset_slot( + ctx, + BigFishAssetSlotSnapshot { + slot_id: slot.slot_id.replace(&source.session_id, target_session_id), + session_id: target_session_id.to_string(), + asset_kind: slot.asset_kind, + level: slot.level, + motion_key: slot.motion_key, + status: slot.status, + asset_url: slot.asset_url, + prompt_snapshot: slot.prompt_snapshot, + updated_at_micros: input.remixed_at_micros, + }, + ); + } + + get_big_fish_session_tx( + ctx, + BigFishSessionGetInput { + session_id: target_session_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + }, + ) +} + pub(crate) fn build_big_fish_session_snapshot( ctx: &ReducerContext, row: &BigFishCreationSession, @@ -784,6 +938,12 @@ pub(crate) fn build_big_fish_work_summary( level_motion_ready_count: coverage.level_motion_ready_count, background_ready: coverage.background_ready, play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + published_at_micros: row + .published_at + .or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at)) + .map(|value| value.to_micros_since_unix_epoch()), }) } @@ -821,6 +981,13 @@ mod tests { last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()), publish_ready: false, play_count: 0, + remix_count: 0, + like_count: 0, + published_at: if stage == BigFishCreationStage::Published { + Some(Timestamp::from_micros_since_unix_epoch(1)) + } else { + None + }, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), } diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs index 7e82cd91..ec35a400 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -18,6 +18,9 @@ pub struct BigFishCreationSession { pub(crate) last_assistant_reply: Option, pub(crate) publish_ready: bool, pub(crate) play_count: u32, + pub(crate) remix_count: u32, + pub(crate) like_count: u32, + pub(crate) published_at: Option, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, } diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 1cbfe3e7..b93ce023 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -337,6 +337,10 @@ pub struct CustomWorldProfile { profile_payload_json: String, playable_npc_count: u32, landmark_count: u32, + // 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。 + play_count: u32, + remix_count: u32, + like_count: u32, author_display_name: String, published_at: Option, // 软删除后保留 profile 真相,供审计与幂等删除使用。 @@ -484,6 +488,10 @@ pub struct CustomWorldGalleryEntry { theme_mode: CustomWorldThemeMode, playable_npc_count: u32, landmark_count: u32, + // 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。 + play_count: u32, + remix_count: u32, + like_count: u32, published_at: Timestamp, updated_at: Timestamp, } @@ -2161,6 +2169,48 @@ pub fn get_custom_world_gallery_detail_by_code( } } +#[spacetimedb::procedure] +pub fn remix_custom_world_profile( + ctx: &mut ProcedureContext, + input: module_custom_world::CustomWorldProfileRemixInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| remix_custom_world_profile_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry: Some(entry), + gallery_entry, + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn record_custom_world_profile_play( + ctx: &mut ProcedureContext, + input: module_custom_world::CustomWorldProfilePlayRecordInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| record_custom_world_profile_play_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry: Some(entry), + gallery_entry: Some(gallery_entry), + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn list_custom_world_works( ctx: &mut ProcedureContext, @@ -2466,6 +2516,9 @@ fn upsert_custom_world_profile_record( profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: existing.published_at, deleted_at: None, @@ -2488,6 +2541,9 @@ fn upsert_custom_world_profile_record( profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, @@ -2632,6 +2688,9 @@ fn publish_custom_world_profile_record( profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: Some(published_at), deleted_at: None, @@ -2695,6 +2754,9 @@ fn unpublish_custom_world_profile_record( profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, @@ -2754,6 +2816,9 @@ fn delete_custom_world_profile_record( profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count, author_display_name: existing.author_display_name.clone(), published_at: None, deleted_at: Some(deleted_at), @@ -2924,6 +2989,177 @@ fn get_custom_world_gallery_detail_record_by_code( )) } +fn remix_custom_world_profile_record( + ctx: &ReducerContext, + input: module_custom_world::CustomWorldProfileRemixInput, +) -> Result< + ( + CustomWorldProfileSnapshot, + Option, + ), + String, +> { + let source_owner_user_id = input.source_owner_user_id.trim(); + let source_profile_id = input.source_profile_id.trim(); + let target_owner_user_id = input.target_owner_user_id.trim(); + let target_profile_id = input.target_profile_id.trim(); + if source_owner_user_id.is_empty() + || source_profile_id.is_empty() + || target_owner_user_id.is_empty() + || target_profile_id.is_empty() + { + return Err("custom_world remix 参数不能为空".to_string()); + } + if input.author_display_name.trim().is_empty() { + return Err("custom_world remix 作者名不能为空".to_string()); + } + + // Remix 只允许从已发布源作品派生草稿,同时把源作品的公开 remix 计数同步到画廊。 + let source = ctx + .db + .custom_world_profile() + .profile_id() + .find(&source_profile_id.to_string()) + .filter(|row| row.owner_user_id == source_owner_user_id) + .filter(|row| { + row.publication_status == CustomWorldPublicationStatus::Published + && row.deleted_at.is_none() + && row.published_at.is_some() + }) + .ok_or_else(|| "custom_world 已发布源作品不存在".to_string())?; + if ctx + .db + .custom_world_profile() + .profile_id() + .find(&target_profile_id.to_string()) + .is_some() + { + return Err("custom_world remix 目标 profile 已存在".to_string()); + } + + let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); + let next_source = CustomWorldProfile { + profile_id: source.profile_id.clone(), + owner_user_id: source.owner_user_id.clone(), + public_work_code: source.public_work_code.clone(), + author_public_user_code: source.author_public_user_code.clone(), + source_agent_session_id: source.source_agent_session_id.clone(), + publication_status: source.publication_status, + world_name: source.world_name.clone(), + subtitle: source.subtitle.clone(), + summary_text: source.summary_text.clone(), + theme_mode: source.theme_mode, + cover_image_src: source.cover_image_src.clone(), + profile_payload_json: source.profile_payload_json.clone(), + playable_npc_count: source.playable_npc_count, + landmark_count: source.landmark_count, + play_count: source.play_count, + remix_count: source.remix_count.saturating_add(1), + like_count: source.like_count, + author_display_name: source.author_display_name.clone(), + published_at: source.published_at, + deleted_at: source.deleted_at, + created_at: source.created_at, + updated_at: remixed_at, + }; + ctx.db + .custom_world_profile() + .profile_id() + .delete(&source.profile_id); + let updated_source = ctx.db.custom_world_profile().insert(next_source); + let source_gallery = sync_custom_world_gallery_entry_from_profile(ctx, &updated_source)?; + + // 新草稿继承作品内容,但互动计数从 0 开始,避免把源作品热度复制成用户资产。 + let draft = CustomWorldProfile { + profile_id: target_profile_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + public_work_code: None, + author_public_user_code: None, + source_agent_session_id: None, + publication_status: CustomWorldPublicationStatus::Draft, + world_name: source.world_name.clone(), + subtitle: source.subtitle.clone(), + summary_text: source.summary_text.clone(), + theme_mode: source.theme_mode, + cover_image_src: source.cover_image_src.clone(), + profile_payload_json: source.profile_payload_json.clone(), + playable_npc_count: source.playable_npc_count, + landmark_count: source.landmark_count, + play_count: 0, + remix_count: 0, + like_count: 0, + author_display_name: input.author_display_name.trim().to_string(), + published_at: None, + deleted_at: None, + created_at: remixed_at, + updated_at: remixed_at, + }; + let inserted_draft = ctx.db.custom_world_profile().insert(draft); + Ok(( + build_custom_world_profile_snapshot(&inserted_draft), + Some(source_gallery), + )) +} + +fn record_custom_world_profile_play_record( + ctx: &ReducerContext, + input: module_custom_world::CustomWorldProfilePlayRecordInput, +) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> { + let owner_user_id = input.owner_user_id.trim(); + let profile_id = input.profile_id.trim(); + if owner_user_id.is_empty() || profile_id.is_empty() { + return Err("custom_world play 参数不能为空".to_string()); + } + let existing = ctx + .db + .custom_world_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .filter(|row| { + row.publication_status == CustomWorldPublicationStatus::Published + && row.deleted_at.is_none() + && row.published_at.is_some() + }) + .ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?; + let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + // 游玩计数是公开广场消费数据,只增加计数并保持作品内容不变。 + let next_row = CustomWorldProfile { + profile_id: existing.profile_id.clone(), + owner_user_id: existing.owner_user_id.clone(), + public_work_code: existing.public_work_code.clone(), + author_public_user_code: existing.author_public_user_code.clone(), + source_agent_session_id: existing.source_agent_session_id.clone(), + publication_status: existing.publication_status, + world_name: existing.world_name.clone(), + subtitle: existing.subtitle.clone(), + summary_text: existing.summary_text.clone(), + theme_mode: existing.theme_mode, + cover_image_src: existing.cover_image_src.clone(), + profile_payload_json: existing.profile_payload_json.clone(), + playable_npc_count: existing.playable_npc_count, + landmark_count: existing.landmark_count, + play_count: existing.play_count.saturating_add(1), + remix_count: existing.remix_count, + like_count: existing.like_count, + author_display_name: existing.author_display_name.clone(), + published_at: existing.published_at, + deleted_at: existing.deleted_at, + created_at: existing.created_at, + updated_at: played_at, + }; + ctx.db + .custom_world_profile() + .profile_id() + .delete(&existing.profile_id); + let inserted = ctx.db.custom_world_profile().insert(next_row); + let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; + Ok(( + build_custom_world_profile_snapshot(&inserted), + gallery_entry, + )) +} + fn list_custom_world_work_snapshots( ctx: &ReducerContext, input: CustomWorldWorksListInput, @@ -5054,6 +5290,9 @@ fn sync_custom_world_gallery_entry_from_profile( theme_mode: profile.theme_mode, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, + play_count: profile.play_count, + remix_count: profile.remix_count, + like_count: profile.like_count, published_at, updated_at: profile.updated_at, }; @@ -5135,6 +5374,9 @@ fn ensure_custom_world_profile_public_fields( profile_payload_json: profile.profile_payload_json.clone(), playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, + play_count: profile.play_count, + remix_count: profile.remix_count, + like_count: profile.like_count, author_display_name: profile.author_display_name.clone(), published_at: profile.published_at, deleted_at: profile.deleted_at, @@ -5161,6 +5403,9 @@ fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWo profile_payload_json: profile.profile_payload_json.clone(), playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, + play_count: profile.play_count, + remix_count: profile.remix_count, + like_count: profile.like_count, author_display_name: profile.author_display_name.clone(), published_at: profile.published_at, deleted_at: profile.deleted_at, @@ -5185,6 +5430,9 @@ fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldP profile_payload_json: row.profile_payload_json.clone(), playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, author_display_name: row.author_display_name.clone(), published_at_micros: row .published_at @@ -5337,6 +5585,9 @@ fn build_custom_world_gallery_entry_snapshot( theme_mode: row.theme_mode, playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } @@ -6405,6 +6656,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, @@ -6426,6 +6680,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: Some(Timestamp::from_micros_since_unix_epoch(2)), @@ -6447,6 +6704,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, @@ -6507,6 +6767,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: if publication_status == CustomWorldPublicationStatus::Published { Some(Timestamp::from_micros_since_unix_epoch(2)) @@ -6568,6 +6831,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 0ba811af..6b3badcc 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -669,6 +669,43 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("play_count".to_string()) .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("remix_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("like_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("published_at".to_string()) + .or_insert(serde_json::Value::Null); + } + } + if table_name == "custom_world_profile" || table_name == "custom_world_gallery_entry" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:自定义世界公开互动计数字段晚于基础作品表加入,旧迁移包按 0 兼容。 + object + .entry("play_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("remix_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("like_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + } + } + if table_name == "puzzle_work_profile" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:拼图公开互动计数晚于基础作品表加入,旧迁移包按 0 兼容。 + object + .entry("play_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("remix_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("like_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); } } next_value diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index d3b34a62..77879e69 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -11,7 +11,7 @@ use module_puzzle::{ PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, - PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, + PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces, @@ -77,6 +77,8 @@ pub struct PuzzleWorkProfileRow { cover_asset_id: Option, publication_status: PuzzlePublicationStatus, play_count: u32, + remix_count: u32, + like_count: u32, anchor_pack_json: String, publish_ready: bool, created_at: Timestamp, @@ -387,6 +389,25 @@ pub fn get_puzzle_gallery_detail( } } +#[spacetimedb::procedure] +pub fn remix_puzzle_work( + ctx: &mut ProcedureContext, + input: PuzzleWorkRemixInput, +) -> PuzzleAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| remix_puzzle_work_tx(tx, input.clone())) { + Ok(session) => PuzzleAgentSessionProcedureResult { + ok: true, + session_json: Some(serialize_json(&session)), + error_message: None, + }, + Err(message) => PuzzleAgentSessionProcedureResult { + ok: false, + session_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn start_puzzle_run( ctx: &mut ProcedureContext, @@ -931,6 +952,8 @@ fn update_puzzle_work_tx( cover_asset_id: input.cover_asset_id, publication_status: row.publication_status, play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, anchor_pack_json: row.anchor_pack_json.clone(), publish_ready: row.publish_ready, created_at: row.created_at, @@ -1033,6 +1056,140 @@ fn get_puzzle_gallery_detail_tx( build_puzzle_work_profile_from_row(&row) } +fn remix_puzzle_work_tx( + ctx: &TxContext, + input: PuzzleWorkRemixInput, +) -> Result { + let source_profile_id = input.source_profile_id.trim(); + let target_owner_user_id = input.target_owner_user_id.trim(); + let target_session_id = input.target_session_id.trim(); + let target_profile_id = input.target_profile_id.trim(); + let target_work_id = input.target_work_id.trim(); + if source_profile_id.is_empty() + || target_owner_user_id.is_empty() + || target_session_id.is_empty() + || target_profile_id.is_empty() + || target_work_id.is_empty() + { + return Err("拼图 remix 参数不能为空".to_string()); + } + if input.author_display_name.trim().is_empty() { + return Err("拼图 remix 作者名不能为空".to_string()); + } + ensure_session_missing(ctx, target_session_id)?; + ensure_message_missing(ctx, input.welcome_message_id.trim())?; + if ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&target_profile_id.to_string()) + .is_some() + { + return Err("拼图 remix 目标作品已存在".to_string()); + } + + let source = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&source_profile_id.to_string()) + .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) + .ok_or_else(|| "拼图已发布源作品不存在".to_string())?; + let source_profile = build_puzzle_work_profile_from_row(&source)?; + let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); + + replace_puzzle_work_profile( + ctx, + &source, + PuzzleWorkProfileRow { + profile_id: source.profile_id.clone(), + work_id: source.work_id.clone(), + owner_user_id: source.owner_user_id.clone(), + source_session_id: source.source_session_id.clone(), + author_display_name: source.author_display_name.clone(), + level_name: source.level_name.clone(), + summary: source.summary.clone(), + theme_tags_json: source.theme_tags_json.clone(), + cover_image_src: source.cover_image_src.clone(), + cover_asset_id: source.cover_asset_id.clone(), + publication_status: source.publication_status, + play_count: source.play_count, + remix_count: source.remix_count.saturating_add(1), + like_count: source.like_count, + anchor_pack_json: source.anchor_pack_json.clone(), + publish_ready: source.publish_ready, + created_at: source.created_at, + updated_at: remixed_at, + published_at: source.published_at, + }, + ); + + let draft = PuzzleResultDraft { + level_name: source_profile.level_name.clone(), + summary: source_profile.summary.clone(), + theme_tags: source_profile.theme_tags.clone(), + forbidden_directives: Vec::new(), + creator_intent: None, + anchor_pack: source_profile.anchor_pack.clone(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: source_profile.cover_image_src.clone(), + cover_asset_id: source_profile.cover_asset_id.clone(), + generation_status: "ready".to_string(), + }; + ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow { + session_id: target_session_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + seed_text: source_profile.summary.clone(), + current_turn: 1, + progress_percent: 88, + stage: PuzzleAgentStage::DraftReady, + anchor_pack_json: serialize_json(&source_profile.anchor_pack), + draft_json: Some(serialize_json(&draft)), + last_assistant_reply: Some("已从公开作品 Remix 出新的拼图草稿。".to_string()), + published_profile_id: None, + created_at: remixed_at, + updated_at: remixed_at, + }); + ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { + message_id: input.welcome_message_id, + session_id: target_session_id.to_string(), + role: PuzzleAgentMessageRole::Assistant, + kind: PuzzleAgentMessageKind::Summary, + text: "已复制公开作品为你的草稿。".to_string(), + created_at: remixed_at, + }); + ctx.db.puzzle_work_profile().insert(PuzzleWorkProfileRow { + profile_id: target_profile_id.to_string(), + work_id: target_work_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + source_session_id: Some(target_session_id.to_string()), + author_display_name: input.author_display_name.trim().to_string(), + level_name: source_profile.level_name, + summary: source_profile.summary, + theme_tags_json: serialize_json(&source_profile.theme_tags), + cover_image_src: source_profile.cover_image_src, + cover_asset_id: source_profile.cover_asset_id, + publication_status: PuzzlePublicationStatus::Draft, + play_count: 0, + remix_count: 0, + like_count: 0, + anchor_pack_json: serialize_json(&source_profile.anchor_pack), + publish_ready: true, + created_at: remixed_at, + updated_at: remixed_at, + published_at: None, + }); + + get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: target_session_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + }, + ) +} + fn start_puzzle_run_tx( ctx: &TxContext, input: PuzzleRunStartInput, @@ -1308,6 +1465,8 @@ fn build_puzzle_work_profile_from_row( .published_at .map(|value| value.to_micros_since_unix_epoch()), play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, publish_ready: row.publish_ready, anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?, }) @@ -1507,6 +1666,8 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re // 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于 // 广场消费数据,不能因为重新发布被清零。 play_count: existing.play_count.max(profile.play_count), + remix_count: existing.remix_count.max(profile.remix_count), + like_count: existing.like_count.max(profile.like_count), anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: existing.created_at, @@ -1532,6 +1693,8 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re cover_asset_id: profile.cover_asset_id, publication_status: profile.publication_status, play_count: profile.play_count, + remix_count: profile.remix_count, + like_count: profile.like_count, anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: Timestamp::from_micros_since_unix_epoch(profile.updated_at_micros), @@ -1620,6 +1783,8 @@ fn increment_puzzle_profile_play_count( cover_asset_id: row.cover_asset_id.clone(), publication_status: row.publication_status, play_count: row.play_count.saturating_add(1), + remix_count: row.remix_count, + like_count: row.like_count, anchor_pack_json: row.anchor_pack_json.clone(), publish_ready: row.publish_ready, created_at: row.created_at, diff --git a/src/PuzzlePlaygroundApp.tsx b/src/PuzzlePlaygroundApp.tsx index 57bc8aca..acf60ae6 100644 --- a/src/PuzzlePlaygroundApp.tsx +++ b/src/PuzzlePlaygroundApp.tsx @@ -53,6 +53,7 @@ function buildPlaceholderPuzzleWork(): PuzzleWorkSummary { updatedAt: new Date(0).toISOString(), publishedAt: new Date(0).toISOString(), playCount: 0, + likeCount: 0, publishReady: true, }; } diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 1741438e..b18fbddd 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -105,6 +105,7 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(), publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(), playCount: 8, + likeCount: 0, publishReady: true, }, ]} @@ -158,6 +159,7 @@ test('creation hub shows RPG public work code from published library entry', () themeMode: 'tide', playableNpcCount: 3, landmarkCount: 4, + likeCount: 0, }, ]} loading={false} @@ -249,6 +251,7 @@ test('creation hub work code copy button copies without opening the card', async updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(), publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(), playCount: 8, + likeCount: 0, publishReady: true, }, ]} diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index cd3eafea..09e5dea2 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -65,6 +65,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(), publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(), playCount: 12, + likeCount: 0, publishReady: true, }, ]} diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 6ce52d39..92367a72 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -54,7 +54,10 @@ import { getBigFishCreationSession, streamBigFishCreationMessage, } from '../../services/big-fish-creation'; -import { listBigFishGallery } from '../../services/big-fish-gallery'; +import { + listBigFishGallery, + remixBigFishGalleryWork, +} from '../../services/big-fish-gallery'; import { advanceLocalBigFishRuntimeRun, recordBigFishPlay, @@ -91,6 +94,7 @@ import { import { getPuzzleGalleryDetail, listPuzzleGallery, + remixPuzzleGalleryWork, } from '../../services/puzzle-gallery'; import { advanceLocalPuzzleNextLevel, @@ -110,6 +114,8 @@ import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreati import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetailByCode, + remixRpgEntryWorldGallery, + recordRpgEntryWorldGalleryPlay, } from '../../services/rpg-entry/rpgEntryLibraryClient'; import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; @@ -138,6 +144,7 @@ import { } from './platformEntryShared'; import type { PlatformEntryFlowShellProps } from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; +import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; @@ -152,15 +159,19 @@ type PuzzleDetailReturnTarget = { tab: PlatformHomeTab; }; -type PuzzleRuntimeReturnStage = 'puzzle-result' | 'puzzle-gallery-detail'; +type PuzzleRuntimeReturnStage = + | 'puzzle-result' + | 'puzzle-gallery-detail' + | 'work-detail' + | 'platform'; + +type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform'; type AgentResultBlockerView = { code?: string; message: string; }; -type BigFishRuntimeSessionSource = 'draft' | 'work' | null; - const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ 'publish_missing_world_hook', 'publish_missing_player_premise', @@ -201,6 +212,59 @@ function mergePlatformPublicGalleryEntries( ); } +function mapRpgGalleryCardToPublicWorkDetail( + entry: CustomWorldGalleryCard, +): PlatformPublicGalleryCard { + return entry; +} + +function mapPuzzleWorkToPublicWorkDetail( + item: PuzzleWorkSummary, +): PlatformPublicGalleryCard { + return mapPuzzleWorkToPlatformGalleryCard(item); +} + +function mapBigFishWorkToPublicWorkDetail( + item: BigFishWorkSummary, +): PlatformPublicGalleryCard { + return mapBigFishWorkToPlatformGalleryCard(item); +} + +function mapPublicWorkDetailToBigFishWork( + entry: PlatformPublicGalleryCard, +): BigFishWorkSummary | null { + if (!isBigFishGalleryEntry(entry)) { + return null; + } + + const levelCount = Number.parseInt( + entry.themeTags.find((tag) => /^\d+级$/u.test(tag))?.replace('级', '') ?? + '0', + 10, + ); + + return { + workId: entry.workId, + sourceSessionId: entry.profileId, + ownerUserId: entry.ownerUserId, + title: entry.worldName, + subtitle: entry.subtitle, + summary: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + status: 'published', + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + publishReady: true, + levelCount: Number.isNaN(levelCount) ? 0 : levelCount, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: Boolean(entry.coverImageSrc), + playCount: entry.playCount ?? 0, + remixCount: entry.remixCount ?? 0, + likeCount: entry.likeCount ?? 0, + }; +} + function readProfileTextField( profile: CustomWorldProfile | null, paths: string[], @@ -439,6 +503,12 @@ export function PlatformEntryFlowShellImpl({ const [showCreationTypeModal, setShowCreationTypeModal] = useState(false); const [selectedDetailEntry, setSelectedDetailEntry] = useState | null>(null); + const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] = + useState(null); + const [publicWorkDetailError, setPublicWorkDetailError] = useState< + string | null + >(null); + const [isPublicWorkDetailBusy, setIsPublicWorkDetailBusy] = useState(false); const [bigFishWorks, setBigFishWorks] = useState([]); const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState< BigFishWorkSummary[] @@ -454,8 +524,8 @@ export function PlatformEntryFlowShellImpl({ const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState< number | null >(null); - const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] = - useState(null); + const [bigFishRuntimeReturnStage, setBigFishRuntimeReturnStage] = + useState('platform'); const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false); const [bigFishGenerationState, setBigFishGenerationState] = useState(null); @@ -980,10 +1050,13 @@ export function PlatformEntryFlowShellImpl({ response.session.publishedProfileId, ); setSelectedPuzzleDetail(galleryDetail.item); - setSelectionStage('puzzle-gallery-detail'); + const detailEntry = mapPuzzleWorkToPublicWorkDetail(galleryDetail.item); + setSelectedPublicWorkDetail(detailEntry); + setPublicWorkDetailError(null); + setSelectionStage('work-detail'); pushAppHistoryPath( buildPublicWorkStagePath( - 'puzzle-gallery-detail', + 'work-detail', buildPuzzlePublicWorkCode(galleryDetail.item.profileId), ), ); @@ -1060,12 +1133,15 @@ export function PlatformEntryFlowShellImpl({ // 一旦退出登录或鉴权上下文被收回,三类作品缓存必须同步清空,不能等刷新页面。 setShowCreationTypeModal(false); setSelectedDetailEntry(null); + setSelectedPublicWorkDetail(null); + setPublicWorkDetailError(null); + setIsPublicWorkDetailBusy(false); setBigFishWorks([]); setBigFishRun(null); setBigFishRuntimeShare(null); setBigFishRuntimeWork(null); setBigFishRuntimeStartedAt(null); - setBigFishRuntimeSessionSource(null); + setBigFishRuntimeReturnStage('platform'); setBigFishGenerationState(null); setBigFishError(null); setPuzzleOperation(null); @@ -1088,6 +1164,7 @@ export function PlatformEntryFlowShellImpl({ if ( selectionStage !== 'platform' && + selectionStage !== 'work-detail' && selectionStage !== 'detail' && selectionStage !== 'puzzle-gallery-detail' ) { @@ -1150,7 +1227,7 @@ export function PlatformEntryFlowShellImpl({ setBigFishRun(null); setBigFishRuntimeWork(null); setBigFishRuntimeStartedAt(null); - setBigFishRuntimeSessionSource(null); + setBigFishRuntimeReturnStage('platform'); setBigFishGenerationState(null); bigFishFlow.leaveFlow(); }, [bigFishFlow]); @@ -1192,7 +1269,7 @@ export function PlatformEntryFlowShellImpl({ setBigFishRuntimeShare(null); setBigFishRuntimeWork(null); setBigFishRuntimeStartedAt(Date.now()); - setBigFishRuntimeSessionSource('draft'); + setBigFishRuntimeReturnStage('big-fish-result'); setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); setSelectionStage('big-fish-runtime'); void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => { @@ -1221,9 +1298,9 @@ export function PlatformEntryFlowShellImpl({ setBigFishError(null); if (bigFishSession) { setBigFishRuntimeShare(null); + setBigFishRuntimeReturnStage('big-fish-result'); } setBigFishRuntimeStartedAt(Date.now()); - setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work'); setBigFishRun( startLocalBigFishRuntimeRun({ session: bigFishSession, @@ -1245,7 +1322,10 @@ export function PlatformEntryFlowShellImpl({ ]); const startPuzzleRunFromProfile = useCallback( - async (profileId: string) => { + async ( + profileId: string, + returnStage: PuzzleRuntimeReturnStage = 'work-detail', + ) => { if (isPuzzleBusy) { return; } @@ -1258,7 +1338,7 @@ export function PlatformEntryFlowShellImpl({ const { run } = await startPuzzleRun({ profileId: item.profileId }); setSelectedPuzzleDetail(item); setPuzzleRun(run); - setPuzzleRuntimeReturnStage('puzzle-gallery-detail'); + setPuzzleRuntimeReturnStage(returnStage); setSelectionStage('puzzle-runtime'); pushAppHistoryPath( buildPublicWorkStagePath( @@ -1297,6 +1377,8 @@ export function PlatformEntryFlowShellImpl({ updatedAt: now, publishedAt: null, playCount: 0, + remixCount: 0, + likeCount: 0, publishReady: Boolean(puzzleSession?.resultPreview?.publishReady), } satisfies PuzzleWorkSummary; }, @@ -1733,6 +1815,85 @@ export function PlatformEntryFlowShellImpl({ ], ); + const openPublicWorkDetail = useCallback( + (entry: PlatformPublicGalleryCard) => { + setSelectedPublicWorkDetail(entry); + setPublicWorkDetailError(null); + setSelectionStage('work-detail'); + if (entry.publicWorkCode?.trim()) { + pushAppHistoryPath( + buildPublicWorkStagePath('work-detail', entry.publicWorkCode), + ); + } + }, + [setSelectionStage], + ); + + const openRpgPublicWorkDetail = useCallback( + async (entry: CustomWorldGalleryCard) => { + setIsPublicWorkDetailBusy(true); + setPublicWorkDetailError(null); + setSelectionStage('work-detail'); + + try { + const detailEntry = + await detailNavigation.loadGalleryDetailEntry(entry); + setSelectedDetailEntry(detailEntry); + setSelectedPublicWorkDetail( + mapRpgGalleryCardToPublicWorkDetail(detailEntry), + ); + if (detailEntry.publicWorkCode?.trim()) { + pushAppHistoryPath( + buildPublicWorkStagePath('work-detail', detailEntry.publicWorkCode), + ); + } + } catch (error) { + setSelectedPublicWorkDetail(entry); + setPublicWorkDetailError( + resolveRpgCreationErrorMessage(error, '读取作品详情失败。'), + ); + } finally { + setIsPublicWorkDetailBusy(false); + } + }, + [detailNavigation, setSelectedDetailEntry, setSelectionStage], + ); + + const openPuzzlePublicWorkDetail = useCallback( + async ( + profileId: string, + returnTarget: PuzzleDetailReturnTarget = { + tab: platformBootstrap.platformTab, + }, + ) => { + setIsPuzzleBusy(true); + setIsPublicWorkDetailBusy(true); + setPuzzleError(null); + setPublicWorkDetailError(null); + setSelectionStage('work-detail'); + + try { + const { item } = await getPuzzleGalleryDetail(profileId); + setSelectedPuzzleDetail(item); + setPuzzleDetailReturnTarget(returnTarget); + openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item)); + } catch (error) { + setPublicWorkDetailError( + resolvePuzzleErrorMessage(error, '读取拼图详情失败。'), + ); + } finally { + setIsPuzzleBusy(false); + setIsPublicWorkDetailBusy(false); + } + }, + [ + openPublicWorkDetail, + platformBootstrap.platformTab, + resolvePuzzleErrorMessage, + setPuzzleError, + ], + ); + const openPuzzleDetail = useCallback( async ( profileId: string, @@ -1793,7 +1954,10 @@ export function PlatformEntryFlowShellImpl({ ); const startBigFishRunFromWork = useCallback( - (item: BigFishWorkSummary) => { + ( + item: BigFishWorkSummary, + returnStage: BigFishRuntimeReturnStage = 'work-detail', + ) => { const sessionId = item.sourceSessionId?.trim(); if (!sessionId) { setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); @@ -1809,7 +1973,7 @@ export function PlatformEntryFlowShellImpl({ publicWorkCode, }); setBigFishRuntimeStartedAt(Date.now()); - setBigFishRuntimeSessionSource('work'); + setBigFishRuntimeReturnStage(returnStage); setBigFishRun(startLocalBigFishRuntimeRun({ work: item })); setSelectionStage('big-fish-runtime'); pushAppHistoryPath( @@ -1824,6 +1988,157 @@ export function PlatformEntryFlowShellImpl({ [bigFishFlow, resolveBigFishErrorMessage, setSelectionStage], ); + const startSelectedPublicWork = useCallback(() => { + if (!selectedPublicWorkDetail || isPublicWorkDetailBusy) { + return; + } + + if (isBigFishGalleryEntry(selectedPublicWorkDetail)) { + const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail); + if (!work) { + setPublicWorkDetailError('当前作品缺少会话信息,暂时无法进入玩法。'); + return; + } + startBigFishRunFromWork(work); + return; + } + + if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) { + void startPuzzleRunFromProfile(selectedPublicWorkDetail.profileId); + return; + } + + const launchEntry = + selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId + ? selectedDetailEntry + : null; + if (!launchEntry) { + setPublicWorkDetailError('作品详情尚未读取完成。'); + return; + } + + runProtectedAction(() => { + setIsPublicWorkDetailBusy(true); + void recordRpgEntryWorldGalleryPlay( + launchEntry.ownerUserId, + launchEntry.profileId, + ) + .then((updatedEntry) => { + setSelectedDetailEntry(updatedEntry); + setSelectedPublicWorkDetail( + mapRpgGalleryCardToPublicWorkDetail(updatedEntry), + ); + handleCustomWorldSelect(updatedEntry.profile); + }) + .catch((error) => { + setPublicWorkDetailError( + resolveRpgCreationErrorMessage(error, '记录作品游玩失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + }); + }); + }, [ + handleCustomWorldSelect, + isPublicWorkDetailBusy, + runProtectedAction, + selectedDetailEntry, + selectedPublicWorkDetail, + startBigFishRunFromWork, + startPuzzleRunFromProfile, + ]); + + const remixPublicWork = useCallback( + (entry: PlatformPublicGalleryCard) => { + if (isPublicWorkDetailBusy) { + return; + } + + runProtectedAction(() => { + setIsPublicWorkDetailBusy(true); + setPublicWorkDetailError(null); + + if (isBigFishGalleryEntry(entry)) { + void remixBigFishGalleryWork(entry.profileId) + .then((response) => { + bigFishFlow.setSession(response.session); + enterCreateTab(); + setSelectionStage('big-fish-result'); + }) + .catch((error) => { + setPublicWorkDetailError( + resolveBigFishErrorMessage(error, 'Remix 大鱼吃小鱼作品失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + }); + return; + } + + if (isPuzzleGalleryEntry(entry)) { + void remixPuzzleGalleryWork(entry.profileId) + .then((response) => { + puzzleFlow.setSession(response.session); + setPuzzleOperation(null); + enterCreateTab(); + setSelectionStage('puzzle-result'); + }) + .catch((error) => { + setPublicWorkDetailError( + resolvePuzzleErrorMessage(error, 'Remix 拼图作品失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + }); + return; + } + + void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId) + .then((response) => { + const nextEntry = response.entry; + setSelectedDetailEntry(nextEntry); + platformBootstrap.setSavedCustomWorldEntries([ + nextEntry, + ...platformBootstrap.savedCustomWorldEntries.filter( + (entry) => entry.profileId !== nextEntry.profileId, + ), + ]); + detailNavigation.openSavedCustomWorldEditor(nextEntry); + }) + .catch((error) => { + setPublicWorkDetailError( + resolveRpgCreationErrorMessage(error, 'Remix RPG 作品失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + }); + }); + }, + [ + bigFishFlow, + detailNavigation, + enterCreateTab, + isPublicWorkDetailBusy, + platformBootstrap, + puzzleFlow, + resolveBigFishErrorMessage, + resolvePuzzleErrorMessage, + runProtectedAction, + setSelectionStage, + ], + ); + + const remixSelectedPublicWork = useCallback(() => { + if (!selectedPublicWorkDetail) { + return; + } + remixPublicWork(selectedPublicWorkDetail); + }, [remixPublicWork, selectedPublicWorkDetail]); + const handlePublicCodeSearch = useCallback( async (keyword: string) => { const normalizedKeyword = keyword.trim(); @@ -1856,7 +2171,7 @@ export function PlatformEntryFlowShellImpl({ const tryOpenGalleryEntry = async () => { const entry = await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword); - await detailNavigation.openGalleryDetail({ + const card = { ownerUserId: entry.ownerUserId, profileId: entry.profileId, publicWorkCode: entry.publicWorkCode, @@ -1872,7 +2187,12 @@ export function PlatformEntryFlowShellImpl({ themeMode: entry.themeMode, playableNpcCount: entry.playableNpcCount, landmarkCount: entry.landmarkCount, - } satisfies CustomWorldGalleryCard); + playCount: entry.playCount ?? 0, + remixCount: entry.remixCount ?? 0, + likeCount: entry.likeCount ?? 0, + } satisfies CustomWorldGalleryCard; + setSelectedDetailEntry(entry); + openPublicWorkDetail(card); }; const tryOpenPuzzleGalleryEntry = async () => { const entries = @@ -1887,7 +2207,7 @@ export function PlatformEntryFlowShellImpl({ throw new Error('未找到拼图作品。'); } - await openPuzzleDetail(matchedEntry.profileId, { + await openPuzzlePublicWorkDetail(matchedEntry.profileId, { tab: platformBootstrap.platformTab, }); }; @@ -1904,7 +2224,7 @@ export function PlatformEntryFlowShellImpl({ throw new Error('未找到大鱼吃小鱼作品。'); } - await startBigFishRunFromWork(matchedEntry); + openPublicWorkDetail(mapBigFishWorkToPublicWorkDetail(matchedEntry)); }; try { @@ -1959,14 +2279,13 @@ export function PlatformEntryFlowShellImpl({ } }, [ - detailNavigation, bigFishGalleryEntries, - openPuzzleDetail, + openPuzzlePublicWorkDetail, + openPublicWorkDetail, platformBootstrap.platformTab, puzzleGalleryEntries, refreshBigFishGallery, refreshPuzzleGallery, - startBigFishRunFromWork, ], ); @@ -1997,7 +2316,7 @@ export function PlatformEntryFlowShellImpl({ const profileId = work.profileId ?? work.worldKey.replace(/^puzzle:/u, ''); if (profileId) { - void openPuzzleDetail(profileId, { tab: 'profile' }); + void openPuzzlePublicWorkDetail(profileId, { tab: 'profile' }); } return; } @@ -2018,25 +2337,29 @@ export function PlatformEntryFlowShellImpl({ (entry) => entry.sourceSessionId === sessionId, ); if (matchedEntry) { - startBigFishRunFromWork(matchedEntry); + openPublicWorkDetail( + mapBigFishWorkToPublicWorkDetail(matchedEntry), + ); return; } - startBigFishRunFromWork({ - workId: `big-fish:${sessionId}`, - sourceSessionId: sessionId, - ownerUserId: work.ownerUserId ?? '', - title: work.worldTitle, - subtitle: work.worldSubtitle, - summary: work.worldSubtitle, - coverImageSrc: null, - status: 'published', - updatedAt: work.lastPlayedAt, - publishReady: true, - levelCount: 0, - levelMainImageReadyCount: 0, - levelMotionReadyCount: 0, - backgroundReady: false, - }); + openPublicWorkDetail( + mapBigFishWorkToPublicWorkDetail({ + workId: `big-fish:${sessionId}`, + sourceSessionId: sessionId, + ownerUserId: work.ownerUserId ?? '', + title: work.worldTitle, + subtitle: work.worldSubtitle, + summary: work.worldSubtitle, + coverImageSrc: null, + status: 'published', + updatedAt: work.lastPlayedAt, + publishReady: true, + levelCount: 0, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + }), + ); }) .catch((error) => { setBigFishError( @@ -2052,33 +2375,33 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - void detailNavigation.openGalleryDetail({ - ownerUserId, - profileId, - publicWorkCode: null, - authorPublicUserCode: null, - visibility: 'published', - publishedAt: work.firstPlayedAt, - updatedAt: work.lastPlayedAt, - authorDisplayName: work.worldSubtitle, - worldName: work.worldTitle, - subtitle: work.worldSubtitle, - summaryText: '', - coverImageSrc: null, - themeMode: 'martial', - playableNpcCount: 0, - landmarkCount: 0, - }); + void openRpgPublicWorkDetail({ + ownerUserId, + profileId, + publicWorkCode: null, + authorPublicUserCode: null, + visibility: 'published', + publishedAt: work.firstPlayedAt, + updatedAt: work.lastPlayedAt, + authorDisplayName: work.worldSubtitle, + worldName: work.worldTitle, + subtitle: work.worldSubtitle, + summaryText: '', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 0, + landmarkCount: 0, + playCount: 0, + remixCount: 0, + likeCount: 0, }); }, [ - detailNavigation, - openPuzzleDetail, + openPuzzlePublicWorkDetail, + openPublicWorkDetail, + openRpgPublicWorkDetail, refreshBigFishGallery, resolveBigFishErrorMessage, - runProtectedAction, - startBigFishRunFromWork, ], ); @@ -2234,7 +2557,7 @@ export function PlatformEntryFlowShellImpl({ isBigFishCreationVisible ? (item) => { runProtectedAction(() => { - void startBigFishRunFromWork(item); + void startBigFishRunFromWork(item, 'platform'); }); } : null @@ -2254,7 +2577,7 @@ export function PlatformEntryFlowShellImpl({ }} onExperiencePuzzle={(profileId) => { runProtectedAction(() => { - void startPuzzleRunFromProfile(profileId); + void startPuzzleRunFromProfile(profileId, 'platform'); }); }} onDeletePuzzle={(item) => { @@ -2310,42 +2633,18 @@ export function PlatformEntryFlowShellImpl({ onOpenCreateTypePicker={openCreationTypePicker} onOpenGalleryDetail={(entry) => { if (isBigFishGalleryEntry(entry)) { - runProtectedAction(() => { - void startBigFishRunFromWork({ - workId: entry.workId, - sourceSessionId: entry.profileId, - ownerUserId: entry.ownerUserId, - title: entry.worldName, - subtitle: entry.subtitle, - summary: entry.summaryText, - coverImageSrc: entry.coverImageSrc, - status: 'published', - updatedAt: entry.updatedAt, - publishReady: true, - levelCount: Number.parseInt( - entry.themeTags - .find((tag) => /^\d+级$/u.test(tag)) - ?.replace('级', '') ?? '0', - 10, - ), - levelMainImageReadyCount: 0, - levelMotionReadyCount: 0, - backgroundReady: Boolean(entry.coverImageSrc), - }); - }); + openPublicWorkDetail(entry); return; } if (isPuzzleGalleryEntry(entry)) { - void openPuzzleDetail(entry.profileId, { + void openPuzzlePublicWorkDetail(entry.profileId, { tab: platformBootstrap.platformTab, }); return; } - runProtectedAction(() => { - void detailNavigation.openGalleryDetail(entry); - }); + void openRpgPublicWorkDetail(entry); }} onOpenLibraryDetail={(entry) => { runProtectedAction(() => { @@ -2382,6 +2681,28 @@ export function PlatformEntryFlowShellImpl({ )} + {selectionStage === 'work-detail' && selectedPublicWorkDetail && ( + + { + setPublicWorkDetailError(null); + setSelectionStage('platform'); + }} + onStart={startSelectedPublicWork} + onRemix={remixSelectedPublicWork} + /> + + )} + {selectionStage === 'detail' && ( + ) : selectedDetailEntry.visibility !== 'draft' ? ( + { + detailNavigation.setDetailError(null); + entryNavigation.backToPlatformHome(); + }} + onStart={handleStartSelectedWorld} + onRemix={() => { + remixPublicWork( + mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry), + ); + }} + /> ) : ( { runProtectedAction(() => { @@ -2626,11 +2963,7 @@ export function PlatformEntryFlowShellImpl({ error={bigFishError} onBack={() => { reportBigFishObservedPlayTime(); - setSelectionStage( - bigFishRuntimeSessionSource === 'draft' - ? 'big-fish-result' - : 'platform', - ); + setSelectionStage(bigFishRuntimeReturnStage); }} onRestart={() => { reportBigFishObservedPlayTime(); @@ -2783,6 +3116,7 @@ export function PlatformEntryFlowShellImpl({ onStartGame={() => { void startPuzzleRunFromProfile( selectedPuzzleDetail.profileId, + 'puzzle-gallery-detail', ); }} /> diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx new file mode 100644 index 00000000..a055764f --- /dev/null +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -0,0 +1,242 @@ +import { ArrowLeft, Copy, GitFork, Play, Share2 } from 'lucide-react'; +import { useMemo, useState } from 'react'; + +import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; +import { copyTextToClipboard } from '../../services/clipboard'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; +import { + buildPlatformWorldTags, + formatPlatformWorldTime, + resolvePlatformPublicWorkCode, + resolvePlatformWorldCoverImage, + resolvePlatformWorldStats, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +export interface PlatformWorkDetailViewProps { + entry: PlatformPublicGalleryCard; + isBusy: boolean; + error: string | null; + onBack: () => void; + onStart: () => void; + onRemix: () => void; +} + +function formatCompactCount(value: number) { + if (value >= 10000) { + const normalized = value / 10000; + return `${Number.isInteger(normalized) ? normalized.toFixed(0) : normalized.toFixed(1)}万`; + } + return `${value}`; +} + +function getSourceLabel(entry: PlatformPublicGalleryCard) { + if ('sourceType' in entry && entry.sourceType === 'puzzle') { + return '拼图'; + } + if ('sourceType' in entry && entry.sourceType === 'big-fish') { + return '大鱼吃小鱼'; + } + return 'RPG'; +} + +export function PlatformWorkDetailView({ + entry, + isBusy, + error, + onBack, + onStart, + onRemix, +}: PlatformWorkDetailViewProps) { + const coverImage = resolvePlatformWorldCoverImage(entry); + const publicWorkCode = resolvePlatformPublicWorkCode(entry); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const tags = useMemo( + () => + [ + getSourceLabel(entry), + ...buildPlatformWorldTags(entry).map((tag) => tag.trim()), + ] + .filter(Boolean) + .slice(0, 4), + [entry], + ); + const stats = resolvePlatformWorldStats(entry); + const statItems = [ + { label: '改造次数', value: formatCompactCount(stats.remixCount) }, + { label: '游玩次数', value: formatCompactCount(stats.playCount) }, + { label: '点赞次数', value: formatCompactCount(stats.likeCount) }, + { + label: '上线日期', + value: formatPlatformWorldTime(stats.publishedAt), + }, + ]; + + const copyPublicWorkCode = () => { + if (!publicWorkCode) { + return; + } + + void copyTextToClipboard(publicWorkCode).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + window.setTimeout(() => setCopyState('idle'), 1400); + }); + }; + + const sharePublicWork = () => { + if (!publicWorkCode) { + return; + } + + const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`; + void copyTextToClipboard(shareText).then((copied) => { + setShareState(copied ? 'copied' : 'failed'); + window.setTimeout(() => setShareState('idle'), 1400); + }); + }; + + return ( +
+
+ +
详情
+ +
+ +
+
+ {coverImage ? ( + <> +
+ +
+
+
+ {coverImage ? ( +
+
+
+ {entry.worldName} +
+
+ {entry.authorDisplayName} +
+
+ +
+ +
+ {statItems.map((item) => ( +
+
+ {item.label} +
+
+ {item.value} +
+
+ ))} +
+
+ +
+
+ {tags.map((tag) => ( + + {tag} + + ))} +
+

{entry.summaryText}

+ {publicWorkCode ? ( + + ) : null} + {shareState !== 'idle' ? ( +
+ {shareState === 'copied' ? '分享内容已复制' : '分享失败'} +
+ ) : null} + {error ? ( +
{error}
+ ) : null} +
+
+ +
+ +
+
+ ); +} diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 2b5ead0f..2b73feef 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -15,6 +15,7 @@ export type CustomWorldRuntimeLaunchOptions = { export type SelectionStage = | 'platform' + | 'work-detail' | 'detail' | 'agent-workspace' | 'big-fish-agent-workspace' diff --git a/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx index ee194b50..5d4d4d4b 100644 --- a/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx +++ b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx @@ -28,6 +28,7 @@ const detailItem = { updatedAt: '2026-04-25T10:00:00.000Z', publishedAt: '2026-04-25T10:00:00.000Z', playCount: 7, + likeCount: 0, publishReady: true, } satisfies PuzzleWorkSummary; diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 8306d4c7..ff59c5a5 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -708,6 +708,7 @@ beforeEach(() => { themeMode: 'tide', playableNpcCount: 1, landmarkCount: 1, + likeCount: 0, }, entries: [], }); @@ -1449,6 +1450,7 @@ test('clicking a public work while logged out routes through requireAuth', async authorDisplayName: '潮汐作者', playableNpcCount: 3, landmarkCount: 4, + likeCount: 0, }, ]); @@ -1543,6 +1545,7 @@ test('creation hub clears all private work shelves immediately after logout stat updatedAt: '2026-04-25T10:10:00.000Z', publishedAt: null, playCount: 0, + likeCount: 0, publishReady: false, }, ], @@ -1583,6 +1586,7 @@ test('published puzzle works appear on home and category public shelves', async updatedAt: '2026-04-25T09:00:00.000Z', publishedAt: '2026-04-25T09:00:00.000Z', playCount: 3, + likeCount: 0, publishReady: true, } satisfies PuzzleWorkSummary; @@ -1666,6 +1670,7 @@ test('published puzzle detail returns to the source platform tab', async () => { updatedAt: '2026-04-25T09:00:00.000Z', publishedAt: '2026-04-25T09:00:00.000Z', playCount: 3, + likeCount: 0, publishReady: true, } satisfies PuzzleWorkSummary; @@ -1921,6 +1926,7 @@ test('puzzle draft card restores the bound agent session and opens the result vi updatedAt: '2026-04-22T12:10:00.000Z', publishedAt: null, playCount: 0, + likeCount: 0, publishReady: false, }, ], @@ -1967,6 +1973,7 @@ test('published puzzle work card restores its source session for editing', async updatedAt: '2026-04-25T12:10:00.000Z', publishedAt: '2026-04-25T12:10:00.000Z', playCount: 8, + likeCount: 0, publishReady: true, }, ], @@ -2007,6 +2014,7 @@ test('public code search opens a published puzzle by PZ code', async () => { updatedAt: '2026-04-25T12:10:00.000Z', publishedAt: '2026-04-25T12:10:00.000Z', playCount: 8, + likeCount: 0, publishReady: true, }; @@ -3185,6 +3193,7 @@ test('creation hub published work can open detail view before deleting from deta themeMode: 'tide' as const, playableNpcCount: 0, landmarkCount: 0, + likeCount: 0, }; vi.mocked(listRpgCreationWorks) @@ -3272,6 +3281,7 @@ test('creation hub published work enters existing detail view', async () => { themeMode: 'tide', playableNpcCount: 3, landmarkCount: 4, + likeCount: 0, }, ]); @@ -3345,6 +3355,7 @@ test('creation hub published work experience button enters world directly', asyn themeMode: 'tide', playableNpcCount: 3, landmarkCount: 4, + likeCount: 0, }, ]); @@ -3421,6 +3432,7 @@ test('creation hub published work delete button removes the work directly from c themeMode: 'tide' as const, playableNpcCount: 0, landmarkCount: 0, + likeCount: 0, }; vi.mocked(listRpgCreationWorks) diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index bb92befd..9717ece6 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -132,6 +132,7 @@ const puzzlePublicEntry = { summaryText: '一张用于公开分享的拼图作品。', coverImageSrc: null, themeTags: ['奇幻'], + likeCount: 12, visibility: 'published', publishedAt: '1777110165.990127Z', updatedAt: '2026-04-25T10:00:00.000Z', @@ -404,7 +405,7 @@ test('public gallery cards hide work code until detail is opened', async () => { screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), ).toBeNull(); - await user.click(screen.getByRole('button', { name: /查看作品/u })); + await user.click(screen.getByRole('button', { name: /奇幻拼图/u })); expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index a3077037..00a88600 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -8,6 +8,7 @@ import { Clock3, Coins, Copy, + Heart, House, LogIn, MessageCircle, @@ -129,6 +130,18 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [ 'profile', ]; type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; +type MobileHomeChannel = 'recommend' | 'today' | 'category' | 'pc' | 'instant'; + +const MOBILE_HOME_CHANNELS: Array<{ + id: MobileHomeChannel; + label: string; +}> = [ + { id: 'recommend', label: '推荐' }, + { id: 'today', label: '今日游戏' }, + { id: 'category', label: '游戏分类' }, + { id: 'pc', label: 'PC游戏' }, + { id: 'instant', label: '即点即玩' }, +]; function usePlatformDesktopLayout() { const [isDesktopLayout, setIsDesktopLayout] = useState(() => { @@ -303,7 +316,6 @@ function WorldCard({ className?: string; }) { const coverImage = resolvePlatformWorldCoverImage(entry); - const leadPortrait = resolvePlatformWorldLeadPortrait(entry); const tags = [ ...new Set( buildPlatformWorldTags(entry) @@ -311,66 +323,79 @@ function WorldCard({ .filter(Boolean), ), ].slice(0, 3); + const likeCount = getPlatformWorldLikeCount(entry); + const cardLabel = `${entry.worldName},${formatCompactCount(likeCount)}点赞`; return ( @@ -740,6 +765,21 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) { : describePlatformThemeLabel(entry.themeMode); } +function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) { + return Math.max(0, Math.round(entry.likeCount ?? 0)); +} + +function formatCompactCount(value: number) { + const normalizedValue = Math.max(0, Math.round(value)); + if (normalizedValue >= 100000000) { + return `${(normalizedValue / 100000000).toFixed(1)}亿`; + } + if (normalizedValue >= 10000) { + return `${(normalizedValue / 10000).toFixed(1)}万`; + } + return `${normalizedValue}`; +} + function formatSnapshotTime(value: string | null | undefined) { if (!value) { return '刚刚保存'; @@ -1435,6 +1475,8 @@ export function RpgEntryHomeView({ const [selectedCategoryTag, setSelectedCategoryTag] = useState( null, ); + const [mobileHomeChannel, setMobileHomeChannel] = + useState('recommend'); const [visitedTabs, setVisitedTabs] = useState>( () => new Set([activeTab]), ); @@ -1644,6 +1686,19 @@ export function RpgEntryHomeView({ const desktopFeaturedGrid = featuredShelf.slice(0, 4); const desktopReleaseGrid = latestEntries.slice(0, 6); const desktopLibraryPreview = myEntries.slice(0, 2); + const mobileFeedEntries = useMemo(() => { + const entryMap = new Map(); + const sourceEntries = + mobileHomeChannel === 'recommend' + ? [...featuredShelf, ...latestEntries] + : latestEntries; + + sourceEntries.forEach((entry) => { + entryMap.set(buildPublicGalleryCardKey(entry), entry); + }); + + return Array.from(entryMap.values()); + }, [featuredShelf, latestEntries, mobileHomeChannel]); const categoryPageClass = isDesktopLayout ? DESKTOP_PAGE_STAGE_CLASS : MOBILE_PAGE_STAGE_CLASS; @@ -1666,39 +1721,21 @@ export function RpgEntryHomeView({ isSearching={isSearchingPublicCode} /> - +
+ {MOBILE_HOME_CHANNELS.map((channel) => { + const active = mobileHomeChannel === channel.id; + return ( + + ); + })} +
{platformError ? (
@@ -1706,45 +1743,28 @@ export function RpgEntryHomeView({
) : null} -
- +
{isLoadingPlatform ? ( - - ) : featuredShelf.length > 0 ? ( -
- {featuredShelf.map((entry: PlatformPublicGalleryCard) => ( + + ) : mobileFeedEntries.length > 0 ? ( +
+ {mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => ( onOpenGalleryDetail(entry)} - /> - ))} -
- ) : ( - - )} -
- -
- - {isLoadingPlatform ? ( - - ) : latestEntries.length > 0 ? ( -
- {latestEntries.map((entry: PlatformPublicGalleryCard) => ( - onOpenGalleryDetail(entry)} + className="w-full" /> ))}
) : ( - + )}
@@ -1783,7 +1803,7 @@ export function RpgEntryHomeView({ badge={activeCategoryGroup.tag} metaLabel={entry.authorDisplayName} onClick={() => onOpenGalleryDetail(entry)} - className="h-[15rem] w-full min-w-0 sm:h-[16rem]" + className="w-full min-w-0" /> ))} @@ -2226,7 +2246,7 @@ export function RpgEntryHomeView({ badge="推荐" metaLabel={describePublicGalleryCardKind(entry)} onClick={() => onOpenGalleryDetail(entry)} - className="h-[16rem] w-full min-w-0" + className="w-full min-w-0" /> ))} @@ -2304,6 +2324,7 @@ export function RpgEntryHomeView({ authorDisplayName: entry.authorDisplayName, playableNpcCount: 0, landmarkCount: 0, + likeCount: 0, }) } className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left" @@ -2344,7 +2365,7 @@ export function RpgEntryHomeView({ badge={describePublicGalleryCardKind(entry)} metaLabel={entry.authorDisplayName} onClick={() => onOpenGalleryDetail(entry)} - className="h-[17rem] w-full min-w-0" + className="w-full min-w-0" /> ))} diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 7860da98..9026fe87 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -1,9 +1,9 @@ +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; -import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; -import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; import { @@ -30,6 +30,9 @@ export type PlatformPuzzleGalleryCard = { summaryText: string; coverImageSrc: string | null; themeTags: string[]; + playCount?: number; + remixCount?: number; + likeCount?: number; visibility: 'published'; publishedAt: string | null; updatedAt: string; @@ -47,6 +50,9 @@ export type PlatformBigFishGalleryCard = { summaryText: string; coverImageSrc: string | null; themeTags: string[]; + playCount?: number; + remixCount?: number; + likeCount?: number; visibility: 'published'; publishedAt: string | null; updatedAt: string; @@ -90,6 +96,9 @@ export function mapPuzzleWorkToPlatformGalleryCard( summaryText: work.summary, coverImageSrc: work.coverImageSrc, themeTags: work.themeTags, + playCount: work.playCount ?? 0, + remixCount: work.remixCount ?? 0, + likeCount: work.likeCount ?? 0, visibility: 'published', publishedAt: work.publishedAt, updatedAt: work.updatedAt, @@ -111,12 +120,24 @@ export function mapBigFishWorkToPlatformGalleryCard( summaryText: work.summary, coverImageSrc: work.coverImageSrc, themeTags: ['大鱼', `${work.levelCount}级`], + playCount: work.playCount ?? 0, + remixCount: work.remixCount ?? 0, + likeCount: work.likeCount ?? 0, visibility: 'published', - publishedAt: work.updatedAt, + publishedAt: work.publishedAt ?? work.updatedAt, updatedAt: work.updatedAt, }; } +export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) { + return { + playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0, + remixCount: 'remixCount' in entry ? (entry.remixCount ?? 0) : 0, + likeCount: 'likeCount' in entry ? (entry.likeCount ?? 0) : 0, + publishedAt: entry.publishedAt ?? null, + }; +} + export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) { if (entry.coverImageSrc) { return entry.coverImageSrc; diff --git a/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx b/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx index 4960068b..a1dd2f50 100644 --- a/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx +++ b/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx @@ -280,6 +280,7 @@ describe('RPG Agent 草稿恢复', () => { themeMode: 'tide', playableNpcCount: 0, landmarkCount: 0, + likeCount: 0, }, entries: [], }); diff --git a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts index aed62ddf..0e72adf6 100644 --- a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts +++ b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts @@ -161,6 +161,27 @@ export function useRpgEntryLibraryDetail( [appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage], ); + const loadGalleryDetailEntry = useCallback( + async (entry: CustomWorldGalleryCard) => { + const detailEntry = await getRpgEntryWorldGalleryDetail( + entry.ownerUserId, + entry.profileId, + ); + void appendBrowseHistoryEntry({ + ownerUserId: detailEntry.ownerUserId, + profileId: detailEntry.profileId, + worldName: detailEntry.worldName, + subtitle: detailEntry.subtitle, + summaryText: detailEntry.summaryText, + coverImageSrc: detailEntry.coverImageSrc, + themeMode: detailEntry.themeMode, + authorDisplayName: detailEntry.authorDisplayName, + }); + return detailEntry; + }, + [appendBrowseHistoryEntry], + ); + const openGalleryDetail = useCallback( async (entry: CustomWorldGalleryCard) => { setSelectionStage('detail'); @@ -168,26 +189,13 @@ export function useRpgEntryLibraryDetail( setDetailError(null); try { - const detailEntry = await getRpgEntryWorldGalleryDetail( - entry.ownerUserId, - entry.profileId, - ); + const detailEntry = await loadGalleryDetailEntry(entry); setSelectedDetailEntry(detailEntry); if (detailEntry.publicWorkCode?.trim()) { pushAppHistoryPath( buildPublicWorkDetailPath(detailEntry.publicWorkCode), ); } - void appendBrowseHistoryEntry({ - ownerUserId: detailEntry.ownerUserId, - profileId: detailEntry.profileId, - worldName: detailEntry.worldName, - subtitle: detailEntry.subtitle, - summaryText: detailEntry.summaryText, - coverImageSrc: detailEntry.coverImageSrc, - themeMode: detailEntry.themeMode, - authorDisplayName: detailEntry.authorDisplayName, - }); } catch (error) { setSelectedDetailEntry(null); setDetailError( @@ -197,7 +205,11 @@ export function useRpgEntryLibraryDetail( setIsDetailLoading(false); } }, - [appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage], + [ + loadGalleryDetailEntry, + setSelectedDetailEntry, + setSelectionStage, + ], ); const openSavedCustomWorldEditor = useCallback( @@ -489,6 +501,7 @@ export function useRpgEntryLibraryDetail( isSelectedWorldOwned, openLibraryDetail, openGalleryDetail, + loadGalleryDetailEntry, openSavedCustomWorldEditor, handleOpenCreationWork, handlePublishSelectedWorld, diff --git a/src/hooks/rpg-runtime-story/characterChat.ts b/src/hooks/rpg-runtime-story/characterChat.ts index 3acb7f7f..d3f4a694 100644 --- a/src/hooks/rpg-runtime-story/characterChat.ts +++ b/src/hooks/rpg-runtime-story/characterChat.ts @@ -1,11 +1,11 @@ -import {type Dispatch, type SetStateAction,useState} from 'react'; +import { type Dispatch, type SetStateAction, useState } from 'react'; import { generateCharacterPanelChatSuggestions, generateCharacterPanelChatSummary, streamCharacterPanelChatReply, } from '../../services/aiService'; -import type {StoryGenerationContext} from '../../services/aiTypes'; +import type { StoryGenerationContext } from '../../services/aiTypes'; import type { Character, CharacterChatRecord, @@ -47,12 +47,17 @@ export interface CharacterChatUi { sendDraft: () => void; } -export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord { - return state.characterChats[characterId] ?? { - history: [], - summary: '', - updatedAt: null, - }; +export function getCharacterChatRecord( + state: GameState, + characterId: string, +): CharacterChatRecord { + return ( + state.characterChats[characterId] ?? { + history: [], + summary: '', + updatedAt: null, + } + ); } export function trimCharacterChatHistory(history: CharacterChatTurn[]) { @@ -66,7 +71,10 @@ export function buildLocalCharacterChatSummary( ) { const latestTurns = history .slice(-4) - .map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`) + .map( + (turn) => + `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`, + ) .join(' '); const currentSummary = latestTurns @@ -111,7 +119,9 @@ type CharacterChatTargetStatus = { affinity?: number | null; }; -function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus { +function buildTargetStatus( + target: CharacterChatTarget, +): CharacterChatTargetStatus { return { roleLabel: target.roleLabel, hp: target.hp, @@ -129,9 +139,13 @@ export function useCharacterChatFlow({ }: { gameState: GameState; setGameState: Dispatch>; - buildStoryContextFromState: (state: GameState) => StoryGenerationContext; + buildStoryContextFromState: ( + state: GameState, + extras?: { currentStory?: null }, + ) => StoryGenerationContext; }) { - const [characterChatModal, setCharacterChatModal] = useState(null); + const [characterChatModal, setCharacterChatModal] = + useState(null); const loadCharacterChatSuggestions = async ( target: CharacterChatTarget, @@ -139,7 +153,7 @@ export function useCharacterChatFlow({ summary: string, ) => { if (!gameState.worldType || !gameState.playerCharacter) { - setCharacterChatModal(current => + setCharacterChatModal((current) => current && current.target.character.id === target.character.id ? { ...current, @@ -151,7 +165,7 @@ export function useCharacterChatFlow({ return; } - setCharacterChatModal(current => + setCharacterChatModal((current) => current && current.target.character.id === target.character.id ? { ...current, @@ -172,7 +186,7 @@ export function useCharacterChatFlow({ buildTargetStatus(target), ); - setCharacterChatModal(current => + setCharacterChatModal((current) => current && current.target.character.id === target.character.id ? { ...current, @@ -183,7 +197,7 @@ export function useCharacterChatFlow({ ); } catch (error) { console.error('Failed to generate character chat suggestions:', error); - setCharacterChatModal(current => + setCharacterChatModal((current) => current && current.target.character.id === target.character.id ? { ...current, @@ -213,7 +227,11 @@ export function useCharacterChatFlow({ }; const sendCharacterChatDraft = async () => { - if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) { + if ( + !characterChatModal || + !gameState.worldType || + !gameState.playerCharacter + ) { return; } @@ -223,7 +241,10 @@ export function useCharacterChatFlow({ } const target = characterChatModal.target; - const existingRecord = getCharacterChatRecord(gameState, target.character.id); + const existingRecord = getCharacterChatRecord( + gameState, + target.character.id, + ); const baseMessages = trimCharacterChatHistory(characterChatModal.messages); const nextMessages = trimCharacterChatHistory([ ...baseMessages, @@ -233,12 +254,12 @@ export function useCharacterChatFlow({ }, ]); - setCharacterChatModal(current => + setCharacterChatModal((current) => current && current.target.character.id === target.character.id ? { ...current, draft: '', - messages: [...nextMessages, {speaker: 'character', text: ''}], + messages: [...nextMessages, { speaker: 'character', text: '' }], suggestions: [], isSending: true, isLoadingSuggestions: true, @@ -261,12 +282,12 @@ export function useCharacterChatFlow({ draft, buildTargetStatus(target), { - onUpdate: text => { - setCharacterChatModal(current => + onUpdate: (text) => { + setCharacterChatModal((current) => current && current.target.character.id === target.character.id ? { ...current, - messages: [...nextMessages, {speaker: 'character', text}], + messages: [...nextMessages, { speaker: 'character', text }], } : current, ); @@ -275,7 +296,7 @@ export function useCharacterChatFlow({ ); } catch (error) { console.error('Failed to stream character panel chat reply:', error); - setCharacterChatModal(current => + setCharacterChatModal((current) => current && current.target.character.id === target.character.id ? { ...current, @@ -283,10 +304,12 @@ export function useCharacterChatFlow({ messages: baseMessages, isSending: false, isLoadingSuggestions: false, - error: error instanceof Error ? error.message : '未知智能生成错误', - suggestions: current.suggestions.length > 0 - ? current.suggestions - : buildLocalCharacterChatSuggestions(target.character), + error: + error instanceof Error ? error.message : '未知智能生成错误', + suggestions: + current.suggestions.length > 0 + ? current.suggestions + : buildLocalCharacterChatSuggestions(target.character), } : current, ); @@ -315,7 +338,11 @@ export function useCharacterChatFlow({ ); } catch (error) { console.error('Failed to summarize character chat:', error); - nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary); + nextSummary = buildLocalCharacterChatSummary( + target.character, + finalMessages, + existingRecord.summary, + ); } const nextRecord: CharacterChatRecord = { @@ -324,10 +351,10 @@ export function useCharacterChatFlow({ updatedAt: new Date().toISOString(), }; - setGameState(current => + setGameState((current) => buildCharacterChatRecordUpdate(current, target.character.id, nextRecord), ); - setCharacterChatModal(current => + setCharacterChatModal((current) => current && current.target.character.id === target.character.id ? { ...current, @@ -346,8 +373,14 @@ export function useCharacterChatFlow({ modal: characterChatModal, openChat: openCharacterChat, closeChat: () => setCharacterChatModal(null), - setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)), - useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)), + setDraft: (value: string) => + setCharacterChatModal((current) => + current ? { ...current, draft: value } : current, + ), + useSuggestion: (value: string) => + setCharacterChatModal((current) => + current ? { ...current, draft: value } : current, + ), refreshSuggestions: () => { if (!characterChatModal) { return; diff --git a/src/hooks/rpg-runtime-story/choiceActions.ts b/src/hooks/rpg-runtime-story/choiceActions.ts index 46e9fcb8..2fba6259 100644 --- a/src/hooks/rpg-runtime-story/choiceActions.ts +++ b/src/hooks/rpg-runtime-story/choiceActions.ts @@ -1,7 +1,4 @@ -import type { - Dispatch, - SetStateAction, -} from 'react'; +import type { Dispatch, SetStateAction } from 'react'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { isRpgRuntimeServerFunctionId } from '../../services/rpg-runtime'; @@ -25,7 +22,12 @@ import { } from './storyChoiceRuntime'; import type { BattleRewardSummary } from './uiTypes'; -type RuntimeStatsIncrements = Partial>; +type RuntimeStatsIncrements = Partial< + Pick< + GameState['runtimeStats'], + 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled' + > +>; type BuildFallbackStoryForState = ( state: GameState, @@ -63,6 +65,7 @@ type BuildStoryContextFromState = ( lastFunctionId?: string | null; observeSignsRequested?: boolean; recentActionResult?: string | null; + currentStory?: StoryMoment | null; }, ) => StoryGenerationContext; @@ -115,7 +118,11 @@ export function createStoryChoiceActions({ setAiError: Dispatch>; setIsLoading: Dispatch>; setBattleReward: Dispatch>; - buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState; + buildResolvedChoiceState: ( + state: GameState, + option: StoryOption, + character: Character, + ) => ResolvedChoiceState; playResolvedChoice: ( state: GameState, option: StoryOption, @@ -127,14 +134,24 @@ export function createStoryChoiceActions({ buildStoryFromResponse: BuildStoryFromResponse; buildFallbackStoryForState: BuildFallbackStoryForState; generateStoryForState: GenerateStoryForState; - getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null; - getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs']; - getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs']; + getAvailableOptionsForState: ( + state: GameState, + character: Character, + ) => StoryOption[] | null; + getStoryGenerationHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + getResolvedSceneHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; buildNpcStory: BuildNpcStory; handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation; updateQuestLog: UpdateQuestLog; incrementRuntimeStats: IncrementRuntimeStats; - getCampCompanionTravelScene?: (state: GameState, character: Character) => GameState['currentScenePreset'] | null; + getCampCompanionTravelScene?: ( + state: GameState, + character: Character, + ) => GameState['currentScenePreset'] | null; enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean; handleNpcInteraction: (option: StoryOption) => boolean | Promise; handleTreasureInteraction: ( @@ -149,8 +166,12 @@ export function createStoryChoiceActions({ ) => { nextState: GameState; resultText: string } | null; isContinueAdventureOption: (option: StoryOption) => boolean; isCampTravelHomeOption?: (option: StoryOption) => boolean; - isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; - isNpcEncounter?: (encounter: GameState['currentEncounter']) => encounter is Encounter; + isRegularNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isNpcEncounter?: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; npcPreviewTalkFunctionId: string; fallbackCompanionName?: string; turnVisualMs: number; @@ -160,7 +181,10 @@ export function createStoryChoiceActions({ if (!gameState.worldType || !character || isLoading) return; if (option.disabled) return; - if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) { + if ( + currentStory?.deferredOptions?.length && + isContinueAdventureOption(option) + ) { if (currentStory.deferredRuntimeState) { setGameState({ ...gameState, @@ -209,9 +233,9 @@ export function createStoryChoiceActions({ } if ( - option.functionId === npcPreviewTalkFunctionId - && isRegularNpcEncounter(gameState.currentEncounter) - && !gameState.npcInteractionActive + option.functionId === npcPreviewTalkFunctionId && + isRegularNpcEncounter(gameState.currentEncounter) && + !gameState.npcInteractionActive ) { setAiError(null); enterNpcInteraction(gameState.currentEncounter, option.actionText); diff --git a/src/hooks/rpg-runtime-story/npcInteraction.ts b/src/hooks/rpg-runtime-story/npcInteraction.ts index 467a531a..deca6c1b 100644 --- a/src/hooks/rpg-runtime-story/npcInteraction.ts +++ b/src/hooks/rpg-runtime-story/npcInteraction.ts @@ -1,20 +1,13 @@ -import type { - Dispatch, - SetStateAction, -} from 'react'; +import type { Dispatch, SetStateAction } from 'react'; import { useState } from 'react'; -import { - getCharacterById, -} from '../../data/characterPresets'; +import { getCharacterById } from '../../data/characterPresets'; import { buildNpcGiftModalState, buildNpcRecruitModalState, buildNpcTradeModalIntroText, } from '../../data/functionCatalog'; -import { - buildNpcTradeTransactionActionText, -} from '../../data/npcInteractions'; +import { buildNpcTradeTransactionActionText } from '../../data/npcInteractions'; import { streamNpcRecruitDialogue } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import type { @@ -52,6 +45,7 @@ type StoryNpcInteractionRuntime = { state: GameState, extras?: { lastFunctionId?: string | null; + currentStory?: StoryMoment | null; encounterNpcStateOverride?: GameState['npcStates'][string] | null; }, ) => StoryGenerationContext; @@ -67,11 +61,16 @@ type StoryNpcInteractionRuntime = { streaming?: boolean, ) => StoryMoment; generateStoryForState: GenerateStoryForState; - getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs']; + getStoryGenerationHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; getTypewriterDelay: (char: string) => number; }; -function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) { +function buildOfflineRecruitDialogue( + encounter: Encounter, + releasedCompanionName?: string | null, +) { const releaseLine = releasedCompanionName ? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。` : '你:如果你愿意加入,我希望接下来能和你并肩行动。'; @@ -92,10 +91,11 @@ function normalizeRecruitDialogue( const rawLines = dialogueText .replace(/\r/g, '') .split('\n') - .map(line => line.trim()) + .map((line) => line.trim()) .filter(Boolean); - const refusalPattern = /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u; - const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line)); + const refusalPattern = + /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u; + const sanitizedLines = rawLines.filter((line) => !refusalPattern.test(line)); const npcPrefix = `${encounter.npcName}:`; const playerPrefix = '你:'; const releaseLine = releasedCompanionName @@ -108,14 +108,17 @@ function normalizeRecruitDialogue( `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`, ]; - const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3); - if (!workingLines.some(line => line.startsWith(playerPrefix))) { + const workingLines = + sanitizedLines.length > 0 + ? sanitizedLines.slice(0, 5) + : defaultLines.slice(0, 3); + if (!workingLines.some((line) => line.startsWith(playerPrefix))) { const firstDefaultLine = defaultLines[0]; if (firstDefaultLine) { workingLines.unshift(firstDefaultLine); } } - if (!workingLines.some(line => line.startsWith(npcPrefix))) { + if (!workingLines.some((line) => line.startsWith(npcPrefix))) { const secondDefaultLine = defaultLines[1]; if (secondDefaultLine) { workingLines.push(secondDefaultLine); @@ -125,9 +128,9 @@ function normalizeRecruitDialogue( const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`; const lastWorkingLine = workingLines[workingLines.length - 1]; if ( - workingLines.length === 0 - || !lastWorkingLine?.startsWith(npcPrefix) - || refusalPattern.test(lastWorkingLine) + workingLines.length === 0 || + !lastWorkingLine?.startsWith(npcPrefix) || + refusalPattern.test(lastWorkingLine) ) { workingLines.push(acceptanceLine); } else { @@ -158,11 +161,16 @@ export function useStoryNpcInteractionFlow({ gameState: GameState; setGameState: Dispatch>; getNpcEncounterKey: (encounter: Encounter) => string; - getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string]; + getResolvedNpcState: ( + state: GameState, + encounter: Encounter, + ) => GameState['npcStates'][string]; updateNpcState: ( state: GameState, encounter: Encounter, - updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string], + updater: ( + npcState: GameState['npcStates'][string], + ) => GameState['npcStates'][string], ) => GameState; cloneInventoryItemForOwner: ( item: InventoryItem, @@ -173,7 +181,9 @@ export function useStoryNpcInteractionFlow({ }) { const [tradeModal, setTradeModal] = useState(null); const [giftModal, setGiftModal] = useState(null); - const [recruitModal, setRecruitModal] = useState(null); + const [recruitModal, setRecruitModal] = useState( + null, + ); const resolveRecruitmentOnServer = async (params: { encounter: Encounter; @@ -221,7 +231,10 @@ export function useStoryNpcInteractionFlow({ runtime.setCurrentStory(nextStory); return true; } catch (error) { - console.error('Failed to resolve npc recruit action on the server:', error); + console.error( + 'Failed to resolve npc recruit action on the server:', + error, + ); runtime.setAiError( error instanceof Error ? error.message : 'NPC 招募执行失败', ); @@ -245,9 +258,11 @@ export function useStoryNpcInteractionFlow({ const releasedCompanionName = releasedNpcId ? (() => { - const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId); + const releasedCompanion = gameState.companions.find( + (item) => item.npcId === releasedNpcId, + ); return releasedCompanion?.characterId - ? getCharacterById(releasedCompanion.characterId)?.name ?? null + ? (getCharacterById(releasedCompanion.characterId)?.name ?? null) : null; })() : null; @@ -261,7 +276,9 @@ export function useStoryNpcInteractionFlow({ setRecruitModal(null); runtime.setAiError(null); runtime.setIsLoading(true); - runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true)); + runtime.setCurrentStory( + runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true), + ); let dialogueText = ''; let streamedTargetText = ''; @@ -269,20 +286,32 @@ export function useStoryNpcInteractionFlow({ let streamCompleted = false; const typewriterPromise = (async () => { - while (!streamCompleted || displayedText.length < streamedTargetText.length) { + while ( + !streamCompleted || + displayedText.length < streamedTargetText.length + ) { if (displayedText.length >= streamedTargetText.length) { - await new Promise(resolve => window.setTimeout(resolve, 40)); + await new Promise((resolve) => window.setTimeout(resolve, 40)); continue; } const nextChar = streamedTargetText[displayedText.length]; if (!nextChar) { - await new Promise(resolve => window.setTimeout(resolve, 40)); + await new Promise((resolve) => window.setTimeout(resolve, 40)); continue; } displayedText += nextChar; - runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, displayedText, [], true)); - await new Promise(resolve => window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar))); + runtime.setCurrentStory( + runtime.buildDialogueStoryMoment( + encounter.npcName, + displayedText, + [], + true, + ), + ); + await new Promise((resolve) => + window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)), + ); } })(); @@ -294,12 +323,13 @@ export function useStoryNpcInteractionFlow({ runtime.getStoryGenerationHostileNpcs(provisionalState), gameState.storyHistory, runtime.buildStoryContextFromState(provisionalState, { + currentStory: runtime.currentStory, lastFunctionId: 'npc_recruit', }), actionText, recruitPromptSummary, { - onUpdate: text => { + onUpdate: (text) => { streamedTargetText = text; }, }, @@ -311,17 +341,30 @@ export function useStoryNpcInteractionFlow({ streamCompleted = true; await typewriterPromise; console.error('Failed to stream recruit dialogue:', error); - dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName); - runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误'); + dialogueText = + displayedText || + buildOfflineRecruitDialogue(encounter, releasedCompanionName); + runtime.setAiError( + error instanceof Error ? error.message : '未知智能生成错误', + ); } const finalDialogueText = normalizeRecruitDialogue( encounter, - dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName), + dialogueText || + displayedText || + buildOfflineRecruitDialogue(encounter, releasedCompanionName), releasedCompanionName, ); - runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false)); - await new Promise(resolve => window.setTimeout(resolve, 260)); + runtime.setCurrentStory( + runtime.buildDialogueStoryMoment( + encounter.npcName, + finalDialogueText, + [], + false, + ), + ); + await new Promise((resolve) => window.setTimeout(resolve, 260)); await resolveRecruitmentOnServer({ encounter, actionText, @@ -334,8 +377,8 @@ export function useStoryNpcInteractionFlow({ mode: 'buy' | 'sell', ): RuntimeNpcTradeItemView[] => mode === 'buy' - ? gameState.runtimeNpcInteraction?.trade.buyItems ?? [] - : gameState.runtimeNpcInteraction?.trade.sellItems ?? []; + ? (gameState.runtimeNpcInteraction?.trade.buyItems ?? []) + : (gameState.runtimeNpcInteraction?.trade.sellItems ?? []); const findRuntimeTradeItem = (modal: TradeModalState) => { const itemId = @@ -360,27 +403,25 @@ export function useStoryNpcInteractionFlow({ }; const openTradeModal = (encounter: Encounter, actionText: string) => { - setTradeModal( - { - encounter, - actionText, - introText: buildNpcTradeModalIntroText(encounter), - mode: 'buy', - selectedNpcItemId: - gameState.runtimeNpcInteraction?.trade.buyItems.find( - (item) => item.canSubmit, - )?.itemId ?? - gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ?? - null, - selectedPlayerItemId: - gameState.runtimeNpcInteraction?.trade.sellItems.find( - (item) => item.canSubmit, - )?.itemId ?? - gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ?? - null, - selectedQuantity: 1, - }, - ); + setTradeModal({ + encounter, + actionText, + introText: buildNpcTradeModalIntroText(encounter), + mode: 'buy', + selectedNpcItemId: + gameState.runtimeNpcInteraction?.trade.buyItems.find( + (item) => item.canSubmit, + )?.itemId ?? + gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ?? + null, + selectedPlayerItemId: + gameState.runtimeNpcInteraction?.trade.sellItems.find( + (item) => item.canSubmit, + )?.itemId ?? + gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ?? + null, + selectedQuantity: 1, + }); }; const openGiftModal = (encounter: Encounter, actionText: string) => { @@ -390,17 +431,13 @@ export function useStoryNpcInteractionFlow({ gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ?? null; - setGiftModal( - buildNpcGiftModalState( - encounter, - actionText, - selectedItemId, - ), - ); + setGiftModal(buildNpcGiftModalState(encounter, actionText, selectedItemId)); }; const openRecruitModal = (encounter: Encounter, actionText: string) => { - setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText)); + setRecruitModal( + buildNpcRecruitModalState(gameState, encounter, actionText), + ); }; const clearNpcInteractionUi = () => { @@ -448,7 +485,10 @@ export function useStoryNpcInteractionFlow({ runtime.setCurrentStory(nextStory); return true; } catch (error) { - console.error('Failed to resolve npc runtime action on the server:', error); + console.error( + 'Failed to resolve npc runtime action on the server:', + error, + ); runtime.setAiError( error instanceof Error ? error.message : 'NPC 交互执行失败', ); @@ -514,50 +554,62 @@ export function useStoryNpcInteractionFlow({ tradeModal, giftModal, recruitModal, - setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => { - if (!current) return current; - return { - ...current, - mode, - selectedNpcItemId: - current.selectedNpcItemId ?? - gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ?? - null, - selectedPlayerItemId: - current.selectedPlayerItemId ?? - gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ?? - null, - selectedQuantity: 1, - }; - }), - selectTradeNpcItem: (itemId: string) => setTradeModal(current => { - if (!current) return current; - return { - ...current, - selectedNpcItemId: itemId, - selectedQuantity: 1, - }; - }), - selectTradePlayerItem: (itemId: string) => setTradeModal(current => { - if (!current) return current; - return { - ...current, - selectedPlayerItemId: itemId, - selectedQuantity: 1, - }; - }), - setTradeQuantity: (quantity: number) => setTradeModal(current => current - ? { + setTradeMode: (mode: 'buy' | 'sell') => + setTradeModal((current) => { + if (!current) return current; + return { ...current, - selectedQuantity: normalizeTradeQuantity(quantity), - } - : current), + mode, + selectedNpcItemId: + current.selectedNpcItemId ?? + gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ?? + null, + selectedPlayerItemId: + current.selectedPlayerItemId ?? + gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ?? + null, + selectedQuantity: 1, + }; + }), + selectTradeNpcItem: (itemId: string) => + setTradeModal((current) => { + if (!current) return current; + return { + ...current, + selectedNpcItemId: itemId, + selectedQuantity: 1, + }; + }), + selectTradePlayerItem: (itemId: string) => + setTradeModal((current) => { + if (!current) return current; + return { + ...current, + selectedPlayerItemId: itemId, + selectedQuantity: 1, + }; + }), + setTradeQuantity: (quantity: number) => + setTradeModal((current) => + current + ? { + ...current, + selectedQuantity: normalizeTradeQuantity(quantity), + } + : current, + ), closeTradeModal: () => setTradeModal(null), confirmTrade, - selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current), + selectGiftItem: (itemId: string) => + setGiftModal((current) => + current ? { ...current, selectedItemId: itemId } : current, + ), closeGiftModal: () => setGiftModal(null), confirmGift, - selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current), + selectRecruitRelease: (npcId: string) => + setRecruitModal((current) => + current ? { ...current, selectedReleaseNpcId: npcId } : current, + ), closeRecruitModal: () => setRecruitModal(null), confirmRecruit: () => { if (!recruitModal) return; diff --git a/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts b/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts index 47488f1c..9537bb2e 100644 --- a/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts +++ b/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts @@ -55,6 +55,7 @@ type BuildStoryContextFromState = ( lastFunctionId?: string | null; observeSignsRequested?: boolean; recentActionResult?: string | null; + currentStory?: StoryMoment | null; }, ) => StoryGenerationContext; @@ -161,7 +162,10 @@ export async function runLocalStoryChoiceContinuation(params: { params.option, params.character, ); - if (resolvedChoice.optionKind === 'battle' || resolvedChoice.optionKind === 'escape') { + if ( + resolvedChoice.optionKind === 'battle' || + resolvedChoice.optionKind === 'escape' + ) { throw new Error( `战斗与逃脱动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`, ); @@ -194,6 +198,7 @@ export async function runLocalStoryChoiceContinuation(params: { observeSignsRequested: params.option.functionId === 'idle_observe_signs', recentActionResult: combatResolutionContextText, + currentStory: params.currentStory, }), projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } @@ -239,11 +244,11 @@ export async function runLocalStoryChoiceContinuation(params: { lastObserveSignsSceneId: params.option.functionId === 'idle_observe_signs' ? (afterSequence.currentScenePreset?.id ?? null) - : afterSequence.lastObserveSignsSceneId ?? null, + : (afterSequence.lastObserveSignsSceneId ?? null), lastObserveSignsReport: params.option.functionId === 'idle_observe_signs' ? response.storyText - : afterSequence.lastObserveSignsReport ?? null, + : (afterSequence.lastObserveSignsReport ?? null), storyHistory: nextHistory, }, {}, diff --git a/src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts b/src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts index abb46c41..aa84420c 100644 --- a/src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts +++ b/src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts @@ -22,6 +22,7 @@ export type ChoiceRuntimeController = { recentActionResult?: string | null; openingCampBackground?: string | null; openingCampDialogue?: string | null; + currentStory?: StoryMoment | null; encounterNpcStateOverride?: GameState['npcStates'][string] | null; }, ) => StoryGenerationContext; diff --git a/src/hooks/rpg-runtime-story/storyContextBuilder.test.ts b/src/hooks/rpg-runtime-story/storyContextBuilder.test.ts new file mode 100644 index 00000000..706cc354 --- /dev/null +++ b/src/hooks/rpg-runtime-story/storyContextBuilder.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { AnimationState, type GameState, type StoryMoment } from '../../types'; +import { buildStoryContextFromState } from './storyContextBuilder'; + +function createState(overrides: Partial = {}): GameState { + return { + worldType: 'WUXIA', + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 3, + runtimeMode: 'play', + runtimePersistenceDisabled: false, + playerHp: 30, + playerMaxHp: 40, + playerMana: 12, + playerMaxMana: 20, + inBattle: false, + playerX: 0, + playerFacing: 'right', + animationState: AnimationState.IDLE, + playerSkillCooldowns: {}, + currentScenePreset: { + id: 'forest-trail', + name: '林间小径', + description: '风声穿过树梢。', + }, + ...overrides, + } as GameState; +} + +describe('storyContextBuilder', () => { + it('keeps normal play context lightweight', () => { + const context = buildStoryContextFromState(createState()); + + expect(context.runtimeSessionId).toBe('runtime-main'); + expect(context.runtimeSnapshot).toBeUndefined(); + }); + + it('attaches transient snapshot for disabled persistence runtime', () => { + const state = createState({ + runtimeSessionId: 'runtime-preview', + runtimePersistenceDisabled: true, + }); + const currentStory: StoryMoment = { + text: '断桥客站在风口,等你先开口。', + options: [], + }; + + const context = buildStoryContextFromState(state, { currentStory }); + + expect(context.runtimeSnapshot).toEqual({ + bottomTab: 'adventure', + gameState: state, + currentStory, + }); + }); +}); diff --git a/src/hooks/rpg-runtime-story/storyContextBuilder.ts b/src/hooks/rpg-runtime-story/storyContextBuilder.ts index bd858aea..6b8ff629 100644 --- a/src/hooks/rpg-runtime-story/storyContextBuilder.ts +++ b/src/hooks/rpg-runtime-story/storyContextBuilder.ts @@ -1,5 +1,5 @@ import type { StoryGenerationContext } from '../../services/aiTypes'; -import type { GameState } from '../../types'; +import type { GameState, StoryMoment } from '../../types'; export type StoryContextBuilderExtras = { pendingSceneEncounter?: boolean; @@ -9,8 +9,17 @@ export type StoryContextBuilderExtras = { openingCampBackground?: string | null; openingCampDialogue?: string | null; encounterNpcStateOverride?: GameState['npcStates'][string] | null; + currentStory?: StoryMoment | null; }; +function shouldAttachTransientRuntimeSnapshot(state: GameState) { + return ( + state.runtimePersistenceDisabled === true || + state.runtimeMode === 'preview' || + state.runtimeMode === 'test' + ); +} + /** * 运行时 story prompt context 的正式投影已经迁到 server-rs。 * 前端只保留 session 与少量请求元信息,方便旧调用面继续复用同一个函数签名。 @@ -22,6 +31,15 @@ export function buildStoryContextFromState( return { runtimeSessionId: state.runtimeSessionId ?? null, runtimeActionVersion: state.runtimeActionVersion, + runtimeSnapshot: shouldAttachTransientRuntimeSnapshot(state) + ? { + // 中文注释:禁存运行态不会写入正式 runtime_snapshot, + // 聊天/续写请求需要携带本地临时快照供 server-rs 投影上下文。 + bottomTab: 'adventure', + gameState: state, + currentStory: extras.currentStory ?? null, + } + : undefined, playerHp: state.playerHp, playerMaxHp: state.playerMaxHp, playerMana: state.playerMana, diff --git a/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts b/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts index b016a61c..47f9e58e 100644 --- a/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts +++ b/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts @@ -24,6 +24,7 @@ type StoryInteractionCoordinatorParams = { lastFunctionId?: string | null; openingCampBackground?: string | null; openingCampDialogue?: string | null; + currentStory?: StoryMoment | null; encounterNpcStateOverride?: GameState['npcStates'][string] | null; }, ) => StoryGenerationContext; diff --git a/src/hooks/rpg-runtime-story/storyRequestCoordinator.ts b/src/hooks/rpg-runtime-story/storyRequestCoordinator.ts index c8dcf4df..388aae36 100644 --- a/src/hooks/rpg-runtime-story/storyRequestCoordinator.ts +++ b/src/hooks/rpg-runtime-story/storyRequestCoordinator.ts @@ -18,6 +18,7 @@ type BuildStoryContextFromState = ( state: GameState, extras?: { lastFunctionId?: string | null; + currentStory?: StoryMoment | null; }, ) => StoryGenerationContext; @@ -163,8 +164,11 @@ export async function generateStoryForStateWithCoordinator(params: { const context = params.choice ? params.buildStoryContextFromState(params.state, { lastFunctionId: params.lastFunctionId, + currentStory: params.currentStory, }) - : params.buildStoryContextFromState(params.state); + : params.buildStoryContextFromState(params.state, { + currentStory: params.currentStory, + }); const response = params.choice ? await params.requestNextStep( worldType, diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts index 62d928e8..a802ba2a 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts @@ -63,6 +63,7 @@ type BuildStoryContextExtras = { lastFunctionId?: string | null; openingCampBackground?: string | null; openingCampDialogue?: string | null; + currentStory?: StoryMoment | null; encounterNpcStateOverride?: GameState['npcStates'][string] | null; }; @@ -259,7 +260,9 @@ export function createStoryNpcEncounterActions({ }, }); - const buildPendingQuestOfferOptions = (encounter: Encounter): StoryOption[] => [ + const buildPendingQuestOfferOptions = ( + encounter: Encounter, + ): StoryOption[] => [ buildNpcChatQuestOfferOption( encounter, NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.view, @@ -336,8 +339,7 @@ export function createStoryNpcEncounterActions({ ? `你们刚结束一场切磋,${params.resultText}` : `你刚赢下这场交锋,${params.resultText}`, logLines, - battleOutcome: - params.battleMode === 'spar' ? 'spar_complete' : 'victory', + battleOutcome: params.battleMode === 'spar' ? 'spar_complete' : 'victory', }; }; @@ -353,7 +355,10 @@ export function createStoryNpcEncounterActions({ return false; } - const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter); + const reopenedNpcState = getResolvedNpcState( + params.nextState, + params.encounter, + ); const baseStory = buildNpcStory( params.nextState, playerCharacter, @@ -365,7 +370,10 @@ export function createStoryNpcEncounterActions({ ); const fallbackChatOption = baseChatOptions[0] ?? - buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`); + buildNpcChatOption( + params.encounter, + `继续和${params.encounter.npcName}对话`, + ); const combatContext = buildNpcBattleChatCombatContext({ battleMode: params.battleMode, resultText: params.resultText, @@ -487,7 +495,9 @@ export function createStoryNpcEncounterActions({ ); const restoredEncounter = state.sparReturnEncounter ?? - (state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ?? + (state.currentEncounter?.kind === 'npc' + ? state.currentEncounter + : null) ?? activeBattleHostiles[0]?.encounter ?? ({ id: battleNpcId, @@ -756,7 +766,8 @@ export function createStoryNpcEncounterActions({ functionId: option.functionId, actionText: option.actionText, detailText: option.detailText ?? null, - action: option.interaction?.kind === 'npc' ? option.interaction.action : null, + action: + option.interaction?.kind === 'npc' ? option.interaction.action : null, })); const isHostileChat = directive?.isHostileChat === true || @@ -883,10 +894,9 @@ export function createStoryNpcEncounterActions({ encounter: Encounter, playerCharacter: Character, ) => { - const resolvedStateOptions = - collapseNpcChatOptions( - getAvailableOptionsForState(gameState, playerCharacter) ?? [], - ); + const resolvedStateOptions = collapseNpcChatOptions( + getAvailableOptionsForState(gameState, playerCharacter) ?? [], + ); const currentStoryOptions = currentStory?.options ?? []; const currentChatOptions = currentStoryOptions.filter((option) => isNpcChatOptionForEncounter(option, encounter), @@ -1105,7 +1115,9 @@ export function createStoryNpcEncounterActions({ }; }; - const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => { + const buildHostileNpcEscapeOptions = ( + character: Character, + ): StoryOption[] => { const currentScene = gameState.currentScenePreset; const worldType = gameState.worldType; const options: StoryOption[] = []; @@ -1120,34 +1132,24 @@ export function createStoryNpcEncounterActions({ seenSceneIds.add(connection.sceneId); const targetScene = getScenePresetById(worldType, connection.sceneId); const targetSceneName = - targetScene?.name ?? - connection.summary?.trim() ?? - connection.sceneId; + targetScene?.name ?? connection.summary?.trim() ?? connection.sceneId; options.push( - buildHostileNpcEscapeOption( - character, - `逃往${targetSceneName}`, - { - targetSceneId: connection.sceneId, - escapeTargetSceneId: connection.sceneId, - escapeEntry: 'from_left', - }, - ), + buildHostileNpcEscapeOption(character, `逃往${targetSceneName}`, { + targetSceneId: connection.sceneId, + escapeTargetSceneId: connection.sceneId, + escapeEntry: 'from_left', + }), ); } options.push( - buildHostileNpcEscapeOption( - character, - '逃回当前场景起点', - { - targetSceneId: currentScene.id, - escapeTargetSceneId: currentScene.id, - escapeReturnToSceneStart: true, - escapeEntry: 'from_left', - }, - ), + buildHostileNpcEscapeOption(character, '逃回当前场景起点', { + targetSceneId: currentScene.id, + escapeTargetSceneId: currentScene.id, + escapeReturnToSceneStart: true, + escapeEntry: 'from_left', + }), ); } @@ -1326,6 +1328,7 @@ export function createStoryNpcEncounterActions({ getStoryGenerationHostileNpcs(gameState), gameState.storyHistory, buildStoryContextFromState(gameState, { + currentStory, lastFunctionId: 'npc_chat', ...openingCampContext, encounterNpcStateOverride: npcState, @@ -1484,6 +1487,7 @@ export function createStoryNpcEncounterActions({ getStoryGenerationHostileNpcs(gameState), gameState.storyHistory, buildStoryContextFromState(gameState, { + currentStory, lastFunctionId: 'npc_chat', ...openingCampContext, encounterNpcStateOverride: npcState, @@ -1594,17 +1598,17 @@ export function createStoryNpcEncounterActions({ chatDirective.remainingTurns ?? null, limitReason: chatDirective.limitReason ?? null, - terminationMode: chatDirective.terminationMode ?? null, - terminationReason: + terminationMode: chatDirective.terminationMode ?? null, + terminationReason: chatTurn.chatDirective?.terminationReason ?? chatDirective.terminationReason ?? null, - isHostileChat: chatDirective.isHostileChat ?? false, - closingMode: - chatTurn.chatDirective?.closingMode ?? - chatDirective.closingMode ?? - 'free', - forceExitAfterTurn: + isHostileChat: chatDirective.isHostileChat ?? false, + closingMode: + chatTurn.chatDirective?.closingMode ?? + chatDirective.closingMode ?? + 'free', + forceExitAfterTurn: chatTurn.chatDirective?.forceExit ?? chatDirective.forceExitAfterTurn ?? false, @@ -1615,9 +1619,7 @@ export function createStoryNpcEncounterActions({ const pendingQuestIntroText = chatTurn.pendingQuestOffer?.introText?.trim() || ''; if (shouldForceExitAfterTurn) { - const closingDialogue = [ - ...nextDialogue, - ]; + const closingDialogue = [...nextDialogue]; const shouldUseHostileClosureOptions = shouldUseHostileNpcChatClosureOptions( resolvedChatDirective, @@ -1756,13 +1758,9 @@ export function createStoryNpcEncounterActions({ return false; } - void handleNpcChatTurn( - encounter, - `我先结束这轮交谈,继续往前走。`, - { - forcePlayerExit: true, - }, - ); + void handleNpcChatTurn(encounter, `我先结束这轮交谈,继续往前走。`, { + forcePlayerExit: true, + }); return true; }; @@ -1814,7 +1812,10 @@ export function createStoryNpcEncounterActions({ }, } satisfies StoryOption); - if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) { + if ( + !currentStory?.npcChatState && + !npcState.firstMeaningfulContactResolved + ) { void startNpcInitiatedOpening( encounter, seedChatOption, diff --git a/src/index.css b/src/index.css index c3aaa082..de6817ac 100644 --- a/src/index.css +++ b/src/index.css @@ -790,6 +790,22 @@ body { filter: brightness(0.98); } +.platform-public-work-card { + background: var(--platform-subpanel-fill); +} + +.platform-public-work-card__cover { + background: color-mix(in srgb, var(--platform-panel-fill-soft) 86%, #000 14%); +} + +.platform-public-work-card__body { + background: color-mix(in srgb, var(--platform-subpanel-fill) 92%, #000 8%); +} + +.platform-public-work-card__likes { + min-width: 3.2rem; +} + .platform-pill { display: inline-flex; align-items: center; @@ -1243,6 +1259,66 @@ body { max-width: 100%; } + .platform-mobile-home-stage { + gap: 0.75rem; + } + + .platform-mobile-home-stage .platform-desktop-search { + border-radius: 9999px; + padding: 0.64rem 0.9rem; + } + + .platform-mobile-home-channelbar { + margin-right: -0.25rem; + padding-left: 0.08rem; + } + + .platform-mobile-home-channel { + position: relative; + min-height: 2rem; + border: 0; + background: transparent; + color: var(--platform-text-soft); + font-size: 0.92rem; + font-weight: 700; + white-space: nowrap; + } + + .platform-mobile-home-channel--active { + color: var(--platform-text-strong); + } + + .platform-mobile-home-channel--active::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0.1rem; + height: 0.16rem; + border-radius: 9999px; + background: var(--platform-warm-text); + } + + .platform-mobile-home-feed { + min-width: 0; + } + + .platform-public-work-card { + width: 100%; + border-radius: 0.9rem; + background: color-mix(in srgb, var(--platform-subpanel-fill) 82%, #050506 18%); + } + + .platform-public-work-card__cover { + border-radius: 0; + } + + .platform-public-work-card__body { + min-height: 5.4rem; + padding: 0.68rem 0.76rem 0.72rem; + background: color-mix(in srgb, var(--platform-subpanel-fill) 78%, #050506 22%); + } + .platform-mobile-hero-secondary { display: none; } @@ -2959,6 +3035,305 @@ button { scrollbar-width: none; } +.platform-work-detail { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + overflow: hidden; + background: #151515; + color: #f7f7f7; +} + +.platform-work-detail__topbar { + display: grid; + grid-template-columns: 3rem minmax(0, 1fr) 3rem; + align-items: center; + gap: 0.5rem; + padding: calc(env(safe-area-inset-top, 0px) + 0.7rem) 1rem 0.75rem; + background: #020202; +} + +.platform-work-detail__title { + min-width: 0; + text-align: center; + font-size: 1.35rem; + font-weight: 900; +} + +.platform-work-detail__icon-button { + display: inline-flex; + height: 2.5rem; + width: 2.5rem; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: transparent; + color: #f8f8f8; +} + +.platform-work-detail__icon-button:disabled { + opacity: 0.45; +} + +.platform-work-detail__scroll { + min-height: 0; + flex: 1; + overflow-y: auto; + padding-bottom: 1rem; +} + +.platform-work-detail__cover { + position: relative; + display: flex; + min-height: clamp(17rem, 54vh, 28rem); + align-items: center; + justify-content: center; + overflow: hidden; + background: #0c0c0c; +} + +.platform-work-detail__cover-blur { + position: absolute; + inset: -1rem; + height: calc(100% + 2rem); + width: calc(100% + 2rem); + object-fit: cover; + opacity: 0.55; + filter: blur(24px); + transform: scale(1.06); +} + +.platform-work-detail__cover::after { + position: absolute; + inset: 0; + content: ''; + background: + linear-gradient(180deg, rgb(0 0 0 / 5%), rgb(0 0 0 / 42%)), + radial-gradient(circle at center, transparent 0 34%, rgb(0 0 0 / 36%) 100%); +} + +.platform-work-detail__cover-image { + position: relative; + z-index: 1; + max-height: min(72vw, 24rem); + width: min(58vw, 22rem); + max-width: 82%; + object-fit: contain; + box-shadow: 0 1.6rem 4rem rgb(0 0 0 / 38%); +} + +.platform-work-detail__cover-fallback { + aspect-ratio: 3 / 4; + width: min(54vw, 20rem); + border-radius: 0.8rem; + background: + linear-gradient(135deg, rgb(20 184 166 / 55%), rgb(250 204 21 / 34%)), + #202020; +} + +.platform-work-detail__summary { + border-bottom: 0.5rem solid #070707; + background: #181818; + padding: 1.4rem 1rem 1rem; +} + +.platform-work-detail__meta-row { + display: grid; + grid-template-columns: 4.35rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.9rem; +} + +.platform-work-detail__app-icon { + display: flex; + aspect-ratio: 1; + width: 4.35rem; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 1rem; + background: #2b2b2b; + color: #e7e7e7; + font-size: 1.45rem; + font-weight: 900; +} + +.platform-work-detail__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: clamp(1.15rem, 5vw, 1.75rem); + font-weight: 900; +} + +.platform-work-detail__author { + margin-top: 0.45rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: rgb(255 255 255 / 55%); + font-size: 0.9rem; +} + +.platform-work-detail__remix { + display: inline-flex; + min-width: 5.2rem; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + border: 0; + border-radius: 1rem; + background: #12bfb1; + color: #041918; + padding: 0.6rem 0.75rem; + font-size: 0.9rem; + font-weight: 900; +} + +.platform-work-detail__remix:disabled, +.platform-work-detail__start:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.platform-work-detail__stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + margin-top: 1.35rem; +} + +.platform-work-detail__stat { + min-width: 0; + border-left: 1px solid rgb(255 255 255 / 10%); + padding: 0 0.5rem; +} + +.platform-work-detail__stat:first-child { + border-left: 0; + padding-left: 0; +} + +.platform-work-detail__stat-label { + color: rgb(255 255 255 / 52%); + font-size: clamp(0.68rem, 2.8vw, 0.82rem); + line-height: 1.2; +} + +.platform-work-detail__stat-value { + margin-top: 0.35rem; + min-width: 0; + overflow-wrap: anywhere; + color: rgb(255 255 255 / 82%); + font-size: clamp(1rem, 4.2vw, 1.35rem); + font-weight: 900; + line-height: 1.12; +} + +.platform-work-detail__body { + background: #181818; + padding: 1.4rem 1rem 7rem; +} + +.platform-work-detail__chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.platform-work-detail__chip, +.platform-work-detail__code { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 1px solid rgb(255 255 255 / 18%); + border-radius: 0.7rem; + background: transparent; + color: rgb(255 255 255 / 82%); + padding: 0.42rem 0.75rem; + font-size: 0.85rem; + font-weight: 700; +} + +.platform-work-detail__copy { + margin: 0; + color: rgb(255 255 255 / 88%); + font-size: clamp(1rem, 4vw, 1.2rem); + line-height: 1.8; +} + +.platform-work-detail__code { + margin-top: 1.3rem; +} + +.platform-work-detail__error, +.platform-work-detail__toast { + margin-top: 1rem; + border-radius: 0.85rem; + padding: 0.8rem 0.9rem; + font-size: 0.88rem; +} + +.platform-work-detail__error { + border: 1px solid rgb(248 113 113 / 38%); + background: rgb(127 29 29 / 38%); + color: #fecaca; +} + +.platform-work-detail__toast { + border: 1px solid rgb(45 212 191 / 26%); + background: rgb(15 118 110 / 26%); + color: #ccfbf1; +} + +.platform-work-detail__bottom { + position: sticky; + bottom: 0; + z-index: 5; + display: grid; + padding: 0.9rem 1rem calc(env(safe-area-inset-bottom, 0px) + 0.9rem); + background: linear-gradient(180deg, rgb(24 24 24 / 12%), #181818 32%); +} + +.platform-work-detail__start { + display: inline-flex; + min-height: 3.4rem; + align-items: center; + justify-content: center; + gap: 0.55rem; + border: 0; + border-radius: 999px; + background: #10bdb1; + color: #f7fffe; + font-size: 1.25rem; + font-weight: 900; + box-shadow: 0 1.2rem 2.5rem rgb(16 189 177 / 24%); +} + +@media (min-width: 768px) { + .platform-work-detail { + border-radius: 1.2rem; + } + + .platform-work-detail__summary, + .platform-work-detail__body, + .platform-work-detail__bottom { + padding-left: max(2rem, calc((100% - 58rem) / 2)); + padding-right: max(2rem, calc((100% - 58rem) / 2)); + } + + .platform-work-detail__topbar { + padding-left: 2rem; + padding-right: 2rem; + } +} + ::-webkit-scrollbar-track { background: transparent; } diff --git a/src/routing/appPageRoutes.test.ts b/src/routing/appPageRoutes.test.ts index bde8fb7f..cd1a5d49 100644 --- a/src/routing/appPageRoutes.test.ts +++ b/src/routing/appPageRoutes.test.ts @@ -15,7 +15,9 @@ import { describe('appPageRoutes', () => { it('normalizes page paths for stable matching', () => { expect(normalizeAppPath('')).toBe('/'); - expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe('/creation/rpg/agent'); + expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe( + '/creation/rpg/agent', + ); }); it('resolves platform entry stages from independent paths', () => { @@ -52,11 +54,11 @@ describe('appPageRoutes', () => { it('builds and reads public work detail query routes', () => { expect(buildPublicWorkDetailPath('CW-00000001')).toBe( - '/worlds/detail?work=CW-00000001', - ); - expect(buildPublicWorkDetailUrl('CW-00000001', 'https://example.test')).toBe( - 'https://example.test/worlds/detail?work=CW-00000001', + '/works/detail?work=CW-00000001', ); + expect( + buildPublicWorkDetailUrl('CW-00000001', 'https://example.test'), + ).toBe('https://example.test/works/detail?work=CW-00000001'); expect(readPublicWorkCodeFromLocationSearch('?work=CW-00000001')).toBe( 'CW-00000001', ); diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index c8474c2b..077f2af5 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -6,6 +6,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work'; const STAGE_ROUTE_ENTRIES = [ ['platform', '/'], + ['work-detail', '/works/detail'], ['detail', '/worlds/detail'], ['agent-workspace', '/creation/rpg/agent'], ['custom-world-generating', '/creation/rpg/generating'], @@ -57,7 +58,7 @@ export function readPublicWorkCodeFromLocationSearch(search: string) { } export function buildPublicWorkDetailPath(publicWorkCode: string) { - return buildPublicWorkStagePath('detail', publicWorkCode); + return buildPublicWorkStagePath('work-detail', publicWorkCode); } export function buildPublicWorkStagePath( diff --git a/src/services/ai.test.ts b/src/services/ai.test.ts index f4c443f3..d10dc639 100644 --- a/src/services/ai.test.ts +++ b/src/services/ai.test.ts @@ -45,6 +45,7 @@ import { streamCharacterPanelChatReply, streamNpcRecruitDialogue, } from './ai'; +import { streamNpcChatTurn } from './aiService'; import type { StoryGenerationContext } from './aiTypes'; import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes'; @@ -457,6 +458,50 @@ function createSseResponse(text: string) { } as Response; } +function createNpcChatTurnSseResponse(reply: string) { + const encoder = new TextEncoder(); + const completePayload = { + npcReply: reply, + affinityDelta: 0, + affinityText: '这轮对话暂时没有带来明显关系变化。', + suggestions: [], + functionSuggestions: [], + pendingQuestOffer: null, + chatDirective: null, + }; + const chunks = [ + encoder.encode( + `event: reply_delta\ndata: ${JSON.stringify({ text: reply })}\n\n`, + ), + encoder.encode( + `event: complete\ndata: ${JSON.stringify(completePayload)}\n\n`, + ), + encoder.encode('data: [DONE]\n\n'), + ]; + let index = 0; + + return { + ok: true, + status: 200, + headers: new Headers(), + body: { + getReader() { + return { + async read() { + if (index >= chunks.length) { + return { done: true, value: undefined }; + } + const value = chunks[index]; + index += 1; + return { done: false, value }; + }, + }; + }, + }, + text: async () => '', + } as Response; +} + describe('ai runtime client orchestration', () => { const playerCharacter = createCharacter(); const targetCharacter = createCharacter({ @@ -466,6 +511,17 @@ describe('ai runtime client orchestration', () => { personality: 'Dry, practical, and quietly protective.', }); const context = createContext(); + const transientSnapshot: NonNullable< + StoryGenerationContext['runtimeSnapshot'] + > = { + bottomTab: 'adventure', + gameState: { + worldType: WorldType.WUXIA, + runtimeSessionId: 'runtime-preview', + runtimePersistenceDisabled: true, + } as NonNullable['gameState'], + currentStory: null, + }; const targetStatus = createTargetStatus(); const monsters: SceneHostileNpc[] = []; const storyHistory: StoryMoment[] = []; @@ -633,6 +689,86 @@ describe('ai runtime client orchestration', () => { ); }); + it('attaches transient snapshot to session based chat requests only when provided', async () => { + fetchMock.mockResolvedValue( + createApiEnvelopeResponse({ + text: '先确认眼下的局势。\n问清对方的真实目的。\n保持距离继续观察。', + }), + ); + + await generateCharacterPanelChatSuggestions( + WorldType.WUXIA, + playerCharacter, + targetCharacter, + storyHistory, + createContext({ + runtimeSessionId: 'runtime-preview', + runtimeSnapshot: transientSnapshot, + }), + [], + '', + targetStatus, + ); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/chat/character/suggestions', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-preview', + snapshot: transientSnapshot, + targetCharacter, + conversationHistory: [], + conversationSummary: '', + targetStatus, + }), + }), + ); + }); + + it('attaches transient snapshot to npc chat turn session requests', async () => { + const encounter = createEncounter(); + fetchMock.mockResolvedValue( + createNpcChatTurnSseResponse('先把眼前的事说清楚。'), + ); + + const result = await streamNpcChatTurn( + WorldType.WUXIA, + playerCharacter, + encounter, + monsters, + storyHistory, + createContext({ + runtimeSessionId: 'runtime-preview', + runtimeSnapshot: transientSnapshot, + }), + [], + '你刚才看见了什么?', + { chattedCount: 0 }, + ); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/chat/npc/turn/stream', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-preview', + snapshot: transientSnapshot, + encounter, + conversationHistory: [], + dialogue: [], + playerMessage: '你刚才看见了什么?', + npcState: { chattedCount: 0 }, + npcInitiatesConversation: false, + questOfferContext: null, + combatContext: null, + chatDirective: null, + }), + }), + ); + expect(result.npcReply).toBe('先把眼前的事说清楚。'); + }); + it('streams npc recruit dialogue from the runtime api server', async () => { const onUpdate = vi.fn(); const encounter = createEncounter(); diff --git a/src/services/aiService.ts b/src/services/aiService.ts index ba80297b..820a2031 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -42,6 +42,10 @@ function getRuntimeSessionIdFromContext(context: StoryGenerationContext) { return context.runtimeSessionId?.trim() || undefined; } +function getRuntimeSnapshotFromContext(context: StoryGenerationContext) { + return context.runtimeSnapshot; +} + async function requestPlainText( url: string, payload: unknown, @@ -240,9 +244,11 @@ export async function generateCharacterPanelChatSuggestions( targetStatus: CharacterChatTargetStatus, ) { const sessionId = getRuntimeSessionIdFromContext(context); + const snapshot = getRuntimeSnapshotFromContext(context); const payload = sessionId ? ({ sessionId, + ...(snapshot ? { snapshot } : {}), targetCharacter, conversationHistory, conversationSummary, @@ -278,9 +284,11 @@ export async function generateCharacterPanelChatSummary( targetStatus: CharacterChatTargetStatus, ) { const sessionId = getRuntimeSessionIdFromContext(context); + const snapshot = getRuntimeSnapshotFromContext(context); const payload = sessionId ? ({ sessionId, + ...(snapshot ? { snapshot } : {}), targetCharacter, conversationHistory, previousSummary, @@ -318,9 +326,11 @@ export async function streamCharacterPanelChatReply( options: TextStreamOptions = {}, ) { const sessionId = getRuntimeSessionIdFromContext(context); + const snapshot = getRuntimeSnapshotFromContext(context); const payload = sessionId ? ({ sessionId, + ...(snapshot ? { snapshot } : {}), targetCharacter, conversationHistory, conversationSummary, @@ -359,9 +369,11 @@ export async function streamNpcChatDialogue( options: TextStreamOptions = {}, ) { const sessionId = getRuntimeSessionIdFromContext(context); + const snapshot = getRuntimeSnapshotFromContext(context); const payload = sessionId ? ({ sessionId, + ...(snapshot ? { snapshot } : {}), encounter, topic, resultSummary, @@ -411,6 +423,7 @@ export async function streamNpcChatTurn( } = {}, ) { const sessionId = getRuntimeSessionIdFromContext(context); + const snapshot = getRuntimeSnapshotFromContext(context); const commonChatPayload = { encounter, conversationHistory: conversationHistory ?? [], @@ -429,15 +442,18 @@ export async function streamNpcChatTurn( chatDirective: options.chatDirective ? { ...options.chatDirective, - functionOptions: options.chatDirective.functionOptions?.map((item) => ({ - ...item, - })), + functionOptions: options.chatDirective.functionOptions?.map( + (item) => ({ + ...item, + }), + ), } : null, }; const payload = sessionId ? ({ sessionId, + ...(snapshot ? { snapshot } : {}), ...commonChatPayload, } satisfies NpcChatTurnRequest) : ({ @@ -548,9 +564,11 @@ export async function streamNpcRecruitDialogue( options: TextStreamOptions = {}, ) { const sessionId = getRuntimeSessionIdFromContext(context); + const snapshot = getRuntimeSnapshotFromContext(context); const payload = sessionId ? ({ sessionId, + ...(snapshot ? { snapshot } : {}), encounter, invitationText, recruitSummary, diff --git a/src/services/aiTypes.ts b/src/services/aiTypes.ts index 2acfe96a..4789cb31 100644 --- a/src/services/aiTypes.ts +++ b/src/services/aiTypes.ts @@ -21,6 +21,7 @@ import type { EquipmentLoadout, FacingDirection, FactionTensionState, + GameState, GoalStackState, InventoryItem, JourneyBeat, @@ -43,6 +44,7 @@ import type { WorldMutation, WorldType, } from '../types'; +import type { SavedGameSnapshotInput } from '../../packages/shared/src/contracts/runtime'; import type { ConversationPressure, ConversationSituation } from '../types'; export interface StoryRequestOptions { @@ -90,6 +92,7 @@ export interface CustomWorldSceneImageResult { export interface StoryGenerationContext { runtimeSessionId?: string | null; runtimeActionVersion?: number; + runtimeSnapshot?: SavedGameSnapshotInput; playerHp: number; playerMaxHp: number; playerMana: number; diff --git a/src/services/big-fish-gallery/bigFishGalleryClient.ts b/src/services/big-fish-gallery/bigFishGalleryClient.ts index d3c1ef15..ba131d41 100644 --- a/src/services/big-fish-gallery/bigFishGalleryClient.ts +++ b/src/services/big-fish-gallery/bigFishGalleryClient.ts @@ -1,3 +1,4 @@ +import type { BigFishSessionResponse } from '../../../packages/shared/src/contracts/bigFish'; import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import { ApiClientError, type ApiRetryOptions, requestJson } from '../apiClient'; @@ -36,6 +37,20 @@ export async function listBigFishGallery() { } } +/** + * 将公开大鱼吃小鱼作品复制为当前用户草稿。 + */ +export async function remixBigFishGalleryWork(sessionId: string) { + return requestJson( + `${BIG_FISH_GALLERY_API_BASE}/${encodeURIComponent(sessionId)}/remix`, + { + method: 'POST', + }, + 'Remix 大鱼吃小鱼作品失败', + ); +} + export const bigFishGalleryClient = { list: listBigFishGallery, + remix: remixBigFishGalleryWork, }; diff --git a/src/services/big-fish-gallery/index.ts b/src/services/big-fish-gallery/index.ts index 64a12956..ee0ae646 100644 --- a/src/services/big-fish-gallery/index.ts +++ b/src/services/big-fish-gallery/index.ts @@ -1,4 +1,5 @@ export { bigFishGalleryClient, listBigFishGallery, + remixBigFishGalleryWork, } from './bigFishGalleryClient'; diff --git a/src/services/puzzle-gallery/index.ts b/src/services/puzzle-gallery/index.ts index 7f906406..e90c3163 100644 --- a/src/services/puzzle-gallery/index.ts +++ b/src/services/puzzle-gallery/index.ts @@ -2,4 +2,5 @@ export { getPuzzleGalleryDetail, listPuzzleGallery, puzzleGalleryClient, + remixPuzzleGalleryWork, } from './puzzleGalleryClient'; diff --git a/src/services/puzzle-gallery/puzzleGalleryClient.ts b/src/services/puzzle-gallery/puzzleGalleryClient.ts index afc59398..0f8409f2 100644 --- a/src/services/puzzle-gallery/puzzleGalleryClient.ts +++ b/src/services/puzzle-gallery/puzzleGalleryClient.ts @@ -2,6 +2,7 @@ import type { PuzzleWorksResponse, PuzzleWorkSummary, } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import { type ApiRetryOptions, requestJson } from '../apiClient'; const PUZZLE_GALLERY_API_BASE = '/api/runtime/puzzle/gallery'; @@ -43,7 +44,21 @@ export async function getPuzzleGalleryDetail(profileId: string) { ); } +/** + * 将公开拼图作品复制为当前用户的草稿。 + */ +export async function remixPuzzleGalleryWork(profileId: string) { + return requestJson<{ session: PuzzleAgentSessionSnapshot }>( + `${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}/remix`, + { + method: 'POST', + }, + 'Remix 拼图作品失败', + ); +} + export const puzzleGalleryClient = { getDetail: getPuzzleGalleryDetail, list: listPuzzleGallery, + remix: remixPuzzleGalleryWork, }; diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts index 2edd885d..3a733d97 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts @@ -26,6 +26,7 @@ const baseWork: PuzzleWorkSummary = { updatedAt: '2026-04-25T00:00:00.000Z', publishedAt: '2026-04-25T00:00:00.000Z', playCount: 0, + likeCount: 0, publishReady: true, }; diff --git a/src/services/rpg-entry/index.ts b/src/services/rpg-entry/index.ts index d7bbf603..d09bee44 100644 --- a/src/services/rpg-entry/index.ts +++ b/src/services/rpg-entry/index.ts @@ -4,6 +4,8 @@ export { listRpgEntryWorldGallery, listRpgEntryWorldLibrary, publishRpgEntryWorldProfile, + recordRpgEntryWorldGalleryPlay, + remixRpgEntryWorldGallery, rpgEntryLibraryClient, type RuntimeRequestOptions, unpublishRpgEntryWorldProfile, diff --git a/src/services/rpg-entry/rpgEntryLibraryClient.ts b/src/services/rpg-entry/rpgEntryLibraryClient.ts index 0f399121..7fff998d 100644 --- a/src/services/rpg-entry/rpgEntryLibraryClient.ts +++ b/src/services/rpg-entry/rpgEntryLibraryClient.ts @@ -78,6 +78,43 @@ export async function getRpgEntryWorldGalleryDetailByCode( return response.entry; } +export async function remixRpgEntryWorldGallery( + ownerUserId: string, + profileId: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRpgRuntimeJson< + CustomWorldLibraryMutationResponse + >( + `/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/remix`, + { method: 'POST' }, + 'Remix 作品失败', + options, + ); + + return { + entry: response.entry, + entries: Array.isArray(response?.entries) ? response.entries : [], + }; +} + +export async function recordRpgEntryWorldGalleryPlay( + ownerUserId: string, + profileId: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRpgRuntimeJson< + CustomWorldGalleryDetailResponse + >( + `/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/play`, + { method: 'POST' }, + '记录作品游玩失败', + options, + ); + + return response.entry; +} + export async function upsertRpgEntryWorldProfile( profile: CustomWorldProfile, options: RuntimeRequestOptions = {}, @@ -162,6 +199,8 @@ export const rpgEntryLibraryClient = { listWorldGallery: listRpgEntryWorldGallery, getWorldGalleryDetail: getRpgEntryWorldGalleryDetail, getWorldGalleryDetailByCode: getRpgEntryWorldGalleryDetailByCode, + remixWorldGallery: remixRpgEntryWorldGallery, + recordWorldGalleryPlay: recordRpgEntryWorldGalleryPlay, upsertWorldProfile: upsertRpgEntryWorldProfile, deleteWorldProfile: deleteRpgEntryWorldProfile, publishWorldProfile: publishRpgEntryWorldProfile,