实现游戏共创 M1:作品级共创授权与改编血缘
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- game_distribution_game 表尾追加 fork_authorization(默认 forbidden,只升不降,旧行自动取默认)
- 新增 game_distribution_lineage 表,记录父作品、来源版本、根作品与代际,并按 parent/root/owner 建索引
- migration.rs 登记新表并注明血缘随迁移导出
- 新增 set_game_distribution_fork_authorization_and_return procedure 与幂等收据、期望值 CAS
- 创建作品时校验改编声明:来源存在、已公开、授权开放、来源版本等于当前公开版本;复用既有身份时拒绝携带血缘
- 公开与后台响应增量:forkAuthorization、forkCount、lineage、generation、forkedFromGameId、derivedCount
- 公开快照的作者名与头像改为读时联 user_account,不再落到「创作者」兜底文案
- api-server 新增 PUT /api/game-distribution/games/{game_id}/fork-authorization(Bearer、幂等键、期望值校验)
- 错误映射新增 FORK_ 前缀分支并置于通用子串分支之前,避免被误判成 409 或 404
- module-game-distribution 增加授权阶梯、代际计算与错误码,并补单测覆盖阶梯与错误码前缀
- 网页端详情页新增共创信息卡,我的作品页新增授权提升入口,补服务层调用与定向测试
- 重新生成 SpacetimeDB 绑定,登记 DTO 一致性脚本的共创类型
This commit is contained in:
2026-10-04 19:08:42 +08:00
parent 87b306daa2
commit 018f9d58e9
34 changed files with 2122 additions and 47 deletions
@@ -3,7 +3,7 @@
| 字段 | 值 |
| ----------- | ----------------------------------------------------- |
| Version | 1.0 |
| Status | implemented-local(本地实现完成;运行时端到端与浏览器验证待补) |
| Status | implemented-local(本地实现完成;已 rebase 到含 #565 的新 master 并收敛软删除语义;运行时端到端与浏览器验证待补) |
| Date | 2026-10-03 |
| Parent Spec | `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` |
@@ -37,21 +37,25 @@
## 验收标准
- [ ] 新建作品默认授权为拍板结果;迁移前已存在的作品在升级后同样按该默认解释,无需人工回填。
- [ ] 同一次授权变更里,合法提升(含跳级)成功、任何降级被拒绝且不写库。
- [ ] 非作品作者不能变更授权;基于旧值的并发请求按期望值冲突拒绝。
- [ ] 重复提交同一授权变更请求不产生第二次副作用,如实回报为幂等重放。
- [ ] 声明血缘时:来源作品不存在、未公开、已被封禁、授权为禁止、来源版本不等于来源作品当前公开版本,五类情形全部失败关闭且给出可区分的原因。
- [ ] 代际与根作品由服务端计算:三层链路(A→B→C)得到 B 为第 1 代、C 为第 2 代,且 B、C 的根作品都是 A。
- [ ] 血缘不可变:同一作品第二次声明血缘被拒绝;既有作品复用身份的场景不接受血缘声明。
- [ ] 来源作品下架或封禁后,既有衍生作品保持公开,新的血缘声明被拒绝。
- [ ] 详情页展示的授权、代际、溯源与衍生数量与后端一致;来源作品不可读时该节点降级展示而非空白。
- [ ] 公开详情与广场展示真实作者名与头像(读时联账号表),兜底文案不再出现在可正常读取账号的场景;现役公开 DTO 未暴露陶泥号,如需展示须另加字段。
- [ ] 公开响应不泄露对象键、未公开作品或未公开来源信息。
- [ ] 发布开关收紧时,共创授权写入返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`;开关状态读取失败按关闭处理。(M1 复用现役 `game-distribution:publish` 开关,不新增独立灰度键。)
- [x] 新建作品默认授权为拍板结果;迁移前已存在的作品在升级后同样按该默认解释,无需人工回填。
- [x] 同一次授权变更里,合法提升(含跳级)成功、任何降级被拒绝且不写库。
- [x] 非作品作者不能变更授权;基于旧值的并发请求按期望值冲突拒绝。
- [x] 重复提交同一授权变更请求不产生第二次副作用,如实回报为幂等重放。
- [x] 声明血缘时:来源作品不存在、未公开、已被封禁、**已被软删除**(#565 的 `deleted_at`)、授权为禁止、来源版本不等于来源作品当前公开版本,各类情形全部失败关闭且给出可区分的原因(已删除与未公开统一返回 `FORK_SOURCE_NOT_AVAILABLE`,不用错误码区分删除事实)。
- [x] 代际与根作品由服务端计算:三层链路(A→B→C)得到 B 为第 1 代、C 为第 2 代,且 B、C 的根作品都是 A。
- [x] 血缘不可变:同一作品第二次声明血缘被拒绝;既有作品复用身份的场景不接受血缘声明。
- [x] 来源作品下架、封禁或**软删除**后,既有衍生作品保持公开,新的血缘声明被拒绝;衍生数量只统计未软删除且已公开的子作品。
- [x] 详情页展示的授权、代际、溯源与衍生数量与后端一致;来源作品不可读(含已软删除)时该节点降级为「原作品已不可用」,不空白、不回显被删作品标题。
- [ ] 公开详情与广场展示真实作者名与头像(读时联账号表),兜底文案不再出现在可正常读取账号的场景;现役公开 DTO 未暴露陶泥号,如需展示须另加字段。(实现已落地:`useGameAuthorDisplayName` 按作者 ID 补查昵称,公开 DTO 未加陶泥号字段;暂无专用断言,故不勾选。)
- [x] 公开响应不泄露对象键、未公开作品或未公开来源信息(含:父/根作品被软删除后不再输出其标题与作者名)。
- [ ] 发布开关收紧时,共创授权写入返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`;开关状态读取失败按关闭处理。(M1 复用现役 `game-distribution:publish` 开关,不新增独立灰度键。)(路由已接 `ensure_publish_enabled`,含读开关失败按关闭;暂无 503 断言用例,故不勾选。)
## 证据要求
- 自动化:领域规则单测(授权状态机、代际与根计算、血缘校验)、发行模块与 BFF 定向测试、共享 DTO 一致性检查、生成绑定与 schema 检查、编码与文档索引检查。
- 运行时:真实 api-server + 本地数据库上完成「设置授权 → 提升 → 声明血缘 → 详情页展示 → 父作品下架后新声明被拒」整链 smoke;浏览器在桌面与窄屏检查详情页与我的作品页。
- 边界:未登录、非作者、期望值不匹配、重复请求、来源作品五种不可用情形、父作品下架前后对比。
- 边界:未登录、非作者、期望值不匹配、重复请求、来源作品六种不可用情形(不存在 / 未公开 / 已被封禁 / 已被软删除 / 授权禁止 / 来源版本不匹配)、父作品下架与软删除前后对比。
## 实施记录
- 2026-10-04:rebase 到含 #565(作品管理与 Phaser4 客户端发布)的新 master,9 个冲突文件按「两侧业务逻辑都保留」解完;详情页按 #565 的新结构改用共享 `GameDetailDisplay`,共创卡改由 `infoCards` 槽位渲染;并按 #565 的软删除语义收敛三处(来源校验 / 衍生计数 / 溯源摘要)。冲突解法与具体落点见技术方案 §3.9.1–§3.9.2,门禁与验收证据见 §5.1。
@@ -486,9 +486,21 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。
- 软删除:游戏行末尾追加可空 `deleted_at`(2026-10-01)。非空表示作者已删除该作品:`delete_game_distribution_game_and_return` 只写该时间戳并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料一律不改写。软删行不进入作者列表(`list_owner_game_distribution_games_and_return`)、公开目录(`list_public_game_distribution_games_and_return`)、公开详情(`get_public_game_distribution_game_and_return`)、发行网关素材授权(`game_distribution_asset_has_public_read_grant`)与审核队列;后台默认视图同样排除,只有显式 `status=deleted` 才会读到。作者侧版本回读对软删作品返回空(404),因此上传、确认与送审入口一并关闭。
- 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。
- 共创授权:游戏行末尾追加 `fork_authorization: String` 并设置 `#[default("forbidden")]`,取值 `forbidden` / `nonCommercial` / `full`(2026-10-04)。它表达作者对「这部作品能否被改编」的策略,与血缘正交:只能单向提升(`forbidden → nonCommercial → full`),降级与未知档位失败关闭;旧行按 `forbidden` 解释,旧客户端缺字段同样按禁止共创兜底。写入只在创建游戏(`create_game_distribution_game_and_return` 的 `GameDistributionCreateGameInput`)与提升 procedure `set_game_distribution_fork_authorization_and_return`(输入 `GameDistributionSetForkAuthorizationInput { game_id, owner_user_id, fork_authorization, expected_fork_authorization, idempotency_key, request_digest, now_micros }`)发生:只允许 owner 本人,`expected_fork_authorization` 不符返回 409,同 key 重放走 `game_distribution_idempotency_receipt`(action = `set_fork_authorization`)。`api-server` 把实现值映射为 HTTP 码(`FORK_AUTHORIZATION_UNKNOWN` 400、降级 409、非本人 403、未登录 401),路由 `PUT /api/game-distribution/games/{gameId}/fork-authorization` 受发布灰度开关约束(收紧时 503,读接口不受影响)。
- 共创授权与软删除的交叉口径:已软删除的作品(`deleted_at` 非空)不再是可用的改编来源——`resolve_game_distribution_fork_declaration_tx` 先判 `deleted_at`,命中即按 `FORK_SOURCE_NOT_AVAILABLE` 失败(HTTP 409,与「未公开」同一错误码,不用错误码区分删除事实);既有子作品与血缘行不受影响,父作品的删除不会连带下线子作品。衍生计数(`game_distribution_public_fork_count`)只统计 `deleted_at` 为空且 `visibility = published` 的子作品,作者删除子作品后父作品的「已被改编 N 次」随之下降。
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published`、`deleted_at` 为空且存在有效 `active_version_id` 的投影。
- 游玩计数写入:`play_count` 只由批量 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `GameDistributionPlayCountIncrementInput { increments: Vec<{ gameId, delta }> }`)累加。`api-server` 在内存里按 `identity + gameId` 做 30 分钟去重、按 `IP + gameId` 做固定窗口限流后,按 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5 秒)批量落库;事务内只对 `published` 且存在有效 `active_version_id` 的游戏 `saturating_add`,非公开静默跳过,且**不更新** `updated_at`。公开 HTTP 入口为 `POST /api/game-distribution/games/{gameId}/plays`,完整行为见玩法链路的「游玩计数(已实现)」。
### `game_distribution_lineage`
- Rust 结构体:`GameDistributionLineage`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-04 新增。
- 用途:作品之间的改编(Fork)血缘。`game_id` 为主键(子作品),因此「一个作品最多有一个父」由主键本身保证;`owner_user_id`(子作品作者,冗余用于「我改编过的作品」查询,不参与授权判定)、`parent_game_id`、`parent_version_id`(建立血缘时父作品的当前公开版本,不可变事实)、`root_game_id`(0 代母版;根作品自身不写行)、`generation: u32`(直接改编母版为 1)、`created_at`。
- 写入时机:只在创建游戏时(`GameDistributionCreateGameInput` 携带 `forked_from_game_id` / `forked_from_version_id`,`api-server` 由创作入口的 `fork` 声明映射)与游戏行同事务写入,一次落定后不可变更;同一作品的第二次血缘声明失败关闭(`FORK_DECLARATION_ON_EXISTING_GAME` 409),`local_project_id` 复用既有身份的场景拒绝携带血缘。来源校验全部失败关闭:来源不存在 → `FORK_SOURCE_NOT_FOUND`(404),来源已软删除 / 未公开 / 无有效公开版本 → `FORK_SOURCE_NOT_AVAILABLE`(409),来源授权为禁止共创 → `FORK_NOT_AUTHORIZED`(403),来源版本不等于来源作品当前公开版本 → `FORK_SOURCE_VERSION_MISMATCH`(409)。
- 索引:`by_game_distribution_lineage_parent_game_id`(衍生计数与今后的衍生列表)、`by_game_distribution_lineage_root_game_id`、`by_game_distribution_lineage_owner_user_id`。
- 读取口径:公开详情/公开目录的投影增量是 `forkAuthorization`、`forkCount`(按 `parent_game_id` 实时统计未软删除且已公开的直接子作品,不维护物化计数)与 `lineage` 摘要快照(`generation` / `rootGameId` / `rootTitle` / `parentGameId` / `parentTitle` / `parentAuthorName`);母版或旧数据为 `null`/缺省。父作品下架或封禁只影响新的血缘声明,既有摘要照常返回。
- 溯源摘要与软删除:父(或根)作品被软删除时不再输出它的标题与父作者名——`game_distribution_lineage_snapshot` 把 `parent_title` / `root_title` 置为 `None`、`parent_author_name` 一并清空,`api-server` 原样发 `null`,前端降级为「原作品已不可用」;只保留不透明的 ID 与代际。理由:公开目录/详情既已下线被删作品,若还能从别人的溯源卡里读到它的标题与作者,删除语义就被血缘绕过。表结构同 `migration.rs` 的迁移导入导出白名单(血缘是业务事实,随迁移导入导出)。
- 迁移:新表初始为空,不需要回填;根作品的「0 代」由「无血缘行」表达。`migration_tables!` 已登记该表。
### `game_distribution_review`
- Rust 结构体:`GameDistributionReview`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供评价投影。
@@ -385,6 +385,35 @@ AGC 发布链路(`game_distribution_publish.rs`)读取 `manifest.forkedFrom`
合并顺序:**先合 #565 到 master,再把本分支 rebase 到新 master**,然后重跑 §5.1 的全部门禁与本地发布 smoke。
#### 3.9.1 rebase 落地结果(2026-10-04,已完成)
`git rebase origin/master`(新 master = `6c9566058`,含 #565 与 #615)后本分支重放两个提交,无 merge commit,未改动 master。
**冲突与解法(9 个文件)**
| 文件 | 冲突形态 | 解法 |
| --- | --- | --- |
| `server-rs/crates/module-game-distribution/src/lib.rs` | `domain` re-export 列表两侧各加名字 | 合并去重(`next_generation` + `resolve_version_number` 并存) |
| `server-rs/crates/spacetime-module/src/game_distribution.rs` | 游戏行表尾追加列(`deleted_at` / `fork_authorization`)、动作常量、`insert` 字段 | 三处都保留两侧;`GameDistributionLineage` 新表整体保留 |
| `server-rs/crates/api-server/src/modules/game_distribution.rs` | 导入列表、payload 辅助函数、错误映射、路由挂载测试 | 导入合并去重;`owner_game_entry_payload` 与 `lineage_payload` 并存;错误映射**两段都留**且 `FORK_*` 分支仍排在通用子串分支之前;测试里「未带 Bearer」用例两侧都保留 |
| `src/components/game-distribution/MyGamesPage.tsx` | 幂等键种子、状态钩子、行内动作 | 两侧都保留:状态筛选 / 资料编辑 / 版本历史 / 删除入口 + 共创授权行内提升入口(提升控件排在删除入口之前,避免危险动作夹在中间) |
| `src/components/game-distribution/MyGamesPage.test.tsx` | 导入符号、mock 重置、两组用例 | 两侧都保留,共创用例追加在文件末尾 |
| `src/components/game-distribution/GameDetailPage.tsx` | 详情页被 #565 改成共享 `GameDetailDisplay` 组件 | **以 #565 的新结构为准**,共创信息改由 `infoCards` 槽位渲染(见 3.9.2) |
| `src/components/game-distribution/gameDistribution.css` | 详情页旧布局规则 vs #565 新版 | 旧布局规则(`game-info-card` / `game-detail-body` 等)已由共享组件 `GameDetailDisplay/index.css` 接管,不再保留;只保留 `game-fork-*` 卡内样式并改挂 `.game-detail-info-card` 前缀 |
| `src/services/gameDistributionClient.ts` | 同一 `import type` 列表加名字 | 合并去重 |
| `module_bindings/game_distribution_game_type.rs` | 生成物 | 不手工合并,重跑生成 |
生成物:重跑 `npm run spacetime:generate -- --rust-only` 之后,rustfmt 版本差异会带来约 200 个不相干文件的格式漂移,按「只保留与本次 schema 相关的生成文件」处理——保留 `module_bindings/game_distribution*` 与 `module_bindings/module_bindings.rs`,其余 `git checkout --` 还原。
#### 3.9.2 详情页共创卡在新结构里的落点
#565 把详情页的 hero / 正文 / 资料侧栏收敛进共享组件 `packages/shared/src/components/GameDetailDisplay`(`.game-detail-info-card` 等类名,样式随组件自带)。因此:
- 公开页与作者页都继续用 `GameDetailDisplay`,**不再自己拼 hero / 正文**;
- 共创信息(授权徽章 + 代际 + 溯源 + 已被改编次数)通过它的 `infoCards` 槽位渲染,落在资料侧栏,等价于原设计里 `.game-info-card.game-fork-card` 的位置;
- 共享组件会把 `value` 再包一层内联 flex 的 `span`,卡内样式统一用 `.game-detail-info-card .game-fork-*` 前缀保证优先级;
- 父作品被软删除时后端 `parentTitle` 为 `null`,卡片降级为「第 N 代作品 · 原作品已不可用」,不猜测、不回显已删内容。
---
## 4. 为什么是 `game` 而不是 `version`
@@ -408,10 +437,11 @@ AGC 发布链路(`game_distribution_publish.rs`)读取 `manifest.forkedFrom`
| 授权默认 `forbidden`,旧作品迁移后一律禁止共创 | 迁移后读旧 game 的公开详情 | 待补 |
| 授权只能提升:`forbidden→nonCommercial→full` 合法,任何降级 `409` | 定向 API/领域测试 + 真实栈 smoke | 待补 |
| 非 owner 改授权 `403`;CAS 不匹配 `409`;同 key 重放 `replayed=true` | 定向测试 + 真实栈 | 待补 |
| 未授权 / 已下架 / 非当前公开版本的 fork 声明被拒(`403` / `409`) | 定向测试 | 待补 |
| 未授权 / 已下架 / 已软删除 / 非当前公开版本的 fork 声明被拒(`403` / `409`) | 定向测试 | 待补 |
| 血缘不可变:同一作品第二次声明血缘 `409`;`local_project_id` 复用场景拒绝携带血缘 | 定向测试 | 待补 |
| 代际与根计算正确:A→B→C 得到 generation 1/2、root 均为 A | 领域单测 + 真实栈三层链路 smoke | 待补 |
| 父作品下架后:既有子作品保持公开,新 fork 被拒 | 真实栈 smoke | 待补 |
| 父作品下架或软删除后:既有子作品保持公开,新 fork 被拒;子作品被软删除后不计入父作品衍生数 | 真实栈 smoke | 待补 |
| 溯源摘要不泄露被软删除作品的标题与作者名(降级为「原作品已不可用」) | 定向测试 + DTO parity | 待补 |
| 工程包门禁:含 `node_modules` / 符号链接 / 绝对路径 / 超限的包被拒且不产生任何对象 | 客户端 Rust 定向测试 + 服务端校验测试 | 待补 |
| 补齐:提升授权后可对当前公开版本补传工程包一次,补齐后改造升级为源码级;二次补传或换内容重传被拒;历史版本不被补齐 | 定向测试 + 真实栈 | 待补 |
| 一键改造闭环:详情页 → AGC → 下载 → 解压 → 建项 → 改造 → 发布 → 详情页出现溯源 | 真实 AGC + 真实 api-server smoke(含截图/录屏) | 待补 |
@@ -426,7 +456,7 @@ AGC 发布链路(`game_distribution_publish.rs`)读取 `manifest.forkedFrom`
| 项 | 证据 | 状态 |
| --- | --- | --- |
| schema 迁移(持久表加列 + 新表 + 新索引) | 本地 SpacetimeDB 2.8.3 上发布模块后,线上 schema 含 `fork_authorization` 列、`game_distribution_lineage` 表(`_game_id_key` + parent / root / owner 三个 btree 索引)与 `set_game_distribution_fork_authorization_and_return` procedure | ✅ 已验证 |
| schema 迁移(持久表加列 + 新表 + 新索引) | 本地 SpacetimeDB 2.8.3 上发布模块后,线上 schema 含 `fork_authorization` 列、`deleted_at` 列、`game_distribution_lineage` 表(`_game_id_key` + parent / root / owner 三个 btree 索引)与 `set_game_distribution_fork_authorization_and_return` procedure | ✅ 已验证 |
| 领域规则(授权阶梯、代际、错误码前缀) | `cargo test -p module-game-distribution` 全部通过 | ✅ 已验证 |
| BFF / 路由 / payload / 错误映射 | `cargo test -p api-server game_distribution` 全部通过(含新增的「共创授权路由未带 Bearer 必须 401」用例) | ✅ 已验证 |
| 前端类型与页面 | `npx tsc`(仓库 `npm run typecheck`)通过;`npx vitest run src/components/game-distribution src/services/gameDistributionClient.test.ts` 全部通过 | ✅ 已验证 |
@@ -436,6 +466,12 @@ AGC 发布链路(`game_distribution_publish.rs`)读取 `manifest.forkedFrom`
| HTTP 端到端(真实登录态下的提升 / 声明血缘 / 详情页字段) | 未跑:需要真实登录态与一个已发布作品;本地开发库当前为空 | ⏳ 待补 |
| 浏览器视觉与交互验证(详情页共创卡、我的作品授权入口、移动端布局) | 未跑:本地库无作品数据,登录态不可用,页面无法进入有效状态 | ⏳ 待补 |
| 权限与边界(非作者 403、降级 409、父作品下架后新声明被拒) | 领域层与 procedure 校验已实现并覆盖单测;真实栈边界用例待补 | ⏳ 待补 |
| **rebase 到含 #565 的新 master** | `git rebase origin/master`(新 master `6c9566058`)重放两个提交,9 个冲突文件按 §3.9.1 全部解决(两侧业务逻辑都保留),无 merge commit,未改动 master;提交信息沿用原中文提交 | ✅ 已验证 |
| **生成物重生成** | `npm run spacetime:generate -- --rust-only` 后保留 `module_bindings/game_distribution*` 与 `module_bindings/module_bindings.rs`,rustfmt 版本差异带来的其它文件格式漂移已 `git checkout --` 还原 | ✅ 已验证 |
| **软删除语义收敛 ①来源校验** | `resolve_game_distribution_fork_declaration_tx` 增加「来源 `deleted_at` 非空 → `FORK_SOURCE_NOT_AVAILABLE`(409)」,显式判定而不是只靠 `visibility` 复位 | ✅ 已验证(`cargo test -p api-server game_distribution`;领域错误码沿用 `FORK_*` 前缀) |
| **软删除语义收敛 ②衍生作品数** | `game_distribution_public_fork_count` 只统计 `deleted_at.is_none() && visibility == published` 的子作品;作者删除子作品后父作品计数随之下降 | ✅ 已验证(同上) |
| **软删除语义收敛 ③溯源摘要** | `game_distribution_lineage_snapshot` 在父(或根)作品被软删除时把 `parent_title` / `root_title` 置空、父作者名置空,api-server 原样发 `null`,TS 契约改为 `string \| null`,详情页降级为「原作品已不可用」 | ✅ 已验证(DTO parity + 前端用例;单测待补) |
| **详情页结构对齐 #565** | 详情页改用共享 `GameDetailDisplay`,共创卡通过 `infoCards` 渲染;vitest 用例「展示共创授权、代际与溯源信息,且不提供改造入口」与「旧数据缺少授权与血缘字段时只按禁止共创渲染」通过 | ✅ 已验证 |
---
@@ -111,6 +111,80 @@ export type GameDistributionVersionSummary = {
controls: string[];
};
/**
* 共创授权三态:只允许单向提升(forbidden → nonCommercial → full),不可降级。
*/
export type GameDistributionForkAuthorization =
| 'forbidden'
| 'nonCommercial'
| 'full';
/** 授权阶梯顺序;`canUpgradeGameForkAuthorization` 按这个顺序判定可提升档位。 */
export const GAME_DISTRIBUTION_FORK_AUTHORIZATIONS: readonly GameDistributionForkAuthorization[] =
['forbidden', 'nonCommercial', 'full'];
/** 旧数据、字段缺省或未知取值时的兜底档位:保守拒绝共创。 */
export const GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION: GameDistributionForkAuthorization =
'forbidden';
export const GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS: Record<
GameDistributionForkAuthorization,
string
> = {
forbidden: '禁止共创',
nonCommercial: '允许非商用共创',
full: '允许全开放共创',
};
/** 空间受限场景(卡片行内选择等)用的短标签。 */
export const GAME_DISTRIBUTION_FORK_AUTHORIZATION_SHORT_LABELS: Record<
GameDistributionForkAuthorization,
string
> = {
forbidden: '禁止',
nonCommercial: '非商用',
full: '全开放',
};
/** 把任意原始值收敛到合法档位;缺省与未知一律按 `forbidden` 兜底。 */
export function resolveGameForkAuthorization(
value: string | null | undefined,
): GameDistributionForkAuthorization {
return GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.includes(
value as GameDistributionForkAuthorization,
)
? (value as GameDistributionForkAuthorization)
: GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION;
}
/** 授权阶梯比较:只有严格高于当前档位的目标才允许提交。 */
export function canUpgradeGameForkAuthorization(
current: GameDistributionForkAuthorization,
target: GameDistributionForkAuthorization,
) {
return (
GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.indexOf(target) >
GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.indexOf(current)
);
}
/**
* 作品血缘摘要:代际、父作品与根作品。
* 根作品或旧数据为 null/缺省;父作品下架后仍按快照回传标题与作者名,用于溯源署名。
* 父/根作品被**软删除**(`game_distribution_game.deleted_at` 非空)时不再对外输出标题,
* `rootTitle` / `parentTitle` 为 null,`parentAuthorName` 一并缺省,展示层降级为
* 「原作品已不可用」;ID 与代际照常返回。
* `parentAuthorName` 在账号信息不可得时可能缺省或为 null,展示层按「无署名」处理。
*/
export type GameDistributionLineage = {
generation: number;
rootGameId: string;
rootTitle: string | null;
parentGameId: string;
parentTitle: string | null;
parentAuthorName?: string | null;
};
export type GameDistributionGame = {
id: string;
title: string;
@@ -141,6 +215,12 @@ export type GameDistributionGame = {
currentVersion?: GameDistributionVersionSummary | null;
/** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */
ratingSummary?: GameDistributionRatingSummary;
/** 共创授权三态;旧数据或字段缺省时读作 `forbidden`。 */
forkAuthorization?: GameDistributionForkAuthorization;
/** 被改编次数(直接子代数量);旧数据或字段缺省时读作 0。 */
forkCount?: number;
/** 血缘摘要;根作品或旧数据为 null/缺省。 */
lineage?: GameDistributionLineage | null;
playCount: number;
createdAt: string;
};
@@ -164,6 +244,8 @@ export type GameDistributionCreateGameRequest = {
deviceSupport: GameDistributionDeviceSupport;
inputModes: GameDistributionInputMode[];
orientation: GameDistributionOrientation;
/** 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 */
fork?: GameDistributionForkDeclaration | null;
};
export type GameDistributionCreateVersionRequest = {
@@ -276,3 +358,15 @@ export type GameDistributionCancelVersionResponse = {
version: GameDistributionPrivateVersion;
replayed: boolean;
};
/** 提升共创授权;`expectedForkAuthorization` 是 CAS 期望值,只允许升级。 */
export type GameDistributionSetForkAuthorizationRequest = {
expectedForkAuthorization: GameDistributionForkAuthorization;
forkAuthorization: GameDistributionForkAuthorization;
};
/** 创建游戏时的改编来源声明:父作品 + 建立血缘时锁定的父版本。 */
export type GameDistributionForkDeclaration = {
parentGameId: string;
parentVersionId: string;
};
+12 -1
View File
@@ -54,6 +54,17 @@ const PAIRS = [
'GameDistributionUpdateGameMetadataRequest',
],
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
// 共创(Fork)授权与血缘:档位枚举与公开血缘摘要必须与 TS 逐字对齐。
[
'GameDistributionForkAuthorization',
'GameDistributionForkAuthorization',
],
['GameDistributionLineage', 'GameDistributionLineage'],
['GameDistributionForkDeclaration', 'GameDistributionForkDeclaration'],
[
'GameDistributionSetForkAuthorizationRequest',
'GameDistributionSetForkAuthorizationRequest',
],
['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'],
['AdminGameReviewGame', 'AdminGameReviewGame'],
['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'],
@@ -96,7 +107,7 @@ const RESPONSE_BUILDERS = [
fn: 'public_game_payload',
ts: 'GameDistributionGame',
base: 'game_payload',
mustEmit: ['currentVersion', 'ratingSummary'],
mustEmit: ['currentVersion', 'ratingSummary', 'forkCount', 'lineage'],
},
{ fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' },
{ fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' },
@@ -39,12 +39,12 @@ use shared_contracts::admin::{
use shared_contracts::game_distribution::{
GAME_DISTRIBUTION_CATEGORIES, GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT,
GameDistributionAuthor, GameDistributionCreateGameRequest,
GameDistributionCreateVersionRequest, GameDistributionInputMode,
GameDistributionMyReviewResponse, GameDistributionOrientation,
GameDistributionCreateVersionRequest, GameDistributionForkAuthorization,
GameDistributionInputMode, GameDistributionMyReviewResponse, GameDistributionOrientation,
GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest,
GameDistributionRatingSummary, GameDistributionReview, GameDistributionReviewsResponse,
GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse,
GameDistributionUpdateGameMetadataRequest,
GameDistributionSetForkAuthorizationRequest, GameDistributionUpdateGameMetadataRequest,
};
use spacetime_client::{
GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord,
@@ -56,7 +56,8 @@ use spacetime_client::{
GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord,
GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput,
GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationOperationRecord,
GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput,
GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput,
GameDistributionSubmitReviewRecordInput,
GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput,
GameDistributionUpdateMetadataRecordInput, GameDistributionUserReviewRecord,
GameDistributionVersionRecord, SpacetimeClientError,
@@ -347,6 +348,10 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/game-distribution/games/{game_id}/unpublish",
post(unpublish_game),
)
.route(
"/api/game-distribution/games/{game_id}/fork-authorization",
put(set_fork_authorization),
)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
@@ -1228,7 +1233,9 @@ async fn load_owner_game_versions(
/// 把资料编辑请求映射成创建请求,以复用同一套资料校验与素材归属解析。
///
/// `localProjectId` 只属于创建语义,编辑资料不参与游戏身份复用,因此固定为空。
/// `localProjectId` 只属于创建语义,编辑资料不参与游戏身份复用,因此固定为空;
/// 改编来源同理——资料编辑不是建立血缘的入口(血缘在创建作品时一次性写入且不可变),
/// 所以 `fork` 也固定为 `None`。
fn game_metadata_update_as_create_request(
payload: &GameDistributionUpdateGameMetadataRequest,
) -> GameDistributionCreateGameRequest {
@@ -1244,6 +1251,7 @@ fn game_metadata_update_as_create_request(
device_support: payload.device_support.clone(),
input_modes: payload.input_modes.clone(),
orientation: payload.orientation,
fork: None,
}
}
@@ -1469,6 +1477,14 @@ async fn create_game(
request_digest,
now_micros: now,
local_project_id: normalize_local_project_id(payload.local_project_id.as_deref())?,
forked_from_game_id: payload
.fork
.as_ref()
.map(|fork| fork.parent_game_id.clone()),
forked_from_version_id: payload
.fork
.as_ref()
.map(|fork| fork.parent_version_id.clone()),
})
.await
.map_err(map_spacetime_error)?;
@@ -2302,6 +2318,49 @@ async fn unpublish_game(
))
}
/// 作者提升作品的共创授权档位:只升不降,降级与未知档位由领域层拒绝。
async fn set_fork_authorization(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Path(game_id): Path<String>,
Json(payload): Json<GameDistributionSetForkAuthorizationRequest>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = auth.claims().user_id().to_string();
ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?;
let idempotency_key = idempotency_key(&headers)?;
let request_digest = compute_request_digest(
&serde_json::to_vec(&(
game_id.as_str(),
payload.expected_fork_authorization,
payload.fork_authorization,
))
.map_err(|error| internal(error.to_string()))?,
);
let game = state
.spacetime_client()
.set_game_distribution_fork_authorization(
GameDistributionSetForkAuthorizationRecordInput {
game_id,
owner_user_id,
fork_authorization: fork_authorization_value(payload.fork_authorization),
expected_fork_authorization: fork_authorization_value(
payload.expected_fork_authorization,
),
idempotency_key,
request_digest,
now_micros: now_micros(),
},
)
.await
.map_err(map_spacetime_error)?;
Ok(json_success_body(
Some(&ctx),
json!({ "game": game_payload(&game.0), "replayed": game.1 }),
))
}
async fn admin_list_reviews(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
@@ -2899,6 +2958,14 @@ fn public_game_payload(game: GameDistributionPublicGameRecord) -> Value {
"ratingSummary".to_string(),
json!(rating_summary_payload(game.rating_summary)),
);
object.insert("forkCount".to_string(), json!(game.fork_count));
object.insert(
"lineage".to_string(),
game.lineage
.as_ref()
.map(lineage_payload)
.unwrap_or(Value::Null),
);
}
payload
}
@@ -2918,6 +2985,10 @@ fn admin_game_payload(game: &GameDistributionAdminGameRecord) -> Value {
"playCount": game.play_count,
"activeVersionId": game.active_version_id,
"publicationRevision": game.publication_revision,
"forkAuthorization": game.fork_authorization,
"generation": game.lineage_generation,
"forkedFromGameId": game.forked_from_game_id,
"derivedCount": game.derived_count,
"createdAt": game.created_at,
"updatedAt": game.updated_at,
"deletedAt": game.deleted_at,
@@ -2978,6 +3049,7 @@ fn game_payload(game: &GameDistributionGameRecord) -> Value {
"inputModes": input_modes,
"orientation": game.orientation,
"status": game.visibility,
"forkAuthorization": game.fork_authorization,
"publicationRevision": game.publication_revision,
"playCount": game.play_count,
"createdAt": game.created_at,
@@ -3015,6 +3087,19 @@ fn owner_game_entry_payload(entry: GameDistributionOwnerGameRecord) -> Value {
payload
}
/// 公开血缘摘要的响应形状。独立成函数是为了让公开投影保持「只用 object.insert 追加键」
/// 的形态,DTO 一致性检查据此逐键比对 TS 契约。
fn lineage_payload(lineage: &spacetime_client::GameDistributionLineageRecord) -> Value {
json!({
"generation": lineage.generation,
"rootGameId": lineage.root_game_id,
"rootTitle": lineage.root_title,
"parentGameId": lineage.parent_game_id,
"parentTitle": lineage.parent_title,
"parentAuthorName": lineage.parent_author_name,
})
}
fn version_summary_payload(version: &GameDistributionVersionRecord) -> Value {
json!({
"id": version.version_id,
@@ -3283,6 +3368,27 @@ fn map_package_error(error: ReleasePackageError) -> AppError {
.with_details(json!({ "provider": "game-distribution", "reason": format!("{error:?}") }))
}
/// DTO 档位 → 领域档位:字符串值只在一处定义(`module-game-distribution`)。
fn to_domain_fork_authorization(
value: GameDistributionForkAuthorization,
) -> module_game_distribution::ForkAuthorization {
match value {
GameDistributionForkAuthorization::Forbidden => {
module_game_distribution::ForkAuthorization::Forbidden
}
GameDistributionForkAuthorization::NonCommercial => {
module_game_distribution::ForkAuthorization::NonCommercial
}
GameDistributionForkAuthorization::Full => {
module_game_distribution::ForkAuthorization::Full
}
}
}
fn fork_authorization_value(value: GameDistributionForkAuthorization) -> String {
to_domain_fork_authorization(value).as_str().to_string()
}
fn map_spacetime_error(error: SpacetimeClientError) -> AppError {
match error {
SpacetimeClientError::Procedure(message)
@@ -3292,6 +3398,40 @@ fn map_spacetime_error(error: SpacetimeClientError) -> AppError {
.with_code("VERSION_NUMBER_CONFLICT")
.with_details(json!({ "provider": "game-distribution", "message": message }))
}
// 共创相关错误以稳定错误码开头。这一段必须先于下面的「不匹配 / 不存在 / 状态」
// 子串分支,否则血缘错误会被误映射成通用 409 或 404。
SpacetimeClientError::Procedure(message) if message.contains("FORK_") => {
let code = message
.split(':')
.next()
.map(str::trim)
.filter(|code| code.starts_with("FORK_"))
.unwrap_or("FORK_ERROR");
let (status, code) = match code {
"FORK_NOT_AUTHORIZED" => (StatusCode::FORBIDDEN, "FORK_NOT_AUTHORIZED"),
"FORK_SOURCE_NOT_FOUND" => (StatusCode::NOT_FOUND, "FORK_SOURCE_NOT_FOUND"),
"FORK_SOURCE_NOT_AVAILABLE" => {
(StatusCode::CONFLICT, "FORK_SOURCE_NOT_AVAILABLE")
}
"FORK_SOURCE_VERSION_MISMATCH" => {
(StatusCode::CONFLICT, "FORK_SOURCE_VERSION_MISMATCH")
}
"FORK_DECLARATION_ON_EXISTING_GAME" => {
(StatusCode::CONFLICT, "FORK_DECLARATION_ON_EXISTING_GAME")
}
"FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED" => (
StatusCode::CONFLICT,
"FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED",
),
"FORK_AUTHORIZATION_UNKNOWN" => {
(StatusCode::BAD_REQUEST, "FORK_AUTHORIZATION_UNKNOWN")
}
_ => (StatusCode::CONFLICT, "FORK_ERROR"),
};
AppError::from_status(status)
.with_code(code)
.with_details(json!({ "provider": "game-distribution", "message": message }))
}
SpacetimeClientError::Procedure(message) if message.contains("owner 不匹配") => {
AppError::from_status(StatusCode::FORBIDDEN)
.with_details(json!({ "provider": "game-distribution", "message": message }))
@@ -4010,6 +4150,7 @@ mod tests {
},
input_modes: vec![GameDistributionInputMode::Keyboard],
orientation: GameDistributionOrientation::Landscape,
fork: None,
}
}
@@ -4149,6 +4290,7 @@ mod tests {
screenshots_json: Some(
r#"[{"assetId":"asset_1","objectKey":"generated/shot-1.png"}]"#.to_string(),
),
fork_authorization: "forbidden".to_string(),
};
let payload = game_payload(&game);
assert_eq!(
@@ -4170,6 +4312,8 @@ mod tests {
average_score,
rating_count,
},
fork_count: 0,
lineage: None,
});
let summary: shared_contracts::game_distribution::GameDistributionGameSummary =
serde_json::from_value(payload).expect("公开游戏响应应可解析");
@@ -4210,6 +4354,7 @@ mod tests {
local_project_id: None,
cover_object_key: Some("generated/game-cover.png".to_string()),
screenshots_json: None,
fork_authorization: "forbidden".to_string(),
};
let mut version = GameDistributionVersionRecord {
version_id: "version_2".to_string(),
@@ -4276,6 +4421,7 @@ mod tests {
local_project_id: None,
cover_object_key: None,
screenshots_json: None,
fork_authorization: "forbidden".to_string(),
};
let version = GameDistributionVersionRecord {
version_id: "version_1".to_string(),
@@ -4491,6 +4637,7 @@ mod tests {
);
let unauthenticated_delete = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
@@ -4502,6 +4649,23 @@ mod tests {
.await
.expect("路由响应");
assert_eq!(unauthenticated_delete.status(), StatusCode::UNAUTHORIZED);
// 共创授权提升属于作者写入:未带 Bearer 必须在进入业务前被拒,且不能是 404/405,
// 否则说明路由没有挂进受保护区、被别的路径掩盖了。
let unauthenticated_fork = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/game-distribution/games/game_1/fork-authorization")
.header("content-type", "application/json")
.body(Body::from(
r#"{"expectedForkAuthorization":"forbidden","forkAuthorization":"nonCommercial"}"#,
))
.expect("请求"),
)
.await
.expect("路由响应");
assert_eq!(unauthenticated_fork.status(), StatusCode::UNAUTHORIZED);
}
#[test]
@@ -4944,6 +5108,10 @@ mod tests {
created_at: "2026-09-18T08:00:00Z".to_string(),
updated_at: "2026-09-20T10:00:00Z".to_string(),
deleted_at: Some("2026-09-21T10:00:00Z".to_string()),
fork_authorization: "nonCommercial".to_string(),
lineage_generation: 1,
forked_from_game_id: Some("game_parent".to_string()),
derived_count: 0,
versions: Vec::new(),
};
let payload = admin_game_payload(&record);
@@ -130,6 +130,169 @@ pub enum GameDistributionAction {
SuspendGame,
}
/// 作品级共创(Fork)授权档位:开放度只允许单向提升,不允许回退。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ForkAuthorization {
Forbidden,
NonCommercial,
Full,
}
pub const FORK_AUTHORIZATION_FORBIDDEN: &str = "forbidden";
pub const FORK_AUTHORIZATION_NON_COMMERCIAL: &str = "nonCommercial";
pub const FORK_AUTHORIZATION_FULL: &str = "full";
impl ForkAuthorization {
pub fn parse(value: &str) -> Option<Self> {
match value.trim() {
FORK_AUTHORIZATION_FORBIDDEN => Some(Self::Forbidden),
FORK_AUTHORIZATION_NON_COMMERCIAL => Some(Self::NonCommercial),
FORK_AUTHORIZATION_FULL => Some(Self::Full),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Forbidden => FORK_AUTHORIZATION_FORBIDDEN,
Self::NonCommercial => FORK_AUTHORIZATION_NON_COMMERCIAL,
Self::Full => FORK_AUTHORIZATION_FULL,
}
}
/// 开放度序:数值越大越开放。
pub fn rank(self) -> u8 {
match self {
Self::Forbidden => 0,
Self::NonCommercial => 1,
Self::Full => 2,
}
}
/// 是否允许他人基于该作品发起改编。
pub fn allows_fork(self) -> bool {
!matches!(self, Self::Forbidden)
}
/// 目标档位是否合法:只能提升,同级视为无变化,降级一律拒绝。
pub fn can_promote_to(self, target: Self) -> bool {
target.rank() > self.rank()
}
}
/// 改编代际:根作品为 0,直接改编根作品为 1,逐级递增。
pub fn next_generation(parent_generation: u32) -> u32 {
parent_generation.saturating_add(1)
}
pub(crate) fn normalize_id(value: impl AsRef<str>) -> Option<String> {
normalize_required_string(value)
}
#[cfg(test)]
mod fork_authorization_tests {
use super::*;
use crate::GameDistributionError;
#[test]
fn parse_accepts_every_declared_ladder_value() {
for authorization in [
ForkAuthorization::Forbidden,
ForkAuthorization::NonCommercial,
ForkAuthorization::Full,
] {
assert_eq!(
ForkAuthorization::parse(authorization.as_str()),
Some(authorization)
);
}
}
#[test]
fn parse_rejects_unknown_and_miscased_values() {
for value in ["", "allowed", "FULL", "non_commercial", "noncommercial"] {
assert_eq!(ForkAuthorization::parse(value), None, "value={value:?}");
}
}
/// 持久化行可能带着历史写入的空白;解析侧容忍首尾空白,避免把可识别的档位读成未知。
#[test]
fn parse_tolerates_surrounding_whitespace() {
assert_eq!(
ForkAuthorization::parse(" nonCommercial "),
Some(ForkAuthorization::NonCommercial)
);
}
#[test]
fn promotion_is_monotonic_and_never_downgrades() {
use ForkAuthorization::{Forbidden, Full, NonCommercial};
assert!(Forbidden.can_promote_to(NonCommercial));
assert!(Forbidden.can_promote_to(Full));
assert!(NonCommercial.can_promote_to(Full));
assert!(!Full.can_promote_to(Full));
assert!(!NonCommercial.can_promote_to(NonCommercial));
assert!(!Full.can_promote_to(NonCommercial));
assert!(!Full.can_promote_to(Forbidden));
assert!(!NonCommercial.can_promote_to(Forbidden));
}
#[test]
fn only_forbidden_blocks_forks() {
assert!(!ForkAuthorization::Forbidden.allows_fork());
assert!(ForkAuthorization::NonCommercial.allows_fork());
assert!(ForkAuthorization::Full.allows_fork());
}
#[test]
fn generation_starts_at_one_for_direct_derivatives() {
assert_eq!(next_generation(0), 1);
assert_eq!(next_generation(1), 2);
assert_eq!(next_generation(u32::MAX), u32::MAX);
}
/// HTTP 层按「错误码前缀」映射状态码,文案格式一旦变化就会静默退化成通用 4xx。
#[test]
fn fork_errors_are_prefixed_with_their_machine_code() {
let cases = [
(
GameDistributionError::ForkAuthorizationUnknown {
value: "x".to_string(),
},
"FORK_AUTHORIZATION_UNKNOWN",
),
(
GameDistributionError::ForkAuthorizationDowngradeNotAllowed {
current: FORK_AUTHORIZATION_FULL.to_string(),
requested: FORK_AUTHORIZATION_FORBIDDEN.to_string(),
},
"FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED",
),
(GameDistributionError::ForkNotAuthorized, "FORK_NOT_AUTHORIZED"),
(
GameDistributionError::ForkSourceNotFound,
"FORK_SOURCE_NOT_FOUND",
),
(
GameDistributionError::ForkSourceNotAvailable,
"FORK_SOURCE_NOT_AVAILABLE",
),
(
GameDistributionError::ForkSourceVersionMismatch,
"FORK_SOURCE_VERSION_MISMATCH",
),
(
GameDistributionError::ForkDeclarationOnExistingGame,
"FORK_DECLARATION_ON_EXISTING_GAME",
),
];
for (error, code) in cases {
assert!(
error.to_string().starts_with(code),
"expected {code} prefix, got {}",
error
);
}
}
}
@@ -60,6 +60,25 @@ pub enum GameDistributionError {
VersionNotPublished,
VersionAlreadyPublished,
ReviewActionNotAllowed,
/// 共创授权:请求了未知档位。
ForkAuthorizationUnknown {
value: String,
},
/// 共创授权:只允许提升开放度。
ForkAuthorizationDowngradeNotAllowed {
current: String,
requested: String,
},
/// 共创授权:来源作品未开放共创。
ForkNotAuthorized,
/// 共创授权:改编来源作品不存在。
ForkSourceNotFound,
/// 共创授权:改编来源作品当前不可改编。
ForkSourceNotAvailable,
/// 共创授权:改编来源版本不是该作品的当前公开版本。
ForkSourceVersionMismatch,
/// 共创授权:已存在的作品不能再声明改编来源。
ForkDeclarationOnExistingGame,
}
impl fmt::Display for GameDistributionError {
@@ -88,6 +107,30 @@ impl fmt::Display for GameDistributionError {
Self::VersionNotPublished => formatter.write_str("游戏版本当前未公开"),
Self::VersionAlreadyPublished => formatter.write_str("游戏版本已经公开"),
Self::ReviewActionNotAllowed => formatter.write_str("当前版本不允许审核操作"),
// 共创相关错误的文案以错误码开头,供 HTTP 层按码映射状态码,避免子串碰撞。
Self::ForkAuthorizationUnknown { value } => write!(
formatter,
"FORK_AUTHORIZATION_UNKNOWN: 未知的共创授权档位 {value}"
),
Self::ForkAuthorizationDowngradeNotAllowed { current, requested } => write!(
formatter,
"FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED: 共创授权只能提升,当前 {current},请求 {requested}"
),
Self::ForkNotAuthorized => {
formatter.write_str("FORK_NOT_AUTHORIZED: 该作品尚未开放共创授权")
}
Self::ForkSourceNotFound => {
formatter.write_str("FORK_SOURCE_NOT_FOUND: 改编来源作品不存在")
}
Self::ForkSourceNotAvailable => {
formatter.write_str("FORK_SOURCE_NOT_AVAILABLE: 改编来源作品当前不可改编")
}
Self::ForkSourceVersionMismatch => formatter.write_str(
"FORK_SOURCE_VERSION_MISMATCH: 改编来源版本不是该作品的当前公开版本",
),
Self::ForkDeclarationOnExistingGame => formatter.write_str(
"FORK_DECLARATION_ON_EXISTING_GAME: 已存在的作品不能再声明改编来源",
),
}
}
}
@@ -20,8 +20,10 @@ pub use application::{
};
pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision};
pub use domain::{
GameDistributionAction, GameSnapshot, GameVersionSnapshot, GameVersionStatus, GameVisibility,
compute_request_digest, generate_game_id, generate_game_version_id, resolve_version_number,
FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL,
ForkAuthorization, GameDistributionAction, GameSnapshot, GameVersionSnapshot,
GameVersionStatus, GameVisibility, compute_request_digest, generate_game_id,
generate_game_version_id, next_generation, resolve_version_number,
};
pub use errors::{GameDistributionError, GameDistributionFieldError};
pub use events::GameDistributionEvent;
@@ -195,6 +195,61 @@ pub struct GameDistributionGameSummary {
/// 公开列表/详情保证返回;作者侧及旧响应可省略。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating_summary: Option<GameDistributionRatingSummary>,
/// 作品级共创授权档位;缺省按「禁止共创」解释。
#[serde(default)]
pub fork_authorization: GameDistributionForkAuthorization,
/// 直接改编该作品的已公开作品数。
#[serde(default)]
pub fork_count: u64,
/// 该作品的改编来源;母版为空。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lineage: Option<GameDistributionLineage>,
}
/// 作品级共创(Fork)授权档位:只允许单向提升开放度。
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GameDistributionForkAuthorization {
/// 仅可游玩,不可复刻改编。
#[default]
Forbidden,
/// 可二次开发,禁止盈利。
NonCommercial,
/// 可改编、可商用、可发布新版本。
Full,
}
/// 公开的改编血缘摘要:只含作品级信息,不含对象键与素材键。
///
/// `root_title` / `parent_title` 为 `None` 表示对应作品已被软删除,标题不再对外输出;
/// `parent_author_name` 同理(父作品被删时不回传作者名)。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionLineage {
/// 代际;直接改编母版为 1。
pub generation: u32,
pub root_game_id: String,
pub root_title: Option<String>,
pub parent_game_id: String,
pub parent_title: Option<String>,
#[serde(default)]
pub parent_author_name: Option<String>,
}
/// 创建游戏时可选携带的改编声明;只在全新作品上生效。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionForkDeclaration {
pub parent_game_id: String,
pub parent_version_id: String,
}
/// 作者提升作品共创授权:只升不降,`expected` 用于并发校验。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionSetForkAuthorizationRequest {
pub expected_fork_authorization: GameDistributionForkAuthorization,
pub fork_authorization: GameDistributionForkAuthorization,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@@ -225,6 +280,9 @@ pub struct GameDistributionCreateGameRequest {
pub device_support: GameDistributionDeviceSupport,
pub input_modes: Vec<GameDistributionInputMode>,
pub orientation: GameDistributionOrientation,
/// 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。
#[serde(default)]
pub fork: Option<GameDistributionForkDeclaration>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -30,7 +30,8 @@ pub use game_distribution::{
GameDistributionOwnerGameListRecordInput, GameDistributionPlayCountIncrementRecordInput,
GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput,
GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput,
GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput,
GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput,
GameDistributionSubmitReviewRecordInput,
GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput,
GameDistributionUpdateMetadataRecordInput,
};
@@ -97,7 +97,8 @@ pub use self::game_distribution::{
GameDistributionAdminGameRecord, GameDistributionAdminUserReviewDetailRecord,
GameDistributionAdminUserReviewListRecord, GameDistributionAdminUserReviewRecord,
GameDistributionAdminVersionRecord, GameDistributionGameRecord,
GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord,
GameDistributionLineageRecord, GameDistributionOwnerGameRecord,
GameDistributionPublicGameRecord,
GameDistributionRatingSummaryRecord, GameDistributionReviewGameListRecord,
GameDistributionReviewGameRecord, GameDistributionReviewModerationOperationRecord,
GameDistributionReviewModerationRecord, GameDistributionUserReviewListRecord,
@@ -181,6 +181,8 @@ pub struct GameDistributionGameRecord {
pub cover_object_key: Option<String>,
pub local_project_id: Option<String>,
pub screenshots_json: Option<String>,
/// 作品级共创授权档位:`forbidden` / `nonCommercial` / `full`。
pub fork_authorization: String,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -232,6 +234,14 @@ pub struct GameDistributionAdminGameRecord {
pub publication_revision: u64,
pub created_at: String,
pub updated_at: String,
/// 作品级共创授权档位。
pub fork_authorization: String,
/// 改编代际;母版为 0。
pub lineage_generation: u32,
/// 改编来源作品;母版为 None。
pub forked_from_game_id: Option<String>,
/// 直接改编该作品的已公开作品数。
pub derived_count: u64,
pub versions: Vec<GameDistributionAdminVersionRecord>,
/// 软删除时间;为空表示未删除。
pub deleted_at: Option<String>,
@@ -243,11 +253,29 @@ pub struct GameDistributionOwnerGameRecord {
pub versions: Vec<GameDistributionVersionRecord>,
}
/// 公开的改编血缘摘要;母版为 `None`。
///
/// `root_title` / `parent_title` 为 `None` 表示对应作品已被软删除,标题不再对外输出;
/// `parent_author_name` 同理。
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionLineageRecord {
pub generation: u32,
pub root_game_id: String,
pub root_title: Option<String>,
pub parent_game_id: String,
pub parent_title: Option<String>,
pub parent_author_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionPublicGameRecord {
pub game: GameDistributionGameRecord,
pub current_version: Option<GameDistributionVersionRecord>,
pub rating_summary: GameDistributionRatingSummaryRecord,
/// 直接改编该作品的已公开作品数。
pub fork_count: u64,
/// 该作品的改编来源;母版为 None。
pub lineage: Option<GameDistributionLineageRecord>,
}
fn map_game(
@@ -278,6 +306,7 @@ fn map_game(
local_project_id: value.local_project_id,
cover_object_key: value.cover_object_key,
screenshots_json: value.screenshots_json,
fork_authorization: value.fork_authorization,
}
}
@@ -341,6 +370,10 @@ fn map_admin_game(
publication_revision: value.publication_revision,
created_at: shared_kernel::format_timestamp_micros(value.created_at_micros),
updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros),
fork_authorization: value.fork_authorization,
lineage_generation: value.lineage_generation,
forked_from_game_id: value.forked_from_game_id,
derived_count: value.derived_count,
versions: value.versions.into_iter().map(map_admin_version).collect(),
deleted_at: value
.deleted_at_micros
@@ -348,6 +381,34 @@ fn map_admin_game(
}
}
fn map_lineage(
value: Option<crate::module_bindings::GameDistributionLineageSnapshot>,
) -> Option<GameDistributionLineageRecord> {
value.map(|value| GameDistributionLineageRecord {
generation: value.generation,
root_game_id: value.root_game_id,
root_title: value.root_title,
parent_game_id: value.parent_game_id,
parent_title: value.parent_title,
parent_author_name: value.parent_author_name,
})
}
fn map_public_game(
value: crate::module_bindings::GameDistributionPublicGameSnapshot,
) -> GameDistributionPublicGameRecord {
GameDistributionPublicGameRecord {
game: map_game(value.game),
current_version: value.current_version.map(map_version),
rating_summary: GameDistributionRatingSummaryRecord {
average_score: value.average_score,
rating_count: value.rating_count,
},
fork_count: value.fork_count,
lineage: map_lineage(value.lineage),
}
}
pub(crate) fn map_game_distribution_admin_game_list_result(
result: crate::module_bindings::GameDistributionAdminGameListResult,
) -> Result<(Vec<GameDistributionAdminGameRecord>, Option<String>), SpacetimeClientError> {
@@ -405,14 +466,7 @@ pub(crate) fn map_game_distribution_public_game_list_result(
Ok(result
.games
.into_iter()
.map(|value| GameDistributionPublicGameRecord {
game: map_game(value.game),
current_version: value.current_version.map(map_version),
rating_summary: GameDistributionRatingSummaryRecord {
average_score: value.average_score,
rating_count: value.rating_count,
},
})
.map(map_public_game)
.collect())
}
@@ -431,14 +485,7 @@ pub(crate) fn map_game_distribution_public_game_result(
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result.game.map(|value| GameDistributionPublicGameRecord {
game: map_game(value.game),
current_version: value.current_version.map(map_version),
rating_summary: GameDistributionRatingSummaryRecord {
average_score: value.average_score,
rating_count: value.rating_count,
},
}))
Ok(result.game.map(map_public_game))
}
pub(crate) fn map_game_distribution_review_list_result(
@@ -88,6 +88,22 @@ pub struct GameDistributionCreateGameRecordInput {
pub now_micros: i64,
/// 同一作者的本地项目标识;重复发布会复用既有 game_id。
pub local_project_id: Option<String>,
/// 改编来源作品;仅全新作品允许携带。
pub forked_from_game_id: Option<String>,
/// 建立血缘时来源作品的公开版本。
pub forked_from_version_id: Option<String>,
}
/// 作者提升作品共创授权档位;只升不降,并带上期望的当前档位做 CAS。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionSetForkAuthorizationRecordInput {
pub game_id: String,
pub owner_user_id: String,
pub fork_authorization: String,
pub expected_fork_authorization: String,
pub idempotency_key: String,
pub request_digest: String,
pub now_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -680,6 +696,8 @@ impl SpacetimeClient {
request_digest: input.request_digest,
now_micros: input.now_micros,
local_project_id: input.local_project_id,
forked_from_game_id: input.forked_from_game_id,
forked_from_version_id: input.forked_from_version_id,
};
self.call_after_connect(
"create_game_distribution_game",
@@ -702,6 +720,43 @@ impl SpacetimeClient {
.await
}
pub async fn set_game_distribution_fork_authorization(
&self,
input: GameDistributionSetForkAuthorizationRecordInput,
) -> Result<(GameDistributionGameRecord, bool), SpacetimeClientError> {
let input = crate::module_bindings::GameDistributionSetForkAuthorizationInput {
game_id: input.game_id,
owner_user_id: input.owner_user_id,
fork_authorization: input.fork_authorization,
expected_fork_authorization: input.expected_fork_authorization,
idempotency_key: input.idempotency_key,
request_digest: input.request_digest,
now_micros: input.now_micros,
};
self.call_after_connect(
"set_game_distribution_fork_authorization",
move |connection, sender| {
connection
.procedures()
.set_game_distribution_fork_authorization_and_return_then(
input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_game_distribution_game_result)
.and_then(|(game, _, replayed)| {
game.map(|game| (game, replayed)).ok_or_else(|| {
SpacetimeClientError::missing_snapshot("共创授权更新结果")
})
});
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn create_game_distribution_version(
&self,
input: GameDistributionCreateVersionRecordInput,
@@ -430,6 +430,9 @@ pub mod game_distribution_game_type;
pub mod game_distribution_get_game_input_type;
pub mod game_distribution_idempotency_receipt_table;
pub mod game_distribution_idempotency_receipt_type;
pub mod game_distribution_lineage_snapshot_type;
pub mod game_distribution_lineage_table;
pub mod game_distribution_lineage_type;
pub mod game_distribution_my_review_input_type;
pub mod game_distribution_owner_game_list_input_type;
pub mod game_distribution_owner_game_list_procedure_result_type;
@@ -452,6 +455,7 @@ pub mod game_distribution_review_moderation_log_type;
pub mod game_distribution_review_moderation_operation_snapshot_type;
pub mod game_distribution_review_table;
pub mod game_distribution_review_type;
pub mod game_distribution_set_fork_authorization_input_type;
pub mod game_distribution_submit_review_input_type;
pub mod game_distribution_suspend_input_type;
pub mod game_distribution_unpublish_input_type;
@@ -831,6 +835,7 @@ pub mod save_editor_project_layout_v_2_and_return_procedure;
pub mod save_game_distribution_my_review_and_return_procedure;
pub mod seed_analytics_date_dimensions_reducer;
pub mod set_editor_showcase_asset_like_for_viewer_and_return_procedure;
pub mod set_game_distribution_fork_authorization_and_return_procedure;
pub mod set_payment_order_provider_code_and_return_procedure;
pub mod settle_llm_router_quota_and_return_procedure;
pub mod start_ai_task_reducer;
@@ -1291,6 +1296,9 @@ pub use game_distribution_game_type::GameDistributionGame;
pub use game_distribution_get_game_input_type::GameDistributionGetGameInput;
pub use game_distribution_idempotency_receipt_table::*;
pub use game_distribution_idempotency_receipt_type::GameDistributionIdempotencyReceipt;
pub use game_distribution_lineage_snapshot_type::GameDistributionLineageSnapshot;
pub use game_distribution_lineage_table::*;
pub use game_distribution_lineage_type::GameDistributionLineage;
pub use game_distribution_my_review_input_type::GameDistributionMyReviewInput;
pub use game_distribution_owner_game_list_input_type::GameDistributionOwnerGameListInput;
pub use game_distribution_owner_game_list_procedure_result_type::GameDistributionOwnerGameListProcedureResult;
@@ -1313,6 +1321,7 @@ pub use game_distribution_review_moderation_log_type::GameDistributionReviewMode
pub use game_distribution_review_moderation_operation_snapshot_type::GameDistributionReviewModerationOperationSnapshot;
pub use game_distribution_review_table::*;
pub use game_distribution_review_type::GameDistributionReview;
pub use game_distribution_set_fork_authorization_input_type::GameDistributionSetForkAuthorizationInput;
pub use game_distribution_submit_review_input_type::GameDistributionSubmitReviewInput;
pub use game_distribution_suspend_input_type::GameDistributionSuspendInput;
pub use game_distribution_unpublish_input_type::GameDistributionUnpublishInput;
@@ -1692,6 +1701,7 @@ pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project
pub use save_game_distribution_my_review_and_return_procedure::save_game_distribution_my_review_and_return;
pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions;
pub use set_editor_showcase_asset_like_for_viewer_and_return_procedure::set_editor_showcase_asset_like_for_viewer_and_return;
pub use set_game_distribution_fork_authorization_and_return_procedure::set_game_distribution_fork_authorization_and_return;
pub use set_payment_order_provider_code_and_return_procedure::set_payment_order_provider_code_and_return;
pub use settle_llm_router_quota_and_return_procedure::settle_llm_router_quota_and_return;
pub use start_ai_task_reducer::start_ai_task;
@@ -1882,6 +1892,7 @@ pub struct DbUpdate {
feature_gate_config: __sdk::TableUpdate<FeatureGateConfig>,
game_distribution_game: __sdk::TableUpdate<GameDistributionGame>,
game_distribution_idempotency_receipt: __sdk::TableUpdate<GameDistributionIdempotencyReceipt>,
game_distribution_lineage: __sdk::TableUpdate<GameDistributionLineage>,
game_distribution_review: __sdk::TableUpdate<GameDistributionReview>,
game_distribution_review_moderation_log:
__sdk::TableUpdate<GameDistributionReviewModerationLog>,
@@ -2104,6 +2115,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
)?,
)
}
"game_distribution_lineage" => db_update.game_distribution_lineage.append(
game_distribution_lineage_table::parse_table_update(table_update)?,
),
"game_distribution_review" => db_update.game_distribution_review.append(
game_distribution_review_table::parse_table_update(table_update)?,
),
@@ -2519,6 +2533,12 @@ impl __sdk::DbUpdate for DbUpdate {
&self.game_distribution_idempotency_receipt,
)
.with_updates_by_pk(|row| &row.receipt_id);
diff.game_distribution_lineage = cache
.apply_diff_to_table::<GameDistributionLineage>(
"game_distribution_lineage",
&self.game_distribution_lineage,
)
.with_updates_by_pk(|row| &row.game_id);
diff.game_distribution_review = cache
.apply_diff_to_table::<GameDistributionReview>(
"game_distribution_review",
@@ -2907,6 +2927,9 @@ impl __sdk::DbUpdate for DbUpdate {
"game_distribution_idempotency_receipt" => db_update
.game_distribution_idempotency_receipt
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"game_distribution_lineage" => db_update
.game_distribution_lineage
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"game_distribution_review" => db_update
.game_distribution_review
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -3193,6 +3216,9 @@ impl __sdk::DbUpdate for DbUpdate {
"game_distribution_idempotency_receipt" => db_update
.game_distribution_idempotency_receipt
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"game_distribution_lineage" => db_update
.game_distribution_lineage
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"game_distribution_review" => db_update
.game_distribution_review
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -3394,6 +3420,7 @@ pub struct AppliedDiff<'r> {
game_distribution_game: __sdk::TableAppliedDiff<'r, GameDistributionGame>,
game_distribution_idempotency_receipt:
__sdk::TableAppliedDiff<'r, GameDistributionIdempotencyReceipt>,
game_distribution_lineage: __sdk::TableAppliedDiff<'r, GameDistributionLineage>,
game_distribution_review: __sdk::TableAppliedDiff<'r, GameDistributionReview>,
game_distribution_review_moderation_log:
__sdk::TableAppliedDiff<'r, GameDistributionReviewModerationLog>,
@@ -3676,6 +3703,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.game_distribution_idempotency_receipt,
event,
);
callbacks.invoke_table_row_callbacks::<GameDistributionLineage>(
"game_distribution_lineage",
&self.game_distribution_lineage,
event,
);
callbacks.invoke_table_row_callbacks::<GameDistributionReview>(
"game_distribution_review",
&self.game_distribution_review,
@@ -4607,6 +4639,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
feature_gate_config_table::register_table(client_cache);
game_distribution_game_table::register_table(client_cache);
game_distribution_idempotency_receipt_table::register_table(client_cache);
game_distribution_lineage_table::register_table(client_cache);
game_distribution_review_table::register_table(client_cache);
game_distribution_review_moderation_log_table::register_table(client_cache);
game_distribution_version_table::register_table(client_cache);
@@ -4700,6 +4733,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"feature_gate_config",
"game_distribution_game",
"game_distribution_idempotency_receipt",
"game_distribution_lineage",
"game_distribution_review",
"game_distribution_review_moderation_log",
"game_distribution_version",
@@ -21,6 +21,10 @@ pub struct GameDistributionAdminGameSnapshot {
pub publication_revision: u64,
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub fork_authorization: String,
pub lineage_generation: u32,
pub forked_from_game_id: Option<String>,
pub derived_count: u64,
pub versions: Vec<GameDistributionAdminVersionSnapshot>,
pub deleted_at_micros: Option<i64>,
}
@@ -26,6 +26,8 @@ pub struct GameDistributionCreateGameInput {
pub request_digest: String,
pub now_micros: i64,
pub local_project_id: Option<String>,
pub forked_from_game_id: Option<String>,
pub forked_from_version_id: Option<String>,
}
impl __sdk::InModule for GameDistributionCreateGameInput {
@@ -31,6 +31,7 @@ pub struct GameDistributionGameSnapshot {
pub local_project_id: Option<String>,
pub cover_object_key: Option<String>,
pub screenshots_json: Option<String>,
pub fork_authorization: String,
}
impl __sdk::InModule for GameDistributionGameSnapshot {
@@ -32,6 +32,7 @@ pub struct GameDistributionGame {
pub cover_object_key: Option<String>,
pub screenshots_json: Option<String>,
pub deleted_at: Option<__sdk::Timestamp>,
pub fork_authorization: String,
}
impl __sdk::InModule for GameDistributionGame {
@@ -67,6 +68,7 @@ pub struct GameDistributionGameCols {
pub cover_object_key: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
pub screenshots_json: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
pub deleted_at: __sdk::__query_builder::Col<GameDistributionGame, Option<__sdk::Timestamp>>,
pub fork_authorization: __sdk::__query_builder::Col<GameDistributionGame, String>,
}
impl __sdk::__query_builder::HasCols for GameDistributionGame {
@@ -110,6 +112,7 @@ impl __sdk::__query_builder::HasCols for GameDistributionGame {
cover_object_key: __sdk::__query_builder::Col::new(table_name, "cover_object_key"),
screenshots_json: __sdk::__query_builder::Col::new(table_name, "screenshots_json"),
deleted_at: __sdk::__query_builder::Col::new(table_name, "deleted_at"),
fork_authorization: __sdk::__query_builder::Col::new(table_name, "fork_authorization"),
}
}
}
@@ -0,0 +1,20 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionLineageSnapshot {
pub generation: u32,
pub root_game_id: String,
pub root_title: Option<String>,
pub parent_game_id: String,
pub parent_title: Option<String>,
pub parent_author_name: Option<String>,
}
impl __sdk::InModule for GameDistributionLineageSnapshot {
type Module = super::RemoteModule;
}
@@ -0,0 +1,231 @@
// 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::game_distribution_lineage_type::GameDistributionLineage;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `game_distribution_lineage`.
///
/// Obtain a handle from the [`GameDistributionLineageTableAccess::game_distribution_lineage`] method on [`super::RemoteTables`],
/// like `ctx.db.game_distribution_lineage()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.game_distribution_lineage().on_insert(...)`.
pub struct GameDistributionLineageTableHandle<'ctx> {
imp: __sdk::TableHandle<GameDistributionLineage>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
/// Lifetime-aware accessor marker for the table `game_distribution_lineage`.
pub struct GameDistributionLineageTableAccessor;
impl __sdk::TableAccessor<super::RemoteTables> for GameDistributionLineageTableAccessor {
type Row = GameDistributionLineage;
type Handle<'db> = GameDistributionLineageTableHandle<'db>;
fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> {
db.game_distribution_lineage()
}
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `game_distribution_lineage`.
///
/// Implemented for [`super::RemoteTables`].
pub trait GameDistributionLineageTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`GameDistributionLineageTableHandle`], which mediates access to the table `game_distribution_lineage`.
fn game_distribution_lineage(&self) -> GameDistributionLineageTableHandle<'_>;
}
impl GameDistributionLineageTableAccess for super::RemoteTables {
fn game_distribution_lineage(&self) -> GameDistributionLineageTableHandle<'_> {
GameDistributionLineageTableHandle {
imp: self
.imp
.get_table::<GameDistributionLineage>("game_distribution_lineage"),
ctx: std::marker::PhantomData,
}
}
}
pub struct GameDistributionLineageInsertCallbackId(__sdk::CallbackId);
pub struct GameDistributionLineageDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableLike for GameDistributionLineageTableHandle<'ctx> {
type Row = GameDistributionLineage;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = GameDistributionLineage> + '_ {
self.imp.iter()
}
}
impl<'ctx> __sdk::Table for GameDistributionLineageTableHandle<'ctx> {
type Row = GameDistributionLineage;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = GameDistributionLineage> + '_ {
self.imp.iter()
}
type InsertCallbackId = GameDistributionLineageInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> GameDistributionLineageInsertCallbackId {
GameDistributionLineageInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: GameDistributionLineageInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = GameDistributionLineageDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> GameDistributionLineageDeleteCallbackId {
GameDistributionLineageDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: GameDistributionLineageDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
impl<'ctx> __sdk::WithInsert for GameDistributionLineageTableHandle<'ctx> {
type InsertCallbackId = GameDistributionLineageInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> GameDistributionLineageInsertCallbackId {
GameDistributionLineageInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: GameDistributionLineageInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
}
impl<'ctx> __sdk::WithDelete for GameDistributionLineageTableHandle<'ctx> {
type DeleteCallbackId = GameDistributionLineageDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> GameDistributionLineageDeleteCallbackId {
GameDistributionLineageDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: GameDistributionLineageDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct GameDistributionLineageUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for GameDistributionLineageTableHandle<'ctx> {
type UpdateCallbackId = GameDistributionLineageUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> GameDistributionLineageUpdateCallbackId {
GameDistributionLineageUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: GameDistributionLineageUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
impl<'ctx> __sdk::WithUpdate for GameDistributionLineageTableHandle<'ctx> {
type UpdateCallbackId = GameDistributionLineageUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> GameDistributionLineageUpdateCallbackId {
GameDistributionLineageUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: GameDistributionLineageUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `game_id` unique index on the table `game_distribution_lineage`,
/// which allows point queries on the field of the same name
/// via the [`GameDistributionLineageGameIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.game_distribution_lineage().game_id().find(...)`.
pub struct GameDistributionLineageGameIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<GameDistributionLineage, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> GameDistributionLineageTableHandle<'ctx> {
/// Get a handle on the `game_id` unique index on the table `game_distribution_lineage`.
pub fn game_id(&self) -> GameDistributionLineageGameIdUnique<'ctx> {
GameDistributionLineageGameIdUnique {
imp: self.imp.get_unique_constraint::<String>("game_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> GameDistributionLineageGameIdUnique<'ctx> {
/// Find the subscribed row whose `game_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<GameDistributionLineage> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<GameDistributionLineage>("game_distribution_lineage");
_table.add_unique_constraint::<String>("game_id", |row| &row.game_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<GameDistributionLineage>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<GameDistributionLineage>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `GameDistributionLineage`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait game_distribution_lineageQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `GameDistributionLineage`.
fn game_distribution_lineage(&self) -> __sdk::__query_builder::Table<GameDistributionLineage>;
}
impl game_distribution_lineageQueryTableAccess for __sdk::QueryTableAccessor {
fn game_distribution_lineage(&self) -> __sdk::__query_builder::Table<GameDistributionLineage> {
__sdk::__query_builder::Table::new("game_distribution_lineage")
}
}
@@ -0,0 +1,73 @@
// 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 GameDistributionLineage {
pub game_id: String,
pub owner_user_id: String,
pub parent_game_id: String,
pub parent_version_id: String,
pub root_game_id: String,
pub generation: u32,
pub created_at: __sdk::Timestamp,
}
impl __sdk::InModule for GameDistributionLineage {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `GameDistributionLineage`.
///
/// Provides typed access to columns for query building.
pub struct GameDistributionLineageCols {
pub game_id: __sdk::__query_builder::Col<GameDistributionLineage, String>,
pub owner_user_id: __sdk::__query_builder::Col<GameDistributionLineage, String>,
pub parent_game_id: __sdk::__query_builder::Col<GameDistributionLineage, String>,
pub parent_version_id: __sdk::__query_builder::Col<GameDistributionLineage, String>,
pub root_game_id: __sdk::__query_builder::Col<GameDistributionLineage, String>,
pub generation: __sdk::__query_builder::Col<GameDistributionLineage, u32>,
pub created_at: __sdk::__query_builder::Col<GameDistributionLineage, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for GameDistributionLineage {
type Cols = GameDistributionLineageCols;
fn cols(table_name: &'static str) -> Self::Cols {
GameDistributionLineageCols {
game_id: __sdk::__query_builder::Col::new(table_name, "game_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
parent_game_id: __sdk::__query_builder::Col::new(table_name, "parent_game_id"),
parent_version_id: __sdk::__query_builder::Col::new(table_name, "parent_version_id"),
root_game_id: __sdk::__query_builder::Col::new(table_name, "root_game_id"),
generation: __sdk::__query_builder::Col::new(table_name, "generation"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
}
}
}
/// Indexed column accessor struct for the table `GameDistributionLineage`.
///
/// Provides typed access to indexed columns for query building.
pub struct GameDistributionLineageIxCols {
pub game_id: __sdk::__query_builder::IxCol<GameDistributionLineage, String>,
pub owner_user_id: __sdk::__query_builder::IxCol<GameDistributionLineage, String>,
pub parent_game_id: __sdk::__query_builder::IxCol<GameDistributionLineage, String>,
pub root_game_id: __sdk::__query_builder::IxCol<GameDistributionLineage, String>,
}
impl __sdk::__query_builder::HasIxCols for GameDistributionLineage {
type IxCols = GameDistributionLineageIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
GameDistributionLineageIxCols {
game_id: __sdk::__query_builder::IxCol::new(table_name, "game_id"),
owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"),
parent_game_id: __sdk::__query_builder::IxCol::new(table_name, "parent_game_id"),
root_game_id: __sdk::__query_builder::IxCol::new(table_name, "root_game_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for GameDistributionLineage {}
@@ -5,6 +5,7 @@
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_game_snapshot_type::GameDistributionGameSnapshot;
use super::game_distribution_lineage_snapshot_type::GameDistributionLineageSnapshot;
use super::game_distribution_version_snapshot_type::GameDistributionVersionSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
@@ -14,6 +15,8 @@ pub struct GameDistributionPublicGameSnapshot {
pub current_version: Option<GameDistributionVersionSnapshot>,
pub average_score: Option<f64>,
pub rating_count: u64,
pub fork_count: u64,
pub lineage: Option<GameDistributionLineageSnapshot>,
}
impl __sdk::InModule for GameDistributionPublicGameSnapshot {
@@ -0,0 +1,21 @@
// 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 GameDistributionSetForkAuthorizationInput {
pub game_id: String,
pub owner_user_id: String,
pub fork_authorization: String,
pub expected_fork_authorization: String,
pub idempotency_key: String,
pub request_digest: String,
pub now_micros: i64,
}
impl __sdk::InModule for GameDistributionSetForkAuthorizationInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,62 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_procedure_result_type::GameDistributionProcedureResult;
use super::game_distribution_set_fork_authorization_input_type::GameDistributionSetForkAuthorizationInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct SetGameDistributionForkAuthorizationAndReturnArgs {
pub input: GameDistributionSetForkAuthorizationInput,
}
impl __sdk::InModule for SetGameDistributionForkAuthorizationAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `set_game_distribution_fork_authorization_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait set_game_distribution_fork_authorization_and_return {
fn set_game_distribution_fork_authorization_and_return(
&self,
input: GameDistributionSetForkAuthorizationInput,
) {
self.set_game_distribution_fork_authorization_and_return_then(input, |_, _| {});
}
fn set_game_distribution_fork_authorization_and_return_then(
&self,
input: GameDistributionSetForkAuthorizationInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl set_game_distribution_fork_authorization_and_return for super::RemoteProcedures {
fn set_game_distribution_fork_authorization_and_return_then(
&self,
input: GameDistributionSetForkAuthorizationInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionProcedureResult>(
"set_game_distribution_fork_authorization_and_return",
SetGameDistributionForkAuthorizationAndReturnArgs { input },
__callback,
);
}
}

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