diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 8637a4e5e..874791ecd 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -317,7 +317,7 @@ pub(crate) project_bundle_sha256: Option, | 方法 / 路径 | 说明 | | --- | --- | -| `GET /games?search=&category=&authorId=&limit=&cursor=`(**既有,2026-10-07 补真分页**) | 公开游戏目录(游戏广场)。匿名可读 + `no-store`;只含公开可玩的作品(`published` + 未软删除 + 有当前公开版本)。**真游标分页**:`limit` 缺省 **48**(保持既有首屏语义,AGC 共创页首屏就是 48 条)、上限 **100**(约束单响应体量),超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{gameId}"`(与 `/my-collections`、主题列表同一套 `"{micros}:{id}"` 惯例,解析只切第一个冒号);**游标格式非法 → 400 `CATALOG_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页)。响应 `{ games: [<公开作品投影>], nextCursor }`;排序 `createdAt` 倒序 + `gameId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」。**修正的问题**:此前 `limit` 写死 48 且 `nextCursor` 恒为 `null`,库里第 49 条起的作品(含最老的母版与主干)在 HTTP 面永久不可见(客户端只能显示「已检查最新 48 个作品」)——数据一直在、详情与 `/lineage` 都读得到,缺的只是翻页通道。**网页侧已接(2026-10-07)**:`listGames()` 返回 `{ games, nextCursor }` 并支持 `limit` / `cursor` 透传(不再丢弃游标、不再假装「一次拿全」);游戏广场与创作者主页都在 `nextCursor` 非空时渲染「加载更多」,点击用服务端游标取下一页、**按 `gameId` 去重追加**、加载中禁用、**失败保留已加载内容**并给可读提示、**末页(`nextCursor = null`)自动隐藏入口**。真机实测(真实栈 51 条 published):首屏 48 条 → 加载更多 → 51 条,最老的 `共创演示·A0/A1/A11` 可见 | +| `GET /games?search=&category=&authorId=&limit=&cursor=&forkable=`(**既有,2026-10-07 补真分页与 `forkable` 过滤**) | 公开游戏目录(游戏广场)。匿名可读 + `no-store`;只含公开可玩的作品(`published` + 未软删除 + 有当前公开版本)。**真游标分页**:`limit` 缺省 **48**(保持既有首屏语义,AGC 共创页首屏就是 48 条)、上限 **100**(约束单响应体量),超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{gameId}"`(与 `/my-collections`、主题列表同一套 `"{micros}:{id}"` 惯例,解析只切第一个冒号);**游标格式非法 → 400 `CATALOG_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页)。响应 `{ games: [<公开作品投影>], nextCursor }`;排序 `createdAt` 倒序 + `gameId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」。**`forkable`(2026-10-07 新增,供 AGC「共创」页)**:真值(`1` / `true`,**大小写不敏感、先 trim**)时只保留**可被共创**的作品(`forkAuthorization != forbidden`);**省略 / 空 / `0` / `false` / 非法取值(如 `abc`)一律按「不过滤」处理**——非法取值**宽容降级**而不是 400,理由:这是公开只读列表,`forkable` 只是可选的展示过滤,客户端(尤其旧版外壳)拼错一个可选参数不该让整个游戏广场 400;而「不过滤」正好等于本参数出现之前的既有行为,降级既不会多列(相对现状只会少列)也不改变任何既有调用方的语义。过滤**在事务内、切页之前**完成(与可见性过滤同一处、同一序,api-server **不**过滤返回结果),因此禁止共创的作品**不占页名额**、翻页不重不漏、末页 `nextCursor` 仍为 `null`,分页 / 游标语义与不带该参数时逐字相同;**未知档位按禁止解释**(与 `/fork-source` 的 403 判据同一口径,避免列出「点进去必然失败」的入口)。响应形状**不变**(`forkAuthorization` 本来就在公开作品投影里)。**修正的问题**:此前 `limit` 写死 48 且 `nextCursor` 恒为 `null`,库里第 49 条起的作品(含最老的母版与主干)在 HTTP 面永久不可见(客户端只能显示「已检查最新 48 个作品」)——数据一直在、详情与 `/lineage` 都读得到,缺的只是翻页通道。**网页侧已接(2026-10-07)**:`listGames()` 返回 `{ games, nextCursor }` 并支持 `limit` / `cursor` 透传(不再丢弃游标、不再假装「一次拿全」);游戏广场与创作者主页都在 `nextCursor` 非空时渲染「加载更多」,点击用服务端游标取下一页、**按 `gameId` 去重追加**、加载中禁用、**失败保留已加载内容**并给可读提示、**末页(`nextCursor = null`)自动隐藏入口**。真机实测(真实栈 51 条 published):首屏 48 条 → 加载更多 → 51 条,最老的 `共创演示·A0/A1/A11` 可见 | | `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)。**不追加 `forkSourceAvailable`**:网页端「改造这个作品」入口的显隐只依据 `forkAuthorization`(已公开 + 非禁止即可引导去取件),真实可复刻形态由 `/games/{gameId}/fork-source` 的 `source` 字段回答,不需要在公开详情里提前判断;若 M2b 需要在详情页区分「可源码级改造 / 只能参考」,再在 M2b 里加该字段。**另追加 `collected`(仅登录用户,2026-10-06)**:登录已认证时返回 `true` / `false`(真实投影,值来自 `is_game_distribution_collected_and_return`);**匿名请求不返回该字段**(也不发 `false`——`false` 会把「未登录」说成「没收藏」)。该字段随请求者变化,所以这条路径必须 `no-store`、不得有任何共享缓存。**再追加 `themes: [{ themeId, name, badge }]`(2026-10-06 增量,共创主题)**:该作品所属的**公开**主题(只含 `status == published`);**匿名与登录都发、无主题时恒发空数组**(与「仅登录才发」的 `collected` 不同——没有主题与没登录因此不会被混成同一种缺键)。口径是「先取该作品的**根**、再按根反查公开主题」,所以**第 N 代作品也能看到并跳到主题页**;单条只够渲染跳转入口(简介与成员数去主题页取)**。**版本摘要 `currentVersion` 再追加 `changeSummary`(2026-10-06,衍生作品发布必填项)**:该版本相对**改编来源**作品的核心改动说明——衍生作品必有(`20–500` 个字符,详见下方作者路由的 `POST /versions` 行),0 代母版为 **`null`**(发键不发值:客户端不必靠缺键猜),因此详情页与族谱溯源能逐代展示「这一版改了什么」。它不含任何私有字段(没有对象键 / 素材 id),匿名可读,也不需要按查看者变化 | | `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status, coverObjectKey }`(`coverObjectKey` 为该节点作品**当前生效的封面对象键**,与公开目录 / 详情**同源同口径**、都来自游戏行 `cover_object_key`,**无封面时为 `null`**;只发对象键,不带 `coverAssetId` 等私有 id),按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点。**「累计世代数」不是服务端字段**:本作品子树的深度 = 子树最大代际 − 本作品代际(根作品若有 3 层后代则为 3),前端从同一次响应的 `generation` 自算,服务端不新增字段也不为此多查一次。**右侧面板的「本次核心改动说明」不贴到节点上**:`LineageNode` 保持现在的字段集(不加 `changeSummary`),面板只对**选中节点**请求一次公开详情取 `currentVersion.changeSummary`(衍生作品为字符串、母版为 `null`,见上方公开详情行)——即**不做 N+1**:不是每渲染一个节点就查一次详情(两条前端口径已落地:`bd73acfcc`) | | `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 | @@ -479,8 +479,9 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr - 侧栏共创卡文案按同一口径:`{N 代…} · 改编自父代《…》` + `父代作者 …`;子代侧沿用「累计衍生作品 N」(全部世代)与「累计创作世代数」。 - 面包屑横向可滚动且**当前项常驻可见**;尊重 `prefers-reduced-motion`;底部留 `env(safe-area-inset-bottom)`。 - **筛选行口径**(2026-10-06 追加):`高亮整棵子树`复选框**已删除**——它恒为真、没有用户可见意义,子树高亮改为**恒定开启**(`collectLineageHighlight` 默认即含子树;页面不再持有该开关状态,也不再有恒为 `true` 的布尔往下传)。`改动类型`下拉**保留但禁用**并在旁边写明原因(`changeSummary` 是自由文本、服务端没有可枚举分类),不编造值域。 - - **移动端门限豁免(2026-10-06)**:平台原有的「移动端仅支持作品展示」欢迎弹窗只看 `!isDesktopLayout`、忽略路由,会把公开只读的族谱树 / 共创主题页首屏盖住。现在按**显式白名单**豁免这三个 stage:`game-lineage` / `game-themes` / `game-theme`(清单在 `PlatformActiveMobileWelcomeDialog.tsx` 的 `MOBILE_WELCOME_EXEMPT_STAGES`,键名即 stage 名,不做前缀/范围匹配)。**创作 / 项目 / 发布 / 我的 / 平台首页仍保持门限**——那些才是「移动端不支持体验创作工具」的部分;弹窗文案未改(与豁免后的事实不矛盾:族谱与主题页本身就是作品展示)。 + - **移动端门限豁免(2026-10-06)**:平台原有的「移动端仅支持作品展示」欢迎弹窗只看 `!isDesktopLayout`、忽略路由,会把公开只读的族谱树 / 共创主题页首屏盖住。现在按**显式白名单**豁免这几个只读浏览 stage:`game-lineage` / `game-themes` / `game-theme` / `game-co-creation`(清单在 `PlatformActiveMobileWelcomeDialog.tsx` 的 `MOBILE_WELCOME_EXEMPT_STAGES`,键名即 stage 名,不做前缀/范围匹配)。**创作 / 项目 / 发布 / 我的 / 平台首页仍保持门限**——那些才是「移动端不支持体验创作工具」的部分;弹窗文案未改(与豁免后的事实不矛盾:族谱与主题页本身就是作品展示)。 - **截断 / 空树 / 不可用父节点**在两种形态下都有降级:截断提示、空态、以及「原作品已不可用」的占位节点(可聚焦、不可点进详情、不展示被删作品标题与作者名)。 +- **「共创」页签口径(2026-10-07,用户口径)**:页签与「游戏」页签**复用同一个 `GameGalleryPage`**(`scope="forkable"`),数据来自公开目录并带 `forkable=1`(分页/游标/去重/末页/失败态全部沿用已实现的「加载更多」);**服务端尚未生效期间客户端再兜一层同义过滤**(`forkAuthorization !== 'forbidden'`,未知按禁止失败关闭),两条口径一致、服务端上线后等价幂等。页签不再指向共创主题列表(主题列表页与路由保留,无页签入口);页面标题 `game-co-creation` → 「共创 - 陶泥儿」,路由 `/games/co-creation`,移动端门限同样按「只读浏览 = 作品展示」豁免。空态为「暂时没有开放共创的作品」。 - **初始适配口径(2026-10-07 复核)**:初次进入按**宽高同时 fit**(`min(scaleX, scaleY)` + `FIT_PADDING`)并**水平垂直都居中**;若这个比例低于可读下限 `READABLE_MIN_SCALE = 0.6`,就**不强行全览**,改用可读缩放 + 平移,并给出明确提示「树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「适应窗口」看全树。」——两种情况下都**不会**在没有提示的前提下把最深的代际丢在视口外。窄屏「图谱视图」另按**宽度**适配(见上文)。 - **主干线唯一性复核(2026-10-07,真实数据)**:演示树 `共创演示·A0|星海工坊`(8 节点、`playCount` 全 0,仅 A111 = 1)实测 **每代恰好一个主干**:A0(0 代)/ A1(1 代)/ A11(2 代)/ A111(3 代);A2、A3、A12、A21 **都不是**主干。同代并列 0 热度时取「同代中**创建时间最早**」的那个(`/lineage` 契约即「代际升序、同代按创建时间升序」,布局只按输入顺序稳定取第一个),因此结果确定、不随渲染变化。**结论:主干逻辑未改**;新增「每代 isTrunk 数量恰好 1」的断言把它钉住(`lineageTreeLayout.test.ts`)。 @@ -602,7 +603,7 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr ### 3.10 共创主题(平台命名的作品树归组实体) -> **形态**:共创 Tab 里有多个「共创主题」,点进去是一棵(或一组)作品树;**主题由平台 / 运营命名,不由根作品决定**;作品在游戏 Tab 里仍作为独立作品展示,详情页有「开始共创」入口,并能看到 / 跳到对应主题页。 +> **形态**:网页端「游戏 / 共创」两个页签**展示的都是游戏卡片**(同一套组件、同一套游标分页)——「游戏」= 全部公开作品,「**共创**」= **只列支持共创的作品**(请求带 `forkable=1`,见 §3.4 目录接口)。**2026-10-07 口径变更**:早前「共创」页签进的是共创主题列表,用户明确「先别整主题了」,所以页签改直出作品卡片;**共创主题的实现与路由全部保留**(`/games/themes` 主题列表、`/games/theme?id=` 主题详情、作品详情里的主题链接),只是**页签不再指向主题列表**,主题相关页面改由「作品详情 → 所属主题」或直接 URL 进入。 > > 本节把 §7 第 3 条(「是否引入共创主题实体」)从**待拍板**收敛为**已拍板**:采纳「平台命名主题 → 作品树」。归属关系的**曝光口径不变**——作品之间不互相挂靠,主题是运营侧的**额外归组维度**,不是作品的可读锚点。**设计定稿于 2026-10-06(commit `063c04a1b`);服务端三块随后落地**:数据模型与领域纯函数 `2fa201e0d`、公开读路径 `742723a58`、后台写路径 `033e3aa79`。**前台(共创 Tab / 主题页 / 详情页入口)与后台管理 UI 仍未做**。 >