1
This commit is contained in:
BIN
.codex-home-desktop-wait.png
Normal file
BIN
.codex-home-desktop-wait.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 514 KiB |
BIN
.codex-home-desktop.png
Normal file
BIN
.codex-home-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 510 KiB |
BIN
.codex-home-mobile-wait.png
Normal file
BIN
.codex-home-mobile-wait.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
.codex-home-mobile.png
Normal file
BIN
.codex-home-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -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 检查。
|
||||
@@ -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 检查和必要前端测试。
|
||||
@@ -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 像素风模态窗口外壳、交互边界和迁移顺序。
|
||||
|
||||
@@ -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 主运行态 |
|
||||
|
||||
## 落地边界
|
||||
|
||||
|
||||
@@ -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=作品号` 的完整网址。
|
||||
|
||||
@@ -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` 单测、编码检查通过。
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,7 +17,9 @@ export interface PuzzleWorkSummary {
|
||||
publicationStatus: PuzzleWorkPublicationStatus;
|
||||
updatedAt: string;
|
||||
publishedAt: string | null;
|
||||
playCount: number;
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
publishReady: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Cus
|
||||
landmarkCount: Array.isArray(profile.landmarks)
|
||||
? profile.landmarks.length
|
||||
: 0,
|
||||
likeCount: 0,
|
||||
} satisfies CustomWorldLibraryEntry<CustomWorldProfileRecord>);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -249,6 +249,9 @@ export type CustomWorldLibraryEntry<TProfile = CustomWorldProfileRecord> = {
|
||||
themeMode: CustomWorldThemeMode;
|
||||
playableNpcCount: number;
|
||||
landmarkCount: number;
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
};
|
||||
|
||||
export type CustomWorldGalleryCard = Omit<
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppState>,
|
||||
Path((owner_user_id, profile_id)): Path<(String, String)>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path((owner_user_id, profile_id)): Path<(String, String)>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
character: Option<Value>,
|
||||
@@ -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<Option<Value>, 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<Value> {
|
||||
@@ -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<Value>,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
player_character: Value,
|
||||
@@ -54,6 +57,8 @@ pub struct RuntimeNpcDialogueRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
character: Value,
|
||||
@@ -77,6 +82,8 @@ pub struct RuntimeNpcRecruitDialogueRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
#[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<Option<Value>, 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<Option<Value>, 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,
|
||||
|
||||
@@ -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<i64>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -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<i64>,
|
||||
pub deleted_at_micros: Option<i64>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -208,7 +208,12 @@ pub struct PuzzleWorkProfile {
|
||||
pub publication_status: PuzzlePublicationStatus,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
#[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(),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct BigFishWorkSummaryResponse {
|
||||
pub cover_image_src: Option<String>,
|
||||
pub status: String,
|
||||
pub updated_at: String,
|
||||
#[serde(default)]
|
||||
pub published_at: Option<String>,
|
||||
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)]
|
||||
|
||||
@@ -35,6 +35,10 @@ pub struct PuzzleWorkSummaryResponse {
|
||||
#[serde(default)]
|
||||
pub published_at: Option<String>,
|
||||
pub play_count: u32,
|
||||
#[serde(default)]
|
||||
pub remix_count: u32,
|
||||
#[serde(default)]
|
||||
pub like_count: u32,
|
||||
pub publish_ready: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<BigFishSessionRecord, SpacetimeClientError> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CustomWorldLibraryMutationRecord, SpacetimeClientError> {
|
||||
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<CustomWorldLibraryMutationRecord, SpacetimeClientError> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
pub status: String,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
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<String>,
|
||||
status: String,
|
||||
updated_at_micros: i64,
|
||||
#[serde(default)]
|
||||
published_at_micros: Option<i64>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ pub struct BigFishCreationSession {
|
||||
pub last_assistant_reply: Option<String>,
|
||||
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<BigFishCreationSession, Option<String>>,
|
||||
pub publish_ready: __sdk::__query_builder::Col<BigFishCreationSession, bool>,
|
||||
pub play_count: __sdk::__query_builder::Col<BigFishCreationSession, u32>,
|
||||
pub remix_count: __sdk::__query_builder::Col<BigFishCreationSession, u32>,
|
||||
pub like_count: __sdk::__query_builder::Col<BigFishCreationSession, u32>,
|
||||
pub published_at: __sdk::__query_builder::Col<BigFishCreationSession, Option<__sdk::Timestamp>>,
|
||||
pub created_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<CustomWorldGalleryEntry, CustomWorldThemeMode>,
|
||||
pub playable_npc_count: __sdk::__query_builder::Col<CustomWorldGalleryEntry, u32>,
|
||||
pub landmark_count: __sdk::__query_builder::Col<CustomWorldGalleryEntry, u32>,
|
||||
pub play_count: __sdk::__query_builder::Col<CustomWorldGalleryEntry, u32>,
|
||||
pub remix_count: __sdk::__query_builder::Col<CustomWorldGalleryEntry, u32>,
|
||||
pub like_count: __sdk::__query_builder::Col<CustomWorldGalleryEntry, u32>,
|
||||
pub published_at: __sdk::__query_builder::Col<CustomWorldGalleryEntry, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<CustomWorldGalleryEntry, __sdk::Timestamp>,
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<i64>,
|
||||
pub deleted_at_micros: Option<i64>,
|
||||
|
||||
@@ -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<CustomWorldProfile, String>,
|
||||
pub playable_npc_count: __sdk::__query_builder::Col<CustomWorldProfile, u32>,
|
||||
pub landmark_count: __sdk::__query_builder::Col<CustomWorldProfile, u32>,
|
||||
pub play_count: __sdk::__query_builder::Col<CustomWorldProfile, u32>,
|
||||
pub remix_count: __sdk::__query_builder::Col<CustomWorldProfile, u32>,
|
||||
pub like_count: __sdk::__query_builder::Col<CustomWorldProfile, u32>,
|
||||
pub author_display_name: __sdk::__query_builder::Col<CustomWorldProfile, String>,
|
||||
pub published_at: __sdk::__query_builder::Col<CustomWorldProfile, Option<__sdk::Timestamp>>,
|
||||
pub deleted_at: __sdk::__query_builder::Col<CustomWorldProfile, Option<__sdk::Timestamp>>,
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct PuzzleWorkProfileRow {
|
||||
pub cover_asset_id: Option<String>,
|
||||
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<PuzzleWorkProfileRow, PuzzlePublicationStatus>,
|
||||
pub play_count: __sdk::__query_builder::Col<PuzzleWorkProfileRow, u32>,
|
||||
pub remix_count: __sdk::__query_builder::Col<PuzzleWorkProfileRow, u32>,
|
||||
pub like_count: __sdk::__query_builder::Col<PuzzleWorkProfileRow, u32>,
|
||||
pub anchor_pack_json: __sdk::__query_builder::Col<PuzzleWorkProfileRow, String>,
|
||||
pub publish_ready: __sdk::__query_builder::Col<PuzzleWorkProfileRow, bool>,
|
||||
pub created_at: __sdk::__query_builder::Col<PuzzleWorkProfileRow, __sdk::Timestamp>,
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<CustomWorldLibraryMutationResult, __sdk::InternalError>,
|
||||
) + 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<CustomWorldLibraryMutationResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>(
|
||||
"record_custom_world_profile_play",
|
||||
RecordCustomWorldProfilePlayArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + 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<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>(
|
||||
"remix_big_fish_work",
|
||||
RemixBigFishWorkArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<CustomWorldLibraryMutationResult, __sdk::InternalError>,
|
||||
) + 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<CustomWorldLibraryMutationResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>(
|
||||
"remix_custom_world_profile",
|
||||
RemixCustomWorldProfileArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl remix_puzzle_work for super::RemoteProcedures {
|
||||
fn remix_puzzle_work_then(
|
||||
&self,
|
||||
input: PuzzleWorkRemixInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>(
|
||||
"remix_puzzle_work",
|
||||
RemixPuzzleWorkArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PuzzleAgentSessionRecord, SpacetimeClientError> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<BigFishSessionSnapshot, String> {
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ pub struct BigFishCreationSession {
|
||||
pub(crate) last_assistant_reply: Option<String>,
|
||||
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<Timestamp>,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
@@ -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<Timestamp>,
|
||||
// 软删除后保留 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<CustomWorldGalleryEntrySnapshot>,
|
||||
),
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
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<PuzzleAgentSessionSnapshot, String> {
|
||||
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,
|
||||
|
||||
@@ -53,6 +53,7 @@ function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
publishedAt: new Date(0).toISOString(),
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
|
||||
useState<PlatformPublicGalleryCard | null>(null);
|
||||
const [publicWorkDetailError, setPublicWorkDetailError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isPublicWorkDetailBusy, setIsPublicWorkDetailBusy] = useState(false);
|
||||
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
|
||||
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
|
||||
BigFishWorkSummary[]
|
||||
@@ -454,8 +524,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
|
||||
useState<BigFishRuntimeSessionSource>(null);
|
||||
const [bigFishRuntimeReturnStage, setBigFishRuntimeReturnStage] =
|
||||
useState<BigFishRuntimeReturnStage>('platform');
|
||||
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
||||
const [bigFishGenerationState, setBigFishGenerationState] =
|
||||
useState<MiniGameDraftGenerationState | null>(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({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'work-detail' && selectedPublicWorkDetail && (
|
||||
<motion.div
|
||||
key="platform-work-detail"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<PlatformWorkDetailView
|
||||
entry={selectedPublicWorkDetail}
|
||||
isBusy={isPublicWorkDetailBusy || isPuzzleBusy || isBigFishBusy}
|
||||
error={publicWorkDetailError}
|
||||
onBack={() => {
|
||||
setPublicWorkDetailError(null);
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onStart={startSelectedPublicWork}
|
||||
onRemix={remixSelectedPublicWork}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'detail' && (
|
||||
<motion.div
|
||||
key="platform-detail"
|
||||
@@ -2396,6 +2717,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
{detailNavigation.detailError || '正在读取作品详情...'}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedDetailEntry.visibility !== 'draft' ? (
|
||||
<PlatformWorkDetailView
|
||||
entry={mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry)}
|
||||
isBusy={detailNavigation.isMutatingDetail}
|
||||
error={detailNavigation.detailError}
|
||||
onBack={() => {
|
||||
detailNavigation.setDetailError(null);
|
||||
entryNavigation.backToPlatformHome();
|
||||
}}
|
||||
onStart={handleStartSelectedWorld}
|
||||
onRemix={() => {
|
||||
remixPublicWork(
|
||||
mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PlatformEntryWorldDetailView
|
||||
entry={selectedDetailEntry}
|
||||
@@ -2428,7 +2765,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
: null
|
||||
}
|
||||
onUnpublish={
|
||||
selectedDetailEntry.visibility === 'published' &&
|
||||
selectedDetailEntry.visibility !== 'draft' &&
|
||||
detailNavigation.isSelectedWorldOwned
|
||||
? () => {
|
||||
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',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
242
src/components/platform-entry/PlatformWorkDetailView.tsx
Normal file
242
src/components/platform-entry/PlatformWorkDetailView.tsx
Normal file
@@ -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 (
|
||||
<div className="platform-work-detail">
|
||||
<div className="platform-work-detail__topbar">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__icon-button"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="platform-work-detail__title">详情</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__icon-button"
|
||||
onClick={sharePublicWork}
|
||||
disabled={!publicWorkCode}
|
||||
aria-label="分享"
|
||||
title="分享"
|
||||
>
|
||||
<Share2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="platform-work-detail__scroll">
|
||||
<section className="platform-work-detail__cover">
|
||||
{coverImage ? (
|
||||
<>
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="platform-work-detail__cover-blur"
|
||||
/>
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="platform-work-detail__cover-image"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="platform-work-detail__cover-fallback" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="platform-work-detail__summary">
|
||||
<div className="platform-work-detail__meta-row">
|
||||
<div className="platform-work-detail__app-icon">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
entry.worldName.slice(0, 1)
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="platform-work-detail__name">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
<div className="platform-work-detail__author">
|
||||
{entry.authorDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__remix"
|
||||
onClick={onRemix}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<GitFork className="h-5 w-5" />
|
||||
Remix
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="platform-work-detail__stats">
|
||||
{statItems.map((item) => (
|
||||
<div key={item.label} className="platform-work-detail__stat">
|
||||
<div className="platform-work-detail__stat-label">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="platform-work-detail__stat-value">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="platform-work-detail__body">
|
||||
<div className="platform-work-detail__chips">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="platform-work-detail__chip">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="platform-work-detail__copy">{entry.summaryText}</p>
|
||||
{publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__code"
|
||||
onClick={copyPublicWorkCode}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span>{publicWorkCode}</span>
|
||||
{copyState !== 'idle' ? (
|
||||
<span>{copyState === 'copied' ? '已复制' : '复制失败'}</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
{shareState !== 'idle' ? (
|
||||
<div className="platform-work-detail__toast">
|
||||
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-work-detail__error">{error}</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="platform-work-detail__bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__start"
|
||||
onClick={onStart}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Play className="h-5 w-5 fill-current" />
|
||||
启动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type CustomWorldRuntimeLaunchOptions = {
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'work-detail'
|
||||
| 'detail'
|
||||
| 'agent-workspace'
|
||||
| 'big-fish-agent-workspace'
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
|
||||
aria-label={cardLabel}
|
||||
className={`platform-public-work-card platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
|
||||
>
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-40"
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<ResolvedAssetImage
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
|
||||
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.02),rgba(0,0,0,0.18))]" />
|
||||
<div className="absolute left-3 top-3 flex min-w-0 max-w-[calc(100%-1.5rem)] flex-wrap gap-1.5">
|
||||
<span className="platform-pill platform-pill--warm max-w-[9rem] truncate px-2.5">
|
||||
{badge}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
<span className="platform-pill platform-pill--neutral max-w-[9rem] truncate px-2.5">
|
||||
{metaLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="line-clamp-1 text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
|
||||
<div className="platform-public-work-card__body flex min-h-[7.25rem] flex-col gap-2 px-3.5 py-3">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
|
||||
{entry.summaryText || '等待补充世界摘要。'}
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-0.5 line-clamp-1 break-words text-[11px] font-medium text-[var(--platform-text-soft)]">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag, index) => (
|
||||
<span
|
||||
key={`world-tag-${index}-${tag || 'empty'}`}
|
||||
className="platform-pill platform-pill--neutral px-2.5"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{describePublicGalleryCardKind(entry)}
|
||||
<div className="platform-public-work-card__likes shrink-0 text-right">
|
||||
<div className="flex items-center justify-end gap-1 text-xs font-black text-[var(--platform-warm-text)]">
|
||||
<Heart className="h-3.5 w-3.5 fill-current" />
|
||||
<span>{formatCompactCount(likeCount)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] font-semibold text-[var(--platform-text-soft)]">
|
||||
点赞
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="line-clamp-2 break-words text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)]">
|
||||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex min-w-0 flex-wrap gap-1.5">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag, index) => (
|
||||
<span
|
||||
key={`world-tag-${index}-${tag || 'empty'}`}
|
||||
className="platform-pill platform-pill--neutral max-w-full px-2.5"
|
||||
>
|
||||
<span className="truncate">{tag}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{describePublicGalleryCardKind(entry)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -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<string | null>(
|
||||
null,
|
||||
);
|
||||
const [mobileHomeChannel, setMobileHomeChannel] =
|
||||
useState<MobileHomeChannel>('recommend');
|
||||
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
|
||||
() => 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<string, PlatformPublicGalleryCard>();
|
||||
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}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLeadPublicEntry}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--warm shrink-0">
|
||||
作品
|
||||
</span>
|
||||
<div className="platform-mobile-hero-secondary platform-pill platform-pill--neutral max-w-full px-3 text-[11px] tracking-[0.08em]">
|
||||
{leadPublicEntry
|
||||
? describePublicGalleryCardKind(leadPublicEntry)
|
||||
: '作品广场'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
|
||||
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
|
||||
{leadPublicEntry?.summaryText ||
|
||||
leadPublicEntry?.subtitle ||
|
||||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
|
||||
</div>
|
||||
<div className="mt-4 flex min-w-0 items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span className="min-w-0 break-all">查看作品</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{MOBILE_HOME_CHANNELS.map((channel) => {
|
||||
const active = mobileHomeChannel === channel.id;
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={() => setMobileHomeChannel(channel.id)}
|
||||
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
|
||||
>
|
||||
{channel.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
@@ -1706,45 +1743,28 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SectionHeader title="精选推荐" detail="为你挑选" />
|
||||
<section className="platform-mobile-home-feed">
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取精选作品..." />
|
||||
) : featuredShelf.length > 0 ? (
|
||||
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{featuredShelf.map((entry: PlatformPublicGalleryCard) => (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : mobileFeedEntries.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:featured`}
|
||||
key={`${buildPublicGalleryCardKey(entry)}:mobile-feed:${mobileHomeChannel}`}
|
||||
entry={entry}
|
||||
badge="推荐"
|
||||
metaLabel={describePublicGalleryCardKind(entry)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有精选作品。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="最新发布" detail="玩家广场" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取最新发布..." />
|
||||
) : latestEntries.length > 0 ? (
|
||||
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{latestEntries.map((entry: PlatformPublicGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:latest`}
|
||||
entry={entry}
|
||||
badge={describePublicGalleryCardKind(entry)}
|
||||
badge={
|
||||
mobileHomeChannel === 'recommend'
|
||||
? '推荐'
|
||||
: describePublicGalleryCardKind(entry)
|
||||
}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -280,6 +280,7 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
likeCount: 0,
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: { currentStory?: null },
|
||||
) => StoryGenerationContext;
|
||||
}) {
|
||||
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
|
||||
const [characterChatModal, setCharacterChatModal] =
|
||||
useState<CharacterChatModalState | null>(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;
|
||||
|
||||
@@ -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<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
|
||||
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<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
|
||||
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<boolean>;
|
||||
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);
|
||||
|
||||
@@ -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<SetStateAction<GameState>>;
|
||||
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<TradeModalState | null>(null);
|
||||
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{},
|
||||
|
||||
@@ -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;
|
||||
|
||||
57
src/hooks/rpg-runtime-story/storyContextBuilder.test.ts
Normal file
57
src/hooks/rpg-runtime-story/storyContextBuilder.test.ts
Normal file
@@ -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> = {}): 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -24,6 +24,7 @@ type StoryInteractionCoordinatorParams = {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
375
src/index.css
375
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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<StoryGenerationContext['runtimeSnapshot']>['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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<GameState, string, StoryMoment>;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
|
||||
@@ -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<BigFishSessionResponse>(
|
||||
`${BIG_FISH_GALLERY_API_BASE}/${encodeURIComponent(sessionId)}/remix`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'Remix 大鱼吃小鱼作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishGalleryClient = {
|
||||
list: listBigFishGallery,
|
||||
remix: remixBigFishGalleryWork,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
bigFishGalleryClient,
|
||||
listBigFishGallery,
|
||||
remixBigFishGalleryWork,
|
||||
} from './bigFishGalleryClient';
|
||||
|
||||
@@ -2,4 +2,5 @@ export {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
puzzleGalleryClient,
|
||||
remixPuzzleGalleryWork,
|
||||
} from './puzzleGalleryClient';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ export {
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
recordRpgEntryWorldGalleryPlay,
|
||||
remixRpgEntryWorldGallery,
|
||||
rpgEntryLibraryClient,
|
||||
type RuntimeRequestOptions,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
|
||||
@@ -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<CustomWorldProfile>
|
||||
>(
|
||||
`/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<CustomWorldProfile>
|
||||
>(
|
||||
`/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,
|
||||
|
||||
Reference in New Issue
Block a user