Add public work read model and smooth puzzle transitions
This commit is contained in:
@@ -32,6 +32,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 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。
|
||||
|
||||
92
docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md
Normal file
92
docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md
Normal file
@@ -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 / 拼图等运行态存档选择入口统一在个人中心 `次级入口 >
|
||||
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
|
||||
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
|
||||
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
|
||||
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
|
||||
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。
|
||||
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
38
packages/shared/src/contracts/publicWork.ts
Normal file
38
packages/shared/src/contracts/publicWork.ts
Normal file
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
server-rs/crates/api-server/src/modules/public_work.rs
Normal file
16
server-rs/crates/api-server/src/modules/public_work.rs
Normal file
@@ -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)
|
||||
}
|
||||
154
server-rs/crates/api-server/src/public_work.rs
Normal file
154
server-rs/crates/api-server/src/public_work.rs
Normal file
@@ -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;
|
||||
|
||||
63
server-rs/crates/shared-contracts/src/public_work.rs
Normal file
63
server-rs/crates/shared-contracts/src/public_work.rs
Normal file
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
115
server-rs/crates/spacetime-client/src/mapper/public_work.rs
Normal file
115
server-rs/crates/spacetime-client/src/mapper/public_work.rs
Normal file
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6).
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
@@ -559,6 +559,10 @@ pub mod profile_task_reward_claim_table;
|
||||
pub mod profile_task_reward_claim_type;
|
||||
pub mod profile_wallet_ledger_table;
|
||||
pub mod profile_wallet_ledger_type;
|
||||
pub mod public_work_detail_entry_table;
|
||||
pub mod public_work_detail_entry_type;
|
||||
pub mod public_work_gallery_entry_table;
|
||||
pub mod public_work_gallery_entry_type;
|
||||
pub mod public_work_like_table;
|
||||
pub mod public_work_like_type;
|
||||
pub mod public_work_play_daily_stat_table;
|
||||
@@ -1588,6 +1592,10 @@ pub use profile_task_reward_claim_table::*;
|
||||
pub use profile_task_reward_claim_type::ProfileTaskRewardClaim;
|
||||
pub use profile_wallet_ledger_table::*;
|
||||
pub use profile_wallet_ledger_type::ProfileWalletLedger;
|
||||
pub use public_work_detail_entry_table::*;
|
||||
pub use public_work_detail_entry_type::PublicWorkDetailEntry;
|
||||
pub use public_work_gallery_entry_table::*;
|
||||
pub use public_work_gallery_entry_type::PublicWorkGalleryEntry;
|
||||
pub use public_work_like_table::*;
|
||||
pub use public_work_like_type::PublicWorkLike;
|
||||
pub use public_work_play_daily_stat_table::*;
|
||||
@@ -2404,6 +2412,8 @@ pub struct DbUpdate {
|
||||
profile_task_progress: __sdk::TableUpdate<ProfileTaskProgress>,
|
||||
profile_task_reward_claim: __sdk::TableUpdate<ProfileTaskRewardClaim>,
|
||||
profile_wallet_ledger: __sdk::TableUpdate<ProfileWalletLedger>,
|
||||
public_work_detail_entry: __sdk::TableUpdate<PublicWorkDetailEntry>,
|
||||
public_work_gallery_entry: __sdk::TableUpdate<PublicWorkGalleryEntry>,
|
||||
public_work_like: __sdk::TableUpdate<PublicWorkLike>,
|
||||
public_work_play_daily_stat: __sdk::TableUpdate<PublicWorkPlayDailyStat>,
|
||||
puzzle_agent_message: __sdk::TableUpdate<PuzzleAgentMessageRow>,
|
||||
@@ -2666,6 +2676,12 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
|
||||
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(
|
||||
profile_wallet_ledger_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"public_work_detail_entry" => db_update.public_work_detail_entry.append(
|
||||
public_work_detail_entry_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"public_work_gallery_entry" => db_update.public_work_gallery_entry.append(
|
||||
public_work_gallery_entry_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"public_work_like" => db_update
|
||||
.public_work_like
|
||||
.append(public_work_like_table::parse_table_update(table_update)?),
|
||||
@@ -3328,6 +3344,14 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"match_3_d_gallery_view",
|
||||
&self.match_3_d_gallery_view,
|
||||
);
|
||||
diff.public_work_detail_entry = cache.apply_diff_to_table::<PublicWorkDetailEntry>(
|
||||
"public_work_detail_entry",
|
||||
&self.public_work_detail_entry,
|
||||
);
|
||||
diff.public_work_gallery_entry = cache.apply_diff_to_table::<PublicWorkGalleryEntry>(
|
||||
"public_work_gallery_entry",
|
||||
&self.public_work_gallery_entry,
|
||||
);
|
||||
diff.puzzle_gallery_card_view = cache.apply_diff_to_table::<PuzzleGalleryCardViewRow>(
|
||||
"puzzle_gallery_card_view",
|
||||
&self.puzzle_gallery_card_view,
|
||||
@@ -3564,6 +3588,12 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"profile_wallet_ledger" => db_update
|
||||
.profile_wallet_ledger
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"public_work_detail_entry" => db_update
|
||||
.public_work_detail_entry
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"public_work_gallery_entry" => db_update
|
||||
.public_work_gallery_entry
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"public_work_like" => db_update
|
||||
.public_work_like
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
@@ -3901,6 +3931,12 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"profile_wallet_ledger" => db_update
|
||||
.profile_wallet_ledger
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"public_work_detail_entry" => db_update
|
||||
.public_work_detail_entry
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"public_work_gallery_entry" => db_update
|
||||
.public_work_gallery_entry
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"public_work_like" => db_update
|
||||
.public_work_like
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
@@ -4106,6 +4142,8 @@ pub struct AppliedDiff<'r> {
|
||||
profile_task_progress: __sdk::TableAppliedDiff<'r, ProfileTaskProgress>,
|
||||
profile_task_reward_claim: __sdk::TableAppliedDiff<'r, ProfileTaskRewardClaim>,
|
||||
profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>,
|
||||
public_work_detail_entry: __sdk::TableAppliedDiff<'r, PublicWorkDetailEntry>,
|
||||
public_work_gallery_entry: __sdk::TableAppliedDiff<'r, PublicWorkGalleryEntry>,
|
||||
public_work_like: __sdk::TableAppliedDiff<'r, PublicWorkLike>,
|
||||
public_work_play_daily_stat: __sdk::TableAppliedDiff<'r, PublicWorkPlayDailyStat>,
|
||||
puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>,
|
||||
@@ -4488,6 +4526,16 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
||||
&self.profile_wallet_ledger,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<PublicWorkDetailEntry>(
|
||||
"public_work_detail_entry",
|
||||
&self.public_work_detail_entry,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<PublicWorkGalleryEntry>(
|
||||
"public_work_gallery_entry",
|
||||
&self.public_work_gallery_entry,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<PublicWorkLike>(
|
||||
"public_work_like",
|
||||
&self.public_work_like,
|
||||
@@ -5408,6 +5456,8 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
profile_task_progress_table::register_table(client_cache);
|
||||
profile_task_reward_claim_table::register_table(client_cache);
|
||||
profile_wallet_ledger_table::register_table(client_cache);
|
||||
public_work_detail_entry_table::register_table(client_cache);
|
||||
public_work_gallery_entry_table::register_table(client_cache);
|
||||
public_work_like_table::register_table(client_cache);
|
||||
public_work_play_daily_stat_table::register_table(client_cache);
|
||||
puzzle_agent_message_table::register_table(client_cache);
|
||||
@@ -5518,6 +5568,8 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
"profile_task_progress",
|
||||
"profile_task_reward_claim",
|
||||
"profile_wallet_ledger",
|
||||
"public_work_detail_entry",
|
||||
"public_work_gallery_entry",
|
||||
"public_work_like",
|
||||
"public_work_play_daily_stat",
|
||||
"puzzle_agent_message",
|
||||
|
||||
@@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
pub struct BarkBattleGalleryViewRow {
|
||||
pub work_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub author_display_name: String,
|
||||
pub source_draft_id: Option<String>,
|
||||
pub config_version: u64,
|
||||
pub ruleset_version: String,
|
||||
@@ -38,6 +39,7 @@ impl __sdk::InModule for BarkBattleGalleryViewRow {
|
||||
pub struct BarkBattleGalleryViewRowCols {
|
||||
pub work_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
|
||||
pub owner_user_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
|
||||
pub author_display_name: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
|
||||
pub source_draft_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
|
||||
pub config_version: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, u64>,
|
||||
pub ruleset_version: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
|
||||
@@ -66,6 +68,10 @@ impl __sdk::__query_builder::HasCols for BarkBattleGalleryViewRow {
|
||||
BarkBattleGalleryViewRowCols {
|
||||
work_id: __sdk::__query_builder::Col::new(table_name, "work_id"),
|
||||
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
|
||||
author_display_name: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"author_display_name",
|
||||
),
|
||||
source_draft_id: __sdk::__query_builder::Col::new(table_name, "source_draft_id"),
|
||||
config_version: __sdk::__query_builder::Col::new(table_name, "config_version"),
|
||||
ruleset_version: __sdk::__query_builder::Col::new(table_name, "ruleset_version"),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// 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 super::public_work_detail_entry_type::PublicWorkDetailEntry;
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
/// Table handle for the table `public_work_detail_entry`.
|
||||
///
|
||||
/// Obtain a handle from the [`PublicWorkDetailEntryTableAccess::public_work_detail_entry`] method on [`super::RemoteTables`],
|
||||
/// like `ctx.db.public_work_detail_entry()`.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.public_work_detail_entry().on_insert(...)`.
|
||||
pub struct PublicWorkDetailEntryTableHandle<'ctx> {
|
||||
imp: __sdk::TableHandle<PublicWorkDetailEntry>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the table `public_work_detail_entry`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteTables`].
|
||||
pub trait PublicWorkDetailEntryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Obtain a [`PublicWorkDetailEntryTableHandle`], which mediates access to the table `public_work_detail_entry`.
|
||||
fn public_work_detail_entry(&self) -> PublicWorkDetailEntryTableHandle<'_>;
|
||||
}
|
||||
|
||||
impl PublicWorkDetailEntryTableAccess for super::RemoteTables {
|
||||
fn public_work_detail_entry(&self) -> PublicWorkDetailEntryTableHandle<'_> {
|
||||
PublicWorkDetailEntryTableHandle {
|
||||
imp: self
|
||||
.imp
|
||||
.get_table::<PublicWorkDetailEntry>("public_work_detail_entry"),
|
||||
ctx: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PublicWorkDetailEntryInsertCallbackId(__sdk::CallbackId);
|
||||
pub struct PublicWorkDetailEntryDeleteCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::Table for PublicWorkDetailEntryTableHandle<'ctx> {
|
||||
type Row = PublicWorkDetailEntry;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = PublicWorkDetailEntry> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
|
||||
type InsertCallbackId = PublicWorkDetailEntryInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> PublicWorkDetailEntryInsertCallbackId {
|
||||
PublicWorkDetailEntryInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: PublicWorkDetailEntryInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
|
||||
type DeleteCallbackId = PublicWorkDetailEntryDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> PublicWorkDetailEntryDeleteCallbackId {
|
||||
PublicWorkDetailEntryDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: PublicWorkDetailEntryDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table =
|
||||
client_cache.get_or_make_table::<PublicWorkDetailEntry>("public_work_detail_entry");
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<PublicWorkDetailEntry>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<PublicWorkDetailEntry>", "TableUpdate")
|
||||
.with_cause(e)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for query builder access to the table `PublicWorkDetailEntry`.
|
||||
///
|
||||
/// Implemented for [`__sdk::QueryTableAccessor`].
|
||||
pub trait public_work_detail_entryQueryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Get a query builder for the table `PublicWorkDetailEntry`.
|
||||
fn public_work_detail_entry(&self) -> __sdk::__query_builder::Table<PublicWorkDetailEntry>;
|
||||
}
|
||||
|
||||
impl public_work_detail_entryQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn public_work_detail_entry(&self) -> __sdk::__query_builder::Table<PublicWorkDetailEntry> {
|
||||
__sdk::__query_builder::Table::new("public_work_detail_entry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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 PublicWorkDetailEntry {
|
||||
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 published_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
pub sort_time_micros: i64,
|
||||
pub detail_payload_json: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PublicWorkDetailEntry {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `PublicWorkDetailEntry`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct PublicWorkDetailEntryCols {
|
||||
pub source_type: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
pub work_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
pub profile_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
pub source_session_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, Option<String>>,
|
||||
pub public_work_code: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
pub owner_user_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
pub author_display_name: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
pub world_name: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
pub subtitle: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
pub summary_text: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
pub cover_image_src: __sdk::__query_builder::Col<PublicWorkDetailEntry, Option<String>>,
|
||||
pub cover_asset_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, Option<String>>,
|
||||
pub theme_tags: __sdk::__query_builder::Col<PublicWorkDetailEntry, Vec<String>>,
|
||||
pub play_count: __sdk::__query_builder::Col<PublicWorkDetailEntry, u32>,
|
||||
pub remix_count: __sdk::__query_builder::Col<PublicWorkDetailEntry, u32>,
|
||||
pub like_count: __sdk::__query_builder::Col<PublicWorkDetailEntry, u32>,
|
||||
pub published_at_micros: __sdk::__query_builder::Col<PublicWorkDetailEntry, Option<i64>>,
|
||||
pub updated_at_micros: __sdk::__query_builder::Col<PublicWorkDetailEntry, i64>,
|
||||
pub sort_time_micros: __sdk::__query_builder::Col<PublicWorkDetailEntry, i64>,
|
||||
pub detail_payload_json: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for PublicWorkDetailEntry {
|
||||
type Cols = PublicWorkDetailEntryCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
PublicWorkDetailEntryCols {
|
||||
source_type: __sdk::__query_builder::Col::new(table_name, "source_type"),
|
||||
work_id: __sdk::__query_builder::Col::new(table_name, "work_id"),
|
||||
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
|
||||
source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"),
|
||||
public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"),
|
||||
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
|
||||
author_display_name: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"author_display_name",
|
||||
),
|
||||
world_name: __sdk::__query_builder::Col::new(table_name, "world_name"),
|
||||
subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"),
|
||||
summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"),
|
||||
cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"),
|
||||
cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"),
|
||||
theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"),
|
||||
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_micros: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"published_at_micros",
|
||||
),
|
||||
updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"),
|
||||
sort_time_micros: __sdk::__query_builder::Col::new(table_name, "sort_time_micros"),
|
||||
detail_payload_json: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"detail_payload_json",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// 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 super::public_work_gallery_entry_type::PublicWorkGalleryEntry;
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
/// Table handle for the table `public_work_gallery_entry`.
|
||||
///
|
||||
/// Obtain a handle from the [`PublicWorkGalleryEntryTableAccess::public_work_gallery_entry`] method on [`super::RemoteTables`],
|
||||
/// like `ctx.db.public_work_gallery_entry()`.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.public_work_gallery_entry().on_insert(...)`.
|
||||
pub struct PublicWorkGalleryEntryTableHandle<'ctx> {
|
||||
imp: __sdk::TableHandle<PublicWorkGalleryEntry>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the table `public_work_gallery_entry`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteTables`].
|
||||
pub trait PublicWorkGalleryEntryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Obtain a [`PublicWorkGalleryEntryTableHandle`], which mediates access to the table `public_work_gallery_entry`.
|
||||
fn public_work_gallery_entry(&self) -> PublicWorkGalleryEntryTableHandle<'_>;
|
||||
}
|
||||
|
||||
impl PublicWorkGalleryEntryTableAccess for super::RemoteTables {
|
||||
fn public_work_gallery_entry(&self) -> PublicWorkGalleryEntryTableHandle<'_> {
|
||||
PublicWorkGalleryEntryTableHandle {
|
||||
imp: self
|
||||
.imp
|
||||
.get_table::<PublicWorkGalleryEntry>("public_work_gallery_entry"),
|
||||
ctx: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PublicWorkGalleryEntryInsertCallbackId(__sdk::CallbackId);
|
||||
pub struct PublicWorkGalleryEntryDeleteCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::Table for PublicWorkGalleryEntryTableHandle<'ctx> {
|
||||
type Row = PublicWorkGalleryEntry;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = PublicWorkGalleryEntry> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
|
||||
type InsertCallbackId = PublicWorkGalleryEntryInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> PublicWorkGalleryEntryInsertCallbackId {
|
||||
PublicWorkGalleryEntryInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: PublicWorkGalleryEntryInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
|
||||
type DeleteCallbackId = PublicWorkGalleryEntryDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> PublicWorkGalleryEntryDeleteCallbackId {
|
||||
PublicWorkGalleryEntryDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: PublicWorkGalleryEntryDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table =
|
||||
client_cache.get_or_make_table::<PublicWorkGalleryEntry>("public_work_gallery_entry");
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<PublicWorkGalleryEntry>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<PublicWorkGalleryEntry>", "TableUpdate")
|
||||
.with_cause(e)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for query builder access to the table `PublicWorkGalleryEntry`.
|
||||
///
|
||||
/// Implemented for [`__sdk::QueryTableAccessor`].
|
||||
pub trait public_work_gallery_entryQueryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Get a query builder for the table `PublicWorkGalleryEntry`.
|
||||
fn public_work_gallery_entry(&self) -> __sdk::__query_builder::Table<PublicWorkGalleryEntry>;
|
||||
}
|
||||
|
||||
impl public_work_gallery_entryQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn public_work_gallery_entry(&self) -> __sdk::__query_builder::Table<PublicWorkGalleryEntry> {
|
||||
__sdk::__query_builder::Table::new("public_work_gallery_entry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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 PublicWorkGalleryEntry {
|
||||
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 published_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
pub sort_time_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PublicWorkGalleryEntry {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `PublicWorkGalleryEntry`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct PublicWorkGalleryEntryCols {
|
||||
pub source_type: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
|
||||
pub work_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
|
||||
pub profile_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
|
||||
pub source_session_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Option<String>>,
|
||||
pub public_work_code: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
|
||||
pub owner_user_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
|
||||
pub author_display_name: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
|
||||
pub world_name: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
|
||||
pub subtitle: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
|
||||
pub summary_text: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
|
||||
pub cover_image_src: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Option<String>>,
|
||||
pub cover_asset_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Option<String>>,
|
||||
pub theme_tags: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Vec<String>>,
|
||||
pub play_count: __sdk::__query_builder::Col<PublicWorkGalleryEntry, u32>,
|
||||
pub remix_count: __sdk::__query_builder::Col<PublicWorkGalleryEntry, u32>,
|
||||
pub like_count: __sdk::__query_builder::Col<PublicWorkGalleryEntry, u32>,
|
||||
pub published_at_micros: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Option<i64>>,
|
||||
pub updated_at_micros: __sdk::__query_builder::Col<PublicWorkGalleryEntry, i64>,
|
||||
pub sort_time_micros: __sdk::__query_builder::Col<PublicWorkGalleryEntry, i64>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for PublicWorkGalleryEntry {
|
||||
type Cols = PublicWorkGalleryEntryCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
PublicWorkGalleryEntryCols {
|
||||
source_type: __sdk::__query_builder::Col::new(table_name, "source_type"),
|
||||
work_id: __sdk::__query_builder::Col::new(table_name, "work_id"),
|
||||
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
|
||||
source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"),
|
||||
public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"),
|
||||
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
|
||||
author_display_name: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"author_display_name",
|
||||
),
|
||||
world_name: __sdk::__query_builder::Col::new(table_name, "world_name"),
|
||||
subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"),
|
||||
summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"),
|
||||
cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"),
|
||||
cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"),
|
||||
theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"),
|
||||
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_micros: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"published_at_micros",
|
||||
),
|
||||
updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"),
|
||||
sort_time_micros: __sdk::__query_builder::Col::new(table_name, "sort_time_micros"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub struct PuzzleRunNextLevelInput {
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
171
server-rs/crates/spacetime-client/src/public_work.rs
Normal file
171
server-rs/crates/spacetime-client/src/public_work.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use super::*;
|
||||
use crate::mapper::*;
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn list_public_work_gallery_entries(
|
||||
&self,
|
||||
) -> Result<Vec<PublicWorkGalleryEntryRecord>, SpacetimeClientError> {
|
||||
self.read_after_connect("list_public_work_gallery_entries", move |connection| {
|
||||
let recent_play_counts = public_work_recent_play_counts_by_source(connection);
|
||||
let mut entries = connection
|
||||
.db()
|
||||
.public_work_gallery_entry()
|
||||
.iter()
|
||||
.collect::<Vec<_>>();
|
||||
sort_public_work_gallery_entries(&mut entries);
|
||||
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let recent_play_count_7d = recent_play_counts
|
||||
.get(&(entry.source_type.clone(), entry.profile_id.clone()))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
map_public_work_gallery_entry(entry, recent_play_count_7d)
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_public_work_detail_entries(
|
||||
&self,
|
||||
) -> Result<Vec<PublicWorkDetailEntryRecord>, SpacetimeClientError> {
|
||||
self.read_after_connect("list_public_work_detail_entries", move |connection| {
|
||||
let recent_play_counts = public_work_recent_play_counts_by_source(connection);
|
||||
let mut entries = connection
|
||||
.db()
|
||||
.public_work_detail_entry()
|
||||
.iter()
|
||||
.collect::<Vec<_>>();
|
||||
sort_public_work_detail_entries(&mut entries);
|
||||
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let recent_play_count_7d = recent_play_counts
|
||||
.get(&(entry.source_type.clone(), entry.profile_id.clone()))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
map_public_work_gallery_entry_to_detail_entry(entry, recent_play_count_7d)
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_public_work_detail_by_code(
|
||||
&self,
|
||||
public_work_code: String,
|
||||
) -> Result<PublicWorkDetailEntryRecord, SpacetimeClientError> {
|
||||
let public_work_code = public_work_code.trim().to_string();
|
||||
if public_work_code.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"publicWorkCode 不能为空",
|
||||
));
|
||||
}
|
||||
|
||||
self.read_after_connect("get_public_work_detail_by_code", move |connection| {
|
||||
let recent_play_counts = public_work_recent_play_counts_by_source(connection);
|
||||
let entry = connection
|
||||
.db()
|
||||
.public_work_detail_entry()
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry
|
||||
.public_work_code
|
||||
.eq_ignore_ascii_case(&public_work_code)
|
||||
})
|
||||
.ok_or_else(|| SpacetimeClientError::Procedure("公开作品不存在".to_string()))?;
|
||||
let recent_play_count_7d = recent_play_counts
|
||||
.get(&(entry.source_type.clone(), entry.profile_id.clone()))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(map_public_work_gallery_entry_to_detail_entry(
|
||||
entry,
|
||||
recent_play_count_7d,
|
||||
))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_public_work_detail_by_source_profile(
|
||||
&self,
|
||||
source_type: String,
|
||||
profile_id: String,
|
||||
) -> Result<PublicWorkDetailEntryRecord, SpacetimeClientError> {
|
||||
let source_type = source_type.trim().to_string();
|
||||
let profile_id = profile_id.trim().to_string();
|
||||
if source_type.is_empty() || profile_id.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"sourceType 和 profileId 不能为空",
|
||||
));
|
||||
}
|
||||
|
||||
self.read_after_connect(
|
||||
"get_public_work_detail_by_source_profile",
|
||||
move |connection| {
|
||||
let recent_play_counts = public_work_recent_play_counts_by_source(connection);
|
||||
let entry = connection
|
||||
.db()
|
||||
.public_work_detail_entry()
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry.source_type == source_type && entry.profile_id == profile_id
|
||||
})
|
||||
.ok_or_else(|| SpacetimeClientError::Procedure("公开作品不存在".to_string()))?;
|
||||
let recent_play_count_7d = recent_play_counts
|
||||
.get(&(entry.source_type.clone(), entry.profile_id.clone()))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(map_public_work_gallery_entry_to_detail_entry(
|
||||
entry,
|
||||
recent_play_count_7d,
|
||||
))
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_public_work_gallery_entries(entries: &mut [PublicWorkGalleryEntry]) {
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.sort_time_micros
|
||||
.cmp(&left.sort_time_micros)
|
||||
.then_with(|| left.source_type.cmp(&right.source_type))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
}
|
||||
|
||||
fn sort_public_work_detail_entries(entries: &mut [PublicWorkDetailEntry]) {
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.sort_time_micros
|
||||
.cmp(&left.sort_time_micros)
|
||||
.then_with(|| left.source_type.cmp(&right.source_type))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
}
|
||||
|
||||
fn public_work_recent_play_counts_by_source(
|
||||
connection: &DbConnection,
|
||||
) -> HashMap<(String, String), u32> {
|
||||
let current_day = current_public_work_day();
|
||||
let first_day = current_day - (PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1);
|
||||
let mut counts = HashMap::new();
|
||||
|
||||
for row in connection.db().public_work_play_daily_stat().iter() {
|
||||
if row.played_day < first_day || row.played_day > current_day {
|
||||
continue;
|
||||
}
|
||||
let entry = counts
|
||||
.entry((row.source_type, row.profile_id))
|
||||
.or_insert(0_u32);
|
||||
*entry = entry.saturating_add(row.play_count);
|
||||
}
|
||||
|
||||
counts
|
||||
}
|
||||
@@ -610,6 +610,7 @@ impl SpacetimeClient {
|
||||
run_id: input.run_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
target_profile_id: input.target_profile_id,
|
||||
prefer_similar_work: input.prefer_similar_work,
|
||||
advanced_at_micros: input.advanced_at_micros,
|
||||
};
|
||||
|
||||
|
||||
@@ -549,6 +549,19 @@ fn runtime_config_snapshot(row: &BarkBattlePublishedConfigRow) -> BarkBattleRunt
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_bark_battle_author_display_name(
|
||||
ctx: &AnonymousViewContext,
|
||||
owner_user_id: &str,
|
||||
) -> String {
|
||||
ctx.db
|
||||
.user_account()
|
||||
.user_id()
|
||||
.find(&owner_user_id.to_string())
|
||||
.map(|account| account.display_name.trim().to_string())
|
||||
.filter(|display_name| !display_name.is_empty())
|
||||
.unwrap_or_else(|| "玩家".to_string())
|
||||
}
|
||||
|
||||
fn build_bark_battle_gallery_view_row(
|
||||
ctx: &AnonymousViewContext,
|
||||
row: &BarkBattlePublishedConfigRow,
|
||||
@@ -563,6 +576,7 @@ fn build_bark_battle_gallery_view_row(
|
||||
Ok(BarkBattleGalleryViewRow {
|
||||
work_id: row.work_id.clone(),
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
author_display_name: resolve_bark_battle_author_display_name(ctx, &row.owner_user_id),
|
||||
source_draft_id: row.source_draft_id.clone(),
|
||||
config_version: row.config_version,
|
||||
ruleset_version: row.ruleset_version.clone(),
|
||||
@@ -1096,6 +1110,7 @@ mod tests {
|
||||
let row = BarkBattleGalleryViewRow {
|
||||
work_id: "BB-33333333".to_string(),
|
||||
owner_user_id: "user-3".to_string(),
|
||||
author_display_name: "玩家".to_string(),
|
||||
source_draft_id: Some("bark-battle-draft-3".to_string()),
|
||||
config_version: 1,
|
||||
ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(),
|
||||
@@ -1115,6 +1130,9 @@ mod tests {
|
||||
published_at_micros: 1_713_686_401_234_000,
|
||||
};
|
||||
|
||||
assert_eq!(row.onomatopoeia, vec!["轰!".to_string(), "炸场!".to_string()]);
|
||||
assert_eq!(
|
||||
row.onomatopoeia,
|
||||
vec!["轰!".to_string(), "炸场!".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ pub struct BarkBattleRunSnapshot {
|
||||
pub struct BarkBattleGalleryViewRow {
|
||||
pub work_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub author_display_name: String,
|
||||
pub source_draft_id: Option<String>,
|
||||
pub config_version: u64,
|
||||
pub ruleset_version: String,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::*;
|
||||
use spacetimedb::AnonymousViewContext;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[spacetimedb::table(
|
||||
@@ -4988,6 +4989,28 @@ fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldP
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn custom_world_public_profile_snapshots(
|
||||
ctx: &AnonymousViewContext,
|
||||
) -> Vec<CustomWorldProfileSnapshot> {
|
||||
let mut entries = ctx
|
||||
.db
|
||||
.custom_world_profile()
|
||||
.by_custom_world_profile_publication_status()
|
||||
.filter(CustomWorldPublicationStatus::Published)
|
||||
.filter(|row| row.deleted_at.is_none())
|
||||
.map(|row| build_custom_world_profile_snapshot(&row))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.published_at_micros
|
||||
.unwrap_or(right.updated_at_micros)
|
||||
.cmp(&left.published_at_micros.unwrap_or(left.updated_at_micros))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
fn build_custom_world_agent_session_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
row: &CustomWorldAgentSession,
|
||||
@@ -5125,6 +5148,29 @@ fn build_custom_world_gallery_entry_snapshot(
|
||||
build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts)
|
||||
}
|
||||
|
||||
pub(crate) fn custom_world_public_gallery_snapshots(
|
||||
ctx: &AnonymousViewContext,
|
||||
) -> Vec<CustomWorldGalleryEntrySnapshot> {
|
||||
let mut entries = ctx
|
||||
.db
|
||||
.custom_world_gallery_entry()
|
||||
.by_custom_world_gallery_owner_user_id()
|
||||
.filter(""..)
|
||||
.map(|row| {
|
||||
build_custom_world_gallery_entry_snapshot_with_recent_counts(&row, &HashMap::new())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.published_at_micros
|
||||
.cmp(&left.published_at_micros)
|
||||
.then_with(|| right.updated_at_micros.cmp(&left.updated_at_micros))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
fn build_custom_world_gallery_entry_snapshot_with_recent_counts(
|
||||
row: &CustomWorldGalleryEntry,
|
||||
recent_play_counts: &HashMap<String, u32>,
|
||||
@@ -5173,6 +5219,10 @@ fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
|
||||
format!("CW-{normalized_digits}")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_public_work_code(profile_id: &str) -> String {
|
||||
build_public_work_code_from_profile_id(profile_id)
|
||||
}
|
||||
|
||||
fn build_public_user_code_from_owner_user_id(owner_user_id: &str) -> String {
|
||||
owner_user_id
|
||||
.trim_start_matches("user_")
|
||||
|
||||
@@ -34,6 +34,7 @@ mod gameplay;
|
||||
mod jump_hop;
|
||||
mod match3d;
|
||||
mod migration;
|
||||
mod public_work;
|
||||
mod puzzle;
|
||||
mod runtime;
|
||||
mod square_hole;
|
||||
@@ -52,6 +53,7 @@ pub use gameplay::*;
|
||||
pub use jump_hop::*;
|
||||
pub use match3d::*;
|
||||
pub use migration::*;
|
||||
pub use public_work::*;
|
||||
pub use runtime::*;
|
||||
pub use square_hole::*;
|
||||
pub use visual_novel::*;
|
||||
|
||||
788
server-rs/crates/spacetime-module/src/public_work.rs
Normal file
788
server-rs/crates/spacetime-module/src/public_work.rs
Normal file
@@ -0,0 +1,788 @@
|
||||
use crate::puzzle::{PuzzleGalleryCardViewRow, puzzle_gallery_card_view, puzzle_gallery_view};
|
||||
use crate::*;
|
||||
use module_custom_world::{CustomWorldGalleryEntrySnapshot, CustomWorldProfileSnapshot};
|
||||
use module_puzzle::PuzzleWorkProfile;
|
||||
use spacetimedb::AnonymousViewContext;
|
||||
|
||||
/// 跨玩法公开作品列表卡片读模型。
|
||||
///
|
||||
/// 该 view 只收口平台公开列表所需字段;玩法专属 runtime 配置仍留在各玩法详情 /
|
||||
/// runtime procedure 中读取。
|
||||
#[spacetimedb::view(accessor = public_work_gallery_entry, public)]
|
||||
pub fn public_work_gallery_entry(ctx: &AnonymousViewContext) -> Vec<PublicWorkGalleryEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
entries.extend(
|
||||
puzzle_gallery_card_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_puzzle_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
custom_world_public_gallery_snapshots(ctx)
|
||||
.into_iter()
|
||||
.map(map_custom_world_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
jump_hop_gallery_card_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_jump_hop_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
wooden_fish_gallery_card_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_wooden_fish_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
match3d_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_match3d_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
square_hole_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_square_hole_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
visual_novel_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_visual_novel_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
big_fish_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_big_fish_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
bark_battle_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_bark_battle_gallery_entry),
|
||||
);
|
||||
|
||||
sort_public_work_gallery_entries(&mut entries);
|
||||
entries
|
||||
}
|
||||
|
||||
/// 跨玩法公开作品详情摘要读模型。
|
||||
///
|
||||
/// `detail_payload_json` 只承载平台详情页展示扩展字段,不承载正式 runtime 配置。
|
||||
#[spacetimedb::view(accessor = public_work_detail_entry, public)]
|
||||
pub fn public_work_detail_entry(ctx: &AnonymousViewContext) -> Vec<PublicWorkDetailEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
entries.extend(
|
||||
puzzle_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_puzzle_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
custom_world_public_profile_snapshots(ctx)
|
||||
.into_iter()
|
||||
.map(map_custom_world_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
jump_hop_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_jump_hop_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
wooden_fish_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_wooden_fish_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
match3d_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_match3d_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
square_hole_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_square_hole_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
visual_novel_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_visual_novel_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
big_fish_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_big_fish_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
bark_battle_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_bark_battle_detail_entry),
|
||||
);
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.sort_time_micros
|
||||
.cmp(&left.sort_time_micros)
|
||||
.then_with(|| left.source_type.cmp(&right.source_type))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct PublicWorkGalleryEntry {
|
||||
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 published_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
pub sort_time_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct PublicWorkDetailEntry {
|
||||
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 published_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
pub sort_time_micros: i64,
|
||||
pub detail_payload_json: String,
|
||||
}
|
||||
|
||||
fn sort_public_work_gallery_entries(entries: &mut [PublicWorkGalleryEntry]) {
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.sort_time_micros
|
||||
.cmp(&left.sort_time_micros)
|
||||
.then_with(|| left.source_type.cmp(&right.source_type))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
}
|
||||
|
||||
fn gallery_to_detail(
|
||||
entry: PublicWorkGalleryEntry,
|
||||
detail_payload_json: String,
|
||||
) -> PublicWorkDetailEntry {
|
||||
PublicWorkDetailEntry {
|
||||
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: entry.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,
|
||||
published_at_micros: entry.published_at_micros,
|
||||
updated_at_micros: entry.updated_at_micros,
|
||||
sort_time_micros: entry.sort_time_micros,
|
||||
detail_payload_json,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_gallery_entry(row: PuzzleGalleryCardViewRow) -> PublicWorkGalleryEntry {
|
||||
let world_name = choose_non_empty(&[row.work_title.as_str(), row.level_name.as_str()]);
|
||||
let summary_text = choose_non_empty(&[row.work_description.as_str(), row.summary.as_str()]);
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "puzzle".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: row.source_session_id,
|
||||
public_work_code: build_prefixed_public_work_code("PZ", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name,
|
||||
subtitle: "拼图关卡".to_string(),
|
||||
summary_text,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: row.cover_asset_id,
|
||||
theme_tags: row.theme_tags,
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_detail_entry(row: PuzzleWorkProfile) -> PublicWorkDetailEntry {
|
||||
let entry = map_puzzle_gallery_entry(PuzzleGalleryCardViewRow {
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
source_session_id: row.source_session_id,
|
||||
author_display_name: row.author_display_name,
|
||||
work_title: row.work_title,
|
||||
work_description: row.work_description,
|
||||
level_name: row.level_name,
|
||||
summary: row.summary,
|
||||
theme_tags: row.theme_tags,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: row.cover_asset_id,
|
||||
publication_status: row.publication_status,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
published_at_micros: row.published_at_micros,
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
point_incentive_total_half_points: row.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: row.point_incentive_claimed_points,
|
||||
publish_ready: row.publish_ready,
|
||||
generation_status: None,
|
||||
});
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "puzzle",
|
||||
"levelCount": row.levels.len(),
|
||||
"coverSlides": row.levels.iter().filter_map(|level| {
|
||||
level.cover_image_src.as_ref().map(|image_src| json!({
|
||||
"id": level.level_id,
|
||||
"imageSrc": image_src,
|
||||
"label": level.level_name,
|
||||
}))
|
||||
}).collect::<Vec<_>>(),
|
||||
}));
|
||||
gallery_to_detail(entry, detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_custom_world_gallery_entry(row: CustomWorldGalleryEntrySnapshot) -> PublicWorkGalleryEntry {
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "custom-world".to_string(),
|
||||
work_id: format!("custom-world-work-{}", row.profile_id),
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: None,
|
||||
public_work_code: row.public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.world_name,
|
||||
subtitle: row.subtitle,
|
||||
summary_text: row.summary_text,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: vec![format_custom_world_theme_mode(row.theme_mode).to_string()],
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
published_at_micros: Some(row.published_at_micros),
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: row.published_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_custom_world_detail_entry(row: CustomWorldProfileSnapshot) -> PublicWorkDetailEntry {
|
||||
let public_work_code = row
|
||||
.public_work_code
|
||||
.clone()
|
||||
.unwrap_or_else(|| custom_world::build_custom_world_public_work_code(&row.profile_id));
|
||||
let published_at_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
let entry = PublicWorkGalleryEntry {
|
||||
source_type: "custom-world".to_string(),
|
||||
work_id: format!("custom-world-work-{}", row.profile_id),
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: row.source_agent_session_id.clone(),
|
||||
public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.world_name,
|
||||
subtitle: row.subtitle,
|
||||
summary_text: row.summary_text,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: vec![format_custom_world_theme_mode(row.theme_mode).to_string()],
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
published_at_micros: Some(published_at_micros),
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: published_at_micros,
|
||||
};
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "custom-world",
|
||||
"authorPublicUserCode": row.author_public_user_code,
|
||||
"sourceAgentSessionId": row.source_agent_session_id,
|
||||
"themeMode": format_custom_world_theme_mode(row.theme_mode),
|
||||
"playableNpcCount": row.playable_npc_count,
|
||||
"landmarkCount": row.landmark_count,
|
||||
}));
|
||||
gallery_to_detail(entry, detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalleryEntry {
|
||||
let subtitle = jump_hop_difficulty_label(&row.difficulty).to_string();
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "jump-hop".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: None,
|
||||
public_work_code: row.public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle,
|
||||
summary_text: row.work_description,
|
||||
cover_image_src: empty_string_to_option(row.cover_image_src),
|
||||
cover_asset_id: None,
|
||||
theme_tags: fallback_tags(row.theme_tags, &["跳一跳"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let entry = PublicWorkGalleryEntry {
|
||||
source_type: "jump-hop".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: empty_string_to_option(row.source_session_id.clone()),
|
||||
public_work_code: build_prefixed_public_work_code("JH", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle: jump_hop_difficulty_label(&row.difficulty).to_string(),
|
||||
summary_text: row.work_description,
|
||||
cover_image_src: empty_string_to_option(row.cover_image_src),
|
||||
cover_asset_id: None,
|
||||
theme_tags: fallback_tags(row.theme_tags, &["跳一跳"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: row.published_at_micros.unwrap_or(row.updated_at_micros),
|
||||
};
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "jump-hop",
|
||||
"difficulty": row.difficulty,
|
||||
"stylePreset": row.style_preset,
|
||||
"tileAssetCount": row.tile_assets.len(),
|
||||
"platformCount": row.path.platforms.len(),
|
||||
"generationStatus": row.generation_status,
|
||||
}));
|
||||
gallery_to_detail(entry, detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_wooden_fish_gallery_entry(row: WoodenFishGalleryCardViewRow) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "wooden-fish".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: None,
|
||||
public_work_code: row.public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle: "敲木鱼".to_string(),
|
||||
summary_text: row.work_description,
|
||||
cover_image_src: empty_string_to_option(row.cover_image_src),
|
||||
cover_asset_id: None,
|
||||
theme_tags: fallback_tags(row.theme_tags, &["敲木鱼"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_wooden_fish_detail_entry(row: WoodenFishGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let entry = PublicWorkGalleryEntry {
|
||||
source_type: "wooden-fish".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: empty_string_to_option(row.source_session_id),
|
||||
public_work_code: row.public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle: "敲木鱼".to_string(),
|
||||
summary_text: row.work_description,
|
||||
cover_image_src: empty_string_to_option(row.cover_image_src),
|
||||
cover_asset_id: None,
|
||||
theme_tags: fallback_tags(row.theme_tags, &["敲木鱼"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: row.published_at_micros.unwrap_or(row.updated_at_micros),
|
||||
};
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "wooden-fish",
|
||||
"hitObjectPrompt": row.hit_object_prompt,
|
||||
"floatingWords": row.floating_words,
|
||||
"generationStatus": row.generation_status,
|
||||
"hasBackgroundAsset": row.background_asset.is_some(),
|
||||
"hasHitSoundAsset": row.hit_sound_asset.is_some(),
|
||||
}));
|
||||
gallery_to_detail(entry, detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_match3d_gallery_entry(row: Match3DGalleryViewRow) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "match3d".to_string(),
|
||||
work_id: row.profile_id.clone(),
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: empty_string_to_option(row.source_session_id),
|
||||
public_work_code: build_prefixed_public_work_code("M3", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.game_name,
|
||||
subtitle: "经典消除玩法".to_string(),
|
||||
summary_text: row.summary_text,
|
||||
cover_image_src: empty_string_to_option(row.cover_image_src),
|
||||
cover_asset_id: empty_string_to_option(row.cover_asset_id),
|
||||
theme_tags: fallback_tags(row.tags, &[row.theme_text.as_str(), "抓大鹅"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_detail_entry(row: Match3DGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "match3d",
|
||||
"themeText": row.theme_text,
|
||||
"referenceImageSrc": row.reference_image_src,
|
||||
"clearCount": row.clear_count,
|
||||
"difficulty": row.difficulty,
|
||||
"generatedItemAssetsReady": row.generated_item_assets_json.as_ref().is_some_and(|value| !value.trim().is_empty()),
|
||||
}));
|
||||
gallery_to_detail(map_match3d_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_square_hole_gallery_entry(row: SquareHoleGalleryViewRow) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "square-hole".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: empty_string_to_option(row.source_session_id),
|
||||
public_work_code: build_prefixed_public_work_code("SH", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.game_name,
|
||||
subtitle: choose_non_empty(&[row.twist_rule.as_str(), "反直觉形状分拣"]),
|
||||
summary_text: row.summary_text,
|
||||
cover_image_src: empty_string_to_option(row.cover_image_src),
|
||||
cover_asset_id: None,
|
||||
theme_tags: fallback_tags(row.tags, &[row.theme_text.as_str(), "方洞挑战"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_square_hole_detail_entry(row: SquareHoleGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "square-hole",
|
||||
"themeText": row.theme_text,
|
||||
"twistRule": row.twist_rule,
|
||||
"backgroundPrompt": row.background_prompt,
|
||||
"backgroundImageSrc": empty_string_to_option(row.background_image_src.clone()),
|
||||
"shapeCount": row.shape_count,
|
||||
"difficulty": row.difficulty,
|
||||
"shapeOptionCount": row.shape_options.len(),
|
||||
"holeOptionCount": row.hole_options.len(),
|
||||
}));
|
||||
gallery_to_detail(map_square_hole_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_visual_novel_gallery_entry(row: VisualNovelGalleryViewRow) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "visual-novel".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: row.source_session_id,
|
||||
public_work_code: build_prefixed_public_work_code("VN", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle: "视觉小说模板".to_string(),
|
||||
summary_text: row.work_description,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: fallback_tags(row.tags, &["视觉小说"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_visual_novel_detail_entry(row: VisualNovelGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "visual-novel",
|
||||
"sourceAssetIds": row.source_asset_ids,
|
||||
"createdAtMicros": row.created_at_micros,
|
||||
}));
|
||||
gallery_to_detail(map_visual_novel_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_big_fish_gallery_entry(row: BigFishWorkSummarySnapshot) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "big-fish".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.source_session_id.clone(),
|
||||
source_session_id: Some(row.source_session_id.clone()),
|
||||
public_work_code: build_prefixed_public_work_code("BF", &row.source_session_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: "玩家".to_string(),
|
||||
world_name: row.title,
|
||||
subtitle: choose_non_empty(&[row.subtitle.as_str(), "大鱼吃小鱼"]),
|
||||
summary_text: row.summary,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: vec!["大鱼".to_string(), format!("{}级", row.level_count)],
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_big_fish_detail_entry(row: BigFishWorkSummarySnapshot) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "big-fish",
|
||||
"status": row.status,
|
||||
"publishReady": row.publish_ready,
|
||||
"levelCount": row.level_count,
|
||||
"levelMainImageReadyCount": row.level_main_image_ready_count,
|
||||
"levelMotionReadyCount": row.level_motion_ready_count,
|
||||
"backgroundReady": row.background_ready,
|
||||
}));
|
||||
gallery_to_detail(map_big_fish_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_bark_battle_gallery_entry(row: BarkBattleGalleryViewRow) -> PublicWorkGalleryEntry {
|
||||
let cover_image_src = row
|
||||
.ui_background_image_src
|
||||
.clone()
|
||||
.or_else(|| row.player_character_image_src.clone())
|
||||
.or_else(|| row.opponent_character_image_src.clone())
|
||||
.or_else(|| Some("/creation-type-references/bark-battle.webp".to_string()));
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "bark-battle".to_string(),
|
||||
work_id: row.work_id.clone(),
|
||||
profile_id: row.work_id.clone(),
|
||||
source_session_id: row.source_draft_id,
|
||||
public_work_code: build_bark_battle_public_work_code(&row.work_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: "玩家".to_string(),
|
||||
world_name: choose_non_empty(&[row.title.as_str(), "汪汪声浪大作战"]),
|
||||
subtitle: format!(
|
||||
"汪汪声浪 · {}",
|
||||
bark_battle_difficulty_label(&row.difficulty_preset)
|
||||
),
|
||||
summary_text: choose_non_empty(&[
|
||||
row.description.as_str(),
|
||||
row.theme_description.as_str(),
|
||||
"用声音能量挑战对手。",
|
||||
]),
|
||||
cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: vec![
|
||||
"汪汪声浪".to_string(),
|
||||
bark_battle_difficulty_label(&row.difficulty_preset).to_string(),
|
||||
],
|
||||
play_count: saturating_u64_to_u32(row.play_count),
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: Some(row.published_at_micros),
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: row.published_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_bark_battle_detail_entry(row: BarkBattleGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "bark-battle",
|
||||
"difficultyPreset": row.difficulty_preset,
|
||||
"themeDescription": row.theme_description,
|
||||
"playerImageDescription": row.player_image_description,
|
||||
"opponentImageDescription": row.opponent_image_description,
|
||||
"onomatopoeia": row.onomatopoeia,
|
||||
"playerCharacterImageSrc": row.player_character_image_src,
|
||||
"opponentCharacterImageSrc": row.opponent_character_image_src,
|
||||
"uiBackgroundImageSrc": row.ui_background_image_src,
|
||||
"finishCount": row.finish_count,
|
||||
}));
|
||||
gallery_to_detail(map_bark_battle_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn build_prefixed_public_work_code(prefix: &str, value: &str) -> String {
|
||||
let normalized = normalize_public_code_text(value);
|
||||
let fallback = if normalized.is_empty() {
|
||||
"00000000".to_string()
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
let suffix = last_eight_padded(&fallback);
|
||||
|
||||
format!("{prefix}-{suffix}")
|
||||
}
|
||||
|
||||
fn build_bark_battle_public_work_code(work_id: &str) -> String {
|
||||
let normalized = normalize_public_code_text(work_id);
|
||||
let without_prefix = normalized
|
||||
.strip_prefix("BB")
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| normalized.clone());
|
||||
let fallback = if without_prefix.is_empty() {
|
||||
if normalized.is_empty() {
|
||||
"00000000".to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
} else {
|
||||
without_prefix
|
||||
};
|
||||
|
||||
format!("BB-{}", last_eight_padded(&fallback))
|
||||
}
|
||||
|
||||
fn normalize_public_code_text(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_uppercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn last_eight_padded(value: &str) -> String {
|
||||
let suffix = value
|
||||
.chars()
|
||||
.rev()
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
format!("{suffix:0>8}")
|
||||
}
|
||||
|
||||
fn choose_non_empty(values: &[&str]) -> String {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.trim())
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn empty_string_to_option(value: String) -> Option<String> {
|
||||
let value = value.trim().to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
fn fallback_tags(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
|
||||
let normalized = values
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if normalized.is_empty() {
|
||||
fallback
|
||||
.iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_hop_difficulty_label(value: &str) -> &'static str {
|
||||
match value.trim() {
|
||||
"easy" => "轻松节奏",
|
||||
"advanced" => "进阶跳台",
|
||||
"challenge" => "极限路线",
|
||||
_ => "标准路线",
|
||||
}
|
||||
}
|
||||
|
||||
fn bark_battle_difficulty_label(value: &str) -> &'static str {
|
||||
match value.trim() {
|
||||
"easy" => "轻松",
|
||||
"hard" => "高能",
|
||||
_ => "普通",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_custom_world_theme_mode(value: CustomWorldThemeMode) -> &'static str {
|
||||
match value {
|
||||
CustomWorldThemeMode::Martial => "martial",
|
||||
CustomWorldThemeMode::Arcane => "arcane",
|
||||
CustomWorldThemeMode::Machina => "machina",
|
||||
CustomWorldThemeMode::Tide => "tide",
|
||||
CustomWorldThemeMode::Rift => "rift",
|
||||
CustomWorldThemeMode::Mythic => "mythic",
|
||||
}
|
||||
}
|
||||
|
||||
fn saturating_u64_to_u32(value: u64) -> u32 {
|
||||
value.min(u64::from(u32::MAX)) as u32
|
||||
}
|
||||
|
||||
fn json_string(value: JsonValue) -> String {
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
@@ -2081,51 +2081,54 @@ fn advance_puzzle_next_level_tx(
|
||||
let same_work_next_profile =
|
||||
selected_profile_level_after_runtime_level(¤t_profile, current_level)
|
||||
.map(|level| profile_for_single_level(¤t_profile, &level));
|
||||
let candidates = if same_work_next_profile.is_none() {
|
||||
let should_select_similar_work = input.prefer_similar_work || same_work_next_profile.is_none();
|
||||
let candidates = if should_select_similar_work {
|
||||
list_published_puzzle_profiles(ctx)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let similar_work_next_profile = if same_work_next_profile.is_none() {
|
||||
let similar_work_next_profile = if should_select_similar_work {
|
||||
let selected_candidates = select_next_profiles(
|
||||
¤t_profile,
|
||||
¤t_run.played_profile_ids,
|
||||
&candidates,
|
||||
3,
|
||||
);
|
||||
Some(
|
||||
if let Some(target_profile_id) = input.target_profile_id.as_ref().and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}) {
|
||||
if let Some(target_profile_id) = input.target_profile_id.as_ref().and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}) {
|
||||
Some(
|
||||
selected_candidates
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.profile_id == target_profile_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "目标拼图作品不在当前下一关候选中".to_string())?
|
||||
} else {
|
||||
selected_candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.cloned()
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?
|
||||
},
|
||||
)
|
||||
.ok_or_else(|| "目标拼图作品不在当前下一关候选中".to_string())?,
|
||||
)
|
||||
} else {
|
||||
selected_candidates.into_iter().next().cloned()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next_profile = same_work_next_profile
|
||||
let similar_work_profiles = similar_work_next_profile
|
||||
.as_ref()
|
||||
.or(similar_work_next_profile.as_ref())
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let next_profile = module_puzzle::select_runtime_next_profile(
|
||||
same_work_next_profile.as_ref(),
|
||||
&similar_work_profiles,
|
||||
input.prefer_similar_work,
|
||||
)
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?;
|
||||
let mut next_run = if same_work_next_profile.is_some() {
|
||||
module_puzzle::advance_next_level_at(
|
||||
let mut next_run = if similar_work_next_profile.is_some() {
|
||||
module_puzzle::advance_to_new_work_first_level_at(
|
||||
¤t_run,
|
||||
next_profile,
|
||||
micros_to_millis(input.advanced_at_micros),
|
||||
)
|
||||
} else {
|
||||
module_puzzle::advance_to_new_work_first_level_at(
|
||||
module_puzzle::advance_next_level_at(
|
||||
¤t_run,
|
||||
next_profile,
|
||||
micros_to_millis(input.advanced_at_micros),
|
||||
|
||||
@@ -695,7 +695,10 @@ function isRecommendRuntimeReadyForEntry(
|
||||
return Boolean(state.match3dRun);
|
||||
}
|
||||
if (expectedKind === 'puzzle') {
|
||||
return Boolean(state.puzzleRun);
|
||||
return (
|
||||
state.puzzleRun?.entryProfileId === entry.profileId ||
|
||||
state.puzzleRun?.currentLevel?.profileId === entry.profileId
|
||||
);
|
||||
}
|
||||
if (expectedKind === 'square-hole') {
|
||||
return Boolean(state.squareHoleRun);
|
||||
@@ -3139,6 +3142,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
useState<PuzzleRuntimeAuthMode>('default');
|
||||
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
|
||||
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
|
||||
const puzzleStartInFlightKeyRef = useRef<string | null>(null);
|
||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||
const puzzleRunRef = useRef<PuzzleRunSnapshot | null>(null);
|
||||
const errorSetterRefNoop = useMemo(
|
||||
@@ -9019,10 +9023,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
levelId?: string | null,
|
||||
options: { embedded?: boolean; authMode?: PuzzleRuntimeAuthMode } = {},
|
||||
) => {
|
||||
if (isPuzzleBusy) {
|
||||
const normalizedLevelId = levelId?.trim() ?? '';
|
||||
const startKey = `${profileId}:${normalizedLevelId}`;
|
||||
if (isPuzzleBusy || puzzleStartInFlightKeyRef.current === startKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
puzzleStartInFlightKeyRef.current = startKey;
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
|
||||
@@ -9031,7 +9038,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
|
||||
const startRunPayload = {
|
||||
profileId: item.profileId,
|
||||
levelId: levelId ?? null,
|
||||
levelId: normalizedLevelId || null,
|
||||
};
|
||||
const canUseRuntimeGuestAuth =
|
||||
options.embedded || options.authMode === 'isolated';
|
||||
@@ -9086,6 +9093,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (puzzleStartInFlightKeyRef.current === startKey) {
|
||||
puzzleStartInFlightKeyRef.current = null;
|
||||
}
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
@@ -9857,7 +9867,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
const submitKey = `${puzzleRun.runId}:${currentLevel.profileId}:${currentLevel.gridSize}:${currentLevel.elapsedMs}`;
|
||||
const submitKey = [
|
||||
puzzleRun.runId,
|
||||
currentLevel.profileId,
|
||||
currentLevel.levelId ?? currentLevel.levelIndex,
|
||||
currentLevel.gridSize,
|
||||
currentLevel.elapsedMs,
|
||||
].join(':');
|
||||
if (submittedPuzzleLeaderboardKeysRef.current.has(submitKey)) {
|
||||
return;
|
||||
}
|
||||
@@ -9895,7 +9911,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
void platformBootstrap.refreshSaveArchives();
|
||||
})
|
||||
.catch((error) => {
|
||||
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'),
|
||||
);
|
||||
@@ -9942,6 +9957,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
? await buildRecommendRuntimeGuestOptions()
|
||||
: {};
|
||||
const targetProfileId = _target?.profileId?.trim() ?? '';
|
||||
const preferSimilarWork =
|
||||
activeRecommendRuntimeKind === 'puzzle' &&
|
||||
puzzleRuntimeReturnStage === 'platform' &&
|
||||
puzzleRun.nextLevelMode === 'sameWork';
|
||||
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
|
||||
const itemPromise =
|
||||
selectedPuzzleDetail?.profileId === targetProfileId
|
||||
@@ -9981,10 +10000,35 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleRuntimeAuthMode === 'isolated'
|
||||
? await advancePuzzleNextLevel(
|
||||
puzzleRun.runId,
|
||||
{},
|
||||
preferSimilarWork ? { preferSimilarWork: true } : {},
|
||||
runtimeGuestOptions,
|
||||
)
|
||||
: await advancePuzzleNextLevel(puzzleRun.runId);
|
||||
: await advancePuzzleNextLevel(
|
||||
puzzleRun.runId,
|
||||
preferSimilarWork ? { preferSimilarWork: true } : {},
|
||||
);
|
||||
const nextProfileId = run.currentLevel?.profileId?.trim() ?? '';
|
||||
if (
|
||||
nextProfileId &&
|
||||
selectedPuzzleDetail?.profileId !== nextProfileId
|
||||
) {
|
||||
const item = await getPuzzleGalleryDetail(nextProfileId).then(
|
||||
(response) => response.item,
|
||||
);
|
||||
const nextRecommendEntry =
|
||||
mapPuzzleWorkToPlatformGalleryCard(item);
|
||||
setPuzzleGalleryEntries((current) => {
|
||||
const nextEntries = current.filter(
|
||||
(entry) => entry.profileId !== item.profileId,
|
||||
);
|
||||
nextEntries.push(item);
|
||||
return nextEntries;
|
||||
});
|
||||
setSelectedPuzzleDetail(item);
|
||||
setActiveRecommendEntryKey(
|
||||
getPlatformPublicGalleryEntryKey(nextRecommendEntry),
|
||||
);
|
||||
}
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||
@@ -9996,8 +10040,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
isPuzzleBusy,
|
||||
isPuzzleLeaderboardBusy,
|
||||
activeRecommendRuntimeKind,
|
||||
puzzleRun,
|
||||
puzzleRuntimeReturnStage,
|
||||
puzzleRuntimeAuthMode,
|
||||
setActiveRecommendEntryKey,
|
||||
setPuzzleGalleryEntries,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
setIsPuzzleBusy,
|
||||
@@ -12394,6 +12442,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
recommendRuntimeStartRequestRef.current = startRequestId;
|
||||
const isCurrentStartRequest = () =>
|
||||
recommendRuntimeStartRequestRef.current === startRequestId;
|
||||
setActiveRecommendEntryKey(entryKey);
|
||||
setActiveRecommendRuntimeKind(runtimeKind);
|
||||
setActiveRecommendRuntimeError(null);
|
||||
setIsStartingRecommendEntry(true);
|
||||
if (entryKey !== activeRecommendEntryKey) {
|
||||
await saveAndExitRecommendPuzzleRuntime();
|
||||
if (!isCurrentStartRequest()) {
|
||||
@@ -12546,14 +12598,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
const selectAdjacentRecommendRuntimeEntry = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
(direction: 1 | -1, baseEntryKey?: string | null) => {
|
||||
if (recommendRuntimeEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedBaseEntryKey =
|
||||
baseEntryKey?.trim() || activeRecommendEntryKey;
|
||||
const activeIndex = recommendRuntimeEntries.findIndex(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
|
||||
getPlatformPublicGalleryEntryKey(entry) === normalizedBaseEntryKey,
|
||||
);
|
||||
const baseIndex = activeIndex >= 0 ? activeIndex : 0;
|
||||
const nextIndex =
|
||||
@@ -12564,7 +12618,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
if (
|
||||
getPlatformPublicGalleryEntryKey(nextEntry) === activeRecommendEntryKey
|
||||
getPlatformPublicGalleryEntryKey(nextEntry) === normalizedBaseEntryKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -14325,18 +14379,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
isStartingRecommendEntry={
|
||||
isStartingRecommendEntry ||
|
||||
isBigFishBusy ||
|
||||
isPuzzleBusy ||
|
||||
(isPuzzleBusy &&
|
||||
!(activeRecommendRuntimeKind === 'puzzle' && puzzleRun)) ||
|
||||
isMatch3DBusy ||
|
||||
isSquareHoleBusy ||
|
||||
isVisualNovelBusy ||
|
||||
isWoodenFishBusy
|
||||
}
|
||||
recommendRuntimeError={activeRecommendRuntimeError}
|
||||
onSelectNextRecommendEntry={() =>
|
||||
selectAdjacentRecommendRuntimeEntry(1)
|
||||
onSelectNextRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(1, activeEntryKey)
|
||||
}
|
||||
onSelectPreviousRecommendEntry={() =>
|
||||
selectAdjacentRecommendRuntimeEntry(-1)
|
||||
onSelectPreviousRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(-1, activeEntryKey)
|
||||
}
|
||||
onLikeRecommendEntry={(entry) => {
|
||||
likePublicWork(entry);
|
||||
|
||||
@@ -882,6 +882,7 @@ test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关
|
||||
expect(nextSprite).toBeTruthy();
|
||||
expect(nextSprite?.style.backgroundSize).toBe('320% 480%');
|
||||
expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%');
|
||||
expect(screen.getByRole('button', { name: '下一关' }).textContent).toBe('');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
@@ -971,6 +972,11 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
nextLevelProfileId: 'profile-1',
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
@@ -986,7 +992,9 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
const nextButton = screen.getByRole('button', { name: /下一关/u });
|
||||
@@ -1002,6 +1010,53 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('推荐页关闭通关弹窗后保留底部下一关入口且不叠加下一关素材图', async () => {
|
||||
vi.useFakeTimers();
|
||||
const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: 'profile-1',
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithoutRecommendedNextProfile}
|
||||
embedded
|
||||
hideBackButton
|
||||
hideExitControls
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
});
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
const nextButton = screen.getByRole('button', { name: /下一关/u });
|
||||
expect(nextButton).toBeTruthy();
|
||||
expect(
|
||||
nextButton.querySelector('[data-puzzle-ui-sprite="next"]'),
|
||||
).toBeTruthy();
|
||||
expect(nextButton.textContent?.trim()).toBe('');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('当前作品没有下一关时展示三个相似作品并可选择进入', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
|
||||
@@ -1933,6 +1933,7 @@ export function PuzzleRuntimeShell({
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
aria-label={hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||||
onClick={() => {
|
||||
if (hasSimilarWorkChoices) {
|
||||
setDismissedClearKey(null);
|
||||
@@ -1944,9 +1945,8 @@ export function PuzzleRuntimeShell({
|
||||
levelId: run.nextLevelId ?? null,
|
||||
});
|
||||
}}
|
||||
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
|
||||
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center justify-center rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
|
||||
>
|
||||
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="next"
|
||||
|
||||
@@ -6245,6 +6245,260 @@ test('home recommendation keeps logged-in puzzle start on default auth instead o
|
||||
);
|
||||
});
|
||||
|
||||
test('logged out home recommendation next starts the next puzzle work', async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstWork = {
|
||||
workId: 'puzzle-work-public-next-1',
|
||||
profileId: 'puzzle-profile-public-next-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-public-next-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '家常菜',
|
||||
summary: '酱猪蹄不是酱肘子。',
|
||||
themeTags: ['家常菜', '拼图'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
publishedAt: '2026-04-25T10:00:00.000Z',
|
||||
playCount: 47,
|
||||
likeCount: 1,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const secondWork = {
|
||||
...firstWork,
|
||||
workId: 'puzzle-work-public-next-2',
|
||||
profileId: 'puzzle-profile-public-next-2',
|
||||
ownerUserId: 'user-3',
|
||||
sourceSessionId: 'puzzle-session-public-next-2',
|
||||
authorDisplayName: '贝壳作者',
|
||||
levelName: '贝壳',
|
||||
summary: '第二个公开拼图。',
|
||||
themeTags: ['贝壳', '拼图'],
|
||||
playCount: 1,
|
||||
likeCount: 0,
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [firstWork, secondWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === secondWork.profileId ? secondWork : firstWork,
|
||||
}));
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const recommendNavButton = document.querySelector<HTMLButtonElement>(
|
||||
'.platform-bottom-nav [aria-label="推荐"]',
|
||||
);
|
||||
expect(recommendNavButton).toBeTruthy();
|
||||
await user.click(recommendNavButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: firstWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '下一个' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: secondWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation puzzle next level switches to similar work detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const entryWork = {
|
||||
workId: 'puzzle-work-public-guest-1',
|
||||
profileId: 'puzzle-profile-public-guest-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-public-guest-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '旋转碎片并接通星桥机关。',
|
||||
themeTags: ['机关', '星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫塔',
|
||||
pictureDescription: '首关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
{
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '同作品第二关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const similarWork = {
|
||||
...entryWork,
|
||||
workId: 'puzzle-work-similar-guest-1',
|
||||
profileId: 'puzzle-profile-similar-guest-1',
|
||||
levelName: '风塔试炼',
|
||||
summary: '另一套奇幻机关拼图。',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'similar-level-1',
|
||||
levelName: '风塔试炼',
|
||||
pictureDescription: '相似作品首关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const clearedRun = buildClearedPuzzleRun({
|
||||
runId: 'run-puzzle-profile-public-guest-1',
|
||||
entryProfileId: entryWork.profileId,
|
||||
profileId: entryWork.profileId,
|
||||
levelName: entryWork.levelName,
|
||||
levelIndex: 1,
|
||||
elapsedMs: 18_000,
|
||||
});
|
||||
const clearedRunWithSameWorkNext: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: entryWork.profileId,
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: entryWork.profileId,
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
};
|
||||
const startedRun = buildMockPuzzleRun(entryWork.profileId, entryWork.levelName);
|
||||
const similarRun = {
|
||||
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName),
|
||||
runId: clearedRun.runId,
|
||||
entryProfileId: entryWork.profileId,
|
||||
currentLevelIndex: 2,
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName)
|
||||
.currentLevel!,
|
||||
runId: clearedRun.runId,
|
||||
levelIndex: 2,
|
||||
levelId: 'similar-level-1',
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [entryWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === similarWork.profileId ? similarWork : entryWork,
|
||||
}));
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: {
|
||||
...startedRun,
|
||||
currentLevel: {
|
||||
...startedRun.currentLevel!,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: clearedRunWithSameWorkNext,
|
||||
});
|
||||
let resolveAdvancePuzzleNextLevel!: (value: { run: PuzzleRunSnapshot }) => void;
|
||||
vi.mocked(advancePuzzleNextLevel).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAdvancePuzzleNextLevel = resolve;
|
||||
}),
|
||||
);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedRun);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: entryWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
|
||||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
||||
|
||||
const dialog = await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: '通关完成' },
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||||
clearedRun.runId,
|
||||
{ preferSimilarWork: true },
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
|
||||
resolveAdvancePuzzleNextLevel({ run: similarRun });
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(similarWork.profileId);
|
||||
});
|
||||
expect(
|
||||
await screen.findByLabelText('风塔试炼 作品信息', undefined, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText('风塔试炼').length).toBeGreaterThan(0);
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
|
||||
const match3dCard: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-card-1',
|
||||
@@ -7321,6 +7575,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelId: null,
|
||||
},
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
vi.mocked(listProfileSaveArchives).mockClear();
|
||||
vi.mocked(listProfileSaveArchives).mockRejectedValueOnce(
|
||||
|
||||
@@ -195,8 +195,8 @@ export interface RpgEntryHomeViewProps {
|
||||
activeRecommendEntryKey?: string | null;
|
||||
isStartingRecommendEntry?: boolean;
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectNextRecommendEntry?: () => void;
|
||||
onSelectPreviousRecommendEntry?: () => void;
|
||||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
@@ -5056,6 +5056,9 @@ export function RpgEntryHomeView({
|
||||
const [recommendShareState, setRecommendShareState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const activeRecommendEntryKeyForSelection = activeRecommendEntry
|
||||
? buildPublicGalleryCardKey(activeRecommendEntry)
|
||||
: null;
|
||||
const recommendShareResetTimerRef = useRef<number | null>(null);
|
||||
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const recommendDragStartRef = useRef<{
|
||||
@@ -5078,15 +5081,16 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
window.setTimeout(() => {
|
||||
if (direction === 1) {
|
||||
onSelectNextRecommendEntry?.();
|
||||
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||
} else {
|
||||
onSelectPreviousRecommendEntry?.();
|
||||
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||
}
|
||||
setRecommendDragOffsetY(0);
|
||||
setRecommendDragCommitDirection(null);
|
||||
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
|
||||
},
|
||||
[
|
||||
activeRecommendEntryKeyForSelection,
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
recommendDragCommitDirection,
|
||||
@@ -5186,9 +5190,10 @@ export function RpgEntryHomeView({
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectNextRecommendEntry?.();
|
||||
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||
}, [
|
||||
activeRecommendEntry,
|
||||
activeRecommendEntryKeyForSelection,
|
||||
commitRecommendDrag,
|
||||
isAuthenticated,
|
||||
onSelectNextRecommendEntry,
|
||||
|
||||
@@ -30,6 +30,9 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
const PUZZLE_RUNTIME_LEADERBOARD_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 0,
|
||||
};
|
||||
type PuzzleRuntimeRequestOptions = RuntimeGuestRequestOptions;
|
||||
|
||||
/**
|
||||
@@ -125,16 +128,22 @@ export async function advancePuzzleNextLevel(
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const targetProfileId = payload.targetProfileId?.trim() ?? '';
|
||||
const preferSimilarWork = payload.preferSimilarWork === true;
|
||||
const requestPayload = {
|
||||
...(targetProfileId ? { targetProfileId } : {}),
|
||||
...(preferSimilarWork ? { preferSimilarWork: true } : {}),
|
||||
};
|
||||
const hasRequestPayload = Object.keys(requestPayload).length > 0;
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
|
||||
{
|
||||
method: 'POST',
|
||||
...(targetProfileId
|
||||
...(hasRequestPayload
|
||||
? {
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ targetProfileId }),
|
||||
body: JSON.stringify(requestPayload),
|
||||
}
|
||||
: {
|
||||
headers: buildRuntimeGuestHeaders(options),
|
||||
@@ -156,20 +165,20 @@ export async function submitPuzzleLeaderboard(
|
||||
payload: SubmitPuzzleLeaderboardRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交拼图排行榜失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
|
||||
import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient';
|
||||
import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient';
|
||||
import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient';
|
||||
import { startPuzzleRun } from './puzzle-runtime/puzzleRuntimeClient';
|
||||
import {
|
||||
advancePuzzleNextLevel,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
} from './puzzle-runtime/puzzleRuntimeClient';
|
||||
import { startSquareHoleRun } from './square-hole-runtime/squareHoleRuntimeClient';
|
||||
import { startVisualNovelRun } from './visual-novel-runtime/visualNovelRuntimeClient';
|
||||
|
||||
@@ -87,6 +91,21 @@ describe('recommended runtime guest launch clients', () => {
|
||||
),
|
||||
expectedUrl: '/api/runtime/puzzle/runs',
|
||||
},
|
||||
{
|
||||
name: 'puzzle leaderboard',
|
||||
start: () =>
|
||||
submitPuzzleLeaderboard(
|
||||
'run-puzzle-1',
|
||||
{
|
||||
profileId: 'puzzle-profile-1',
|
||||
gridSize: 3,
|
||||
elapsedMs: 18_000,
|
||||
nickname: '玩家',
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/leaderboard',
|
||||
},
|
||||
])(
|
||||
'$name start request uses the runtime guest bearer token without touching login auth',
|
||||
async ({ start, expectedUrl }) => {
|
||||
@@ -110,4 +129,63 @@ describe('recommended runtime guest launch clients', () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('puzzle next level can carry preferSimilarWork through the runtime guest request', async () => {
|
||||
await advancePuzzleNextLevel(
|
||||
'run-puzzle-1',
|
||||
{ preferSimilarWork: true },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
|
||||
expect(url).toBe('/api/runtime/puzzle/runs/run-puzzle-1/next-level');
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ preferSimilarWork: true }),
|
||||
}),
|
||||
);
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('puzzle leaderboard submission does not retry unsafe writes', async () => {
|
||||
await submitPuzzleLeaderboard(
|
||||
'run-puzzle-1',
|
||||
{
|
||||
profileId: 'puzzle-profile-1',
|
||||
gridSize: 3,
|
||||
elapsedMs: 18_000,
|
||||
nickname: '玩家',
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
|
||||
expect(url).toBe('/api/runtime/puzzle/runs/run-puzzle-1/leaderboard');
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user