diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 75b0bcff..840846b4 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -48,6 +48,22 @@ - 验证方式:玩法接入 PRD 和实现验收必须列出作品架链路;若一个玩法具备发布或试玩能力,但缺少 `/api/creation//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//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 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。 diff --git a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md new file mode 100644 index 00000000..faa08e20 --- /dev/null +++ b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md @@ -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//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` diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4e0ded6e..90e69e6a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -131,7 +131,7 @@ npm run check:server-rs-ddd 3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。 4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option>` 加 `#[default(None::>)]`,业务层归一为空数组。 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` 做跨层 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` - 源码:`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` - 源码:`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` - 源码:`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` - 源码:`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` - 源码:`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` - 源码:`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` - 源码:`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` - 源码:`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` - 源码:`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` - 源码:`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 路径处理。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 2438ee9b..226211b9 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -108,6 +108,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。 - 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。 - 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。 +- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。 - 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 49f37d51..c6484f20 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -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'; diff --git a/packages/shared/src/contracts/publicWork.ts b/packages/shared/src/contracts/publicWork.ts new file mode 100644 index 00000000..71aae5d9 --- /dev/null +++ b/packages/shared/src/contracts/publicWork.ts @@ -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; +} diff --git a/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index 5e705782..11d91c27 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -136,6 +136,7 @@ export interface DragPuzzlePieceRequest { export interface AdvancePuzzleNextLevelRequest { targetProfileId?: string | null; + preferSimilarWork?: boolean; } export interface UsePuzzleRuntimePropRequest { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 0a4afeaf..92599040 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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( diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 8695904b..cb8d50d0 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -702,7 +702,7 @@ pub async fn list_custom_world_gallery( ) -> Result, 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(), }, )) diff --git a/server-rs/crates/api-server/src/custom_world/mappers.rs b/server-rs/crates/api-server/src/custom_world/mappers.rs index c89d2058..ee10422e 100644 --- a/server-rs/crates/api-server/src/custom_world/mappers.rs +++ b/server-rs/crates/api-server/src/custom_world/mappers.rs @@ -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 { diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index a422c80c..b8e793c1 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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; diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index d890a36f..9d643493 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -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; diff --git a/server-rs/crates/api-server/src/modules/public_work.rs b/server-rs/crates/api-server/src/modules/public_work.rs new file mode 100644 index 00000000..1a1ab0b8 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/public_work.rs @@ -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 { + 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) +} diff --git a/server-rs/crates/api-server/src/public_work.rs b/server-rs/crates/api-server/src/public_work.rs new file mode 100644 index 00000000..d46059fe --- /dev/null +++ b/server-rs/crates/api-server/src/public_work.rs @@ -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, + Extension(request_context): Extension, +) -> Result, 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::>(); + 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, + Path(public_work_code): Path, + Extension(request_context): Extension, +) -> Result, 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 +} diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 46834284..673d7ce6 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -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 diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index de8d2994..89ae4291 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -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, diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index a3cdfa8b..76cc5b42 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -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!["机关"]); diff --git a/server-rs/crates/module-puzzle/src/commands.rs b/server-rs/crates/module-puzzle/src/commands.rs index 66cf0b1a..c8960fab 100644 --- a/server-rs/crates/module-puzzle/src/commands.rs +++ b/server-rs/crates/module-puzzle/src/commands.rs @@ -228,6 +228,8 @@ pub struct PuzzleRunNextLevelInput { pub owner_user_id: String, #[serde(default)] pub target_profile_id: Option, + #[serde(default)] + pub prefer_similar_work: bool, pub advanced_at_micros: i64, } diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index ea60bfe9..2f13b4be 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -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; diff --git a/server-rs/crates/shared-contracts/src/public_work.rs b/server-rs/crates/shared-contracts/src/public_work.rs new file mode 100644 index 00000000..5a085699 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/public_work.rs @@ -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, + 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, + #[serde(default)] + pub cover_asset_id: Option, + pub theme_tags: Vec, + 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, + 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, + #[serde(default)] + pub has_more: bool, + #[serde(default)] + pub next_cursor: Option, + #[serde(default)] + pub total_count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PublicWorkDetailResponse { + pub item: PublicWorkDetailEntryResponse, +} diff --git a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs index c85e3e57..8c5796e0 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs @@ -29,6 +29,8 @@ pub struct DragPuzzlePieceRequest { pub struct AdvancePuzzleNextLevelRequest { #[serde(default)] pub target_profile_id: Option, + #[serde(default)] + pub prefer_similar_work: bool, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 5bd54ff4..1324e919 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -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, 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", diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index df2d43b4..c5218ecc 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/mapper/public_work.rs b/server-rs/crates/spacetime-client/src/mapper/public_work.rs new file mode 100644 index 00000000..ad42a395 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/public_work.rs @@ -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, + 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, + pub cover_asset_id: Option, + pub theme_tags: Vec, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, + pub published_at: Option, + 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, + } +} diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index 0b7d8ec6..024edcde 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -750,6 +750,7 @@ pub struct PuzzleRunNextLevelRecordInput { pub run_id: String, pub owner_user_id: String, pub target_profile_id: Option, + pub prefer_similar_work: bool, pub advanced_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 3aa8dc89..9488d36e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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, profile_task_reward_claim: __sdk::TableUpdate, profile_wallet_ledger: __sdk::TableUpdate, + public_work_detail_entry: __sdk::TableUpdate, + public_work_gallery_entry: __sdk::TableUpdate, public_work_like: __sdk::TableUpdate, public_work_play_daily_stat: __sdk::TableUpdate, puzzle_agent_message: __sdk::TableUpdate, @@ -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::( + "public_work_detail_entry", + &self.public_work_detail_entry, + ); + diff.public_work_gallery_entry = cache.apply_diff_to_table::( + "public_work_gallery_entry", + &self.public_work_gallery_entry, + ); diff.puzzle_gallery_card_view = cache.apply_diff_to_table::( "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::( + "public_work_detail_entry", + &self.public_work_detail_entry, + event, + ); + callbacks.invoke_table_row_callbacks::( + "public_work_gallery_entry", + &self.public_work_gallery_entry, + event, + ); callbacks.invoke_table_row_callbacks::( "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", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs index f26c77e4..5f459a79 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs @@ -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, 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, pub owner_user_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, pub source_draft_id: __sdk::__query_builder::Col>, pub config_version: __sdk::__query_builder::Col, pub ruleset_version: __sdk::__query_builder::Col, @@ -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"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_detail_entry_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_detail_entry_table.rs new file mode 100644 index 00000000..7d2e4408 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_detail_entry_table.rs @@ -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, + 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::("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 + '_ { + 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) { + let _table = + client_cache.get_or_make_table::("public_work_detail_entry"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "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; +} + +impl public_work_detail_entryQueryTableAccess for __sdk::QueryTableAccessor { + fn public_work_detail_entry(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("public_work_detail_entry") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_detail_entry_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_detail_entry_type.rs new file mode 100644 index 00000000..d738ee0d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_detail_entry_type.rs @@ -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, + 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, + pub cover_asset_id: Option, + pub theme_tags: Vec, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub published_at_micros: Option, + 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, + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col>, + pub public_work_code: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub world_name: __sdk::__query_builder::Col, + pub subtitle: __sdk::__query_builder::Col, + pub summary_text: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col>, + pub cover_asset_id: __sdk::__query_builder::Col>, + pub theme_tags: __sdk::__query_builder::Col>, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, + pub updated_at_micros: __sdk::__query_builder::Col, + pub sort_time_micros: __sdk::__query_builder::Col, + pub detail_payload_json: __sdk::__query_builder::Col, +} + +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", + ), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_gallery_entry_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_gallery_entry_table.rs new file mode 100644 index 00000000..c8e11905 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_gallery_entry_table.rs @@ -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, + 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::("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 + '_ { + 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) { + let _table = + client_cache.get_or_make_table::("public_work_gallery_entry"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "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; +} + +impl public_work_gallery_entryQueryTableAccess for __sdk::QueryTableAccessor { + fn public_work_gallery_entry(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("public_work_gallery_entry") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_gallery_entry_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_gallery_entry_type.rs new file mode 100644 index 00000000..33c8d2b7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_gallery_entry_type.rs @@ -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, + 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, + pub cover_asset_id: Option, + pub theme_tags: Vec, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub published_at_micros: Option, + 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, + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col>, + pub public_work_code: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub world_name: __sdk::__query_builder::Col, + pub subtitle: __sdk::__query_builder::Col, + pub summary_text: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col>, + pub cover_asset_id: __sdk::__query_builder::Col>, + pub theme_tags: __sdk::__query_builder::Col>, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, + pub updated_at_micros: __sdk::__query_builder::Col, + pub sort_time_micros: __sdk::__query_builder::Col, +} + +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"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_next_level_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_next_level_input_type.rs index 7feb5b93..0dd5721c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_next_level_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_next_level_input_type.rs @@ -10,6 +10,7 @@ pub struct PuzzleRunNextLevelInput { pub run_id: String, pub owner_user_id: String, pub target_profile_id: Option, + pub prefer_similar_work: bool, pub advanced_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/public_work.rs b/server-rs/crates/spacetime-client/src/public_work.rs new file mode 100644 index 00000000..2d8a0d23 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/public_work.rs @@ -0,0 +1,171 @@ +use super::*; +use crate::mapper::*; + +impl SpacetimeClient { + pub async fn list_public_work_gallery_entries( + &self, + ) -> Result, 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::>(); + 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, 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::>(); + 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 { + 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 { + 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 +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index f6ddd839..0854b502 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -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, }; diff --git a/server-rs/crates/spacetime-module/src/bark_battle.rs b/server-rs/crates/spacetime-module/src/bark_battle.rs index da981776..96763abd 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle.rs @@ -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()] + ); } } diff --git a/server-rs/crates/spacetime-module/src/bark_battle/types.rs b/server-rs/crates/spacetime-module/src/bark_battle/types.rs index 380d9b1f..4dfda5b9 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/types.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle/types.rs @@ -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, pub config_version: u64, pub ruleset_version: String, diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index a3249fa3..06133e32 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -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 { + 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::>(); + + 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 { + 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::>(); + + 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, @@ -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_") diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index adaeb114..2955c517 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -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::*; diff --git a/server-rs/crates/spacetime-module/src/public_work.rs b/server-rs/crates/spacetime-module/src/public_work.rs new file mode 100644 index 00000000..5f2ed6f2 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/public_work.rs @@ -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 { + 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 { + 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, + 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, + pub cover_asset_id: Option, + pub theme_tags: Vec, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub published_at_micros: Option, + 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, + 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, + pub cover_asset_id: Option, + pub theme_tags: Vec, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub published_at_micros: Option, + 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::>(), + })); + 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::>() + .into_iter() + .rev() + .collect::(); + 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 { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn fallback_tags(values: Vec, fallback: &[&str]) -> Vec { + let normalized = values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect::>(); + 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()) +} diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a22ed976..c124a297 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -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::>(); + 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), diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 8bd30231..01a81b32 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -705,7 +705,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); @@ -3149,6 +3152,7 @@ export function PlatformEntryFlowShellImpl({ useState('default'); const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false); const submittedPuzzleLeaderboardKeysRef = useRef(new Set()); + const puzzleStartInFlightKeyRef = useRef(null); const [puzzleRun, setPuzzleRun] = useState(null); const puzzleRunRef = useRef(null); const errorSetterRefNoop = useMemo( @@ -9105,10 +9109,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); @@ -9117,7 +9124,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'; @@ -9171,6 +9178,9 @@ export function PlatformEntryFlowShellImpl({ } return false; } finally { + if (puzzleStartInFlightKeyRef.current === startKey) { + puzzleStartInFlightKeyRef.current = null; + } setIsPuzzleBusy(false); } }, @@ -9949,7 +9959,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; } @@ -9987,7 +10003,6 @@ export function PlatformEntryFlowShellImpl({ void platformBootstrap.refreshSaveArchives(); }) .catch((error) => { - submittedPuzzleLeaderboardKeysRef.current.delete(submitKey); setPuzzleError( resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'), ); @@ -10034,6 +10049,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 @@ -10073,10 +10092,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, '准备下一关失败。')); @@ -10088,8 +10132,12 @@ export function PlatformEntryFlowShellImpl({ [ isPuzzleBusy, isPuzzleLeaderboardBusy, + activeRecommendRuntimeKind, puzzleRun, + puzzleRuntimeReturnStage, puzzleRuntimeAuthMode, + setActiveRecommendEntryKey, + setPuzzleGalleryEntries, resolvePuzzleErrorMessage, selectedPuzzleDetail, setIsPuzzleBusy, @@ -12489,6 +12537,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()) { @@ -12641,14 +12693,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 = @@ -12659,7 +12713,7 @@ export function PlatformEntryFlowShellImpl({ return; } if ( - getPlatformPublicGalleryEntryKey(nextEntry) === activeRecommendEntryKey + getPlatformPublicGalleryEntryKey(nextEntry) === normalizedBaseEntryKey ) { return; } @@ -14437,18 +14491,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); diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx index fda6527a..e2604ace 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx @@ -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( + , + ); + + 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(); diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index a2cd504e..92f395de 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -1933,6 +1933,7 @@ export function PuzzleRuntimeShell({