Files
Genarrative/packages/shared/src/contracts/gameDistribution.ts
T
suzmii 6aa2f97410
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
feat(游戏共创): 贡献归集与归因(接口 + 契约 + 文档;反洗稿改判搁置)
产品口径:「子代所有的都算父代的」,并要能溯源「多少是子代给的、多少是自己的」。本期**只做计算**,
不含资金/分成结算。

- 递归定义:`inherited(W) = Σ_{c∈直接子代} total(c)`,`total(W) = own(W) + inherited(W)`;每个后代在它的每个
  祖先里只计一次。
- 接口:`GET /api/game-distribution/games/{game_id}/contribution`(Bearer + `no-store`)——**仅该作品作者**:
  未登录/失效 401(中间件)、非作者 403(复用既有 owner-mismatch → FORBIDDEN,不新增第二套鉴权)、
  作品不存在或根自身已软删 404。响应:`{ gameId, own, inherited, total, byGeneration[], directChildren[],
  nodeCount, truncated, truncatedReason }`;指标结构 `ContributionTotals{playCount}` 可扩展(将来加点赞/收藏/收入)。
- 不变量(测试钉住):`inherited == Σ byGeneration.total == Σ directChildren.total`、`total == own + inherited`、
  `nodeCount == Σ byGeneration.gameCount`;`byGeneration` 只含后代(绝对代际,升序)。
- 上限与截断:节点 500 / 深度 32;超限如实标 `truncated: true` + `truncatedReason ∈ {node_limit, depth_limit}`,
  已计入部分仍自洽(与族谱 `truncated` 同约定,不静默给半个数);遍历只用血缘表既有索引,**不新增表/索引**。
- 契约:shared-contracts 4 个 DTO + TS 镜像 + parity(+4 组 → 66 组);7 个生成绑定(含 procedure)。
- 文档:技术方案新增 `§3.11 贡献归集与权益归因`(定义/归因分解/接口/上限与截断/鉴权口径与理由)+ §3.4 路由行 +
  §5.4 实施证据;**任务 B**:反洗稿(相似度校验)从「待拍板」改为「**产品已决定搁置(2026-10-06)**」并从待拍板
  清单移除,结算口径统一为「本期只做贡献归集与归因计算」。
- 测试:module-game-distribution **131 passed**(contribution 12 条,含 A→B→C 三层链路不变量);spacetime-module
  **304 passed / 1 ignored**(+3 结构断言);spacetime-client 43 passed;api-server game_distribution **107 passed**(+3)。

门禁:wasm build 0;`cargo check --all-targets` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`
0(98 表);DTO parity 0(66 组 / 17 构建器 / 15 手拼类型);`check:project-bundle-policy-parity` OK;
`check:encoding` 0(5543 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。

注:本块未提交 `scripts/*.mjs`(`check-game-distribution-lineage-e2e.mjs` / `-theme-e2e.mjs` /
`capture-game-lineage-visual.mjs`)——属其它 owner,工作区保留未提交。
2026-10-06 18:05:49 +08:00

856 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<gameId>/`,客户端按当前 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<GameDistributionGameVisibility, 'published' | 'unpublished'>;
/** 公开切换 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;
};