This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 514 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 510 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
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)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user