export const GAME_DISTRIBUTION_CATEGORIES = [ '休闲', '益智', '动作', '冒险', '模拟', '策略', '其他', ] as const; export type GameDistributionCategory = (typeof GAME_DISTRIBUTION_CATEGORIES)[number]; export type GameDistributionPublishMetadataSuggestionRequest = { name: string; goal?: string | null; /** 脱敏且有界的项目上下文摘要。 */ context?: string | null; }; export type GameDistributionPublishMetadataSuggestion = { summary: string; category: GameDistributionCategory; }; export type GameDistributionDeviceSupport = { desktop: boolean; mobile: boolean; touch: boolean; }; export type GameDistributionInputMode = 'keyboard' | 'mouse' | 'touch'; export type GameDistributionOrientation = | 'landscape' | 'portrait' | 'responsive'; export type GameDistributionAuthor = { id: string; name: string; avatarUrl?: string | null; }; export type GameDistributionReview = { /** 管理员隐藏后仍允许本人读取和编辑,不参与公共统计。 */ isHidden: boolean; id: string; gameId: string; author: GameDistributionAuthor; score: number; comment: string; createdAt: string; updatedAt: string; }; export type GameDistributionRatingSummary = { averageScore: number | null; ratingCount: number; }; export type GameDistributionReviewsResponse = { reviews: GameDistributionReview[]; page: number; pageSize: number; total: number; totalPages: number; ratingSummary: GameDistributionRatingSummary; }; export type GameDistributionMyReviewResponse = { review: GameDistributionReview | null; }; export type GameDistributionSaveReviewRequest = { score: number; comment?: string; }; export type GameDistributionSaveReviewResponse = { review: GameDistributionReview; ratingSummary: GameDistributionRatingSummary; }; export type GameDistributionVersionStatus = | 'awaiting_upload' | 'uploaded' | 'validating' | 'pending_review' | 'published' | 'upload_failed' | 'validation_failed' | 'rejected' | 'cancelled' | 'revoked'; export type GameDistributionGameVisibility = | 'unpublished' | 'published' | 'suspended'; export type GameDistributionVersionSummary = { id: string; version: string; /** * 发行入口:平台同源路径 `/games//`,客户端按当前 origin 解析后再交给 iframe。 * 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。 * * 买断制作品在未购买时不下发公开入口,这一项为 `null`;游玩入口改由播放会话接口签发。 */ entryUrl: string | null; sha256: string; publishedAt: string; controls: string[]; /** * 本版相对改编来源作品的核心改动说明(衍生作品必有;母版为 `null`)。 * * 版本级字段:详情页与族谱溯源逐版渲染「每一代的差异」。公开投影里它不含任何私有字段 * (没有对象键 / 素材 id),因此匿名可读;母版发 `null` 而不是省略该键。 */ changeSummary?: string | null; }; /** * 共创授权三态:只允许单向提升(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; }; /** * 族谱树上的单个作品节点。 * * 只有公开且未软删除的作品会成为节点(锚点不可读时接口直接 404),因此 `title` 必然存在; * `authorName` 为 null 表示作者信息不可得。`parentGameId` 指向的作品可能不在树里 * (父作品已删/未公开),展示层据此把该节点标注为「原作品不可用」,不猜测父作品内容。 * 节点只带作品级公开信息,不含**素材键 / 内部 id**(例如 `coverAssetId`);`coverObjectKey` * 是公开投影的一部分,与公开目录 / 详情同源同口径,无封面时为 null。 */ export type GameDistributionLineageNode = { gameId: string; title: string; authorName?: string | null; /** 代际;母版为 0。 */ generation: number; /** 父作品 ID;母版为 null。 */ parentGameId?: string | null; playCount: number; status: GameDistributionGameVisibility; /** * 该节点作品当前生效的封面对象键(与公开目录 / 详情同源,都来自游戏行 * `cover_object_key`);无封面时为 null。只发对象键,不带 `coverAssetId` 等私有 id。 */ coverObjectKey?: string | null; }; /** * 族谱读接口响应:`nodes` 按代际升序、同代按创建时间升序稳定排序,含根(若根可见); * 超过节点上限时截断尾部并置 `truncated`,不静默丢弃。 */ export type GameDistributionLineageResponse = { /** 请求作品所属的根作品 ID;根不可用时仍返回,展示层据此标注树顶不可用。 */ rootGameId: string; /** 根节点在可见集合中的投影;根已删或未公开时为 null。 */ root?: GameDistributionLineageNode | null; nodes: GameDistributionLineageNode[]; truncated: boolean; }; /** 直接衍生作品(「被改编」)列表响应;只含对外可见的直接子代。 */ export type GameDistributionDerivedResponse = { gameId: string; nodes: GameDistributionLineageNode[]; truncated: boolean; }; /** * 归集的指标集合(当前只有游玩数)。 * * 单独成结构体而不是把字段摊进响应里:将来加点赞 / 收藏 / 收入只需加字段, * 递归定义(`inherited(W) = Σ 直接子代 total`、`total = own + inherited`)与投影形状都不动。 */ export type GameDistributionContributionTotals = { playCount: number; }; /** * 按代际分解的一条:**只含后代**(不含根自身),`generation` 为绝对代际(与族谱一致)。 * * 语义:这一条是祖先 `inherited` 里**由这一代自己产生**的那一份,`total` 即该代贡献; * `inherited` 恒为 `0`——该代从更深代际继承到的量已计入更深代际那一条,重复计入会让 * 「各代之和」大于祖先的 `inherited`(同一个后代会在多层分解里被重复计数)。 */ export type GameDistributionContributionGeneration = { generation: number; /** 这一代计入分解的后代节点数。 */ gameCount: number; own: GameDistributionContributionTotals; inherited: GameDistributionContributionTotals; total: GameDistributionContributionTotals; }; /** 直接子代明细的一条:`total` 是该直接子代**含自己整棵子树**的值。 */ export type GameDistributionContributionChild = { gameId: string; generation: number; own: GameDistributionContributionTotals; inherited: GameDistributionContributionTotals; total: GameDistributionContributionTotals; }; /** * 作者视角的贡献归集与归因响应(`GET /api/game-distribution/games/{gameId}/contribution`)。 * * 不变量(在**截断后的子树**上依然成立): * - `inherited == Σ byGeneration.total == Σ directChildren.total`; * - `total == own + inherited`; * - `nodeCount == Σ byGeneration.gameCount`(= 参与计算的**后代**节点数,不含根)。 * * `truncated` 为真时 `truncatedReason` 取 `node_limit` / `depth_limit`,未截断为 `null` * (与族谱接口 `truncated` 同约定:超限如实标注,不静默给半个数)。 * **本期只做计算**:响应里没有资金 / 分成 / 结算字段。 */ export type GameDistributionContributionResponse = { gameId: string; own: GameDistributionContributionTotals; inherited: GameDistributionContributionTotals; total: GameDistributionContributionTotals; byGeneration: GameDistributionContributionGeneration[]; directChildren: GameDistributionContributionChild[]; /** 参与计算的后代节点数(不含根)。 */ nodeCount: number; truncated: boolean; truncatedReason: string | null; }; /** * Fork 取件内容的形态。M2a 只有已构建的发行成品包;M2b 引入工程源包后,同一版本同时存在 * 两者时优先 `project`。 */ export type GameDistributionForkSourceKind = 'package' | 'project'; /** * Fork 取件元数据:客户端据此校验取到的内容并决定建项形态。 * * 只含版本身份与摘要,**不含 OSS 对象键**——下载走同源受鉴权路径 `downloadPath`, * 对象键只留在服务端。`sha256` / `bytes` 取自版本行已存的发行包摘要,不重新计算。 */ export type GameDistributionForkSource = { gameId: string; versionId: string; source: GameDistributionForkSourceKind; sha256: string; bytes: number; /** 同源相对路径;客户端按当前 origin 解析后带 Bearer 请求,不接受绝对 URL。 */ downloadPath: string; }; /** Fork 取件元数据接口(`GET …/fork-source`)的响应体。 */ export type GameDistributionForkSourceResponse = { forkSource: GameDistributionForkSource; }; export type GameDistributionGame = { id: string; title: string; summary: string; description: string; category: GameDistributionCategory; tags: string[]; coverColor: string; icon: string; /** * 公开封面的 OSS objectKey;未上传封面时为空,展示层需回退到 coverColor/icon 占位。 * 通过 `/api/assets/read-url` 换签名 URL 读取,不接受直连外链。 */ coverObjectKey?: string | null; /** 公开截图的 OSS objectKey 列表,最多 6 张,随版本冻结。 */ screenshots?: string[]; author: GameDistributionAuthor; deviceSupport: GameDistributionDeviceSupport; inputModes?: GameDistributionInputMode[]; orientation?: GameDistributionOrientation; status: Extract; /** 公开切换 CAS 版本号;作者下架与管理员审核都必须回传当前值。 */ publicationRevision: number; /** * 公开目录与公开详情一定带这一项(没有已公开版本时为 null);作者侧响应(我的作品列表、 * 创建、提交、审核、撤回)走服务端同一个 `game_payload`,不带这一项。 */ currentVersion?: GameDistributionVersionSummary | null; /** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */ ratingSummary?: GameDistributionRatingSummary; /** 共创授权三态;旧数据或字段缺省时读作 `forbidden`。 */ forkAuthorization?: GameDistributionForkAuthorization; /** 被改编次数(直接子代数量);旧数据或字段缺省时读作 0。 */ forkCount?: number; /** 血缘摘要;根作品或旧数据为 null/缺省。 */ lineage?: GameDistributionLineage | null; /** * 当前登录用户是否已收藏(收录)该作品。 * * **只有公开详情在登录时返回**:匿名请求(含携带无效 token 按匿名处理)不带这个键, * 也不发 `false`——`false` 会把「未登录」说成「没收藏」,客户端没法区分,会渲染出错误的 * 收藏按钮态。公开目录不返回。值来自服务端真实投影,不是前端本地状态。 */ collected?: boolean; /** * 该作品所属的**公开**共创主题(先取作品的根、再按根反查,跨主题多归属因此是数组)。 * * **只有公开详情返回**(匿名与登录都返回;没有所属主题时是空数组,展示层据此不渲染空容器), * 公开目录与作者侧不带这个键。第 N 代作品同样能看到主题——反查用的是根,不是作品自身。 * 排序与公开主题列表共用同一份比较器(创建时间倒序 + themeId 升序兜底)。 */ themes?: GameDistributionThemeReference[]; /** * 买断价(整数泥点,`0` 表示免费)。后端公开投影始终下发;作者侧与旧响应可省略 * (Rust 侧 `#[serde(default)]` 同样允许缺字段),读取方必须按 `0`(免费)兜底。 */ priceMudPoints?: number; /** * 当前查看者是否已拥有;匿名与未登录恒为 `false`。旧响应可省略,读取方按未购买兜底。 */ purchased?: boolean; playCount: number; createdAt: string; }; export type GameDistributionListResponse = { games: GameDistributionGame[]; nextCursor?: string | null; }; /** * 「我的收藏(收录)」列表接口(`GET /api/game-distribution/my-collections`)的响应体。 * * 形状与公开目录 `GameDistributionListResponse` 一致(同一份公开投影),但分页语义不同: * 默认一页 20 条、上限 50 条,排序按「收藏时间倒序 + collectionId 升序兜底」。 * `nextCursor` 是真实游标(`"{createdAtMicros}:{collectionId}"`),最后一页为 `null`; * 游标格式非法时服务端返回 400(不是 200 的空页)。 */ export type GameDistributionMyCollectionsResponse = { games: GameDistributionGame[]; nextCursor?: string | null; }; /** * 共创主题的三态处置:`draft`(未发布)/ `published`(公开可见)/ `archived`(归档下架)。 * * 公开侧只出现 `published`;`archived` 不是删除(主题行与成员行都保留,只是不再公开)。 * 后台写接口的 `status` 用这个联合类型,非法取值由服务端拒绝(400)。 */ export type GameDistributionThemeStatus = 'draft' | 'published' | 'archived'; /** * 作品详情「所属主题」增量的单个条目:只够渲染一个跳转入口(主题页里才有完整信息)。 */ export type GameDistributionThemeReference = { themeId: string; name: string; badge: string; }; /** * 公开主题列表的单条:主题名、简介、角标与**当前公开可见成员数**。 * * `memberCount` 是**真实可见成员数**(不含草稿 / 已下架成员),服务端不回报「全部成员行数」。 * 它是真实数:可见成员超过响应体积上限(50)时,它与主题详情 `roots` 的长度**故意不相等**—— * 「这份 `roots` 被截断」由详情响应上的 `rootsTruncated` 单独表达,客户端不要用这个字段反推。 */ export type GameDistributionThemeSummary = { themeId: string; name: string; summary: string; badge: string; memberCount: number; }; /** * 公开主题列表接口(`GET /api/game-distribution/themes`)的响应体。 * * 只含 `published` 主题。分页沿用既有游标惯例:默认 20 条、上限 50 条、超界截断; * `nextCursor` 是真实游标(`"{createdAtMicros}:{themeId}"`),最后一页为 `null`;游标格式非法时 * 服务端返回 400(不是 200 的空页)。排序为创建时间倒序 + `themeId` 升序兜底(全序,不重不漏)。 */ export type GameDistributionThemeListResponse = { themes: GameDistributionThemeSummary[]; nextCursor?: string | null; }; /** * 公开主题详情接口(`GET /api/game-distribution/themes/{themeId}`)的响应体。 * * 顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。`roots` 逐条是公开目录同一份 * 作品投影,排序为 `sortOrder` 升序 + `memberId` 升序兜底。主题不存在或未发布时接口返回 404 * (不返回空壳);已发布但可见成员为空时返回 200 + 空 `roots`(展示层按空态而不是错误渲染)。 * * 两个成员数相关的字段口径**独立,不要互相推导**: * - `memberCount` 是**真实可见成员数**(不截断),回答「这个主题有多少棵作品树」; * - `rootsTruncated` 才是「**下面这份 `roots` 被响应体积上限(50)截断**」的信号,与族谱响应的 * `truncated` 同一个约定(列表被上限截断时置 `true`)。`roots.length !== memberCount` 时就得看它。 */ export type GameDistributionThemeDetail = { themeId: string; name: string; summary: string; badge: string; memberCount: number; roots: GameDistributionGame[]; rootsTruncated: boolean; }; /** * 后台创建主题(`POST /admin/api/game-distribution/themes`)的请求体。 * * `name` 必填且非空;其余字段省略时按「空文本 / 排序 0 / `draft`」落库(与表列默认一致)。 */ export type GameDistributionCreateThemeRequest = { name: string; summary?: string; badge?: string; sortOrder?: number; status?: GameDistributionThemeStatus; }; /** * 后台更新主题(`PUT /admin/api/game-distribution/themes/{themeId}`)的请求体。 * * **整体覆盖**这几个字段(不是部分更新):字段都必填,避免「省略 = 保留旧值」与「省略 = 清空」 * 两种解释在客户端之间漂移。 */ export type GameDistributionUpdateThemeRequest = { name: string; summary: string; badge: string; sortOrder: number; status: GameDistributionThemeStatus; }; /** * 后台增 / 改成员(`PUT …/themes/{themeId}/members/{rootGameId}`)的请求体。 * * 成员身份完全由路径给出,body 只承载运营权重;重复调用由确定性主键保证幂等。 */ export type GameDistributionUpsertThemeMemberRequest = { sortOrder?: number; }; /** * 后台主题条目:`{ themeId, name, summary, badge, sortOrder, status, memberCount, createdAt, updatedAt }`。 * * 与公开的 `GameDistributionThemeSummary` **刻意不同形**:后台多出 `sortOrder` / `status` / 时间戳, * 且 `memberCount` 的口径是**成员行总数**(含当前对外不可见的成员)——后台没有「不该被探测」的顾虑, * 运营需要知道这个主题挂了几个根;公开投影里的同名键是**当前可见**成员数。 * * 没有 Rust 结构体可对(服务端由 `admin_theme_payload` 逐字段手拼 JSON),因此在 DTO parity 里 * 走 `TS_ONLY_TYPES` + `RESPONSE_BUILDERS` 登记:形状由机器门禁钉住,后台 UI 与实现不能各自漂移。 */ export type GameDistributionAdminTheme = { themeId: string; name: string; summary: string; badge: string; sortOrder: number; status: GameDistributionThemeStatus; memberCount: number; createdAt: string; updatedAt: string; }; /** * 后台主题列表响应:`{ themes: [...] }`。 * * **没有 `nextCursor`**:这条路径不分页(`limit` 缺省与上限都是 200),因此这里也不能声明可选游标, * 否则前端会以为存在第二页。 */ export type GameDistributionAdminThemeListResponse = { themes: GameDistributionAdminTheme[]; }; /** * 后台创建 / 更新主题的响应:`{ theme, replayed }`。 * * `replayed` 必须原样发出:客户端据此区分「这次真的写了」与「同键重放,库里没动」。 */ export type GameDistributionAdminThemeMutationResponse = { theme: GameDistributionAdminTheme; replayed: boolean; }; /** * 后台成员写入的响应:`{ themeId, rootGameId, sortOrder, createdAt }`。 * * `createdAt` 是**首次挂载**的时间:重复 `PUT` 只改 `sortOrder`,不会刷新它。 */ export type GameDistributionAdminThemeMemberResponse = { themeId: string; rootGameId: string; sortOrder: number; createdAt: string; }; /** * 后台移除成员的响应:`{ themeId, rootGameId }`。 * * 刻意不含「之前存不存在」:删掉的与本来就没有的回同一个形状,重复调用逐字节相同。 */ export type GameDistributionAdminThemeMemberRemovalResponse = { themeId: string; rootGameId: string; }; /** * 后台成员行的细粒度状态(**只给后台**,公开投影不发这个键)。 * * - `published` / `unpublished` / `suspended`:成员行还在、游戏行也在,原样透传游戏行的可见性; * - `deleted`:游戏行被软删除; * - `missing`:游戏行**根本不存在**(历史脏行)。 * * 与同一行的 `visible` **是两个独立字段、不许互相推导**:`visible` 回答「此刻公开投影会不会 * 出现它」,`visibility` 回答「为什么」。因此 `visibility === 'published'` 但没有当前公开版本时 * `visible === false`——这是刻意的,运营据此看出「作品已公开、但没有公开版本」这种异常。 */ export type GameDistributionAdminThemeMemberVisibility = | 'published' | 'unpublished' | 'suspended' | 'deleted' | 'missing'; /** * 后台主题成员行:`GET /admin/api/game-distribution/themes/{themeId}/members` 的 `members[]`。 * * **后台看得到全部成员行**(含当前对外不可见的),这是本接口存在的理由:草稿 / 归档主题在公开 * 投影里根本不存在,运营没法在发布前核对名单。 * * `title` 取游戏行标题,游戏行不存在时为 `null`(该行仍要出现在名单里,否则与 `totalMembers` 对不上)。 * 只为后台渲染而发,后台本就允许看草稿作品。 */ export type GameDistributionAdminThemeMemberRow = { rootGameId: string; title: string | null; sortOrder: number; /** 首次挂载时间(重复 PUT 只改 `sortOrder`,不刷新它)。 */ createdAt: string; /** 此刻**公开投影**是否包含它(未删除 + 已公开 + 有当前公开版本)。 */ visible: boolean; visibility: GameDistributionAdminThemeMemberVisibility; }; /** * 后台主题成员列表响应。 * * `totalMembers` 是该主题的**成员行总数**(含当前不可见的成员),**不受分页影响**;它与 * `members.length` 不是一回事(后者只是这一页)。 * * 分页与其它列表同一口径:`limit` 缺省 **20** / 上限 **50** / `0` 取默认 / 超界截断;游标形如 * `"{sortOrder}:{memberId}"`(`memberId` 本身是 `"{themeId}:{rootGameId}"`,解析只切第一个冒号), * 排序为 `sortOrder` 升序 + 成员主键升序兜底(全序,翻页不重不漏);**非法游标 → 400 * `THEME_INVALID_CURSOR`**;末页 `nextCursor` 为 `null`。 * * 主题不存在 → 404 `THEME_NOT_FOUND`;`draft` / `archived` **不是** 404(后台要看得见)。 */ export type GameDistributionAdminThemeMemberListResponse = { themeId: string; totalMembers: number; members: GameDistributionAdminThemeMemberRow[]; nextCursor: string | null; }; export type GameDistributionCreateGameRequest = { /** 发布方本地项目标识;同一作者重复发布会复用既有 gameId。 */ localProjectId?: string | null; title: string; summary: string; description?: string; category: GameDistributionCategory; tags?: string[]; coverAssetId?: string; /** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */ screenshots?: string[]; deviceSupport: GameDistributionDeviceSupport; inputModes: GameDistributionInputMode[]; orientation: GameDistributionOrientation; /** * 上架时选择的共创授权档位:`forbidden` / `nonCommercial` / `full`。 * * 缺省按「禁止共创」解释(与库表默认、与旧客户端行为一致);非法取值整请求 400,不会静默 * 落成 `forbidden`。上架之后只能通过 `PUT …/fork-authorization` 单向提升。 * * **只对母版(0 代作品)生效**:带 `fork` 声明时,服务端在新作品创建时**继承父作品当时的 * 档位**,这里传的值被忽略且不报错(旧客户端惯常带默认值);服务端不接受「收窄」。 * 字段形状刻意保持不变——改成 `Option` 之类的形状会让创建请求的幂等摘要漂移。 */ forkAuthorization?: GameDistributionForkAuthorization; /** 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 */ fork?: GameDistributionForkDeclaration | null; }; export type GameDistributionCreateVersionRequest = { localProjectId?: string | null; /** * 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。 * * 传入时只要求 `>= 1`:同一 `gameId` 的同一个版本号可以反复提交,每次提交生成新的 * `versionId`,允许重复标签与回退到更小的版本号,不与已有最大值比较。 * 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。 */ versionNumber?: number | null; /** * 作者提交的买断价(整数泥点,`0` 表示免费)。 * * 价格随版本冻结,审核通过时与资料一起生效到游戏行;缺省按 `0`(免费)处理。 * 取值范围由服务端约束:`0..=1_000_000`,付费必须是正整数。 */ priceMudPoints?: number; packageSha256: string; packageBytes: number; packageFileCount: number; packageEntryPath: 'index.html'; gameMetadata: GameDistributionCreateGameRequest; /** * 「本次核心改动说明」:**衍生作品**(该作品有改编来源)发布新版本时必填, * trim 后按**字符**计 20–500 个字符;0 代母版忽略该字段(不校验、不落库)。 * * 缺失 → 400 `FORK_CHANGE_SUMMARY_REQUIRED`;长度越界 → 400 `FORK_CHANGE_SUMMARY_INVALID`。 * 它参与请求摘要:同一个 `Idempotency-Key` 换了说明会被按「同键不同请求」拒绝(409), * 省略该键的旧客户端请求与升级前逐字节一致(缺省不序列化 `null`)。 */ changeSummary?: string | null; }; /** * 作者编辑游戏级展示资料。 * * 只覆盖游戏行上的展示字段;随版本冻结的包摘要与资料快照不受影响。 * `expectedPublicationRevision` 是公开切换 CAS,并发变化返回 409。 */ export type GameDistributionUpdateGameMetadataRequest = { expectedPublicationRevision: number; title: string; summary: string; description?: string | null; category: GameDistributionCategory; tags?: string[]; coverAssetId?: string | null; /** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */ screenshots?: string[]; deviceSupport: GameDistributionDeviceSupport; inputModes: GameDistributionInputMode[]; orientation: GameDistributionOrientation; }; export type GameDistributionPrivateVersion = { versionId: string; gameId: string; versionNumber: number; packageSha256: string; packageBytes: number; packageFileCount: number; status: GameDistributionVersionStatus; /** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */ publicationRevision: number; /** 该版本冻结的买断价(整数泥点,`0` 表示免费);无价格的历史版本按免费口径处理。 */ priceMudPoints?: number; reviewReason?: string | null; /** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */ entryUrl: string | null; /** 工程源包字节数;0 表示该版本未上传工程源包(作者上架时选择不给源码)。 */ projectBundleBytes: number; /** 工程源包整包 SHA-256;未上传缺省。对象键不下发,只在服务端使用。 */ projectBundleSha256?: string | null; createdAt: string; updatedAt: string; }; /** * 版本状态的下一步动作,由服务端派生;客户端只按它渲染主行动作,不自行推断状态。 */ export type GameDistributionRecoveryAction = | 'upload' | 'submit' | 'wait' | 'none' | 'reupload' | 'fix_package' | 'fix_metadata'; /** 冻结资料里的截图:同时保留素材 ID(作者续发可复用)与对象键(展示换签用)。 */ export type GameDistributionFrozenScreenshot = { assetId: string; objectKey: string; }; /** * 随版本冻结的游戏资料快照。 * * 只有作者本人(版本回读)与管理员(审核回读)会拿到素材 ID;公开投影只给对象键。 * 历史版本可能没有快照,读取方必须按空值处理。 */ export type GameDistributionVersionFrozenMetadata = { title?: string; summary?: string; description?: string; category?: GameDistributionCategory; tags?: string[]; coverAssetId?: string | null; coverObjectKey?: string | null; screenshots?: GameDistributionFrozenScreenshot[]; deviceSupport?: GameDistributionDeviceSupport; inputModes?: GameDistributionInputMode[]; orientation?: GameDistributionOrientation; }; export type GameDistributionVersionDetail = { game: GameDistributionGame; version: GameDistributionPrivateVersion & { recoveryAction: GameDistributionRecoveryAction; frozenMetadata?: GameDistributionVersionFrozenMetadata | null; }; }; export type GameDistributionCancelVersionRequest = { expectedPublicationRevision: number; reason?: string; }; export type GameDistributionCancelVersionResponse = { game: GameDistributionGame; version: GameDistributionPrivateVersion; replayed: boolean; }; /** 提升共创授权;`expectedForkAuthorization` 是 CAS 期望值,只允许升级。 */ export type GameDistributionSetForkAuthorizationRequest = { expectedForkAuthorization: GameDistributionForkAuthorization; forkAuthorization: GameDistributionForkAuthorization; }; /** 创建游戏时的改编来源声明:父作品 + 建立血缘时锁定的父版本。 */ export type GameDistributionForkDeclaration = { parentGameId: string; parentVersionId: string; }; /** * 收藏(收录)写入 / 取消后的权威投影值。 * * `collected` 是**调用后**的收藏事实(服务端真实投影,不是前端本地状态);PUT 与 DELETE * 共用一个形状,客户端不按「动词」推断结果。`replayed` 只在带幂等键的 PUT 上有意义: * 命中同键收据时为 `true`(本次没产生新事实);DELETE 按确定性主键删除、天然幂等, * 因此不返回该键。 */ export type GameDistributionCollectionState = { collected: boolean; replayed?: boolean; }; /** * 买断制购买请求体。 * * 客户端带上自己看到的价格:与服务端当前价格不一致时返回 409,不会按旧价扣费。 */ export type GameDistributionPurchaseRequest = { expectedPriceMudPoints: number; }; /** 购买记录快照:每账号每作品最多一条,成交价随购买冻结,之后调价不影响既有所有权。 */ export type GameDistributionPurchase = { purchaseId: string; gameId: string; priceMudPoints: number; createdAt: string; }; /** 购买响应:`walletBalance` 是扣费后的泥点余额,`replayed` 表示服务端按同一幂等键重放。 */ export type GameDistributionPurchaseResponse = { purchase: GameDistributionPurchase; walletBalance: number; replayed: boolean; }; /** * 播放会话响应:`playUrl` 是绑定「`gameId` + 用户 + 短时效」的发行入口。 * 结算入口地址后交给 iframe,会话过期或作品下架后不可继续播放。 */ export type GameDistributionPlaySessionResponse = { playUrl: string; expiresAt: string; };