diff --git a/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md b/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md index ddaedb17a..6d1903433 100644 --- a/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md +++ b/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md @@ -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。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 670dd508d..84c7cc365 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 提供评价投影。 diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index b6bb49195..384aa38fb 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -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 用例「展示共创授权、代际与溯源信息,且不提供改造入口」与「旧数据缺少授权与血缘字段时只按禁止共创渲染」通过 | ✅ 已验证 | --- diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index f7803ebdb..10e530625 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -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; +}; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index a149af079..28d376dfd 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -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' }, diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index bc7905ed6..ceb3d3acc 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -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 { "/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, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(game_id): Path, + Json(payload): Json, +) -> Result, 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, Extension(ctx): Extension, @@ -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); diff --git a/server-rs/crates/module-game-distribution/src/domain.rs b/server-rs/crates/module-game-distribution/src/domain.rs index e8d625a1d..8b6f399f8 100644 --- a/server-rs/crates/module-game-distribution/src/domain.rs +++ b/server-rs/crates/module-game-distribution/src/domain.rs @@ -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 { + 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) -> Option { 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 + ); + } + } +} diff --git a/server-rs/crates/module-game-distribution/src/errors.rs b/server-rs/crates/module-game-distribution/src/errors.rs index 2228183c3..020381a7e 100644 --- a/server-rs/crates/module-game-distribution/src/errors.rs +++ b/server-rs/crates/module-game-distribution/src/errors.rs @@ -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: 已存在的作品不能再声明改编来源", + ), } } } diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 0da10868a..00adca54b 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -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; diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index bfcad0083..b76030fb1 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -195,6 +195,61 @@ pub struct GameDistributionGameSummary { /// 公开列表/详情保证返回;作者侧及旧响应可省略。 #[serde(default, skip_serializing_if = "Option::is_none")] pub rating_summary: Option, + /// 作品级共创授权档位;缺省按「禁止共创」解释。 + #[serde(default)] + pub fork_authorization: GameDistributionForkAuthorization, + /// 直接改编该作品的已公开作品数。 + #[serde(default)] + pub fork_count: u64, + /// 该作品的改编来源;母版为空。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lineage: Option, +} + +/// 作品级共创(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, + pub parent_game_id: String, + pub parent_title: Option, + #[serde(default)] + pub parent_author_name: Option, +} + +/// 创建游戏时可选携带的改编声明;只在全新作品上生效。 +#[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, pub orientation: GameDistributionOrientation, + /// 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 + #[serde(default)] + pub fork: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 4f387f1c6..6dedf85d4 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -30,7 +30,8 @@ pub use game_distribution::{ GameDistributionOwnerGameListRecordInput, GameDistributionPlayCountIncrementRecordInput, GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, - GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput, + GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, + GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput, }; diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 07b1a9cf8..9a1bfb69b 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index de0076dec..72f62a832 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -181,6 +181,8 @@ pub struct GameDistributionGameRecord { pub cover_object_key: Option, pub local_project_id: Option, pub screenshots_json: Option, + /// 作品级共创授权档位:`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, + /// 直接改编该作品的已公开作品数。 + pub derived_count: u64, pub versions: Vec, /// 软删除时间;为空表示未删除。 pub deleted_at: Option, @@ -243,11 +253,29 @@ pub struct GameDistributionOwnerGameRecord { pub versions: Vec, } +/// 公开的改编血缘摘要;母版为 `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, + pub parent_game_id: String, + pub parent_title: Option, + pub parent_author_name: Option, +} + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct GameDistributionPublicGameRecord { pub game: GameDistributionGameRecord, pub current_version: Option, pub rating_summary: GameDistributionRatingSummaryRecord, + /// 直接改编该作品的已公开作品数。 + pub fork_count: u64, + /// 该作品的改编来源;母版为 None。 + pub lineage: Option, } 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, +) -> Option { + 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, Option), 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( diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index bdea2bc5f..8fe6d7559 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -88,6 +88,22 @@ pub struct GameDistributionCreateGameRecordInput { pub now_micros: i64, /// 同一作者的本地项目标识;重复发布会复用既有 game_id。 pub local_project_id: Option, + /// 改编来源作品;仅全新作品允许携带。 + pub forked_from_game_id: Option, + /// 建立血缘时来源作品的公开版本。 + pub forked_from_version_id: Option, +} + +/// 作者提升作品共创授权档位;只升不降,并带上期望的当前档位做 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, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index ffce9b440..77cd93411 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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, game_distribution_game: __sdk::TableUpdate, game_distribution_idempotency_receipt: __sdk::TableUpdate, + game_distribution_lineage: __sdk::TableUpdate, game_distribution_review: __sdk::TableUpdate, game_distribution_review_moderation_log: __sdk::TableUpdate, @@ -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::( + "game_distribution_lineage", + &self.game_distribution_lineage, + ) + .with_updates_by_pk(|row| &row.game_id); diff.game_distribution_review = cache .apply_diff_to_table::( "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::( + "game_distribution_lineage", + &self.game_distribution_lineage, + event, + ); callbacks.invoke_table_row_callbacks::( "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", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs index 71e653c96..28508f471 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs @@ -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, + pub derived_count: u64, pub versions: Vec, pub deleted_at_micros: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs index c9279cef3..332e27688 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs @@ -26,6 +26,8 @@ pub struct GameDistributionCreateGameInput { pub request_digest: String, pub now_micros: i64, pub local_project_id: Option, + pub forked_from_game_id: Option, + pub forked_from_version_id: Option, } impl __sdk::InModule for GameDistributionCreateGameInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs index c9146e464..7e110577d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs @@ -31,6 +31,7 @@ pub struct GameDistributionGameSnapshot { pub local_project_id: Option, pub cover_object_key: Option, pub screenshots_json: Option, + pub fork_authorization: String, } impl __sdk::InModule for GameDistributionGameSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs index 88a99c964..0e4a91ed6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs @@ -32,6 +32,7 @@ pub struct GameDistributionGame { pub cover_object_key: Option, pub screenshots_json: Option, 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>, pub screenshots_json: __sdk::__query_builder::Col>, pub deleted_at: __sdk::__query_builder::Col>, + pub fork_authorization: __sdk::__query_builder::Col, } 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"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_snapshot_type.rs new file mode 100644 index 000000000..d2c4b2cb9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_snapshot_type.rs @@ -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, + pub parent_game_id: String, + pub parent_title: Option, + pub parent_author_name: Option, +} + +impl __sdk::InModule for GameDistributionLineageSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_table.rs new file mode 100644 index 000000000..1a8dc339c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_table.rs @@ -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, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_distribution_lineage`. +pub struct GameDistributionLineageTableAccessor; + +impl __sdk::TableAccessor 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::("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 + '_ { + 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 + '_ { + 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, + 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::("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 { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("game_distribution_lineage"); + _table.add_unique_constraint::("game_id", |row| &row.game_id); +} + +#[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 `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; +} + +impl game_distribution_lineageQueryTableAccess for __sdk::QueryTableAccessor { + fn game_distribution_lineage(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_distribution_lineage") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_type.rs new file mode 100644 index 000000000..b41ae6b6d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_type.rs @@ -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, + pub owner_user_id: __sdk::__query_builder::Col, + pub parent_game_id: __sdk::__query_builder::Col, + pub parent_version_id: __sdk::__query_builder::Col, + pub root_game_id: __sdk::__query_builder::Col, + pub generation: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +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, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub parent_game_id: __sdk::__query_builder::IxCol, + pub root_game_id: __sdk::__query_builder::IxCol, +} + +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 {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs index b9cd4b0aa..aafc6cf36 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs @@ -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, pub average_score: Option, pub rating_count: u64, + pub fork_count: u64, + pub lineage: Option, } impl __sdk::InModule for GameDistributionPublicGameSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_set_fork_authorization_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_set_fork_authorization_input_type.rs new file mode 100644 index 000000000..3776a1550 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_set_fork_authorization_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_fork_authorization_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_fork_authorization_and_return_procedure.rs new file mode 100644 index 000000000..4bfbec693 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_fork_authorization_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionProcedureResult>( + "set_game_distribution_fork_authorization_and_return", + SetGameDistributionForkAuthorizationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index e580a3df5..a09d52f98 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -765,6 +765,45 @@ pub struct GameDistributionGame { /// 版本行、发行包对象与冻结资料保持不可变,删除只改游戏行的可见性状态。 #[default(None::)] pub(crate) deleted_at: Option, + /// 作品级共创(Fork)授权档位:`forbidden` / `nonCommercial` / `full`。 + /// 只允许单向提升开放度;旧行按默认「禁止共创」解释。 + #[default("forbidden")] + pub(crate) fork_authorization: String, +} + +/// 作品之间的改编(Fork)血缘:一个作品最多有一个父,主键即子作品 ID。 +/// +/// 血缘在创建子作品时一次性写入,之后不可变更;父作品下架或封禁都不影响既有行。 +#[spacetimedb::table( + accessor = game_distribution_lineage, + index( + accessor = by_game_distribution_lineage_parent_game_id, + btree(columns = [parent_game_id]) + ), + index( + accessor = by_game_distribution_lineage_root_game_id, + btree(columns = [root_game_id]) + ), + index( + accessor = by_game_distribution_lineage_owner_user_id, + btree(columns = [owner_user_id]) + ) +)] +#[derive(Clone)] +pub struct GameDistributionLineage { + /// 子作品;主键承载「一个作品只能有一个父」这条不变量。 + #[primary_key] + pub(crate) game_id: String, + /// 子作品作者,冗余用于「我改编过的作品」查询,不参与授权判定。 + pub(crate) owner_user_id: String, + pub(crate) parent_game_id: String, + /// 建立血缘时父作品的当前公开版本,不可变溯源事实。 + pub(crate) parent_version_id: String, + /// 0 代母版;根作品自身不写行。 + pub(crate) root_game_id: String, + /// 代际;直接改编根作品为 1。 + pub(crate) generation: u32, + pub(crate) created_at: Timestamp, } /// 游戏发行版本。包摘要、确认字节数和送审资料在创建后作为不可变快照保留;状态和 @@ -892,6 +931,7 @@ const GAME_DISTRIBUTION_ACTION_SUSPEND: &str = "suspend"; const GAME_DISTRIBUTION_ACTION_RESTORE: &str = "restore"; const GAME_DISTRIBUTION_ACTION_UPDATE_METADATA: &str = "update_metadata"; const GAME_DISTRIBUTION_ACTION_DELETE: &str = "delete"; +const GAME_DISTRIBUTION_ACTION_SET_FORK_AUTHORIZATION: &str = "set_fork_authorization"; /// 后台游戏管理页每个游戏最多回传多少个版本历史;版本数仍按全量统计。 const GAME_DISTRIBUTION_ADMIN_VERSION_HISTORY_LIMIT: usize = 20; /// 后台游戏管理页一次最多回传多少个游戏。 @@ -926,6 +966,22 @@ pub struct GameDistributionCreateGameInput { pub now_micros: i64, /// 同一作者的本地项目标识;重复发布会复用既有 game_id 而不是新建游戏。 pub local_project_id: Option, + /// 改编来源作品;仅新建游戏时允许携带,复用既有身份时报错。 + pub forked_from_game_id: Option, + /// 建立血缘时来源作品的公开版本,必须等于来源作品当前公开版本。 + pub forked_from_version_id: Option, +} + +/// 作者提升作品共创授权:只允许单向提升,并要求调用方给出期望的当前档位。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +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, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1170,6 +1226,8 @@ pub struct GameDistributionGameSnapshot { pub local_project_id: Option, pub cover_object_key: Option, pub screenshots_json: Option, + /// 作品级共创授权档位;旧行由表默认值补齐为 `forbidden`。 + pub fork_authorization: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1223,6 +1281,14 @@ pub struct GameDistributionAdminGameSnapshot { pub publication_revision: u64, pub created_at_micros: i64, pub updated_at_micros: i64, + /// 作品级共创授权档位。 + pub fork_authorization: String, + /// 改编代际;母版为 0。 + pub lineage_generation: u32, + /// 改编来源作品;母版为 None。 + pub forked_from_game_id: Option, + /// 直接改编该作品的已公开作品数。 + pub derived_count: u64, pub versions: Vec, /// 软删除时间;为空表示未删除。后台列表用它展示"已删除"状态。 pub deleted_at_micros: Option, @@ -1237,12 +1303,32 @@ pub struct GameDistributionAdminGameListResult { pub next_cursor: Option, } +/// 公开的改编血缘摘要:只暴露作品级信息与作者名,不含对象键、素材键或私有字段。 +/// +/// `root_title` / `parent_title` 为 `None` 表示对应作品已被软删除,标题不再对外输出 +/// (由 api-server 原样发 `null`,前端降级为「原作品已不可用」);`parent_author_name` +/// 同理,父作品被删时不回传作者名。ID 与代际保留,用于不依赖标题的溯源展示。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionLineageSnapshot { + /// 代际;直接改编母版为 1。 + pub generation: u32, + pub root_game_id: String, + pub root_title: Option, + pub parent_game_id: String, + pub parent_title: Option, + pub parent_author_name: Option, +} + #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct GameDistributionPublicGameSnapshot { pub game: GameDistributionGameSnapshot, pub current_version: Option, pub average_score: Option, pub rating_count: u64, + /// 直接改编该作品的已公开作品数。 + pub fork_count: u64, + /// 该作品的改编来源;母版为 None。 + pub lineage: Option, } /// 作者自有游戏的聚合快照:游戏身份 + 最近若干版本,供作者管理页展示状态与驳回理由。 @@ -1377,6 +1463,22 @@ pub fn create_game_distribution_version_and_return( } } +/// 提升作品的共创(Fork)授权档位。幂等、只升不降;降级与未知档位一律拒绝且不写库。 +#[spacetimedb::procedure] +pub fn set_game_distribution_fork_authorization_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionSetForkAuthorizationInput, +) -> GameDistributionProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + set_game_distribution_fork_authorization_tx(tx, input.clone()) + }) { + Ok((game, replayed)) => game_distribution_game_result(game, replayed), + Err(error) => game_distribution_result_error(error), + } +} + /// 确认对象存储中的包摘要和校验清单。procedure 不读取、不执行上传内容。 #[spacetimedb::procedure] pub fn confirm_game_distribution_package_and_return( @@ -1790,6 +1892,78 @@ pub fn get_owner_game_distribution_version_and_return( } } +/// 校验改编(Fork)声明并返回(根作品 ID、子作品代际)。 +/// +/// 失败关闭:来源必须存在且未软删除、已公开、授权档位非「禁止共创」、来源版本必须恰好是 +/// 该作品当前公开版本(禁止指向历史版本或伪造)。 +/// +/// 软删除(#565 的 `deleted_at`)单独判一次而不是只靠 `visibility`:删除会把可见性复位成 +/// `unpublished`,但那是删除的副作用;显式按 `deleted_at` 拒绝对外说明「已被作者删除」, +/// 也避免将来改可见性语义时把已删作品重新变成可改编来源。统一用 +/// `FORK_SOURCE_NOT_AVAILABLE`(api-server 映射 409),不额外区分「已删除」与「未公开」, +/// 免得用错误码把删除事实泄露给非作者。 +fn resolve_game_distribution_fork_declaration_tx( + ctx: &ReducerContext, + forked_from_game_id: &str, + forked_from_version_id: &str, +) -> Result<(String, u32), String> { + let source = ctx + .db + .game_distribution_game() + .game_id() + .find(&forked_from_game_id.to_string()) + .ok_or_else(|| { + module_game_distribution::GameDistributionError::ForkSourceNotFound.to_string() + })?; + if source.deleted_at.is_some() { + return Err( + module_game_distribution::GameDistributionError::ForkSourceNotAvailable.to_string(), + ); + } + if source.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED { + return Err( + module_game_distribution::GameDistributionError::ForkSourceNotAvailable.to_string(), + ); + } + let authorization = + module_game_distribution::ForkAuthorization::parse(source.fork_authorization.as_str()) + .unwrap_or(module_game_distribution::ForkAuthorization::Forbidden); + if !authorization.allows_fork() { + return Err(module_game_distribution::GameDistributionError::ForkNotAuthorized.to_string()); + } + let current_version_id = source + .active_version_id + .as_ref() + .filter(|version_id| { + ctx.db + .game_distribution_version() + .version_id() + .find(*version_id) + .map(|version| version.status == GAME_DISTRIBUTION_VERSION_PUBLISHED) + .unwrap_or(false) + }) + .ok_or_else(|| { + module_game_distribution::GameDistributionError::ForkSourceNotAvailable.to_string() + })?; + if current_version_id.as_str() != forked_from_version_id { + return Err(module_game_distribution::GameDistributionError::ForkSourceVersionMismatch + .to_string()); + } + let (root_game_id, parent_generation) = match ctx + .db + .game_distribution_lineage() + .game_id() + .find(&forked_from_game_id.to_string()) + { + Some(lineage) => (lineage.root_game_id, lineage.generation), + None => (source.game_id.clone(), 0), + }; + Ok(( + root_game_id, + module_game_distribution::next_generation(parent_generation), + )) +} + fn create_game_distribution_game_tx( ctx: &ReducerContext, input: GameDistributionCreateGameInput, @@ -1814,6 +1988,22 @@ fn create_game_distribution_game_tx( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); + let forked_from_game_id = + normalize_game_distribution_optional(input.forked_from_game_id.unwrap_or_default()); + let forked_from_version_id = + normalize_game_distribution_optional(input.forked_from_version_id.unwrap_or_default()); + if forked_from_game_id.is_some() != forked_from_version_id.is_some() { + return Err("改编声明必须同时给出来源作品与来源版本".to_string()); + } + let fork_resolution = match ( + forked_from_game_id.as_deref(), + forked_from_version_id.as_deref(), + ) { + (Some(parent_game_id), Some(parent_version_id)) => Some( + resolve_game_distribution_fork_declaration_tx(ctx, parent_game_id, parent_version_id)?, + ), + _ => None, + }; if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; @@ -1845,6 +2035,11 @@ fn create_game_distribution_game_tx( && game.local_project_id.as_deref() == Some(local_project_id) }); if let Some(game) = existing { + // 复用既有作品身份时不允许再声明改编来源:否则会把别人的原创作改判成衍生作品。 + if fork_resolution.is_some() { + return Err(module_game_distribution::GameDistributionError::ForkDeclarationOnExistingGame + .to_string()); + } insert_game_distribution_receipt( ctx, GameDistributionReceiptInput { @@ -1905,7 +2100,26 @@ fn create_game_distribution_game_tx( cover_object_key: None, screenshots_json: None, deleted_at: None, + fork_authorization: module_game_distribution::FORK_AUTHORIZATION_FORBIDDEN.to_string(), }); + // 血缘与游戏行同事务写入:只有全新作品才会走到这里,复用身份已在上面被拒绝。 + if let (Some(parent_game_id), Some(parent_version_id), Some((root_game_id, generation))) = ( + forked_from_game_id.as_deref(), + forked_from_version_id.as_deref(), + fork_resolution, + ) { + ctx.db + .game_distribution_lineage() + .insert(GameDistributionLineage { + game_id: game_id.clone(), + owner_user_id: owner_user_id.clone(), + parent_game_id: parent_game_id.to_string(), + parent_version_id: parent_version_id.to_string(), + root_game_id, + generation, + created_at: now, + }); + } insert_game_distribution_receipt( ctx, GameDistributionReceiptInput { @@ -2775,6 +2989,93 @@ fn unpublish_game_distribution_game_tx( Ok((game_distribution_game_snapshot(&game), false)) } +/// 提升作品的共创授权档位,要求调用方给出期望的当前档位。 +/// +/// 三重失败关闭:期望值不匹配(调用方基于旧状态)→ 冲突;目标档位不高于当前 → 拒绝降级; +/// 档位字符串不在白名单 → 拒绝未知档位。三者都不写库。 +fn set_game_distribution_fork_authorization_tx( + ctx: &ReducerContext, + input: GameDistributionSetForkAuthorizationInput, +) -> Result<(GameDistributionGameSnapshot, bool), String> { + let owner_user_id = required_game_distribution_text(input.owner_user_id, "owner_user_id")?; + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + let idempotency_key = + required_game_distribution_text(input.idempotency_key, "idempotency_key")?; + let request_digest = required_game_distribution_text(input.request_digest, "request_digest")?; + let receipt_id = game_distribution_receipt_id( + owner_user_id.as_str(), + GAME_DISTRIBUTION_ACTION_SET_FORK_AUTHORIZATION, + idempotency_key.as_str(), + ); + if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { + ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; + let game = ctx + .db + .game_distribution_game() + .game_id() + .find(&game_id) + .ok_or_else(|| "幂等收据对应的游戏已不存在".to_string())?; + return Ok((game_distribution_game_snapshot(&game), true)); + } + let target = + module_game_distribution::ForkAuthorization::parse(input.fork_authorization.as_str()) + .ok_or_else(|| { + module_game_distribution::GameDistributionError::ForkAuthorizationUnknown { + value: input.fork_authorization.clone(), + } + .to_string() + })?; + let mut game = ctx + .db + .game_distribution_game() + .game_id() + .find(&game_id) + .ok_or_else(|| "游戏不存在".to_string())?; + if game.owner_user_id != owner_user_id { + return Err("游戏 owner 不匹配".to_string()); + } + let current = + module_game_distribution::ForkAuthorization::parse(game.fork_authorization.as_str()) + .unwrap_or(module_game_distribution::ForkAuthorization::Forbidden); + if current.as_str() != input.expected_fork_authorization.trim() { + return Err("共创授权期望值与当前状态不匹配".to_string()); + } + if !current.can_promote_to(target) { + return Err( + module_game_distribution::GameDistributionError::ForkAuthorizationDowngradeNotAllowed { + current: current.as_str().to_string(), + requested: target.as_str().to_string(), + } + .to_string(), + ); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + game.fork_authorization = target.as_str().to_string(); + game.updated_at = now; + ctx.db + .game_distribution_game() + .game_id() + .update(game.clone()); + insert_game_distribution_receipt( + ctx, + GameDistributionReceiptInput { + receipt_id, + owner_user_id, + action: GAME_DISTRIBUTION_ACTION_SET_FORK_AUTHORIZATION.to_string(), + idempotency_key, + request_digest, + game_id: Some(game_id), + version_id: None, + outcome_kind: "game".to_string(), + outcome_game_id: Some(game.game_id.clone()), + outcome_version_id: None, + outcome_json: None, + now_micros: input.now_micros, + }, + )?; + Ok((game_distribution_game_snapshot(&game), false)) +} + fn suspend_game_distribution_game_tx( ctx: &ReducerContext, input: GameDistributionSuspendInput, @@ -3294,6 +3595,15 @@ fn admin_game_distribution_game_snapshot( let author_avatar_url = account .and_then(|user| user.avatar_url) .or_else(|| game.author_avatar_url.clone()); + let lineage = ctx + .db + .game_distribution_lineage() + .game_id() + .find(&game.game_id); + let (lineage_generation, forked_from_game_id) = lineage + .map(|lineage| (lineage.generation, Some(lineage.parent_game_id))) + .unwrap_or((0, None)); + let derived_count = game_distribution_public_fork_count(ctx, game.game_id.as_str()); GameDistributionAdminGameSnapshot { game_id: game.game_id.clone(), owner_user_id: game.owner_user_id.clone(), @@ -3307,6 +3617,10 @@ fn admin_game_distribution_game_snapshot( publication_revision: game.publication_revision, created_at_micros: game.created_at.to_micros_since_unix_epoch(), updated_at_micros: game.updated_at.to_micros_since_unix_epoch(), + fork_authorization: game.fork_authorization.clone(), + lineage_generation, + forked_from_game_id, + derived_count, versions: versions .iter() .map(admin_game_distribution_version_snapshot) @@ -3712,6 +4026,7 @@ fn game_distribution_game_snapshot(game: &GameDistributionGame) -> GameDistribut local_project_id: game.local_project_id.clone(), cover_object_key: game.cover_object_key.clone(), screenshots_json: game.screenshots_json.clone(), + fork_authorization: game.fork_authorization.clone(), } } @@ -3855,17 +4170,113 @@ fn public_game_distribution_version( }) } +/// 直接改编该作品的**已公开且未软删除**作品数。按血缘索引实时统计,不维护物化计数, +/// 避免与下架/封禁/删除状态产生第二份真相。 +/// +/// 软删除的作品必须排除(#565 的 `deleted_at`):删除只下线游戏行、保留血缘行, +/// 若不判 `deleted_at`,作者删掉子作品后父作品的「已被改编 N 次」会把已删条目算进去, +/// 对外泄露「这里曾经有个作品」并虚报改编热度。 +fn game_distribution_public_fork_count(ctx: &ReducerContext, game_id: &str) -> u64 { + let derived_game_ids: Vec = ctx + .db + .game_distribution_lineage() + .by_game_distribution_lineage_parent_game_id() + .filter(&game_id.to_string()) + .map(|lineage| lineage.game_id) + .collect(); + derived_game_ids + .into_iter() + .filter(|derived_game_id| { + ctx.db + .game_distribution_game() + .game_id() + .find(derived_game_id) + .map(|game| { + game.deleted_at.is_none() + && game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED + }) + .unwrap_or(false) + }) + .count() as u64 +} + +/// 公开血缘摘要。父作品被下架或封禁时仍然返回(作品行不会被删除), +/// 只有父行真的缺失时才整体降级为 None。 +/// +/// 软删除(#565 的 `deleted_at`)按「不可用」处理:父作品已被作者删除时不得再把它的标题 +/// 与作者名回传给任何人(包括改编者),否则删除就等于没删——公开详情、公开目录早已下线, +/// 却能在别人的溯源卡里继续读到。这里把标题/作者名置空(api-server 原样发 `null`), +/// 前端降级为「原作品已不可用」。根作品同理:它是更远的祖先,标题同样是已删作品的信息。 +/// 只保留不透明的 ID 与代际,删掉的作品不因为血缘关系继续对外输出内容。 +fn game_distribution_lineage_snapshot( + ctx: &ReducerContext, + game_id: &str, +) -> Option { + let lineage = ctx + .db + .game_distribution_lineage() + .game_id() + .find(&game_id.to_string())?; + let parent = ctx + .db + .game_distribution_game() + .game_id() + .find(&lineage.parent_game_id)?; + let parent_available = parent.deleted_at.is_none(); + let root_title = match ctx + .db + .game_distribution_game() + .game_id() + .find(&lineage.root_game_id) + { + Some(root) if root.deleted_at.is_none() => Some(root.title), + // 根作品已软删除:与父作品同一口径,标题不外发。 + Some(_) => None, + // 根行缺失(历史数据):退回父标题,但同样受父作品的删除判定约束。 + None => parent_available.then(|| parent.title.clone()), + }; + let (parent_title, parent_author_name) = if parent_available { + (Some(parent.title.clone()), parent.author_name.clone()) + } else { + (None, None) + }; + Some(GameDistributionLineageSnapshot { + generation: lineage.generation, + root_game_id: lineage.root_game_id, + root_title, + parent_game_id: lineage.parent_game_id, + parent_title, + parent_author_name, + }) +} + fn public_game_distribution_snapshot( ctx: &ReducerContext, game: &GameDistributionGame, ) -> Option { let version = public_game_distribution_version(ctx, game)?; let (average_score, rating_count) = game_distribution_user_rating_summary(ctx, &game.game_id); + let fork_count = game_distribution_public_fork_count(ctx, game.game_id.as_str()); + let lineage = game_distribution_lineage_snapshot(ctx, game.game_id.as_str()); + let mut game_snapshot = game_distribution_game_snapshot(game); + // 作者名/头像读时联账号表:发布时冻结的资料可能是空的(历史数据),而公开页展示的是 + // 作者身份,账号改名或换头像后应当立即跟随,与后台游戏管理页保持同一口径。 + if let Some(account) = ctx.db.user_account().user_id().find(&game.owner_user_id) { + let display_name = account.display_name.trim().to_string(); + if !display_name.is_empty() { + game_snapshot.author_name = Some(display_name); + } + if account.avatar_url.is_some() { + game_snapshot.author_avatar_url = account.avatar_url; + } + } Some(GameDistributionPublicGameSnapshot { - game: game_distribution_game_snapshot(game), + game: game_snapshot, current_version: Some(game_distribution_version_snapshot_for_game(ctx, &version)), average_score, rating_count, + fork_count, + lineage, }) } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 83458de87..1484a54c5 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -219,6 +219,8 @@ macro_rules! migration_tables { game_distribution_review_moderation_log, game_distribution_version, game_distribution_idempotency_receipt, + // 作品之间的改编血缘:业务事实,随迁移导出/导入;父作品下架不删除历史行。 + game_distribution_lineage, llm_router_account, llm_router_billing_checkpoint, agc_model_catalog, diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 1fee3520d..1f94a32da 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -7,9 +7,11 @@ import { import { ArrowLeft, Gamepad2, Maximize2 } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import type { - GameDistributionRecoveryAction, - GameDistributionVersionSummary, +import { + GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS, + type GameDistributionRecoveryAction, + type GameDistributionVersionSummary, + resolveGameForkAuthorization, } from '../../../packages/shared/src/contracts/gameDistribution'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { @@ -316,6 +318,11 @@ function GameDetailContent({ ? '' : resolveNonPublicCoverLabel(game.status, latestVersion?.status); const latestReviewReason = latestVersion?.reviewReason?.trim() ?? ''; + const forkAuthorization = resolveGameForkAuthorization( + game.forkAuthorization, + ); + const lineage = game.lineage ?? null; + const forkCount = game.forkCount ?? 0; const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile; const playbackBlocked = !version || mobilePlaybackBlocked; return ( @@ -341,6 +348,46 @@ function GameDetailContent({ controlsLabel={resolveControlsLabel(version, game.inputModes)} versionLabel={versionLabel} rating={publiclyVisible ? : null} + infoCards={[ + { + label: '共创', + value: ( + + + {GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[ + forkAuthorization + ]} + + {lineage ? ( + <> + {/* + 父作品被软删除时服务端不再回传它的标题与作者名(见 + module-game-distribution 的血缘快照),这里降级成「原作品已不可用」, + 既不猜测也不回显已下线作品的信息。 + */} + + {lineage.parentTitle + ? `第 ${lineage.generation} 代作品 · 改编自《${lineage.parentTitle}》` + : `第 ${lineage.generation} 代作品 · 原作品已不可用`} + + {lineage.parentAuthorName ? ( + + 由 {lineage.parentAuthorName} 制作 + + ) : null} + + ) : null} + {forkCount > 0 ? ( + + 已被改编 {forkCount} 次 + + ) : null} + + ), + }, + ]} actions={ <> { expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy(); }); + + it('展示共创授权、代际与溯源信息,且不提供改造入口', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + forkAuthorization: 'nonCommercial', + forkCount: 3, + lineage: { + generation: 2, + rootGameId: 'game-root', + rootTitle: '根作品', + parentGameId: 'game-parent', + parentTitle: '原初之光', + parentAuthorName: '原作者', + }, + }), + ); + render( + , + ); + + expect(await screen.findByText('允许非商用共创')).toBeTruthy(); + expect(screen.getByText('第 2 代作品 · 改编自《原初之光》')).toBeTruthy(); + expect(screen.getByText('由 原作者 制作')).toBeTruthy(); + expect(screen.getByText('已被改编 3 次')).toBeTruthy(); + expect(screen.queryByRole('button', { name: /改造/u })).toBeNull(); + }); + + it('旧数据缺少授权与血缘字段时只按禁止共创渲染', async () => { + getGameMock.mockResolvedValueOnce(createGame()); + render( + , + ); + + expect(await screen.findByText('禁止共创')).toBeTruthy(); + expect(screen.queryByText(/代作品/u)).toBeNull(); + expect(screen.queryByText(/已被改编/u)).toBeNull(); + expect(screen.queryByText(/允许/u)).toBeNull(); + }); + + it('父作品已被软删除时不展示被删作品标题与作者名', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + forkAuthorization: 'full', + forkCount: 0, + lineage: { + generation: 2, + rootGameId: 'game-root', + rootTitle: null, + parentGameId: 'game-parent', + parentTitle: null, + parentAuthorName: null, + }, + }), + ); + render( + , + ); + + expect(await screen.findByText('允许全开放共创')).toBeTruthy(); + expect( + screen.getByText('第 2 代作品 · 原作品已不可用'), + ).toBeTruthy(); + expect(screen.queryByText(/改编自/u)).toBeNull(); + // 血缘作者行(卡内 `.game-fork-lineage__author`)不该出现;hero 里的 + // 「由 测试作者 制作」是作品自身署名,不在断言范围内。 + expect(document.querySelector('.game-fork-lineage__author')).toBeNull(); + }); }); describe('GameDetailPage 作者视角', () => { diff --git a/src/components/game-distribution/MyGamesPage.test.tsx b/src/components/game-distribution/MyGamesPage.test.tsx index 3af54a224..fb851e2fb 100644 --- a/src/components/game-distribution/MyGamesPage.test.tsx +++ b/src/components/game-distribution/MyGamesPage.test.tsx @@ -15,6 +15,7 @@ import { submitGameVersion, unpublishGame, updateMyGame, + updateGameForkAuthorization, } from '../../services/gameDistributionClient'; import { AuthUiContext } from '../auth/AuthUiContext'; import { MyGamesPage } from './MyGamesPage'; @@ -28,6 +29,7 @@ vi.mock('../../services/gameDistributionClient', () => ({ submitGameVersion: vi.fn(), unpublishGame: vi.fn(), updateMyGame: vi.fn(), + updateGameForkAuthorization: vi.fn(), })); type AuthValue = NonNullable>; @@ -115,6 +117,7 @@ beforeEach(() => { vi.mocked(submitGameVersion).mockReset(); vi.mocked(unpublishGame).mockReset(); vi.mocked(updateMyGame).mockReset(); + vi.mocked(updateGameForkAuthorization).mockReset(); vi.mocked(listMyGames).mockResolvedValue([]); vi.mocked(loadVersionRecoveryActions).mockResolvedValue({}); // 默认没有冻结素材:编辑弹窗打开时按“线上封面不可复用”处理,用例按需覆盖。 @@ -590,3 +593,73 @@ test('删除作品需二次确认并带上当前修订号', async () => { expect(String(idempotencyKey)).toContain('game-delete'); await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2)); }); + +test('把共创授权从禁止提升为非商用会携带期望档位并重新加载列表', async () => { + vi.mocked(listMyGames).mockResolvedValue([ + createGame({ forkAuthorization: 'forbidden' }), + ]); + vi.mocked(updateGameForkAuthorization).mockResolvedValue({ + ok: true, + game: { id: 'game-1', forkAuthorization: 'nonCommercial' }, + }); + + renderPage(createAuthValue()); + + expect(await screen.findByText(/共创:禁止共创/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '共创授权' })); + + const nonCommercialOption = screen.getByRole('button', { name: '非商用' }); + expect((nonCommercialOption as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(nonCommercialOption); + + // 首次点击只进入确认态,不发送请求。 + expect(updateGameForkAuthorization).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '确认提升' })); + + await waitFor(() => + expect(updateGameForkAuthorization).toHaveBeenCalledTimes(1), + ); + const [gameId, target, expected, idempotencyKey] = + vi.mocked(updateGameForkAuthorization).mock.calls[0] ?? []; + expect(gameId).toBe('game-1'); + expect(target).toBe('nonCommercial'); + expect(expected).toBe('forbidden'); + expect(String(idempotencyKey)).toContain('game-1'); + await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2)); + expect(screen.getByText(/共创授权已更新为「允许非商用共创」/)).toBeTruthy(); +}); + +test('共创授权当前档位与更低档位不可点', async () => { + vi.mocked(listMyGames).mockResolvedValue([ + createGame({ forkAuthorization: 'nonCommercial' }), + ]); + + renderPage(createAuthValue()); + + expect(await screen.findByText(/共创:允许非商用共创/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '共创授权' })); + + expect( + (screen.getByRole('button', { name: '禁止' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '非商用' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '全开放' }) as HTMLButtonElement) + .disabled, + ).toBe(false); +}); + +test('共创授权已达全开放时入口变为只读展示', async () => { + vi.mocked(listMyGames).mockResolvedValue([ + createGame({ forkAuthorization: 'full' }), + ]); + + renderPage(createAuthValue()); + + expect(await screen.findByText('共创授权已达上限')).toBeTruthy(); + expect(screen.queryByRole('button', { name: '共创授权' })).toBeNull(); +}); diff --git a/src/components/game-distribution/MyGamesPage.tsx b/src/components/game-distribution/MyGamesPage.tsx index d819e999a..e3d902853 100644 --- a/src/components/game-distribution/MyGamesPage.tsx +++ b/src/components/game-distribution/MyGamesPage.tsx @@ -4,6 +4,14 @@ import { ArrowLeft, RefreshCcw } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { GameDistributionRecoveryAction } from '../../../packages/shared/src/contracts/gameDistribution'; +import { + canUpgradeGameForkAuthorization, + GAME_DISTRIBUTION_FORK_AUTHORIZATIONS, + GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS, + GAME_DISTRIBUTION_FORK_AUTHORIZATION_SHORT_LABELS, + type GameDistributionForkAuthorization, + resolveGameForkAuthorization, +} from '../../../packages/shared/src/contracts/gameDistribution'; import { cancelGameVersion, deleteMyGame, @@ -14,6 +22,7 @@ import { loadVersionRecoveryActions, submitGameVersion, unpublishGame, + updateGameForkAuthorization, } from '../../services/gameDistributionClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; @@ -83,6 +92,12 @@ function createDeleteKey(gameId: string) { return `game-delete-${gameId}-${Date.now()}-${deleteKeySeed}`; } +let forkAuthorizationKeySeed = 0; +function createForkAuthorizationKey(gameId: string) { + forkAuthorizationKeySeed += 1; + return `game-fork-authorization-${gameId}-${Date.now()}-${forkAuthorizationKeySeed}`; +} + export type MyGameStatusFilter = | 'all' | 'published' @@ -126,6 +141,12 @@ const VERSION_FILTER_STATUSES: Record< closed: { cancelled: true, revoked: true }, }; +/** 行内授权设置的目标档位;两段式确认后才会发出请求。 */ +type ForkAuthorizationDraft = { + gameId: string; + target: GameDistributionForkAuthorization; +}; + function resolveVersionStatus(game: GameDistributionMyGame) { const latest = game.latestVersion; if (!latest) return null; @@ -169,6 +190,12 @@ export function MyGamesPage({ const [statusFilter, setStatusFilter] = useState('all'); const [versionFilter, setVersionFilter] = useState('all'); + const [forkAuthorizationEditorGameId, setForkAuthorizationEditorGameId] = + useState(''); + const [pendingForkAuthorization, setPendingForkAuthorization] = + useState(null); + const [savingForkAuthorizationGameId, setSavingForkAuthorizationGameId] = + useState(''); const [notice, setNotice] = useState(''); const [error, setError] = useState(''); const canLoad = Boolean(authUi?.user && authUi.canAccessProtectedData); @@ -312,6 +339,38 @@ export function MyGamesPage({ } } + async function handleUpdateForkAuthorization( + game: GameDistributionMyGame, + target: GameDistributionForkAuthorization, + expected: GameDistributionForkAuthorization, + ) { + setSavingForkAuthorizationGameId(game.id); + setForkAuthorizationEditorGameId(''); + setPendingForkAuthorization(null); + setError(''); + setNotice(''); + try { + await updateGameForkAuthorization( + game.id, + target, + expected, + createForkAuthorizationKey(game.id), + ); + setNotice( + `《${game.title}》的共创授权已更新为「${GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[target]}」。`, + ); + await loadGames(); + } catch (updateError: unknown) { + setError( + updateError instanceof Error + ? updateError.message + : '设置共创授权失败', + ); + } finally { + setSavingForkAuthorizationGameId(''); + } + } + async function handleUnpublish(game: GameDistributionMyGame) { setBusyGameId(game.id); setError(''); @@ -492,6 +551,36 @@ export function MyGamesPage({ const resumeLabel = resolveDraftResumeLabel(game); const versions = game.versions; const isHistoryOpen = expandedGameId === game.id; + const forkAuthorization = resolveGameForkAuthorization( + game.forkAuthorization, + ); + const forkAuthorizationLabel = + GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[ + forkAuthorization + ]; + const isForkAuthorizationEditing = + forkAuthorizationEditorGameId === game.id; + const forkAuthorizationTarget = + pendingForkAuthorization?.gameId === game.id + ? pendingForkAuthorization.target + : null; + const isSavingForkAuthorization = + savingForkAuthorizationGameId === game.id; + const forkAuthorizationTargetLabel = forkAuthorizationTarget + ? GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[ + forkAuthorizationTarget + ] + : ''; + const forkAuthorizationOptions = + GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.map((option) => ({ + option, + label: GAME_DISTRIBUTION_FORK_AUTHORIZATION_SHORT_LABELS[option], + fullLabel: GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[option], + canUpgrade: canUpgradeGameForkAuthorization( + forkAuthorization, + option, + ), + })); return (
@@ -523,6 +612,8 @@ export function MyGamesPage({ ) : null} + · + 共创:{forkAuthorizationLabel} {version?.reviewReason ? (

@@ -668,6 +759,100 @@ export function MyGamesPage({ ) ) : null} + {forkAuthorization === 'full' ? ( + + 共创授权已达上限 + + ) : isForkAuthorizationEditing ? ( + forkAuthorizationTarget ? ( + <> + + 确认提升为「{forkAuthorizationTargetLabel}」? + 提升后不可撤销。 + + + void handleUpdateForkAuthorization( + game, + forkAuthorizationTarget, + forkAuthorization, + ) + } + > + 确认提升 + + setPendingForkAuthorization(null)} + > + 取消 + + + ) : ( + <> + {forkAuthorizationOptions.map( + ({ option, label, canUpgrade, fullLabel }) => ( + + setPendingForkAuthorization({ + gameId: game.id, + target: option, + }) + } + > + {label} + + ), + )} + + setForkAuthorizationEditorGameId('') + } + > + 取消 + + + ) + ) : ( + { + setPendingForkAuthorization(null); + setForkAuthorizationEditorGameId(game.id); + }} + > + 共创授权 + + )} { expect(unpublishCalls()).toHaveLength(1); }); + it('共创授权提升只发 PUT,携带期望档位与幂等键,空入参本地失败关闭', async () => { + setStoredAccessToken('author-token', { emit: false }); + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + game: { id: 'game-1', forkAuthorization: 'nonCommercial' }, + replayed: false, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await updateGameForkAuthorization( + 'game-1', + 'nonCommercial', + 'forbidden', + 'fork-key-1', + ); + const forkCalls = () => + fetchMock.mock.calls.filter(([url]) => + String(url).includes('/fork-authorization'), + ); + expect(forkCalls()).toHaveLength(1); + expect(forkCalls()[0]?.[0]).toBe( + '/api/game-distribution/games/game-1/fork-authorization', + ); + expect(forkCalls()[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + expectedForkAuthorization: 'forbidden', + forkAuthorization: 'nonCommercial', + }), + headers: expect.objectContaining({ + 'Idempotency-Key': 'fork-key-1', + }), + }), + ); + + await expect( + updateGameForkAuthorization(' ', 'full', 'forbidden', 'k'), + ).rejects.toThrow('缺少游戏编号'); + await expect( + updateGameForkAuthorization('game-1', 'full', 'forbidden', ' '), + ).rejects.toThrow('缺少幂等键'); + // 本地校验失败不得发出任何授权请求。 + expect(forkCalls()).toHaveLength(1); + }); + it('回读单个版本状态与服务端恢复动作', async () => { setStoredAccessToken('test-access-token', { emit: false }); const fetchMock = vi.fn().mockImplementation(() => diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index 974fab5d7..317df0c76 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -5,6 +5,7 @@ import type { GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, GameDistributionDeviceSupport, + GameDistributionForkAuthorization, GameDistributionGame, GameDistributionMyReviewResponse, GameDistributionRecoveryAction, @@ -12,6 +13,7 @@ import type { GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, GameDistributionUpdateGameMetadataRequest, + GameDistributionSetForkAuthorizationRequest, GameDistributionVersionDetail, GameDistributionVersionStatus as GameDistributionVersionStatusValue, GameDistributionVersionSummary, @@ -26,8 +28,10 @@ import { getGamePlayClientId } from './gamePlayClientId'; export type { GameDistributionAuthor, GameDistributionDeviceSupport, + GameDistributionForkAuthorization, GameDistributionGame, GameDistributionInputMode, + GameDistributionLineage, GameDistributionRatingSummary, GameDistributionRecoveryAction, GameDistributionReview, @@ -306,6 +310,42 @@ export async function unpublishGame( ); } +/** + * 提升作品共创授权(只升不降)。`expectedForkAuthorization` 为 CAS 期望值, + * 服务端发现档位冲突或降级时返回 409,错误信息由 `requestJson` 透传。 + */ +export async function updateGameForkAuthorization( + gameId: string, + forkAuthorization: GameDistributionForkAuthorization, + expectedForkAuthorization: GameDistributionForkAuthorization, + idempotencyKey: string, +) { + const normalizedGameId = gameId.trim(); + const normalizedKey = idempotencyKey.trim(); + if (!normalizedGameId) throw new Error('缺少游戏编号'); + if (!normalizedKey) throw new Error('设置共创授权缺少幂等键'); + const payload: GameDistributionSetForkAuthorizationRequest = { + expectedForkAuthorization, + forkAuthorization, + }; + return requestJson<{ + ok?: boolean; + replayed?: boolean; + game: { + id: string; + forkAuthorization?: GameDistributionForkAuthorization; + }; + }>( + `/games/${encodeURIComponent(normalizedGameId)}/fork-authorization`, + { + method: 'PUT', + headers: { 'Idempotency-Key': normalizedKey }, + body: JSON.stringify(payload), + }, + '设置共创授权失败', + ); +} + export async function getGame(gameId: string) { const normalizedId = gameId.trim(); if (!normalizedId) return null;