codex/public-work-readmodel-smooth-transition #38

Merged
kdletters merged 2 commits from codex/public-work-readmodel-smooth-transition into master 2026-05-26 16:40:16 +08:00
47 changed files with 2678 additions and 79 deletions
+16
View File
@@ -48,6 +48,22 @@
- 验证方式:玩法接入 PRD 和实现验收必须列出作品架链路;若一个玩法具备发布或试玩能力,但缺少 `/api/creation/<play>/works`、前端 client `listWorks``CustomWorldCreationHub` props、`creationWorkShelf` adapter 或草稿 / 已发布作品架测试,则接入不算完成。
- 关联文档:`AGENTS.md``.codex/skills/genarrative-play-type-integration/SKILL.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-26 统一公开作品主读模型收口
- 背景:各玩法原有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 已经足够承载各自 source 投影,但公开列表 / 详情在 `api-server` 侧分散拼装会继续放大重复逻辑和契约漂移。
- 决策:新增跨玩法统一公开主读模型 `public_work_gallery_entry``public_work_detail_entry`。各玩法旧公开 view 不删除,退为 source / 兼容路径;`api-server` 公开列表与详情主路径统一读 public view cache,再映射回现有 HTTP DTO。前端首期仍不直接订阅 SpacetimeDB,只走 BFF HTTP。
- 影响范围:`server-rs/crates/spacetime-module``server-rs/crates/spacetime-client``server-rs/crates/api-server``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`
- 验证方式:`SELECT * FROM public_work_gallery_entry``SELECT * FROM public_work_detail_entry` 可作为 `api-server` 长期订阅目标;`/api/public-works``/api/public-works/{publicWorkCode}` 走统一 cache;旧 `/api/runtime/<play>/gallery` 响应 shape 保持兼容。
- 关联文档:`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态
- 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。
- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。
- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-24 创作 Tab banner 轮播只展示主题赛
- 背景:创作 Tab banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致首屏出现 58000 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。
@@ -0,0 +1,92 @@
# 统一公开作品 ReadModel 设计
更新时间:`2026-05-26`
## 背景
各玩法原本各自维护 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 等公开投影。它们继续保留为 source view 和兼容路径,但公开列表与公开详情的主读模型需要跨玩法统一,避免 `api-server` 在 HTTP 热路径里为每个玩法各写一套拼装逻辑。
## 统一契约
公开列表主读模型:
- `public_work_gallery_entry`
公开详情摘要主读模型:
- `public_work_detail_entry`
统一字段只保留公开层契约所需内容:
- `source_type`
- `work_id`
- `profile_id`
- `source_session_id`
- `public_work_code`
- `owner_user_id`
- `author_display_name`
- `world_name`
- `subtitle`
- `summary_text`
- `cover_image_src`
- `cover_asset_id`
- `theme_tags`
- `play_count`
- `remix_count`
- `like_count`
- `published_at_micros`
- `updated_at_micros`
- `sort_time_micros`
- `detail_payload_json`
其中 `detail_payload_json` 只承载平台详情页展示扩展,不承载正式 runtime 配置、玩法规则或草稿真相。
## 来源与兼容
统一 public view 由现有玩法 source view 组装:
- `puzzle_gallery_card_view`
- `puzzle_gallery_view`
- `custom_world_gallery_entry`
- `jump_hop_gallery_card_view`
- `jump_hop_gallery_view`
- `wooden_fish_gallery_card_view`
- `wooden_fish_gallery_view`
- `match_3_d_gallery_view`
- `square_hole_gallery_view`
- `visual_novel_gallery_view`
- `big_fish_gallery_view`
- `bark_battle_gallery_view`
规则是:
- 旧 view 保留,不删除。
- 旧 view 退到底层 source / 兼容职责。
-`public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。
-`/api/runtime/<play>/gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。
- 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。
## 订阅与路由
`spacetime-client` 当前长期订阅:
- `SELECT * FROM public_work_gallery_entry`
- `SELECT * FROM public_work_detail_entry`
- `SELECT * FROM public_work_play_daily_stat`
- 各玩法 source view 作为兼容缓存和旧路径支撑
`api-server` 当前新增统一公开路由:
- `GET /api/public-works`
- `GET /api/public-works/{publicWorkCode}`
旧 route 继续保留,由 BFF 从统一 cache 映射回旧 DTO 形状。
## 验证
- `npm run spacetime:generate`
- `npm run check:spacetime-schema`
- `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- `npm run typecheck`
- `npm run check:encoding`
@@ -131,7 +131,7 @@ npm run check:server-rs-ddd
3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option<Vec<T>>``#[default(None::<Vec<T>>)]`,业务层归一为空数组。
5. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。
6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server``spacetime-client` 长期订阅后读本地 cache。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅公开统计表后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走 procedure / reducer。中期如要让前端可选直连订阅,只能新增或统一稳定的专用 public read model,例如 `public_work_gallery_entry`,并保持字段、排序键、公开权限和降级语义由后端投影定义;前端不得直接订阅 `puzzle_work_profile``custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback `api-server` BFF 维持。
6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server``spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry``public_work_detail_entry`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile``custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。
7. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。
8. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option<String>` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json``levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。
9. 修改后运行:
@@ -304,7 +304,7 @@ npm run check:server-rs-ddd
- Rust view`big_fish_gallery_view`
- 返回类型:`Vec<BigFishWorkSummarySnapshot>`
- 源码:`server-rs/crates/spacetime-module/src/big_fish/session.rs`
- 说明:大鱼吃小鱼公开广场列表投影,只从 `Published` creation session 组装公开卡片字段;`api-server``spacetime-client` 长期订阅 `SELECT * FROM big_fish_gallery_view` `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'` 后,从本地 cache 构造 `/api/runtime/big-fish/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_big_fish_works` procedure;个人作品列表、详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
- 说明:大鱼吃小鱼公开 source 投影,只从 `Published` creation session 组装公开卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。玩法旧 gallery 路径保留兼容 shape;个人作品列表、详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
### `chapter_progression`
@@ -350,7 +350,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`CustomWorldGalleryEntry`
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
- 作用:自定义世界公开作品列表读模型。`api-server``spacetime-client` 长期订阅 `SELECT * FROM custom_world_gallery_entry` `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'``/api/runtime/custom-world-gallery` 从本地 cache 排序并聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_custom_world_gallery_entries` procedure。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。
- 作用:自定义世界公开 source 读模型。统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该投影并映射成跨玩法契约;`/api/runtime/custom-world-gallery` 保留旧 HTTP shape,并从统一 public cache 映射回旧 DTO。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。
### `custom_world_profile`
@@ -402,14 +402,14 @@ npm run check:server-rs-ddd
- Rust view`jump_hop_gallery_card_view`
- 返回类型:`Vec<JumpHopGalleryCardViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs`
- 说明:跳一跳公开广场列表卡片投影,只暴露 `publication_status = Published` 的作品卡片字段;`api-server``spacetime-client` 长期订阅 `SELECT * FROM jump_hop_gallery_card_view` 后,从本地 cache 构造跳一跳公开列表响应。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。
- 说明:跳一跳公开列表 source 投影,只暴露 `publication_status = Published` 的作品卡片字段;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。
### SpacetimeDB view`jump_hop_gallery_view`
- Rust view`jump_hop_gallery_view`
- 返回类型:`Vec<JumpHopGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs`
- 说明:跳一跳公开详情兼容投影,包含作品、路径和素材字段;公开列表主路径优先使用 `jump_hop_gallery_card_view`
- 说明:跳一跳公开详情兼容投影,包含作品、路径和素材字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要
### `wooden_fish_agent_session`
@@ -437,14 +437,14 @@ npm run check:server-rs-ddd
- Rust view`wooden_fish_gallery_card_view`
- 返回类型:`Vec<WoodenFishGalleryCardViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/wooden_fish.rs`
- 说明:敲木鱼公开广场列表卡片投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server``spacetime-client` 长期订阅 `SELECT * FROM wooden_fish_gallery_card_view` 后,从本地 cache 构造敲木鱼公开列表响应。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。
- 说明:敲木鱼公开列表 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。
### SpacetimeDB view`wooden_fish_gallery_view`
- Rust view`wooden_fish_gallery_view`
- 返回类型:`Vec<WoodenFishGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/wooden_fish.rs`
- 说明:敲木鱼公开详情兼容投影,包含敲击物图案、背景环境图、主题返回按钮图、敲击音效和飘字配置;公开列表主路径优先使用 `wooden_fish_gallery_card_view`
- 说明:敲木鱼公开详情兼容投影,包含敲击物图案、背景环境图、主题返回按钮图、敲击音效和飘字配置;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要
### `match3d_agent_message`
@@ -471,7 +471,7 @@ npm run check:server-rs-ddd
- Rust view`match3d_gallery_view`
- 返回类型:`Vec<Match3DGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/match3d.rs`
- 说明:抓大鹅公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server``spacetime-client` 长期订阅 `SELECT * FROM match_3_d_gallery_view` `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'` 后,从本地 cache 构造 `/api/runtime/match3d/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_match3d_works` procedure个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
- 说明:抓大鹅公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
### `npc_state`
@@ -604,14 +604,14 @@ npm run check:server-rs-ddd
- Rust view`puzzle_gallery_view`
- 返回类型:`Vec<PuzzleWorkProfile>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图广场公开详情兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;公开列表主路径不再订阅该 view
- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要
### SpacetimeDB view`puzzle_gallery_card_view`
- Rust view`puzzle_gallery_card_view`
- 返回类型:`Vec<PuzzleGalleryCardViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图广场公开列表卡片投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;`api-server``spacetime-client` 长期订阅 `SELECT * FROM puzzle_gallery_card_view``SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 后,从本地 cache 构造 `/api/runtime/puzzle/gallery` 响应,并在本地按当前请求时间聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure
- 说明:拼图公开列表 source 投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view`/api/runtime/puzzle/gallery` 保留旧 HTTP shape,并从统一 public cache 映射回 `PuzzleGalleryResponse`
### 拼图公开列表 HTTP 窗口缓存
@@ -624,26 +624,31 @@ npm run check:server-rs-ddd
`spacetime-client` 建立每个池连接时会等待下列订阅初始同步:
- `SELECT * FROM public_work_gallery_entry`
- `SELECT * FROM public_work_detail_entry`
- `SELECT * FROM bark_battle_gallery_view`
- `SELECT * FROM puzzle_gallery_card_view`
- `SELECT * FROM jump_hop_gallery_card_view`
- `SELECT * FROM wooden_fish_gallery_card_view`
- `SELECT * FROM custom_world_gallery_entry`
- `SELECT * FROM match_3_d_gallery_view`
- `SELECT * FROM square_hole_gallery_view`
- `SELECT * FROM visual_novel_gallery_view`
- `SELECT * FROM big_fish_gallery_view`
- `SELECT * FROM jump_hop_gallery_card_view`
下列订阅用于统计或配置缓存,订阅失败不会让公开列表连接整体不可用,调用方保留兼容兜底:
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'`
- `SELECT * FROM creation_entry_config`
- `SELECT * FROM creation_entry_type_config`
- `SELECT * FROM asset_object`
拼图、自定义世界、抓大鹅、方洞挑战、视觉小说和大鱼吃小鱼的公开列表 HTTP 路由都从订阅 cache 读取公开 read model / view。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry``public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape,由 BFF mapper 从统一 public cache 映射回当前 DTO;旧 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
入口配置快照包含 start card、类型弹窗、活动横幅和入口类型列表;入口类型列表新增 `category_id``category_label``category_sort_order` 后,后台 upsert、`shared-contracts``module-runtime``spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。
@@ -652,7 +657,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`
结构化创作和 RPG 的 LLM JSON 链路默认不启用 Responses `web_search`;只有在明确需要联网增强时,才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED``GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` 显式打开。否则未开通工具的上游会先吐自然语言再返回 `ToolNotOpen`,这类失败要按上游工具不可用处理,不要误判成模型返回结果解析失败。
未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile``custom_world_profile` 等源表后自行拼装列表;BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback
统一公开作品 BFF 路由是 `GET /api/public-works``GET /api/public-works/{publicWorkCode}`,响应契约由 `shared-contracts::public_work``packages/shared/src/contracts/publicWork.ts` 共同维护。前端首期仍走 BFF HTTP,不直接订阅 SpacetimeDB;后续若允许浏览器直连订阅,也只能订阅 `public_work_gallery_entry` / `public_work_detail_entry` 这类稳定公开 read model不能订阅 `puzzle_work_profile``custom_world_profile` 等源表后自行拼装列表。设计细节见 `docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`
### `quest_log`
@@ -704,7 +709,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`
- Rust view`square_hole_gallery_view`
- 返回类型:`Vec<SquareHoleGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/square_hole.rs`
- 说明:方洞挑战公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server``spacetime-client` 长期订阅 `SELECT * FROM square_hole_gallery_view` `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'` 后,从本地 cache 构造 `/api/runtime/square-hole/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_square_hole_works` procedure个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
- 说明:方洞挑战公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
### `story_event`
@@ -779,4 +784,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`
- Rust view`visual_novel_gallery_view`
- 返回类型:`Vec<VisualNovelGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
- 说明:视觉小说公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;`api-server``spacetime-client` 长期订阅 `SELECT * FROM visual_novel_gallery_view` `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'` 后,从本地 cache 构造 `/api/runtime/visual-novel/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_visual_novel_works` procedure个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。
- 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。
@@ -107,6 +107,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
+1
View File
@@ -3,6 +3,7 @@ export type * from './creationAudio';
export type * from './hyper3d';
export type * from './jumpHop';
export type * from './puzzleCreativeTemplate';
export type * from './publicWork';
export type * from './visualNovel';
export type * from './barkBattle';
export type * from './woodenFish';
@@ -0,0 +1,38 @@
export interface PublicWorkGalleryEntryResponse {
sourceType: string;
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc?: string | null;
coverAssetId?: string | null;
themeTags: string[];
playCount: number;
remixCount: number;
likeCount: number;
recentPlayCount7d?: number;
publishedAt?: string | null;
updatedAt: string;
sortTimeMicros: number;
}
export interface PublicWorkDetailEntryResponse
extends PublicWorkGalleryEntryResponse {
detailPayloadJson: string;
}
export interface PublicWorkGalleryResponse {
items: PublicWorkGalleryEntryResponse[];
hasMore?: boolean;
nextCursor?: string | null;
totalCount?: number;
}
export interface PublicWorkDetailResponse {
item: PublicWorkDetailEntryResponse;
}
@@ -136,6 +136,7 @@ export interface DragPuzzlePieceRequest {
export interface AdvancePuzzleNextLevelRequest {
targetProfileId?: string | null;
preferSimilarWork?: boolean;
}
export interface UsePuzzleRuntimePropRequest {
+1
View File
@@ -61,6 +61,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::square_hole::router(state.clone()))
.merge(modules::jump_hop::router(state.clone()))
.merge(modules::wooden_fish::router(state.clone()))
.merge(modules::public_work::router(state.clone()))
.merge(modules::puzzle::router(state.clone()))
.merge(visual_novel_router(state.clone()))
.route(
@@ -702,7 +702,7 @@ pub async fn list_custom_world_gallery(
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.list_custom_world_gallery_entries()
.list_public_work_gallery_entries()
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
@@ -713,7 +713,8 @@ pub async fn list_custom_world_gallery(
CustomWorldGalleryResponse {
entries: entries
.into_iter()
.map(|entry| map_custom_world_gallery_card_response(&state, entry))
.filter(|entry| entry.source_type == "custom-world")
.map(|entry| map_public_work_custom_world_gallery_card_response(&state, entry))
.collect(),
},
))
@@ -149,6 +149,43 @@ pub(super) fn map_custom_world_gallery_card_response(
}
}
pub(super) fn map_public_work_custom_world_gallery_card_response(
state: &AppState,
entry: spacetime_client::PublicWorkGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
None,
);
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author.public_user_code.unwrap_or_default(),
visibility: "published".to_string(),
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry
.theme_tags
.first()
.cloned()
.unwrap_or_else(|| "mythic".to_string()),
playable_npc_count: 0,
landmark_count: 0,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: entry.recent_play_count_7d,
}
}
pub(super) fn map_custom_world_work_summary_response(
item: CustomWorldWorkSummaryRecord,
) -> CustomWorldWorkSummaryResponse {
+1
View File
@@ -61,6 +61,7 @@ mod platform_errors;
mod process_metrics;
mod profile_identity;
mod prompt;
mod public_work;
mod puzzle;
mod puzzle_agent_turn;
mod puzzle_gallery_cache;
@@ -11,6 +11,7 @@ pub mod jump_hop;
pub mod match3d;
pub mod platform;
pub mod profile;
pub mod public_work;
pub mod puzzle;
pub mod square_hole;
pub mod story;
@@ -0,0 +1,16 @@
use axum::{Router, routing::get};
use crate::{
public_work::{get_public_work_detail, list_public_works},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/api/public-works", get(list_public_works))
.route(
"/api/public-works/{public_work_code}",
get(get_public_work_detail),
)
.with_state(state)
}
@@ -0,0 +1,154 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::{HeaderName, StatusCode, header},
response::Response,
};
use serde_json::{Value, json};
use shared_contracts::public_work::{
PublicWorkDetailEntryResponse, PublicWorkDetailResponse, PublicWorkGalleryEntryResponse,
PublicWorkGalleryResponse,
};
use spacetime_client::{
PublicWorkDetailEntryRecord, PublicWorkGalleryEntryRecord, SpacetimeClientError,
};
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState, work_author::resolve_work_author_by_user_id,
};
const PUBLIC_WORK_PROVIDER: &str = "public-work";
pub async fn list_public_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_public_work_gallery_entries()
.await
.map_err(|error| {
public_work_error_response(&request_context, map_public_work_client_error(error))
})?
.into_iter()
.map(|entry| map_public_work_gallery_entry_response(&state, entry))
.collect::<Vec<_>>();
let total_count = items.len().min(u32::MAX as usize) as u32;
Ok(json_success_body(
Some(&request_context),
PublicWorkGalleryResponse {
items,
has_more: false,
next_cursor: None,
total_count,
},
))
}
pub async fn get_public_work_detail(
State(state): State<AppState>,
Path(public_work_code): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
if public_work_code.trim().is_empty() {
return Err(public_work_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUBLIC_WORK_PROVIDER,
"message": "publicWorkCode is required",
})),
));
}
let item = state
.spacetime_client()
.get_public_work_detail_by_code(public_work_code)
.await
.map_err(|error| {
public_work_error_response(&request_context, map_public_work_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
PublicWorkDetailResponse {
item: map_public_work_detail_entry_response(&state, item),
},
))
}
pub(crate) fn map_public_work_gallery_entry_response(
state: &AppState,
entry: PublicWorkGalleryEntryRecord,
) -> PublicWorkGalleryEntryResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
None,
);
PublicWorkGalleryEntryResponse {
source_type: entry.source_type,
work_id: entry.work_id,
profile_id: entry.profile_id,
source_session_id: entry.source_session_id,
public_work_code: entry.public_work_code,
owner_user_id: entry.owner_user_id,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
cover_asset_id: entry.cover_asset_id,
theme_tags: entry.theme_tags,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: entry.recent_play_count_7d,
published_at: entry.published_at,
updated_at: entry.updated_at,
sort_time_micros: entry.sort_time_micros,
}
}
pub(crate) fn map_public_work_detail_entry_response(
state: &AppState,
entry: PublicWorkDetailEntryRecord,
) -> PublicWorkDetailEntryResponse {
PublicWorkDetailEntryResponse {
entry: map_public_work_gallery_entry_response(state, entry.entry),
detail_payload_json: entry.detail_payload_json,
}
}
fn map_public_work_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn public_work_error_response(request_context: &RequestContext, error: AppError) -> Response {
let mut response = error.into_response_with_context(Some(request_context));
response.headers_mut().insert(
HeaderName::from_static("x-genarrative-provider"),
header::HeaderValue::from_static(PUBLIC_WORK_PROVIDER),
);
response
}
@@ -1510,7 +1510,7 @@ pub async fn list_puzzle_gallery(
let rebuild_started_at = std::time::Instant::now();
let items = state
.spacetime_client()
.list_puzzle_gallery()
.list_public_work_gallery_entries()
.await
.map_err(|error| {
puzzle_error_response(
@@ -1523,7 +1523,8 @@ pub async fn list_puzzle_gallery(
let response = build_puzzle_gallery_window_response(
items
.into_iter()
.map(|item| map_puzzle_gallery_card_response(&state, item))
.filter(|item| item.source_type == "puzzle")
.map(|item| map_public_work_puzzle_gallery_card_response(&state, item))
.collect(),
);
let cached_response = state
@@ -1881,6 +1882,7 @@ pub async fn advance_puzzle_next_level(
Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => {
AdvancePuzzleNextLevelRequest {
target_profile_id: None,
prefer_similar_work: false,
}
}
Err(error) => {
@@ -1901,6 +1903,7 @@ pub async fn advance_puzzle_next_level(
run_id,
owner_user_id: principal.subject().to_string(),
target_profile_id: payload.target_profile_id,
prefer_similar_work: payload.prefer_similar_work,
advanced_at_micros: current_utc_micros(),
})
.await
@@ -439,6 +439,46 @@ pub(super) fn map_puzzle_gallery_card_response(
}
}
pub(super) fn map_public_work_puzzle_gallery_card_response(
state: &PuzzleApiState,
item: spacetime_client::PublicWorkGalleryEntryRecord,
) -> PuzzleWorkSummaryResponse {
let author = resolve_puzzle_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
None,
);
PuzzleWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
author_display_name: author.display_name,
work_title: item.world_name.clone(),
work_description: item.summary_text.clone(),
level_name: item.world_name,
summary: item.summary_text,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
cover_asset_id: item.cover_asset_id,
publication_status: "published".to_string(),
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,
recent_play_count_7d: item.recent_play_count_7d,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
point_incentive_total_points: 0.0,
point_incentive_claimable_points: 0,
publish_ready: true,
generation_status: Some("ready".to_string()),
levels: Vec::new(),
}
}
pub(super) fn map_puzzle_work_profile_response(
state: &PuzzleApiState,
item: PuzzleWorkProfileRecord,
@@ -1789,6 +1789,18 @@ pub fn select_next_profiles<'a>(
available
}
pub fn select_runtime_next_profile<'a>(
same_work_next_profile: Option<&'a PuzzleWorkProfile>,
similar_work_profiles: &'a [&'a PuzzleWorkProfile],
prefer_similar_work: bool,
) -> Option<&'a PuzzleWorkProfile> {
if prefer_similar_work {
similar_work_profiles.first().copied().or(same_work_next_profile)
} else {
same_work_next_profile.or_else(|| similar_work_profiles.first().copied())
}
}
pub fn recommendation_score(
current_profile: &PuzzleWorkProfile,
candidate: &PuzzleWorkProfile,
@@ -3261,6 +3273,28 @@ mod tests {
assert_eq!(selected.profile_id, "b");
}
#[test]
fn select_runtime_next_profile_prefers_similar_work_when_requested() {
let same_work = build_published_profile("same", "owner-a", vec!["奇幻"]);
let similar_work = build_published_profile("similar", "owner-b", vec!["奇幻"]);
let similar_work_profiles = [&similar_work];
let selected = select_runtime_next_profile(
Some(&same_work),
&similar_work_profiles,
true,
)
.expect("should select similar work first");
assert_eq!(selected.profile_id, "similar");
}
#[test]
fn select_runtime_next_profile_falls_back_to_same_work_when_no_similar_candidate() {
let same_work = build_published_profile("same", "owner-a", vec!["奇幻"]);
let selected = select_runtime_next_profile(Some(&same_work), &[], true)
.expect("should fall back to same work");
assert_eq!(selected.profile_id, "same");
}
#[test]
fn restart_cleared_count_uses_selected_level_index() {
let mut profile = build_published_profile("entry", "owner-a", vec!["机关"]);
@@ -228,6 +228,8 @@ pub struct PuzzleRunNextLevelInput {
pub owner_user_id: String,
#[serde(default)]
pub target_profile_id: Option<String>,
#[serde(default)]
pub prefer_similar_work: bool,
pub advanced_at_micros: i64,
}
@@ -17,6 +17,7 @@ pub mod llm;
pub mod match3d_agent;
pub mod match3d_runtime;
pub mod match3d_works;
pub mod public_work;
pub mod puzzle_agent;
pub mod puzzle_creative_template;
pub mod puzzle_gallery;
@@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
/// 公开作品列表统一契约。
///
/// 该契约面向 BFF 和后续可选前端直连订阅,字段只包含平台公开卡片需要的摘要信息。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkGalleryEntryResponse {
pub source_type: String,
pub work_id: String,
pub profile_id: String,
#[serde(default)]
pub source_session_id: Option<String>,
pub public_work_code: String,
pub owner_user_id: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
#[serde(default)]
pub cover_image_src: Option<String>,
#[serde(default)]
pub cover_asset_id: Option<String>,
pub theme_tags: Vec<String>,
pub play_count: u32,
pub remix_count: u32,
pub like_count: u32,
#[serde(default)]
pub recent_play_count_7d: u32,
#[serde(default)]
pub published_at: Option<String>,
pub updated_at: String,
pub sort_time_micros: i64,
}
/// 公开作品详情统一摘要契约。
///
/// `detail_payload_json` 只承载详情页展示扩展字段,不承载正式 runtime 配置。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkDetailEntryResponse {
#[serde(flatten)]
pub entry: PublicWorkGalleryEntryResponse,
pub detail_payload_json: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkGalleryResponse {
pub items: Vec<PublicWorkGalleryEntryResponse>,
#[serde(default)]
pub has_more: bool,
#[serde(default)]
pub next_cursor: Option<String>,
#[serde(default)]
pub total_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkDetailResponse {
pub item: PublicWorkDetailEntryResponse,
}
@@ -29,6 +29,8 @@ pub struct DragPuzzlePieceRequest {
pub struct AdvancePuzzleNextLevelRequest {
#[serde(default)]
pub target_profile_id: Option<String>,
#[serde(default)]
pub prefer_similar_work: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+5 -2
View File
@@ -47,8 +47,8 @@ pub use mapper::{
Match3DRunClickRecordInput, Match3DRunRecord, Match3DRunRestartRecordInput,
Match3DRunStartRecordInput, Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput,
Match3DTraySlotRecord, Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput,
NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord,
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord,
NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord, PublicWorkDetailEntryRecord,
PublicWorkGalleryEntryRecord, PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord,
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
@@ -106,6 +106,7 @@ pub mod inventory;
pub mod jump_hop;
pub mod match3d;
pub mod npc;
pub mod public_work;
pub mod puzzle;
pub mod runtime;
pub mod square_hole;
@@ -569,6 +570,8 @@ impl SpacetimeClient {
) -> Result<Vec<SubscriptionHandle>, SpacetimeClientError> {
let mut subscriptions = Vec::new();
for query in [
"SELECT * FROM public_work_gallery_entry",
"SELECT * FROM public_work_detail_entry",
"SELECT * FROM bark_battle_gallery_view",
"SELECT * FROM puzzle_gallery_card_view",
"SELECT * FROM jump_hop_gallery_card_view",
@@ -12,6 +12,7 @@ mod inventory;
mod jump_hop;
mod match3d;
mod npc;
mod public_work;
mod puzzle;
mod runtime;
mod runtime_profile;
@@ -94,6 +95,7 @@ pub use self::npc::{
CustomWorldWorkSummaryRecord, NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord,
ResolveNpcBattleInteractionInput,
};
pub use self::public_work::{PublicWorkDetailEntryRecord, PublicWorkGalleryEntryRecord};
pub use self::puzzle::{
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord,
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
@@ -180,6 +182,9 @@ pub(crate) use self::npc::{
build_battle_state_record, map_battle_state_snapshot, map_inventory_item_source_kind,
map_npc_battle_interaction_procedure_result, validate_npc_battle_interaction_input,
};
pub(crate) use self::public_work::{
map_public_work_gallery_entry, map_public_work_gallery_entry_to_detail_entry,
};
pub(crate) use self::puzzle::{
map_puzzle_agent_session_procedure_result, map_puzzle_gallery_card_view_row,
map_puzzle_run_procedure_result, map_puzzle_work_procedure_result,
@@ -0,0 +1,115 @@
use super::*;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicWorkGalleryEntryRecord {
pub source_type: String,
pub work_id: String,
pub profile_id: String,
pub source_session_id: Option<String>,
pub public_work_code: String,
pub owner_user_id: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub theme_tags: Vec<String>,
pub play_count: u32,
pub remix_count: u32,
pub like_count: u32,
pub recent_play_count_7d: u32,
pub published_at: Option<String>,
pub updated_at: String,
pub sort_time_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicWorkDetailEntryRecord {
pub entry: PublicWorkGalleryEntryRecord,
pub detail_payload_json: String,
}
pub(crate) fn map_public_work_gallery_entry(
snapshot: PublicWorkGalleryEntry,
recent_play_count_7d: u32,
) -> PublicWorkGalleryEntryRecord {
PublicWorkGalleryEntryRecord {
source_type: snapshot.source_type,
work_id: snapshot.work_id,
profile_id: snapshot.profile_id,
source_session_id: snapshot.source_session_id,
public_work_code: snapshot.public_work_code,
owner_user_id: snapshot.owner_user_id,
author_display_name: snapshot.author_display_name,
world_name: snapshot.world_name,
subtitle: snapshot.subtitle,
summary_text: snapshot.summary_text,
cover_image_src: snapshot.cover_image_src,
cover_asset_id: snapshot.cover_asset_id,
theme_tags: snapshot.theme_tags,
play_count: snapshot.play_count,
remix_count: snapshot.remix_count,
like_count: snapshot.like_count,
recent_play_count_7d,
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
sort_time_micros: snapshot.sort_time_micros,
}
}
pub(crate) fn map_public_work_gallery_entry_to_detail_entry(
snapshot: PublicWorkDetailEntry,
recent_play_count_7d: u32,
) -> PublicWorkDetailEntryRecord {
let PublicWorkDetailEntry {
source_type,
work_id,
profile_id,
source_session_id,
public_work_code,
owner_user_id,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
cover_asset_id,
theme_tags,
play_count,
remix_count,
like_count,
published_at_micros,
updated_at_micros,
sort_time_micros,
detail_payload_json,
} = snapshot;
PublicWorkDetailEntryRecord {
entry: map_public_work_gallery_entry(
PublicWorkGalleryEntry {
source_type,
work_id,
profile_id,
source_session_id,
public_work_code,
owner_user_id,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
cover_asset_id,
theme_tags,
play_count,
remix_count,
like_count,
published_at_micros,
updated_at_micros,
sort_time_micros,
},
recent_play_count_7d,
),
detail_payload_json,
}
}
@@ -750,6 +750,7 @@ pub struct PuzzleRunNextLevelRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub target_profile_id: Option<String>,
pub prefer_similar_work: bool,
pub advanced_at_micros: i64,
}

Some files were not shown because too many files have changed in this diff Show More